diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index c1454a8895b..d6dec6ac110 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -4,11 +4,25 @@ on: push: branches: - main + paths: + - "ghostty/**" + - ".gitmodules" + - "scripts/ensure-ghosttykit.sh" + - "scripts/download-prebuilt-ghosttykit.sh" + - "scripts/update-ghosttykit.sh" + - ".github/workflows/build-ghosttykit.yml" pull_request: + paths: + - "ghostty/**" + - ".gitmodules" + - "scripts/ensure-ghosttykit.sh" + - "scripts/download-prebuilt-ghosttykit.sh" + - "scripts/update-ghosttykit.sh" + - ".github/workflows/build-ghosttykit.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true jobs: build-ghosttykit: @@ -93,6 +107,12 @@ jobs: set -euo pipefail rm -rf GhosttyKit.xcframework cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework + ARCHIVE="$(find GhosttyKit.xcframework -type f -name 'libghostty.a' -print -quit)" + if [ -z "$ARCHIVE" ]; then + echo "GhosttyKit.xcframework does not contain libghostty.a" >&2 + exit 1 + fi + ./scripts/verify-release-architectures.sh "$ARCHIVE" tar czf GhosttyKit.xcframework.tar.gz GhosttyKit.xcframework - name: Upload xcframework release diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index b3ab0253578..96c3ba2ff86 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -1,15 +1,97 @@ name: macOS Compatibility +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: - push: - branches: - - main pull_request: + paths: + - "Sources/**" + - "CLI/**" + - "daemon/**" + - "programaTests/**" + - "vendor/**" + - "scripts/**" + - ".github/workflows/ci-macos-compat.yml" + - "GhosttyTabs.xcodeproj/**" + - "ghostty/**" + - ".gitmodules" + + workflow_dispatch: jobs: + change-detection: + runs-on: ubuntu-latest + outputs: + run_compat_tests: ${{ steps.detect.outputs.run_compat_tests }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Detect changed files + id: detect + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "run_compat_tests=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + git fetch --no-tags --depth=1 origin "${BASE_SHA}" "${HEAD_SHA}" + CHANGED_FILES="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")" + RUN_COMPAT_TESTS=false + if [[ -z "${CHANGED_FILES}" ]]; then + RUN_COMPAT_TESTS=true + fi + + while IFS= read -r path; do + [[ -z "$path" ]] && continue + + case "$path" in + *.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 + ;; + .github/*) + continue + ;; + Resources/*.xcstrings|Resources/*/*.xcstrings|Resources/*.strings|Resources/*/*.strings|Resources/*.lproj/*) + continue + ;; + *) + RUN_COMPAT_TESTS=true + break + ;; + esac + done <<< "$CHANGED_FILES" + + echo "run_compat_tests=${RUN_COMPAT_TESTS}" >> "$GITHUB_OUTPUT" + + { + echo "### CI scope decision" + if [[ "$RUN_COMPAT_TESTS" == "true" ]]; then + echo "Changed file set contains app-relevant changes; running compat matrix." + else + echo "Pull request appears docs/localization-only; skipping compatibility matrix." + fi + echo "" + echo "Changed files:" + if [[ -z "${CHANGED_FILES}" ]]; then + echo "- " + else + printf '%s\n' "${CHANGED_FILES}" + fi + } >> "$GITHUB_STEP_SUMMARY" compat-tests: + needs: change-detection + if: needs.change-detection.outputs.run_compat_tests == 'true' strategy: - fail-fast: false + fail-fast: true matrix: include: - os: macos-15 @@ -121,47 +203,9 @@ jobs: - name: Run unit tests env: PROGRAMA_SKIP_ZIG_BUILD: ${{ matrix.skip_zig && '1' || '0' }} + PROGRAMA_UNIT_TEST_SCOPE: split-stateful run: | - set -euo pipefail - SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - run_unit_tests() { - xcodebuild -project GhosttyTabs.xcodeproj -scheme programa-unit -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -skip-testing:programaTests/AppDelegateShortcutRoutingTests/testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace \ - test 2>&1 - } - - set +e - run_unit_tests | tee /tmp/test-output.txt - EXIT_CODE=${PIPESTATUS[0]} - OUTPUT=$(cat /tmp/test-output.txt) - set -e - - # SwiftPM binary artifact resolution can occasionally fail on ephemeral - # runners. Retry once after clearing caches. - if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then - echo "SwiftPM package resolution failed, clearing caches and retrying once" - rm -rf ~/Library/Caches/org.swift.swiftpm - mkdir -p ~/Library/Caches/org.swift.swiftpm - rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - set +e - run_unit_tests | tee /tmp/test-output.txt - EXIT_CODE=${PIPESTATUS[0]} - OUTPUT=$(cat /tmp/test-output.txt) - set -e - fi - - if [ "$EXIT_CODE" -ne 0 ]; then - SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) - if echo "$SUMMARY" | grep -q "(0 unexpected)"; then - echo "All failures are expected, treating as pass" - else - echo "Unexpected test failures detected" - exit 1 - fi - fi + ./scripts/ci-run-unit-tests.sh - name: Create virtual display if: matrix.smoke diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52ad1325114..2dc43cbcd88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,41 +4,151 @@ on: push: branches: - main + paths: + - ".github/workflows/ci.yml" + - ".github/workflows/ci-macos-compat.yml" + - ".github/workflows/build-ghosttykit.yml" + - "Sources/**" + - "CLI/**" + - "daemon/**" + - "programaTests/**" + - "vendor/**" + - "scripts/**" + - "GhosttyTabs.xcodeproj/**" + - "ghostty/**" + - "vendor/bonsplit/**" + - ".gitmodules" pull_request: + paths: + - "Sources/**" + - "CLI/**" + - "daemon/**" + - "programaTests/**" + - "tests/**" + - "tests_v2/**" + - "vendor/**" + - "scripts/**" + - ".github/workflows/ci.yml" + - ".github/workflows/ci-macos-compat.yml" + - ".github/workflows/build-ghosttykit.yml" + - "GhosttyTabs.xcodeproj/**" + - "ghostty/**" + - ".gitmodules" concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true jobs: + change-detection: + runs-on: ubuntu-latest + outputs: + run_app_jobs: ${{ steps.detect.outputs.run_app_jobs }} + run_remote_daemon_jobs: ${{ steps.detect.outputs.run_remote_daemon_jobs }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Detect changed files + id: detect + run: | + set -euo pipefail + + # Keep full CI for push events and non-PR runs. + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "run_app_jobs=true" >> "$GITHUB_OUTPUT" + echo "run_remote_daemon_jobs=true" >> "$GITHUB_OUTPUT" + echo "Changed files: full suite (non-PR context)" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + git fetch --no-tags --depth=1 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 + 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" + + echo "run_app_jobs=${RUN_APP_JOBS}" >> "$GITHUB_OUTPUT" + echo "run_remote_daemon_jobs=${RUN_REMOTE_DAEMON_JOBS}" >> "$GITHUB_OUTPUT" + { + echo "### CI scope decision" + if [[ "$RUN_APP_JOBS" == "true" ]]; then + echo "Changed file set contains app-relevant changes; running full macOS jobs." + else + echo "Pull request appears docs/localization-only; skipping heavy app/daemon jobs." + fi + echo "" + echo "Changed files:" + if [[ -z "${CHANGED_FILES}" ]]; then + echo "- " + else + printf '%s\n' "${CHANGED_FILES}" + fi + } >> "$GITHUB_STEP_SUMMARY" workflow-guard-tests: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Validate GitHub-hosted runner guards - run: ./tests/test_ci_self_hosted_guard.sh + - name: Validate GhosttyKit lock ownership + run: ./tests/test_ensure_ghosttykit_locking.sh - name: Validate create-dmg version pinning run: ./tests/test_ci_create_dmg_pinned.sh - - name: Validate unit-test SwiftPM retry guard - run: ./tests/test_ci_unit_test_spm_retry.sh - - - name: Validate cmux scheme test configuration - run: ./tests/test_ci_scheme_testaction_debug.sh + - name: Validate unit-test runner behavior + run: ./tests/test_ci_unit_test_runner_behavior.sh - name: Validate GhosttyKit checksum verification run: ./tests/test_ci_ghosttykit_checksum_verification.sh + - name: Validate release artifact architecture checks + run: ./tests/test_ci_universal_release_settings.sh + - name: Validate release asset guard run: node scripts/release_asset_guard.test.js - - name: Validate current GhosttyKit checksum pin - run: ./tests/test_ci_ghosttykit_checksum_present.sh - remote-daemon-tests: + needs: change-detection + if: needs.change-detection.outputs.run_remote_daemon_jobs == 'true' runs-on: ubuntu-latest steps: - name: Checkout @@ -57,6 +167,8 @@ jobs: run: ./tests/test_remote_daemon_release_assets.sh tests: + needs: change-detection + if: needs.change-detection.outputs.run_app_jobs == 'true' runs-on: macos-15 timeout-minutes: 45 steps: @@ -83,6 +195,12 @@ jobs: export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version + - name: Validate Release reload artifact discovery + run: ./tests/test_reloadp_programa_artifact.sh + + - name: Validate reload entrypoint artifacts and dependency preparation + run: ./tests/test_reload_entrypoints_artifacts.sh + - name: Cache GhosttyKit.xcframework id: cache-ghosttykit uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -154,51 +272,36 @@ jobs: sleep $((attempt * 5)) done - - name: Run unit tests + # Build and exercise the standalone CLI before the long XCTest bundle. This + # keeps parser/security regressions observable even when an unrelated UI + # unit test flakes later in the job. + - name: Build programa CLI regression target + run: | + CLI_REGRESSION_DIR="$RUNNER_TEMP/programa-cli-regression" + rm -rf "$CLI_REGRESSION_DIR" + xcodebuild -project GhosttyTabs.xcodeproj \ + -target programa-cli \ + -configuration Debug \ + CONFIGURATION_BUILD_DIR="$CLI_REGRESSION_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + build + + - name: Run typed CLI registry behavior regression run: | set -euo pipefail - SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - run_unit_tests() { - xcodebuild -project GhosttyTabs.xcodeproj -scheme programa-unit -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -skip-testing:programaTests/AppDelegateShortcutRoutingTests/testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace \ - test 2>&1 - } - # Stream output via tee so CI logs are visible in real time, while still - # capturing for post-run analysis of expected vs unexpected failures. - set +e - run_unit_tests | tee /tmp/test-output.txt - EXIT_CODE=${PIPESTATUS[0]} - OUTPUT=$(cat /tmp/test-output.txt) - set -e - - # SwiftPM binary artifact resolution can occasionally fail on ephemeral - # runners with "Could not resolve package dependencies". Retry once after - # clearing SwiftPM/DerivedData caches to recover from transient corruption. - if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then - echo "SwiftPM package resolution failed, clearing caches and retrying once" - rm -rf ~/Library/Caches/org.swift.swiftpm - mkdir -p ~/Library/Caches/org.swift.swiftpm - rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - set +e - run_unit_tests | tee /tmp/test-output.txt - EXIT_CODE=${PIPESTATUS[0]} - OUTPUT=$(cat /tmp/test-output.txt) - set -e + CLI_BIN="$RUNNER_TEMP/programa-cli-regression/programa" + if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then + echo "programa CLI binary not found in DerivedData" >&2 + exit 1 fi - if [ "$EXIT_CODE" -ne 0 ]; then - SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) - if echo "$SUMMARY" | grep -q "(0 unexpected)"; then - echo "All failures are expected, treating as pass" - else - echo "Unexpected test failures detected" - exit 1 - fi - fi + PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_registry_behavior.py + + - name: Run unit tests + env: + PROGRAMA_UNIT_TEST_SCOPE: split-stateful + run: ./scripts/ci-run-unit-tests.sh - name: Run bundled Ghostty theme picker helper regression run: | @@ -224,6 +327,10 @@ jobs: PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py tests-build-and-lag: + needs: + - tests + - change-detection + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.change-detection.outputs.run_app_jobs == 'true' # Build the full cmux scheme and run the lag regression on GitHub-hosted runners. # Keep lag validation separate from UI regressions so functional UI failures # and performance regressions stay isolated. Broader interactive UI suites @@ -397,6 +504,10 @@ jobs: rm -f /tmp/create-virtual-display ui-regressions: + needs: + - tests + - change-detection + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.change-detection.outputs.run_app_jobs == 'true' runs-on: macos-15 timeout-minutes: 45 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c44a2fd5b43..755162c81a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,10 @@ on: branches: [main] workflow_dispatch: +concurrency: + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: write attestations: write @@ -196,7 +200,7 @@ jobs: export PATH="/usr/local/bin:$PATH" zig version fi - npm install --global "create-dmg@${CREATE_DMG_VERSION}" + ./scripts/install-create-dmg.sh - name: Cache GhosttyKit.xcframework id: cache-ghosttykit-release @@ -251,19 +255,10 @@ jobs: - name: Verify binary architectures if: steps.guard_release_assets.outputs.skip_all != 'true' run: | - set -euo pipefail - APP_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/MacOS/Programa" - CLI_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" - HELPER_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" - APP_ARCHS="$(lipo -archs "$APP_BINARY")" - CLI_ARCHS="$(lipo -archs "$CLI_BINARY")" - HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")" - echo "App binary architectures: $APP_ARCHS" - echo "CLI binary architectures: $CLI_ARCHS" - echo "Ghostty helper architectures: $HELPER_ARCHS" - [[ "$APP_ARCHS" == "arm64" ]] - [[ "$CLI_ARCHS" == "arm64" ]] - [[ "$HELPER_ARCHS" == "arm64" ]] + ./scripts/verify-release-architectures.sh \ + "build-universal/Build/Products/Release/Programa.app/Contents/MacOS/Programa" \ + "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" \ + "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" - name: Build remote daemon release assets and inject manifest if: steps.guard_release_assets.outputs.skip_all != 'true' @@ -353,17 +348,17 @@ jobs: exit 1 fi APP_PATH="build-universal/Build/Products/Release/Programa.app" - ENTITLEMENTS="programa.entitlements" CLI_PATH="$APP_PATH/Contents/Resources/bin/programa" HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" - if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" - fi - if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" - fi - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" - /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" + ./scripts/sign-release-app.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" programa.entitlements + ./scripts/verify-release-entitlements.sh "$APP_PATH" "$CLI_PATH" "$HELPER_PATH" + + - name: Verify embedded Sparkle artifact + if: steps.guard_release_assets.outputs.skip_all != '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' diff --git a/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png deleted file mode 100644 index 9eb723ce56f..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/128_dark.png b/Assets.xcassets/AppIcon.appiconset/128_dark.png deleted file mode 100644 index e27e5dd6149..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/128_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png deleted file mode 100644 index 05775ffa4ca..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/16_dark.png b/Assets.xcassets/AppIcon.appiconset/16_dark.png deleted file mode 100644 index 762583cc938..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/16_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png deleted file mode 100644 index 96740e30fa5..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/256_dark.png b/Assets.xcassets/AppIcon.appiconset/256_dark.png deleted file mode 100644 index 9eb723ce56f..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/256_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png deleted file mode 100644 index 1de949fb161..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/32_dark.png b/Assets.xcassets/AppIcon.appiconset/32_dark.png deleted file mode 100644 index 05775ffa4ca..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/32_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png deleted file mode 100644 index aa90400dba5..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/512_dark.png b/Assets.xcassets/AppIcon.appiconset/512_dark.png deleted file mode 100644 index 96740e30fa5..00000000000 Binary files a/Assets.xcassets/AppIcon.appiconset/512_dark.png and /dev/null differ diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json index b63ce430b48..93a6772ed84 100644 --- a/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,188 +1,68 @@ { - "images": [ + "images" : [ { - "filename": "16.png", - "idiom": "mac", - "scale": "1x", - "size": "16x16" + "filename" : "16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "16_dark.png", - "idiom": "mac", - "scale": "1x", - "size": "16x16" + "filename" : "16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" }, { - "filename": "16@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "16x16" + "filename" : "32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "16@2x_dark.png", - "idiom": "mac", - "scale": "2x", - "size": "16x16" + "filename" : "32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" }, { - "filename": "32.png", - "idiom": "mac", - "scale": "1x", - "size": "32x32" + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "32_dark.png", - "idiom": "mac", - "scale": "1x", - "size": "32x32" + "filename" : "128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" }, { - "filename": "32@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "32x32" + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "32@2x_dark.png", - "idiom": "mac", - "scale": "2x", - "size": "32x32" + "filename" : "256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" }, { - "filename": "128.png", - "idiom": "mac", - "scale": "1x", - "size": "128x128" + "filename" : "512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "128_dark.png", - "idiom": "mac", - "scale": "1x", - "size": "128x128" - }, - { - "filename": "128@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "128x128" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "128@2x_dark.png", - "idiom": "mac", - "scale": "2x", - "size": "128x128" - }, - { - "filename": "256.png", - "idiom": "mac", - "scale": "1x", - "size": "256x256" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "256_dark.png", - "idiom": "mac", - "scale": "1x", - "size": "256x256" - }, - { - "filename": "256@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "256x256" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "256@2x_dark.png", - "idiom": "mac", - "scale": "2x", - "size": "256x256" - }, - { - "filename": "512.png", - "idiom": "mac", - "scale": "1x", - "size": "512x512" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "512_dark.png", - "idiom": "mac", - "scale": "1x", - "size": "512x512" - }, - { - "filename": "512@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "512x512" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "512@2x_dark.png", - "idiom": "mac", - "scale": "2x", - "size": "512x512" + "filename" : "512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/CHANGELOG.md b/CHANGELOG.md index a96b759f4d5..bbb47ea9843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,23 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Changed - New installs now start with the minimal workspace layout (theme already follows the system). Anyone who previously toggled the mode keeps their stored setting. +- CI and release policy checks now exercise executable helpers and built artifacts instead of asserting source-file text; release binaries are also checked for the expected architecture before signing. +- Debug and settings UI copy is fully localized in English and Japanese, and obsolete cmux branding and unassigned dark app-icon variants have been removed. +- Markdown panels now route full-document rendering through a renderer-neutral boundary while preserving the existing MarkdownUI appearance and macOS 14 support; relative document links and images resolve from the Markdown file's directory. +- Sparkle was upgraded to 2.9.4, with release builds now verifying the embedded framework version and its signed updater components before notarization. - Whole-codebase restructuring pass (internal, no behavior change): the remote-daemon stack moved out of `Workspace.swift`, browser data-import out of `BrowserPanel.swift`, v2 browser automation out of `TerminalController.swift`, UI-test harnesses out of `AppDelegate.swift`, and `TabManager`/`GhosttyNSView`/`ContentView` split into per-concern files — the largest source files shrank by 3,000–5,000 lines each, cutting incremental build times. The copy-pasted v1 telemetry-handler skeleton, agent-wrapper commands (Go and Swift), and boilerplate settings accessors were each collapsed onto single shared implementations. ### Fixed +- Release signing now proceeds inside-out without `--deep`, so the bundled `programa` and `ghostty` tools no longer inherit the app's camera, microphone, automation, JIT, or library-validation entitlements; the signed artifact is gated before notarization. +- Debug, Release, and Staging reload entrypoints now prepare GhosttyKit before building; Staging uses the canonical `Programa STAGING` name and `com.darkroom.programa.staging` identity. +- CI now retries only genuine SwiftPM resolution failures and always propagates XCTest failures, including deterministic failures reported as “0 unexpected.” +- CLI command lookup, help, and typed argument validation now complete before opening the app socket, so unknown or malformed invocations cannot connect or trigger focus side effects. +- CLI socket authentication now ignores symlinked, non-regular, foreign-owned, or group/world-accessible password files. +- GhosttyKit cache hits now bypass build locks, stale owners are recovered without stealing live builds, and validated frameworks publish atomically with ownership-safe cleanup. +- Rapid workspace switching and non-focus split reparenting now share one generation-checked focus owner, so delayed callbacks cannot move AppKit input back to a stale workspace or pane. +- Remote agent wrappers now avoid occupied implicit OpenCode ports, keep OMO package metadata isolated from the user's config, and use Programa-owned shim paths. The Release reload helper now locates and launches `Programa.app` after the rebrand. +- Remote-workspace localhost pages now use one browser/proxy alias contract (while accepting the legacy Programa alias), concurrent proxy connections use isolated serial executors so one stalled stream cannot block another, settings files keep applying valid sibling fields when one enum or numeric value is malformed, and browser suggestions contact only the search provider the user selected. +- JSON-RPC now rejects non-object `params` and boolean, fractional, or overflowing integer arguments instead of silently coercing them in workspace, surface, and pane operations. Session autosave change detection now derives from the exact snapshot being written, so same-count metadata and panel-title changes are not skipped. - Port telemetry from shells and agents (`report_ports`, `clear_ports`) no longer blocks on the app's main thread, so a busy UI can't stall the socket. - Notifications in multi-window sessions now respect which window owns the tab: the tab in front of you no longer fires an external banner, and background tabs in other windows are no longer misjudged as focused. - Closing a pane now cleans up everything closing a single surface does — stale unread badges and leaked per-panel state are gone. diff --git a/CLI/CLI+SSH.swift b/CLI/CLI+SSH.swift index 77133e26085..95290a71f99 100644 --- a/CLI/CLI+SSH.swift +++ b/CLI/CLI+SSH.swift @@ -331,6 +331,12 @@ extension ProgramaCLI { ) } + /// Syntax-only entrypoint used by the central registry before it opens or + /// focuses an app socket. Remote arguments after `--` remain passthrough. + func validateSSHCommandArguments(_ commandArgs: [String]) throws { + _ = try parseSSHCommandOptions(commandArgs) + } + /// Normalizes an SSH destination: unwraps a bracketed IPv6 literal and extracts an /// inline port. Returns `(destination, port?)`. Only bracketed forms are altered; /// `user@host`, plain hostnames, and bare IPv6 (`2001:db8::1`) pass through unchanged. @@ -1386,7 +1392,12 @@ extension ProgramaCLI { try self.runSSHSessionEnd(commandArgs: ctx.commandArgs, client: ctx.client) } ), - CommandDescriptor(names: ["remote-daemon-status"], helpLines: ["remote-daemon-status [--os ] [--arch ]"], execute: nil), + CommandDescriptor( + names: ["remote-daemon-status"], + helpLines: ["remote-daemon-status [--os ] [--arch ]"], + connectionPolicy: .local, + execute: nil + ), ] } } diff --git a/CLI/programa.swift b/CLI/programa.swift index 6fbf630c9e7..61eb2474265 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -60,9 +60,35 @@ enum SocketPasswordResolver { let passwordURL = appSupport .appendingPathComponent(directoryName, isDirectory: true) .appendingPathComponent(fileName, isDirectory: false) - guard let data = try? Data(contentsOf: passwordURL) else { + + var pathStat = stat() + guard lstat(passwordURL.path, &pathStat) == 0, + (pathStat.st_mode & S_IFMT) == S_IFREG, + pathStat.st_uid == geteuid(), + (pathStat.st_mode & 0o077) == 0, + pathStat.st_size >= 0, + pathStat.st_size <= 64 * 1024 else { + return nil + } + + let descriptor = open(passwordURL.path, O_RDONLY | O_NOFOLLOW) + guard descriptor >= 0 else { return nil } + defer { close(descriptor) } + + var openedStat = stat() + guard fstat(descriptor, &openedStat) == 0, + (openedStat.st_mode & S_IFMT) == S_IFREG, + openedStat.st_uid == geteuid(), + (openedStat.st_mode & 0o077) == 0, + openedStat.st_dev == pathStat.st_dev, + openedStat.st_ino == pathStat.st_ino, + openedStat.st_size >= 0, + openedStat.st_size <= 64 * 1024 else { return nil } + + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = handle.readDataToEndOfFile() guard let value = String(data: data, encoding: .utf8) else { return nil } @@ -1075,6 +1101,32 @@ struct CommandContext { let windowId: String? } +enum CLICommandConnectionPolicy { + /// The command is parsed and validated first, then receives one connected client. + case socket + /// The command owns any process/socket work it needs and must not be preconnected. + case local +} + +enum CLICommandHelpPolicy { + /// `--help` is handled by Programa without acquiring a socket. + case programa + /// Help flags are forwarded verbatim to the wrapped/internal command. + case passthrough +} + +/// Typed preflight contracts that must succeed before a socket client exists. +/// `.registered` routes through the exhaustive command grammar table; bespoke +/// cases retain additional semantic validation where the schema is richer. +enum CLICommandArgumentContract { + case registered + case noArguments + case focusPanel + case readScreen + case setProgress + case listLog +} + /// Single source of truth for a CLI command's name(s), its one-line entry in /// the grouped `Commands:` help block, and how it executes. /// @@ -1085,17 +1137,9 @@ struct CommandContext { /// help-list membership, and unknown-command exclusion in one place — /// nothing else to keep in sync. /// -/// Not every command routes through this table's `execute` closure: a -/// handful (`welcome`, `shortcuts`, `feedback`, `themes`, `claude-teams`, -/// `omo`, `omx`, `omc`, `codex`, `version`, `remote-daemon-status`) are -/// intercepted earlier in `run()`, before the socket connects, because they -/// have pre-connection semantics (e.g. `version`/`welcome` must work even -/// when Programa isn't running). Those still get a descriptor — with -/// `execute == nil` — purely so `usage()` has one place to read their help -/// line from. If dispatch ever reached the table for one of them (it -/// shouldn't, given the pre-connection interception always returns first), -/// it falls through to "Unknown command", matching prior behavior (they -/// were never switch cases either). +/// Commands whose implementation is intercepted in `run()` still have a +/// descriptor so their connection, help, and argument policies are decided +/// before any socket work begins. /// /// A descriptor with `names: []` is a pure help-text spacer/section-comment /// (e.g. the blank line + "# tmux compatibility commands" header) — it @@ -1115,9 +1159,28 @@ struct CommandDescriptor { /// undocumented (matches several legacy/internal commands that were /// already missing from the old free-text help). let helpLines: [String] - /// Executes the command. `nil` for the pre-connection-special commands - /// described above. + let connectionPolicy: CLICommandConnectionPolicy + let helpPolicy: CLICommandHelpPolicy + let argumentContract: CLICommandArgumentContract + /// Executes the command. `nil` for commands implemented directly by + /// `run()` before generic socket dispatch. let execute: ((CommandContext) throws -> Void)? + + init( + names: [String], + helpLines: [String], + connectionPolicy: CLICommandConnectionPolicy = .socket, + helpPolicy: CLICommandHelpPolicy = .programa, + argumentContract: CLICommandArgumentContract = .registered, + execute: ((CommandContext) throws -> Void)? + ) { + self.names = names + self.helpLines = helpLines + self.connectionPolicy = connectionPolicy + self.helpPolicy = helpPolicy + self.argumentContract = argumentContract + self.execute = execute + } } struct ProgramaCLI { @@ -1243,6 +1306,9 @@ struct ProgramaCLI { print(usage()) return } + if arg.hasPrefix("-") { + throw CLIError(message: "Unknown global option: \(arg)") + } break } @@ -1253,11 +1319,44 @@ struct ProgramaCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) - let resolvedSocketPath = CLISocketPathResolver.resolve( - requestedPath: socketPath, - source: socketPathSource, - environment: processEnv - ) + + guard let descriptor = commandDescriptor(named: command) else { + // Filesystem opening is a fallback only after command lookup, so a + // registered command can never be mistaken for a path. + if looksLikePath(command) { + let resolvedSocketPath = CLISocketPathResolver.resolve( + requestedPath: socketPath, + source: socketPathSource, + environment: processEnv + ) + try openPath(command, socketPath: resolvedSocketPath) + return + } + throw CLIError(message: "Unknown command: \(command). Run 'programa help' to see available commands.") + } + + if descriptor.helpPolicy == .programa, + commandArgs.contains(where: { $0 == "--help" || $0 == "-h" }) { + guard dispatchSubcommandHelp(command: command, commandArgs: commandArgs) else { + throw CLIError(message: "No help is available for command: \(command)") + } + return + } + + try validateArguments(commandArgs, for: command, contract: descriptor.argumentContract) + let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) + + var resolvedSocketPathCache: String? + func resolveSocketPath() -> String { + if let resolvedSocketPathCache { return resolvedSocketPathCache } + let resolved = CLISocketPathResolver.resolve( + requestedPath: socketPath, + source: socketPathSource, + environment: processEnv + ) + resolvedSocketPathCache = resolved + return resolved + } if command == "version" { print(versionSummary()) @@ -1269,22 +1368,8 @@ struct ProgramaCLI { return } - // If the argument looks like a path (not a known command), open a workspace there. - if looksLikePath(command) { - try openPath(command, socketPath: resolvedSocketPath) - return - } - - // Check for --help/-h on subcommands before connecting to the socket, - // so help text is available even when programa is not running. - if command != "__tmux-compat", - command != "claude-teams", - command != "codex", - (commandArgs.contains("--help") || commandArgs.contains("-h")) { - if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) { - return - } - print("Unknown command '\(command)'. Run 'programa help' to see available commands.") + if command == "help" { + print(usage()) return } @@ -1296,7 +1381,7 @@ struct ProgramaCLI { if command == "shortcuts" { try runShortcuts( commandArgs: commandArgs, - socketPath: resolvedSocketPath, + socketPath: resolveSocketPath(), explicitPassword: socketPasswordArg, jsonOutput: jsonOutput ) @@ -1306,7 +1391,7 @@ struct ProgramaCLI { if command == "feedback" { try runFeedback( commandArgs: commandArgs, - socketPath: resolvedSocketPath, + socketPath: resolveSocketPath(), explicitPassword: socketPasswordArg, jsonOutput: jsonOutput ) @@ -1324,7 +1409,7 @@ struct ProgramaCLI { if command == "claude-teams" { try runClaudeTeams( commandArgs: commandArgs, - socketPath: resolvedSocketPath, + socketPath: resolveSocketPath(), explicitPassword: socketPasswordArg ) return @@ -1333,7 +1418,7 @@ struct ProgramaCLI { if command == "omo" { try runOMO( commandArgs: commandArgs, - socketPath: resolvedSocketPath, + socketPath: resolveSocketPath(), explicitPassword: socketPasswordArg ) return @@ -1342,7 +1427,7 @@ struct ProgramaCLI { if command == "omx" { try runOMX( commandArgs: commandArgs, - socketPath: resolvedSocketPath, + socketPath: resolveSocketPath(), explicitPassword: socketPasswordArg ) return @@ -1351,7 +1436,7 @@ struct ProgramaCLI { if command == "omc" { try runOMC( commandArgs: commandArgs, - socketPath: resolvedSocketPath, + socketPath: resolveSocketPath(), explicitPassword: socketPasswordArg ) return @@ -1378,6 +1463,15 @@ struct ProgramaCLI { } } + guard descriptor.connectionPolicy == .socket else { + throw CLIError(message: "Unsupported \(command) subcommand") + } + guard let execute = descriptor.execute else { + throw CLIError(message: "Command is unavailable: \(command)") + } + + let resolvedSocketPath = resolveSocketPath() + let client = SocketClient(path: resolvedSocketPath) try client.connect() defer { client.close() } @@ -1388,8 +1482,6 @@ struct ProgramaCLI { socketPath: resolvedSocketPath ) - let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) - // If the user explicitly targets a window, focus it first so commands route correctly. if let windowId { let normalizedWindow = try normalizeWindowHandle(windowId, client: client) ?? windowId @@ -1405,12 +1497,7 @@ struct ProgramaCLI { idFormatArgProvided: idFormatArg != nil, windowId: windowId ) - if let descriptor = commandDescriptor(named: command), let execute = descriptor.execute { - try execute(ctx) - } else { - print(usage()) - throw CLIError(message: "Unknown command: \(command)") - } + try execute(ctx) } /// Single source of truth for command existence, help text, and dispatch. @@ -1427,30 +1514,32 @@ struct ProgramaCLI { var descriptors: [CommandDescriptor] = [ // MARK: - Pre-connection specials (dispatched earlier in run(); // documented here only so usage() has one source for help text). - CommandDescriptor(names: ["welcome"], helpLines: ["welcome"], execute: nil), - CommandDescriptor(names: ["shortcuts"], helpLines: ["shortcuts"], execute: nil), + CommandDescriptor(names: ["welcome"], helpLines: ["welcome"], connectionPolicy: .local, execute: nil), + CommandDescriptor(names: ["shortcuts"], helpLines: ["shortcuts"], connectionPolicy: .local, execute: nil), CommandDescriptor( names: ["feedback"], helpLines: ["feedback [--email --body [--image ...]] (opens GitHub issues; direct submission disabled)"], + connectionPolicy: .local, execute: nil ), - CommandDescriptor(names: ["themes"], helpLines: ["themes [list|set|clear]"], execute: nil), - CommandDescriptor(names: ["claude-teams"], helpLines: ["claude-teams [claude-args...]"], execute: nil), - CommandDescriptor(names: ["omo"], helpLines: ["omo [opencode-args...]"], execute: nil), - CommandDescriptor(names: ["omx"], helpLines: ["omx [omx-args...]"], execute: nil), - CommandDescriptor(names: ["omc"], helpLines: ["omc [omc-args...]"], execute: nil), - CommandDescriptor(names: ["codex"], helpLines: ["codex "], execute: nil), + CommandDescriptor(names: ["themes"], helpLines: ["themes [list|set|clear]"], connectionPolicy: .local, execute: nil), + CommandDescriptor(names: ["claude-teams"], helpLines: ["claude-teams [claude-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), + CommandDescriptor(names: ["omo"], helpLines: ["omo [opencode-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), + CommandDescriptor(names: ["omx"], helpLines: ["omx [omx-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), + CommandDescriptor(names: ["omc"], helpLines: ["omc [omc-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), + CommandDescriptor(names: ["codex"], helpLines: ["codex "], connectionPolicy: .local, execute: nil), CommandDescriptor( names: ["ping"], helpLines: ["ping"], + argumentContract: .noArguments, execute: { ctx in _ = try ctx.client.sendV2(method: "system.ping") print("PONG") } ), - CommandDescriptor(names: ["version"], helpLines: ["version"], execute: nil), + CommandDescriptor(names: ["version"], helpLines: ["version"], connectionPolicy: .local, execute: nil), CommandDescriptor( names: ["capabilities"], @@ -2090,6 +2179,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["focus-panel"], helpLines: ["focus-panel --panel [--workspace ]"], + argumentContract: .focusPanel, execute: { ctx in let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) guard let panelRaw = self.optionValue(ctx.commandArgs, name: "--panel") else { @@ -2178,6 +2268,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["read-screen"], helpLines: ["read-screen [--workspace ] [--surface ] [--scrollback] [--lines ]"], + argumentContract: .readScreen, execute: { ctx in let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") @@ -2477,6 +2568,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["set-progress"], helpLines: [], + argumentContract: .setProgress, execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) guard let first = parsed.positional.first else { @@ -2541,6 +2633,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["list-log"], helpLines: [], + argumentContract: .listLog, execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) var params: [String: Any] = [:] @@ -2610,6 +2703,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["__tmux-compat"], helpLines: [], + helpPolicy: .passthrough, execute: { ctx in try self.runClaudeTeamsTmuxCompat( commandArgs: ctx.commandArgs, @@ -2760,6 +2854,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["help"], helpLines: ["help"], + connectionPolicy: .local, execute: { ctx in print(self.usage()) } @@ -3975,6 +4070,12 @@ struct ProgramaCLI { Show top-level CLI usage and command list. """ + case "codex": + return """ + Usage: programa codex + + Install or remove Programa's Codex notification hooks. + """ case "welcome": return """ Usage: programa welcome @@ -4830,6 +4931,552 @@ struct ProgramaCLI { return ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] } + /// Validates contracts whose failures can be determined without opening + /// the socket. Handlers retain their checks as a defensive boundary, but + /// malformed invocations now fail before they can affect a running app. + private func preflightFlagArguments( + _ args: [String], + command: String, + valueFlags: Set, + booleanFlags: Set = [], + allowEquals: Bool + ) throws -> (positional: [String], options: [String: String]) { + var positional: [String] = [] + var options: [String: String] = [:] + var pastTerminator = false + var index = 0 + + while index < args.count { + let token = args[index] + if pastTerminator { + positional.append(token) + index += 1 + continue + } + if token == "--" { + pastTerminator = true + index += 1 + continue + } + guard token.hasPrefix("--") else { + positional.append(token) + index += 1 + continue + } + + let body = String(token.dropFirst(2)) + let parts = body.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let name = String(parts[0]) + let hasEqualsValue = parts.count == 2 + + guard valueFlags.contains(name) || booleanFlags.contains(name) else { + throw CLIError(message: "\(command): unknown option --\(name)") + } + guard options[name] == nil else { + throw CLIError(message: "\(command): duplicate option --\(name)") + } + + if booleanFlags.contains(name) { + guard !hasEqualsValue else { + throw CLIError(message: "\(command): --\(name) does not take a value") + } + options[name] = "true" + index += 1 + continue + } + + if hasEqualsValue { + guard allowEquals else { + throw CLIError(message: "\(command): unexpected option syntax --\(name)=; use --\(name) ") + } + let value = String(parts[1]) + guard !value.isEmpty else { + throw CLIError(message: "\(command): --\(name) requires a value") + } + options[name] = value + index += 1 + continue + } + + guard index + 1 < args.count, !args[index + 1].hasPrefix("--") else { + throw CLIError(message: "\(command): --\(name) requires a value") + } + options[name] = args[index + 1] + index += 2 + } + return (positional, options) + } + + /// Exhaustive grammar table for commands that use the shared registry + /// contract. Keeping this switch total makes a newly registered command + /// fail closed before socket acquisition until its grammar is declared. + private func validateRegisteredArguments(_ args: [String], for command: String) throws { + func parse( + values: Set = [], + booleans: Set = [], + minPositionals: Int = 0, + maxPositionals: Int? = 0, + allowEquals: Bool = false + ) throws -> (positional: [String], options: [String: String]) { + let parsed = try preflightFlagArguments( + args, + command: command, + valueFlags: values, + booleanFlags: booleans, + allowEquals: allowEquals + ) + guard parsed.positional.count >= minPositionals else { + throw CLIError(message: "\(command): missing required argument") + } + if let maxPositionals, parsed.positional.count > maxPositionals { + throw CLIError(message: "\(command): unexpected arguments: \(parsed.positional.dropFirst(maxPositionals).joined(separator: " "))") + } + return parsed + } + + func require(_ names: [String], in options: [String: String]) throws { + for name in names where options[name] == nil { + throw CLIError(message: "\(command): --\(name) is required") + } + } + + switch command { + // Socket commands with no command-specific arguments. + case "capabilities", "list-windows", "current-window", "new-window", + "list-workspaces", "refresh-surfaces", "reload-config", "debug-terminals", + "current-workspace", "list-notifications", + "simulate-app-active": + _ = try parse() + + case "rpc": + let parsed = try parse(minPositionals: 1, maxPositionals: 2) + _ = try parseRPCParams(Array(parsed.positional.dropFirst())) + + case "identify": + _ = try parse(values: ["workspace", "surface"], booleans: ["no-caller"]) + + case "focus-window", "close-window": + let parsed = try parse(values: ["window"]) + try require(["window"], in: parsed.options) + guard let rawWindow = parsed.options["window"], isUUID(rawWindow.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CLIError(message: "\(command): invalid window id") + } + + case "move-workspace-to-window": + let parsed = try parse(values: ["workspace", "window"]) + try require(["workspace", "window"], in: parsed.options) + + case "reorder-workspace": + let parsed = try parse(values: ["workspace", "index", "before", "after", "window"]) + try require(["workspace"], in: parsed.options) + let anchors = ["index", "before", "after"].filter { parsed.options[$0] != nil } + guard anchors.count == 1 else { + throw CLIError(message: "reorder-workspace requires exactly one of --index, --before, or --after") + } + if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { + throw CLIError(message: "reorder-workspace: --index must be a nonnegative integer") + } + + case "workspace-action": + let parsed = try parse(values: ["action", "workspace", "title", "color", "description"], maxPositionals: nil) + guard let rawAction = parsed.options["action"] ?? parsed.positional.first else { + throw CLIError(message: "workspace-action requires --action ") + } + let trailing = parsed.options["action"] == nil ? Array(parsed.positional.dropFirst()) : parsed.positional + let action = rawAction.lowercased().replacingOccurrences(of: "-", with: "_") + if action == "rename", parsed.options["title"] == nil, trailing.isEmpty { + throw CLIError(message: "workspace-action rename requires a title") + } + if action == "set_color", parsed.options["color"] == nil, trailing.isEmpty { + throw CLIError(message: "workspace-action set-color requires a color") + } + if action == "set_description", parsed.options["description"] == nil, trailing.isEmpty { + throw CLIError(message: "workspace-action set-description requires a description") + } + + case "new-workspace": + _ = try parse(values: ["name", "description", "cwd", "command"]) + + case "new-split": + let parsed = try parse(values: ["workspace", "surface", "panel"], minPositionals: 1, maxPositionals: 1) + guard ["left", "right", "up", "down"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "new-split: direction must be left, right, up, or down") + } + + case "list-panes", "surface-health", "list-panels": + _ = try parse(values: ["workspace"]) + + case "list-pane-surfaces": + _ = try parse(values: ["workspace", "pane"]) + + case "focus-pane": + let parsed = try parse(values: ["workspace", "pane"], maxPositionals: 1) + guard parsed.options["pane"] != nil || parsed.positional.count == 1 else { + throw CLIError(message: "focus-pane requires --pane ") + } + guard !(parsed.options["pane"] != nil && !parsed.positional.isEmpty) else { + throw CLIError(message: "focus-pane: provide the pane once") + } + + case "new-pane": + let parsed = try parse(values: ["type", "direction", "workspace", "url"]) + if let type = parsed.options["type"], !["terminal", "browser"].contains(type.lowercased()) { + throw CLIError(message: "new-pane: --type must be terminal or browser") + } + if let direction = parsed.options["direction"], !["left", "right", "up", "down"].contains(direction.lowercased()) { + throw CLIError(message: "new-pane: invalid direction") + } + + case "new-surface": + let parsed = try parse(values: ["type", "pane", "workspace", "url"]) + if let type = parsed.options["type"], !["terminal", "browser"].contains(type.lowercased()) { + throw CLIError(message: "new-surface: --type must be terminal or browser") + } + + case "close-surface": + _ = try parse(values: ["surface", "panel", "workspace"]) + + case "move-surface": + let parsed = try parse(values: ["surface", "pane", "workspace", "window", "before", "before-surface", "after", "after-surface", "index", "focus"], maxPositionals: 1) + guard parsed.options["surface"] != nil || parsed.positional.count == 1 else { + throw CLIError(message: "move-surface requires --surface ") + } + guard !(parsed.options["surface"] != nil && !parsed.positional.isEmpty) else { + throw CLIError(message: "move-surface: provide the surface once") + } + if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { + throw CLIError(message: "move-surface: --index must be a nonnegative integer") + } + if let raw = parsed.options["focus"], !["true", "false", "1", "0", "yes", "no", "on", "off"].contains(raw.lowercased()) { + throw CLIError(message: "move-surface: --focus must be true or false") + } + let anchors = ["index", "before", "before-surface", "after", "after-surface"].filter { parsed.options[$0] != nil } + guard anchors.count <= 1 else { + throw CLIError(message: "move-surface accepts only one of --index, --before, or --after") + } + + case "reorder-surface": + let parsed = try parse(values: ["surface", "workspace", "index", "before", "before-surface", "after", "after-surface"], maxPositionals: 1) + guard parsed.options["surface"] != nil || parsed.positional.count == 1 else { + throw CLIError(message: "reorder-surface requires --surface ") + } + guard !(parsed.options["surface"] != nil && !parsed.positional.isEmpty) else { + throw CLIError(message: "reorder-surface: provide the surface once") + } + let anchors = ["index", "before", "before-surface", "after", "after-surface"].filter { parsed.options[$0] != nil } + guard anchors.count == 1 else { + throw CLIError(message: "reorder-surface requires exactly one of --index, --before, or --after") + } + if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { + throw CLIError(message: "reorder-surface: --index must be a nonnegative integer") + } + + case "tab-action": + let parsed = try parse(values: ["action", "tab", "surface", "workspace", "title", "url"], maxPositionals: nil) + guard parsed.options["action"] != nil || !parsed.positional.isEmpty else { + throw CLIError(message: "tab-action requires --action ") + } + + case "rename-tab": + let parsed = try parse(values: ["workspace", "tab", "surface", "title"], maxPositionals: nil) + guard parsed.options["title"] != nil || !parsed.positional.isEmpty else { + throw CLIError(message: "rename-tab requires a title") + } + + case "drag-surface-to-split": + let parsed = try parse(values: ["surface", "panel"], minPositionals: 1, maxPositionals: 1) + guard (parsed.options["surface"] != nil) != (parsed.options["panel"] != nil) else { + throw CLIError(message: "drag-surface-to-split requires exactly one of --surface or --panel") + } + guard ["left", "right", "up", "down"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "drag-surface-to-split: invalid direction") + } + + case "trigger-flash": + _ = try parse(values: ["workspace", "surface", "panel"]) + + case "close-workspace", "select-workspace": + let parsed = try parse(values: ["workspace"]) + try require(["workspace"], in: parsed.options) + + case "rename-workspace", "rename-window": + _ = try parse(values: ["workspace"], minPositionals: 1, maxPositionals: nil) + + case "send": + _ = try parse(values: ["workspace", "surface"], minPositionals: 1, maxPositionals: nil) + + case "send-key": + _ = try parse(values: ["workspace", "surface"], minPositionals: 1, maxPositionals: 1) + + case "send-panel": + let parsed = try parse(values: ["panel", "workspace"], minPositionals: 1, maxPositionals: nil) + try require(["panel"], in: parsed.options) + + case "send-key-panel": + let parsed = try parse(values: ["panel", "workspace"], minPositionals: 1, maxPositionals: 1) + try require(["panel"], in: parsed.options) + + case "notify": + _ = try parse(values: ["title", "subtitle", "body", "workspace", "surface"]) + + case "clear-notifications": + _ = try parse(values: ["workspace"]) + + case "set-status": + let parsed = try parse( + values: ["icon", "color", "url", "link", "priority", "format", "pid", "workspace"], + minPositionals: 2, + maxPositionals: nil, + allowEquals: true + ) + if let priority = parsed.options["priority"], Int(priority) == nil { + throw CLIError(message: "set-status: --priority must be an integer") + } + if let format = parsed.options["format"], !["plain", "markdown", "md"].contains(format.lowercased()) { + throw CLIError(message: "set-status: --format must be plain or markdown") + } + if let pid = parsed.options["pid"], (Int(pid) ?? 0) <= 0 { + throw CLIError(message: "set-status: --pid must be a positive integer") + } + + case "clear-status": + _ = try parse(values: ["workspace"], minPositionals: 1, maxPositionals: 1, allowEquals: true) + + case "list-status", "clear-progress", "clear-log", "sidebar-state": + _ = try parse(values: ["workspace"], allowEquals: true) + + case "log": + let parsed = try parse(values: ["level", "source", "workspace"], minPositionals: 1, maxPositionals: nil, allowEquals: true) + if let level = parsed.options["level"], !["info", "progress", "success", "warning", "error"].contains(level) { + throw CLIError(message: "log: invalid --level value") + } + + case "set-app-focus": + let parsed = try parse(minPositionals: 1, maxPositionals: 1) + guard ["active", "inactive", "clear", "1", "0", "true", "false", "none"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "set-app-focus: invalid state") + } + + case "tree": + _ = try parse(values: ["workspace"], booleans: ["all"]) + + case "markdown": + let parsed = try parse(minPositionals: 1, maxPositionals: 2) + if parsed.positional.count == 2, parsed.positional[0].lowercased() != "open" { + throw CLIError(message: "markdown: unexpected subcommand \(parsed.positional[0])") + } + + case "ssh": + try validateSSHCommandArguments(args) + + case "ssh-session-end": + let parsed = try parse(values: ["relay-port", "workspace", "surface"]) + try require(["relay-port"], in: parsed.options) + guard let rawPort = parsed.options["relay-port"], let port = Int(rawPort), port > 0, port <= 65535 else { + throw CLIError(message: "ssh-session-end: --relay-port must be 1-65535") + } + if parsed.options["workspace"] == nil, + ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] == nil { + throw CLIError(message: "ssh-session-end requires --workspace or PROGRAMA_WORKSPACE_ID") + } + if parsed.options["surface"] == nil, + ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] == nil { + throw CLIError(message: "ssh-session-end requires --surface or PROGRAMA_SURFACE_ID") + } + + case "claude-hook": + let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1) + if let subcommand = parsed.positional.first?.lowercased(), + !["session-start", "active", "stop", "idle", "prompt-submit", "notification", "notify", "session-end", "pre-tool-use", "help", "--help", "-h"].contains(subcommand) { + throw CLIError(message: "claude-hook: unknown event \(subcommand)") + } + + case "codex-hook": + guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { return } + let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1) + if let subcommand = parsed.positional.first?.lowercased(), + !["session-start", "prompt-submit", "stop", "help", "--help", "-h"].contains(subcommand) { + throw CLIError(message: "codex-hook: unknown event \(subcommand)") + } + + case "capture-pane": + let parsed = try parse(values: ["workspace", "surface", "lines"], booleans: ["scrollback"]) + if let lines = parsed.options["lines"], (Int(lines) ?? 0) <= 0 { + throw CLIError(message: "capture-pane: --lines must be greater than 0") + } + case "resize-pane": + let parsed = try parse(values: ["pane", "workspace", "amount"], minPositionals: 1, maxPositionals: 1) + try require(["pane"], in: parsed.options) + guard ["-L", "-R", "-U", "-D"].contains(parsed.positional[0]) else { + throw CLIError(message: "resize-pane requires -L, -R, -U, or -D") + } + if let amount = parsed.options["amount"], (Int(amount) ?? 0) <= 0 { + throw CLIError(message: "resize-pane: --amount must be greater than 0") + } + case "pipe-pane": + let parsed = try parse(values: ["command", "workspace", "surface"], maxPositionals: nil) + guard parsed.options["command"] != nil || !parsed.positional.isEmpty else { + throw CLIError(message: "pipe-pane requires --command ") + } + case "wait-for": + let parsed = try parse(values: ["timeout"], booleans: ["signal"], minPositionals: 1, maxPositionals: 2) + let positionals = parsed.positional.filter { $0 != "-S" } + guard positionals.count == 1 else { throw CLIError(message: "wait-for requires one name") } + if let timeout = parsed.options["timeout"] { + guard let seconds = Double(timeout), seconds.isFinite, seconds >= 0 else { + throw CLIError(message: "wait-for: --timeout must be finite and nonnegative") + } + } + case "swap-pane": + let parsed = try parse(values: ["pane", "target-pane", "workspace"]) + try require(["pane", "target-pane"], in: parsed.options) + case "break-pane": + _ = try parse(values: ["workspace", "pane", "surface"], booleans: ["no-focus"]) + case "join-pane": + let parsed = try parse(values: ["target-pane", "workspace", "pane", "surface"], booleans: ["no-focus"]) + try require(["target-pane"], in: parsed.options) + case "next-window", "previous-window", "last-window", "list-buffers": + _ = try parse() + case "last-pane": + _ = try parse(values: ["workspace"]) + case "find-window": + _ = try parse(booleans: ["content", "select"], minPositionals: 1, maxPositionals: nil) + case "clear-history": + _ = try parse(values: ["workspace", "surface"]) + case "set-hook": + let parsed = try parse(values: ["unset"], booleans: ["list"], maxPositionals: nil) + if parsed.options["list"] == nil, parsed.options["unset"] == nil, parsed.positional.count < 2 { + throw CLIError(message: "set-hook requires ") + } + case "popup", "bind-key", "unbind-key", "copy-mode": + throw CLIError(message: "\(command) is not supported yet in programa CLI parity mode") + case "set-buffer": + _ = try parse(values: ["name"], minPositionals: 1, maxPositionals: nil) + case "paste-buffer": + _ = try parse(values: ["name", "workspace", "surface"]) + case "respawn-pane": + _ = try parse(values: ["workspace", "surface", "command"]) + case "display-message": + let parsed = try parse(booleans: ["print"], minPositionals: 1, maxPositionals: nil) + guard parsed.positional.filter({ $0 != "-p" }).isEmpty == false else { + throw CLIError(message: "display-message requires text") + } + + // These commands own nested or foreign grammars. Their handlers do + // full parsing; flags must remain byte-for-byte passthrough here. + case "__tmux-compat", + "browser", "open-browser", + "navigate", "browser-back", "browser-forward", "browser-reload", "get-url", + "focus-webview", "is-webview-focused": + return + + // Local commands are registered for unified lookup/help, but do not + // acquire the app socket through the generic dispatcher. + case "welcome", "version", "help": + _ = try parse() + case "shortcuts", "feedback", "themes", "claude-teams", "omo", "omx", "omc": + return + case "codex": + let parsed = try parse(minPositionals: 1, maxPositionals: 1) + guard ["install-hooks", "uninstall-hooks"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "codex: expected install-hooks or uninstall-hooks") + } + case "remote-daemon-status": + let parsed = try parse(values: ["os", "arch"]) + if let os = parsed.options["os"], !["darwin", "linux"].contains(os.lowercased()) { + throw CLIError(message: "remote-daemon-status: unsupported --os value") + } + if let arch = parsed.options["arch"], !["arm64", "amd64"].contains(arch.lowercased()) { + throw CLIError(message: "remote-daemon-status: unsupported --arch value") + } + + // Commands with richer bespoke contracts are validated by their + // dedicated cases in `validateArguments`. + case "ping", "focus-panel", "read-screen", "set-progress", "list-log": + return + + default: + throw CLIError(message: "Internal CLI registry error: no argument contract for \(command)") + } + } + + private func validateArguments( + _ args: [String], + for command: String, + contract: CLICommandArgumentContract + ) throws { + switch contract { + case .registered: + try validateRegisteredArguments(args, for: command) + + case .noArguments: + guard args.isEmpty else { + throw CLIError(message: "\(command): unexpected arguments: \(args.joined(separator: " "))") + } + + case .focusPanel: + let parsed = try preflightFlagArguments( + args, + command: command, + valueFlags: ["panel", "workspace"], + allowEquals: false + ) + guard parsed.positional.isEmpty else { + throw CLIError(message: "focus-panel: unexpected arguments: \(parsed.positional.joined(separator: " "))") + } + guard parsed.options["panel"] != nil else { + throw CLIError(message: "focus-panel requires --panel ") + } + + case .readScreen: + let parsed = try preflightFlagArguments( + args, + command: command, + valueFlags: ["workspace", "surface", "lines"], + booleanFlags: ["scrollback"], + allowEquals: false + ) + guard parsed.positional.isEmpty else { + throw CLIError(message: "read-screen: unexpected arguments: \(parsed.positional.joined(separator: " "))") + } + if let lines = parsed.options["lines"] { + guard let count = Int(lines), count > 0 else { + throw CLIError(message: "read-screen: --lines must be greater than 0") + } + } + + case .setProgress: + let parsed = try preflightFlagArguments( + args, + command: command, + valueFlags: ["label", "workspace"], + allowEquals: true + ) + guard parsed.positional.count == 1, let rawValue = parsed.positional.first else { + throw CLIError(message: "set-progress requires a progress value") + } + guard let value = Double(rawValue), value.isFinite, (0.0...1.0).contains(value) else { + throw CLIError(message: "set-progress: invalid progress value '\(rawValue)'; must be between 0.0 and 1.0") + } + + case .listLog: + let parsed = try preflightFlagArguments( + args, + command: command, + valueFlags: ["limit", "workspace"], + allowEquals: true + ) + guard parsed.positional.isEmpty else { + throw CLIError(message: "list-log: unexpected arguments: \(parsed.positional.joined(separator: " "))") + } + if let rawLimit = parsed.options["limit"] { + guard let limit = Int(rawLimit), limit >= 0 else { + throw CLIError(message: "list-log: invalid limit '\(rawLimit)'; must be >= 0") + } + } + } + } + /// Parses CLI-style flags (`--name value` or `--name=value`) into positional args + an /// options dict, mirroring the v1 server-side `parseOptions`/`parseOptionsNoStop` grammar /// so v2-backed sidebar commands can rebuild the same structured params client-side. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8cdaaa1fd7..467bcc35cae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ zig build -Demit-xcframework=true -Doptimize=ReleaseFast ### Basic tests (run on VM) ```bash -ssh programa-vm 'cd /Users/programa/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug -destination "platform=macOS" build && pkill -x "programa DEV" || true && APP=$(find /Users/programa/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/programa DEV.app" -print -quit) && open "$APP" && for i in {1..20}; do [ -S /tmp/programa.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py' +ssh programa-vm 'cd /Users/programa/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug -destination "platform=macOS" build && pkill -x "programa DEV" || true && APP=$(find /Users/programa/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/programa DEV.app" -print -quit) && open "$APP" && for i in {1..20}; do [ -S /tmp/programa.sock ] && break; sleep 0.5; done && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py' ``` ### UI tests (run on VM) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9029452ad01..28305026ebf 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; + MDRD00000000000000000001 /* MarkdownDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = MDRD00000000000000000002 /* MarkdownDocumentView.swift */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; @@ -363,6 +364,7 @@ A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; + MDRD00000000000000000002 /* MarkdownDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownDocumentView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; NRWS00000000000000000027 /* Workspace+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Persistence.swift"; sourceTree = ""; }; NRWS00000000000000000029 /* Workspace+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Layout.swift"; sourceTree = ""; }; @@ -575,6 +577,7 @@ /* Begin PBXShellScriptBuildPhase section */ A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -586,6 +589,7 @@ ); outputPaths = ( ); + name = "Copy Ghostty Resources"; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :ProgramaCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :ProgramaCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; @@ -724,6 +728,7 @@ A5007421 /* BrowserPopupWindowController.swift */, A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, + MDRD00000000000000000002 /* MarkdownDocumentView.swift */, A5001510 /* ProgramaWebView.swift */, A5001415 /* PanelContentView.swift */, A5001211 /* UpdateController.swift */, @@ -973,13 +978,11 @@ Base, ); mainGroup = A5001040; - packageReferences = ( - A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */, - A5001252 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, - A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */, - A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, - A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, - ); + packageReferences = ( + A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */, + A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, + ); productRefGroup = A5001042 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1109,6 +1112,7 @@ A5007420 /* BrowserPopupWindowController.swift in Sources */, A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, + MDRD00000000000000000001 /* MarkdownDocumentView.swift in Sources */, A5001500 /* ProgramaWebView.swift in Sources */, A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, @@ -1314,6 +1318,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug"; + CLANG_WARN_INCOMPLETE_UMBRELLA = NO; CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -1353,6 +1358,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_WARN_INCOMPLETE_UMBRELLA = NO; CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -1555,21 +1561,21 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.5.1; + minimumVersion = 2.9.4; }; }; - A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.4.1; - }; - }; - A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = vendor/bonsplit; + A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; }; + }; + A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = vendor/bonsplit; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1578,16 +1584,16 @@ package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - A5001261 /* Bonsplit */ = { - isa = XCSwiftPackageProductDependency; - package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; - productName = Bonsplit; - }; - A5001291 /* MarkdownUI */ = { - isa = XCSwiftPackageProductDependency; - package = A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; - productName = MarkdownUI; - }; + A5001261 /* Bonsplit */ = { + isa = XCSwiftPackageProductDependency; + package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; + productName = Bonsplit; + }; + A5001291 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCConfigurationList section */ diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7e79c2f26ac..fcb869769f9 100644 --- a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", + "version" : "2.9.4" } }, { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4fda42eab4c..d8c1a38f7de 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -3950,6 +3950,1007 @@ } } }, + "workspace.emptyPanel.title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Empty Panel"}}, + "ja": {"stringUnit": {"state": "translated", "value": "空のパネル"}} + } + }, + "workspace.emptyPanel.terminal": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Terminal"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ターミナル"}} + } + }, + "workspace.emptyPanel.browser": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Browser"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ブラウザ"}} + } + }, + "browser.omnibar.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Browser omnibar"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ブラウザのアドレスバー"}} + } + }, + "debug.updatePill.menu": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Update Pill"}}, + "ja": {"stringUnit": {"state": "translated", "value": "更新ピル"}} + } + }, + "debug.updatePill.show": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Show Update Pill"}}, + "ja": {"stringUnit": {"state": "translated", "value": "更新ピルを表示"}} + } + }, + "debug.updatePill.showLongNightly": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Show Long Nightly Pill"}}, + "ja": {"stringUnit": {"state": "translated", "value": "長いNightly更新ピルを表示"}} + } + }, + "debug.updatePill.showLoading": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Show Loading State"}}, + "ja": {"stringUnit": {"state": "translated", "value": "読み込み状態を表示"}} + } + }, + "debug.updatePill.hide": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Hide Update Pill"}}, + "ja": {"stringUnit": {"state": "translated", "value": "更新ピルを非表示"}} + } + }, + "debug.updatePill.automatic": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Automatic Update Pill"}}, + "ja": {"stringUnit": {"state": "translated", "value": "更新ピルを自動表示"}} + } + }, + "debug.menu.title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Debug"}}, + "ja": {"stringUnit": {"state": "translated", "value": "デバッグ"}} + } + }, + "debug.menu.newLoremTab": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "New Tab With Lorem Search Text"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Lorem検索テキスト付きの新規タブ"}} + } + }, + "debug.menu.newLargeScrollbackTab": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "New Tab With Large Scrollback"}}, + "ja": {"stringUnit": {"state": "translated", "value": "大きなスクロールバック付きの新規タブ"}} + } + }, + "debug.menu.openWorkspaceColors": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Open Workspaces for All Workspace Colors"}}, + "ja": {"stringUnit": {"state": "translated", "value": "すべてのワークスペース色を開く"}} + } + }, + "debug.menu.windows": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Debug Windows"}}, + "ja": {"stringUnit": {"state": "translated", "value": "デバッグウインドウ"}} + } + }, + "debug.menu.background": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Background Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "背景デバッグ…"}} + } + }, + "debug.menu.windowControls": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Debug Window Controls…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "デバッグウインドウコントロール…"}} + } + }, + "debug.menu.menuBarExtra": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Menu Bar Extra Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "メニューバー追加項目デバッグ…"}} + } + }, + "debug.menu.settingsAboutTitlebar": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Settings/About Titlebar Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定/情報タイトルバーデバッグ…"}} + } + }, + "debug.menu.sidebar": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sidebar Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "サイドバーデバッグ…"}} + } + }, + "debug.menu.splitButtonLayout": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Split Button Layout Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "分割ボタンレイアウトデバッグ…"}} + } + }, + "debug.menu.openAllWindows": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Open All Debug Windows"}}, + "ja": {"stringUnit": {"state": "translated", "value": "すべてのデバッグウインドウを開く"}} + } + }, + "debug.shortcutHints.alwaysShow": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Always Show Shortcut Hints"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ショートカットヒントを常に表示"}} + } + }, + "debug.titlebarControls.style": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Titlebar Controls Style"}}, + "ja": {"stringUnit": {"state": "translated", "value": "タイトルバーコントロールのスタイル"}} + } + }, + "Actions": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Actions"}}, + "ja": {"stringUnit": {"state": "translated", "value": "操作"}} + } + }, + "Active Workspace Indicator": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Active Workspace Indicator"}}, + "ja": {"stringUnit": {"state": "translated", "value": "アクティブワークスペースインジケータ"}} + } + }, + "Always show shortcut hints": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Always show shortcut hints"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ショートカットヒントを常に表示"}} + } + }, + "Apply Now": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Apply Now"}}, + "ja": {"stringUnit": {"state": "translated", "value": "今すぐ適用"}} + } + }, + "Background Debug": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Background Debug"}}, + "ja": {"stringUnit": {"state": "translated", "value": "背景デバッグ"}} + } + }, + "Background Debug…": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Background Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "背景デバッグ…"}} + } + }, + "Badge Rect": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Badge Rect"}}, + "ja": {"stringUnit": {"state": "translated", "value": "バッジ領域"}} + } + }, + "Badge Text": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Badge Text"}}, + "ja": {"stringUnit": {"state": "translated", "value": "バッジテキスト"}} + } + }, + "Blending": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Blending"}}, + "ja": {"stringUnit": {"state": "translated", "value": "合成"}} + } + }, + "Blur": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Blur"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ぼかし"}} + } + }, + "Browser DevTools Button": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Browser DevTools Button"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ブラウザ開発者ツールボタン"}} + } + }, + "Button Backdrop Color": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Button Backdrop Color"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ボタン背景色"}} + } + }, + "Changes apply live.": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Changes apply live."}}, + "ja": {"stringUnit": {"state": "translated", "value": "変更はリアルタイムで適用されます。"}} + } + }, + "Closable": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Closable"}}, + "ja": {"stringUnit": {"state": "translated", "value": "閉じることが可能"}} + } + }, + "Color": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Color"}}, + "ja": {"stringUnit": {"state": "translated", "value": "色"}} + } + }, + "Copies sidebar, background, menu bar, and browser devtools settings as one payload.": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Copies sidebar, background, menu bar, and browser devtools settings as one payload."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Copies sidebar, background, menu bar, and browser devtools settings as one payload."}} + } + }, + "Copy All Debug Config": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Copy All Debug Config"}}, + "ja": {"stringUnit": {"state": "translated", "value": "すべてのデバッグ設定をコピー"}} + } + }, + "Copy Button Config": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Copy Button Config"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ボタン設定をコピー"}} + } + }, + "Copy Config": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Copy Config"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定をコピー"}} + } + }, + "Copy Hint Config": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Copy Hint Config"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ヒント設定をコピー"}} + } + }, + "Copy": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Copy"}}, + "ja": {"stringUnit": {"state": "translated", "value": "コピー"}} + } + }, + "Corner Radius": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Corner Radius"}}, + "ja": {"stringUnit": {"state": "translated", "value": "角の半径"}} + } + }, + "Debug Window Controls": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Debug Window Controls"}}, + "ja": {"stringUnit": {"state": "translated", "value": "デバッグウインドウコントロール"}} + } + }, + "Enable Debug Overrides": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Enable Debug Overrides"}}, + "ja": {"stringUnit": {"state": "translated", "value": "デバッグ上書きを有効にする"}} + } + }, + "Enable Glass Effect": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Enable Glass Effect"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ガラス効果を有効にする"}} + } + }, + "Full Size Content View": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Full Size Content View"}}, + "ja": {"stringUnit": {"state": "translated", "value": "フルサイズコンテンツビュー"}} + } + }, + "Glass Effect": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Glass Effect"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ガラス効果"}} + } + }, + "HUD Window": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "HUD Window"}}, + "ja": {"stringUnit": {"state": "translated", "value": "HUDウインドウ"}} + } + }, + "Icon": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Icon"}}, + "ja": {"stringUnit": {"state": "translated", "value": "アイコン"}} + } + }, + "Leading extra": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Leading extra"}}, + "ja": {"stringUnit": {"state": "translated", "value": "先頭の追加間隔"}} + } + }, + "Material": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Material"}}, + "ja": {"stringUnit": {"state": "translated", "value": "マテリアル"}} + } + }, + "Menu Bar Extra Debug": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Menu Bar Extra Debug"}}, + "ja": {"stringUnit": {"state": "translated", "value": "メニューバー追加項目デバッグ"}} + } + }, + "Menu Bar Extra Debug…": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Menu Bar Extra Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "メニューバー追加項目デバッグ…"}} + } + }, + "Menu Bar Extra Icon": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Menu Bar Extra Icon"}}, + "ja": {"stringUnit": {"state": "translated", "value": "メニューバー追加アイコン"}} + } + }, + "Menu": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Menu"}}, + "ja": {"stringUnit": {"state": "translated", "value": "メニュー"}} + } + }, + "Miniaturizable": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Miniaturizable"}}, + "ja": {"stringUnit": {"state": "translated", "value": "最小化可能"}} + } + }, + "Movable by Window Background": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Movable by Window Background"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ウインドウ背景で移動可能"}} + } + }, + "Opacity": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Opacity"}}, + "ja": {"stringUnit": {"state": "translated", "value": "不透明度"}} + } + }, + "Open All Debug Windows": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Open All Debug Windows"}}, + "ja": {"stringUnit": {"state": "translated", "value": "すべてのデバッグウインドウを開く"}} + } + }, + "Open": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Open"}}, + "ja": {"stringUnit": {"state": "translated", "value": "開く"}} + } + }, + "Override unread count": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Override unread count"}}, + "ja": {"stringUnit": {"state": "translated", "value": "未読数を上書き"}} + } + }, + "Popover": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Popover"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ポップオーバー"}} + } + }, + "Preset": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Preset"}}, + "ja": {"stringUnit": {"state": "translated", "value": "プリセット"}} + } + }, + "Presets": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Presets"}}, + "ja": {"stringUnit": {"state": "translated", "value": "プリセット"}} + } + }, + "Preview Count": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Preview Count"}}, + "ja": {"stringUnit": {"state": "translated", "value": "件数プレビュー"}} + } + }, + "Preview": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Preview"}}, + "ja": {"stringUnit": {"state": "translated", "value": "プレビュー"}} + } + }, + "Reapply to Open Windows": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reapply to Open Windows"}}, + "ja": {"stringUnit": {"state": "translated", "value": "開いているウインドウに再適用"}} + } + }, + "Reset (0)": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset (0)"}}, + "ja": {"stringUnit": {"state": "translated", "value": "リセット(0)"}} + } + }, + "Reset Active Indicator": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Active Indicator"}}, + "ja": {"stringUnit": {"state": "translated", "value": "アクティブインジケータをリセット"}} + } + }, + "Reset All": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset All"}}, + "ja": {"stringUnit": {"state": "translated", "value": "すべてリセット"}} + } + }, + "Reset Blur": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Blur"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ぼかしをリセット"}} + } + }, + "Reset Button": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Button"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ボタンをリセット"}} + } + }, + "Reset Hints": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Hints"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ヒントをリセット"}} + } + }, + "Reset Indicator Style": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Indicator Style"}}, + "ja": {"stringUnit": {"state": "translated", "value": "インジケータスタイルをリセット"}} + } + }, + "Reset Shape": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Shape"}}, + "ja": {"stringUnit": {"state": "translated", "value": "形状をリセット"}} + } + }, + "Reset Tint": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Tint"}}, + "ja": {"stringUnit": {"state": "translated", "value": "色合いをリセット"}} + } + }, + "Reset": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset"}}, + "ja": {"stringUnit": {"state": "translated", "value": "リセット"}} + } + }, + "Resizable": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Resizable"}}, + "ja": {"stringUnit": {"state": "translated", "value": "サイズ変更可能"}} + } + }, + "Settings/About Titlebar Debug": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Settings/About Titlebar Debug"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定/情報タイトルバーデバッグ"}} + } + }, + "Settings/About Titlebar Debug…": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Settings/About Titlebar Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定/情報タイトルバーデバッグ…"}} + } + }, + "Shape": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Shape"}}, + "ja": {"stringUnit": {"state": "translated", "value": "形状"}} + } + }, + "Shortcut Hints": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Shortcut Hints"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ショートカットヒント"}} + } + }, + "Show Toolbar": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Show Toolbar"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ツールバーを表示"}} + } + }, + "Sidebar Appearance": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sidebar Appearance"}}, + "ja": {"stringUnit": {"state": "translated", "value": "サイドバーの外観"}} + } + }, + "Sidebar Debug": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sidebar Debug"}}, + "ja": {"stringUnit": {"state": "translated", "value": "サイドバーデバッグ"}} + } + }, + "Sidebar Debug…": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sidebar Debug…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "サイドバーデバッグ…"}} + } + }, + "Sidebar": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sidebar"}}, + "ja": {"stringUnit": {"state": "translated", "value": "サイドバー"}} + } + }, + "Split Button Layout": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Split Button Layout"}}, + "ja": {"stringUnit": {"state": "translated", "value": "分割ボタンレイアウト"}} + } + }, + "State": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "State"}}, + "ja": {"stringUnit": {"state": "translated", "value": "状態"}} + } + }, + "Strength": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Strength"}}, + "ja": {"stringUnit": {"state": "translated", "value": "強度"}} + } + }, + "Style Mask": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Style Mask"}}, + "ja": {"stringUnit": {"state": "translated", "value": "スタイルマスク"}} + } + }, + "Style": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Style"}}, + "ja": {"stringUnit": {"state": "translated", "value": "スタイル"}} + } + }, + "Tint Color": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Tint Color"}}, + "ja": {"stringUnit": {"state": "translated", "value": "色合い"}} + } + }, + "Tint changes apply live. Enable/disable requires reload.": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Tint changes apply live. Enable/disable requires reload."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Tint changes apply live. Enable/disable requires reload."}} + } + }, + "Tint": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Tint"}}, + "ja": {"stringUnit": {"state": "translated", "value": "色合い"}} + } + }, + "Tip: enable override count, then tune until the menu bar icon looks right.": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Tip: enable override count, then tune until the menu bar icon looks right."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Tip: enable override count, then tune until the menu bar icon looks right."}} + } + }, + "Title Visibility": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Title Visibility"}}, + "ja": {"stringUnit": {"state": "translated", "value": "タイトルの表示"}} + } + }, + "Titlebar Spacing": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Titlebar Spacing"}}, + "ja": {"stringUnit": {"state": "translated", "value": "タイトルバーの間隔"}} + } + }, + "Titled": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Titled"}}, + "ja": {"stringUnit": {"state": "translated", "value": "タイトル付き"}} + } + }, + "Toolbar Style": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Toolbar Style"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ツールバースタイル"}} + } + }, + "Transparent Titlebar": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Transparent Titlebar"}}, + "ja": {"stringUnit": {"state": "translated", "value": "透明なタイトルバー"}} + } + }, + "Under Window": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Under Window"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ウインドウの下"}} + } + }, + "Unread Count": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Unread Count"}}, + "ja": {"stringUnit": {"state": "translated", "value": "未読数"}} + } + }, + "When disabled, Programa uses normal default titlebar behavior for this window.": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "When disabled, Programa uses normal default titlebar behavior for this window."}}, + "ja": {"stringUnit": {"state": "translated", "value": "When disabled, Programa uses normal default titlebar behavior for this window."}} + } + }, + "Window Background Glass": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Window Background Glass"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ウインドウ背景のガラス"}} + } + }, + "Window Title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Window Title"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ウインドウタイトル"}} + } + }, + "Settings Window": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Settings Window"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定ウインドウ"}} + } + }, + "About Window": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "About Window"}}, + "ja": {"stringUnit": {"state": "translated", "value": "情報ウインドウ"}} + } + }, + "Settings": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Settings"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定"}} + } + }, + "About Programa": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "About Programa"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Programaについて"}} + } + }, + "Hidden": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Hidden"}}, + "ja": {"stringUnit": {"state": "translated", "value": "非表示"}} + } + }, + "Visible": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Visible"}}, + "ja": {"stringUnit": {"state": "translated", "value": "表示"}} + } + }, + "Automatic": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Automatic"}}, + "ja": {"stringUnit": {"state": "translated", "value": "自動"}} + } + }, + "Expanded": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Expanded"}}, + "ja": {"stringUnit": {"state": "translated", "value": "展開"}} + } + }, + "Preference": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Preference"}}, + "ja": {"stringUnit": {"state": "translated", "value": "環境設定"}} + } + }, + "Unified": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Unified"}}, + "ja": {"stringUnit": {"state": "translated", "value": "統合"}} + } + }, + "Unified Compact": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Unified Compact"}}, + "ja": {"stringUnit": {"state": "translated", "value": "統合コンパクト"}} + } + }, + "Reset Settings": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset Settings"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定をリセット"}} + } + }, + "Reset About": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Reset About"}}, + "ja": {"stringUnit": {"state": "translated", "value": "情報をリセット"}} + } + }, + "Sidebar Cmd+1…9": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sidebar Cmd+1…9"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Sidebar Cmd+1…9"}} + } + }, + "Titlebar Buttons": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Titlebar Buttons"}}, + "ja": {"stringUnit": {"state": "translated", "value": "タイトルバーボタン"}} + } + }, + "Pane Ctrl/Cmd+1…9": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Pane Ctrl/Cmd+1…9"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Pane Ctrl/Cmd+1…9"}} + } + }, + "X": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "X"}}, + "ja": {"stringUnit": {"state": "translated", "value": "X"}} + } + }, + "Y": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Y"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Y"}} + } + }, + "Width": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Width"}}, + "ja": {"stringUnit": {"state": "translated", "value": "幅"}} + } + }, + "Height": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Height"}}, + "ja": {"stringUnit": {"state": "translated", "value": "高さ"}} + } + }, + "1-digit size": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "1-digit size"}}, + "ja": {"stringUnit": {"state": "translated", "value": "1桁のサイズ"}} + } + }, + "2-digit size": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "2-digit size"}}, + "ja": {"stringUnit": {"state": "translated", "value": "2桁のサイズ"}} + } + }, + "1-digit X": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "1-digit X"}}, + "ja": {"stringUnit": {"state": "translated", "value": "1-digit X"}} + } + }, + "2-digit X": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "2-digit X"}}, + "ja": {"stringUnit": {"state": "translated", "value": "2-digit X"}} + } + }, + "1-digit Y": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "1-digit Y"}}, + "ja": {"stringUnit": {"state": "translated", "value": "1-digit Y"}} + } + }, + "2-digit Y": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "2-digit Y"}}, + "ja": {"stringUnit": {"state": "translated", "value": "2-digit Y"}} + } + }, + "Text width adjust": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Text width adjust"}}, + "ja": {"stringUnit": {"state": "translated", "value": "テキスト幅の調整"}} + } + }, + "Pre-composited paneBackground": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Pre-composited paneBackground"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Pre-composited paneBackground"}} + } + }, + "Raw paneBackground (opaque)": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Raw paneBackground (opaque)"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Raw paneBackground (opaque)"}} + } + }, + "barBackground (tab chrome)": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "barBackground (tab chrome)"}}, + "ja": {"stringUnit": {"state": "translated", "value": "barBackground (tab chrome)"}} + } + }, + "windowBackgroundColor": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "windowBackgroundColor"}}, + "ja": {"stringUnit": {"state": "translated", "value": "windowBackgroundColor"}} + } + }, + "controlBackgroundColor": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "controlBackgroundColor"}}, + "ja": {"stringUnit": {"state": "translated", "value": "controlBackgroundColor"}} + } + }, + "Pre-composited barBackground": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Pre-composited barBackground"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Pre-composited barBackground"}} + } + }, "debug.browserProfilePopover.group.padding": { "extractionState": "manual", "localizations": { @@ -8225,6 +9226,41 @@ } } }, + "update.error.stillStarting": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Updater is still starting. Try again in a moment."}}, + "ja": {"stringUnit": {"state": "translated", "value": "アップデーターはまだ起動中です。しばらくしてからもう一度お試しください。"}} + } + }, + "remoteDaemon.error.missingVerifiedManifest": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "This build does not include a verified programad-remote manifest for %@-%@. Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback."}}, + "ja": {"stringUnit": {"state": "translated", "value": "このビルドには%@-%@用の検証済みprogramad-remoteマニフェストが含まれていません。リリースビルドを使用するか、開発専用のフォールバックとしてPROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1を設定してください。"}} + } + }, + "remoteDaemon.error.repoRootNotFound": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Cannot locate the Programa repository root for the development-only programad-remote build fallback."}}, + "ja": {"stringUnit": {"state": "translated", "value": "開発専用のprogramad-remoteビルドフォールバック用のProgramaリポジトリルートが見つかりません。"}} + } + }, + "remoteDaemon.error.missingDaemonModule": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Missing daemon module at %@."}}, + "ja": {"stringUnit": {"state": "translated", "value": "%@にデーモンモジュールがありません。"}} + } + }, + "remoteDaemon.error.goRequired": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Go is required for the development-only programad-remote build fallback."}}, + "ja": {"stringUnit": {"state": "translated", "value": "開発専用のprogramad-remoteビルドフォールバックにはGoが必要です。"}} + } + }, "socketControl.error.passwordFilePath": { "extractionState": "manual", "localizations": { diff --git a/Resources/settings.schema.json b/Resources/settings.schema.json index 351cd4777f4..fbe36ecb85b 100644 --- a/Resources/settings.schema.json +++ b/Resources/settings.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/darkroomengineering/programa/main/Resources/settings.schema.json", - "title": "cmux settings.json", - "description": "User-level cmux settings. cmux accepts JSON with comments (JSONC) and trailing commas, but the underlying data model matches this schema.", + "title": "Programa settings.json", + "description": "User-level Programa settings. Programa accepts JSON with comments (JSONC) and trailing commas, but the underlying data model matches this schema.", "type": "object", "additionalProperties": false, "properties": { @@ -49,12 +49,12 @@ "focusPaneOnFirstClick": { "type": "boolean", "default": true, - "description": "When cmux is inactive, the first click can activate and focus the clicked pane." + "description": "When Programa is inactive, the first click can activate and focus the clicked pane." }, "preferredEditor": { "type": "string", "default": "", - "description": "Custom editor command used by cmux where applicable. Leave empty to use the default." + "description": "Custom editor command used by Programa where applicable. Leave empty to use the default." }, "reorderOnNotification": { "type": "boolean", @@ -64,7 +64,7 @@ "warnBeforeQuit": { "type": "boolean", "default": true, - "description": "Show a confirmation before quitting cmux." + "description": "Show a confirmation before quitting Programa." }, "renameSelectsExistingName": { "type": "boolean", @@ -218,7 +218,7 @@ "claudeCodeIntegration": { "type": "boolean", "default": true, - "description": "Enable cmux integration hooks for Claude Code." + "description": "Enable Programa integration hooks for Claude Code." }, "claudeBinaryPath": { "type": "string", @@ -251,7 +251,7 @@ "type": "string" }, "default": [], - "description": "Directories whose cmux.json commands can run without confirmation." + "description": "Directories whose programa.json (or legacy cmux.json) commands can run without confirmation." } } }, @@ -355,7 +355,7 @@ "bindings": { "type": "object", "default": {}, - "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut or an array for a chord.", + "description": "Shortcut overrides keyed by Programa action id. Use a string for a single shortcut or an array for a chord.", "propertyNames": { "enum": [ "openSettings", diff --git a/Sources/AppDelegate+UITestHarnesses.swift b/Sources/AppDelegate+UITestHarnesses.swift index be21aab7392..76bffbc7c58 100644 --- a/Sources/AppDelegate+UITestHarnesses.swift +++ b/Sources/AppDelegate+UITestHarnesses.swift @@ -1,14 +1,23 @@ // Extracted from AppDelegate.swift (nuclear-review N3): XCUITest-only instrumentation. import AppKit +@preconcurrency import Dispatch import SwiftUI import Bonsplit import CoreServices import UserNotifications import WebKit import Combine -import ObjectiveC.runtime +@preconcurrency import ObjectiveC.runtime import Darwin +#if DEBUG +@MainActor +private final class SocketListenerUITestObservationState { + var observer: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? +} +#endif + extension AppDelegate { #if DEBUG func setupJumpUnreadUITestIfNeeded() { @@ -108,10 +117,12 @@ extension AppDelegate { object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } - guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } - self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: surfaceId) + MainActor.assumeIsolated { + guard let self else { return } + guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } + guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: surfaceId) + } } } @@ -462,7 +473,7 @@ extension AppDelegate { } private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) { - guard let browserPanel = tab.browserPanel(for: browserPanelId) else { + guard tab.browserPanel(for: browserPanelId) != nil else { writeGotoSplitTestData([ "webViewFocused": "false", "setupError": "Browser panel missing" @@ -527,7 +538,7 @@ extension AppDelegate { object: nil, queue: .main ) { _ in - recordFocusedState() + MainActor.assumeIsolated { recordFocusedState() } }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidFocusSurface, @@ -536,11 +547,11 @@ extension AppDelegate { ) { note in guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, surfaceId == browserPanelId else { return } - recordFocusedState() + MainActor.assumeIsolated { recordFocusedState() } }) panelsCancellable = tab.$panels .map { _ in () } - .sink { _ in recordFocusedState() } + .sink { _ in MainActor.assumeIsolated { recordFocusedState() } } DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { [weak self] in guard let self else { return } if !resolved { @@ -617,10 +628,12 @@ extension AppDelegate { object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus") - self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus") + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus") + } }) gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( @@ -628,10 +641,12 @@ extension AppDelegate { object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit") - self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit") + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit") + } }) } @@ -691,20 +706,20 @@ extension AppDelegate { forName: .browserDidBecomeFirstResponderWebView, object: nil, queue: .main - ) { [weak self] notification in - guard let self else { return } - guard notification.object as? WKWebView === panel.webView else { return } - Task { @MainActor in evaluate() } + ) { notification in + MainActor.assumeIsolated { + guard notification.object as? WKWebView === panel.webView else { return } + evaluate() + } }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidFocusSurface, object: nil, queue: .main - ) { [weak self] notification in - guard let self else { return } + ) { notification in guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, surfaceId == panelId else { return } - Task { @MainActor in evaluate() } + MainActor.assumeIsolated { evaluate() } }) panelsCancellable = tab.$panels .map { _ in () } @@ -1437,7 +1452,7 @@ extension AppDelegate { panelsCancellable?.cancel() panelsCancellable = workspace.$panels .map { _ in () } - .sink { _ in attemptResolve() } + .sink { _ in MainActor.assumeIsolated { attemptResolve() } } } if let surfaceId = resolvedSurfaceId() { resolved = true @@ -1448,7 +1463,7 @@ extension AppDelegate { tabsCancellable = tabManager.$tabs .map { _ in () } - .sink { _ in attemptResolve() } + .sink { _ in MainActor.assumeIsolated { attemptResolve() } } focusObserver = NotificationCenter.default.addObserver( forName: .ghosttyDidFocusSurface, object: nil, @@ -1456,7 +1471,7 @@ extension AppDelegate { ) { note in guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, candidateTabId == tabId else { return } - attemptResolve() + MainActor.assumeIsolated { attemptResolve() } } surfaceReadyObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, @@ -1465,7 +1480,7 @@ extension AppDelegate { ) { note in guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, workspaceId == tabId else { return } - attemptResolve() + MainActor.assumeIsolated { attemptResolve() } } DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { if !resolved { @@ -1584,7 +1599,7 @@ extension AppDelegate { panelsCancellable?.cancel() panelsCancellable = workspace.$panels .map { _ in () } - .sink { _ in attemptFocus() } + .sink { _ in MainActor.assumeIsolated { attemptFocus() } } guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else { resolved = true cleanup() @@ -1625,7 +1640,7 @@ extension AppDelegate { object: self, queue: .main ) { _ in - attemptFocus() + MainActor.assumeIsolated { attemptFocus() } }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidBecomeFirstResponderSurface, @@ -1636,7 +1651,7 @@ extension AppDelegate { let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, candidateTabId == tabId, candidateSurfaceId == surfaceId else { return } - attemptFocus() + MainActor.assumeIsolated { attemptFocus() } }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidFocusSurface, @@ -1647,7 +1662,7 @@ extension AppDelegate { let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, candidateTabId == tabId, candidateSurfaceId == surfaceId else { return } - attemptFocus() + MainActor.assumeIsolated { attemptFocus() } }) observers.append(NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, @@ -1658,11 +1673,11 @@ extension AppDelegate { let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, workspaceId == tabId, readySurfaceId == surfaceId else { return } - attemptFocus() + MainActor.assumeIsolated { attemptFocus() } }) selectedTabCancellable = tabManager.$selectedTabId .map { _ in () } - .sink { _ in attemptFocus() } + .sink { _ in MainActor.assumeIsolated { attemptFocus() } } DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { if !resolved { attemptFocus() @@ -1699,8 +1714,7 @@ extension AppDelegate { let socketPath = config.path let socketMode = config.mode.rawValue - var observer: NSObjectProtocol? - var timeoutWorkItem: DispatchWorkItem? + let observationState = SocketListenerUITestObservationState() func publishCurrentState(isTimedOut: Bool) { let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) @@ -1732,28 +1746,33 @@ extension AppDelegate { "socketFailureSignals": failureSignals, ], at: dataPath) guard isReady || isTimedOut else { return } - timeoutWorkItem?.cancel() - if let observer { + observationState.timeoutWorkItem?.cancel() + if let observer = observationState.observer { NotificationCenter.default.removeObserver(observer) + observationState.observer = nil } } } } - observer = NotificationCenter.default.addObserver( + observationState.observer = NotificationCenter.default.addObserver( forName: .socketListenerDidStart, object: TerminalController.shared, queue: .main ) { notification in let startedPath = notification.userInfo?["path"] as? String guard startedPath == socketPath else { return } - publishCurrentState(isTimedOut: false) + MainActor.assumeIsolated { + publishCurrentState(isTimedOut: false) + } } let timeout = DispatchWorkItem { - publishCurrentState(isTimedOut: true) + MainActor.assumeIsolated { + publishCurrentState(isTimedOut: true) + } } - timeoutWorkItem = timeout + observationState.timeoutWorkItem = timeout DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: timeout) restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8d8882050ba..d920844a0fd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -723,7 +723,7 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { +final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, NSMenuItemValidation { nonisolated(unsafe) static var shared: AppDelegate? private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) @@ -973,7 +973,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { launchServicesRegistrationQueue.async(execute: work) } - private var lastSessionAutosaveFingerprint: Int? + private var lastSessionAutosaveFingerprint: Data? private var lastSessionAutosavePersistedAt: Date = .distantPast private var lastTypingActivityAt: TimeInterval = 0 private var didHandleExplicitOpenIntentAtStartup = false @@ -1171,7 +1171,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.openNewMainWindow(nil) } self.moveUITestWindowToTargetDisplayIfNeeded() - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSRunningApplication.current.activate(options: [.activateAllWindows]) // On headless CI runners, activate() silently fails (no GUI session). // Force windows visible so the terminal surface starts rendering. for window in NSApp.windows { @@ -2166,42 +2166,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent didDisableSuddenTermination = false } - private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { - guard !includeScrollback else { return nil } - - var hasher = Hasher() - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - lhs.windowId.uuidString < rhs.windowId.uuidString - } - hasher.combine(contexts.count) - - for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { - hasher.combine(context.windowId) - hasher.combine(context.tabManager.sessionAutosaveFingerprint()) - hasher.combine(context.sidebarState.isVisible) - hasher.combine( - Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) - ) - - switch context.sidebarSelectionState.selection { - case .tabs: - hasher.combine(0) - case .notifications: - hasher.combine(1) - } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - Self.hashFrame(window.frame, into: &hasher) - } else { - hasher.combine(-1) - } - } - - return hasher.finalize() - } - @discardableResult - private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + private func saveSessionSnapshot( + includeScrollback: Bool, + removeWhenEmpty: Bool = false, + prebuiltSnapshot: AppSessionSnapshot? = nil + ) -> Bool { if Self.shouldSkipSessionSaveDuringStartupRestore( isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, includeScrollback: includeScrollback @@ -2227,7 +2197,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif - guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + guard let snapshot = prebuiltSnapshot ?? buildSessionSnapshot(includeScrollback: includeScrollback) else { persistSessionSnapshot( nil, removeWhenEmpty: removeWhenEmpty, @@ -2346,7 +2316,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let fingerprintStart = ProcessInfo.processInfo.systemUptime #endif - let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) + let autosaveSnapshot = buildSessionSnapshot(includeScrollback: false) + let autosaveFingerprint = autosaveSnapshot.flatMap { + SessionPersistenceStore.contentIdentity(for: $0) + } #if DEBUG fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 #endif @@ -2369,7 +2342,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let saveStart = ProcessInfo.processInfo.systemUptime #endif - _ = saveSessionSnapshot(includeScrollback: false) + _ = saveSessionSnapshot( + includeScrollback: false, + prebuiltSnapshot: autosaveSnapshot + ) #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif @@ -2393,11 +2369,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp && includeScrollback } - nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( + nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( isTerminatingApp: Bool, includeScrollback: Bool, - previousFingerprint: Int?, - currentFingerprint: Int?, + previousFingerprint: Fingerprint?, + currentFingerprint: Fingerprint?, lastPersistedAt: Date, now: Date, maximumAutosaveSkippableInterval: TimeInterval = 60 @@ -2416,24 +2392,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func updateSessionAutosaveSaveState( includeScrollback: Bool, persistedAt: Date, - fingerprint: Int? + fingerprint: Data? ) { guard !isTerminatingApp, !includeScrollback else { return } lastSessionAutosaveFingerprint = fingerprint lastSessionAutosavePersistedAt = persistedAt } - private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { - let standardized = frame.standardized - let quantized = [ - standardized.origin.x, - standardized.origin.y, - standardized.size.width, - standardized.size.height, - ].map { Int(($0 * 2).rounded()) } - quantized.forEach { hasher.combine($0) } - } - private func persistSessionSnapshot( _ snapshot: AppSessionSnapshot?, removeWhenEmpty: Bool, @@ -2460,7 +2425,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if synchronously { writeBlock() } else { - sessionPersistenceQueue.async(execute: writeBlock) + sessionPersistenceQueue.async(execute: DispatchWorkItem(block: writeBlock)) } } @@ -2587,8 +2552,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: window, queue: .main ) { [weak self] note in - guard let self, let closing = note.object as? NSWindow else { return } - self.unregisterMainWindow(closing) + MainActor.assumeIsolated { + guard let self, let closing = note.object as? NSWindow else { return } + self.unregisterMainWindow(closing) + } } } updateCommandPaletteState(for: windowId) { state in @@ -3800,7 +3767,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent destinationPanelId: UUID, destinationManager: TabManager ) { - let reassert: () -> Void = { [weak self, weak destinationManager] in + let reassert: @MainActor @Sendable () -> Void = { [weak self, weak destinationManager] in guard let self, let destinationManager else { return } guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), workspace.panels[destinationPanelId] != nil else { @@ -4974,7 +4941,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { window.makeKeyAndOrderFront(nil) setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } if shouldTemporarilyDisallowFullScreenTiling { DispatchQueue.main.async { [weak window] in @@ -5176,7 +5143,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent SettingsWindowController.shared.show(navigationTarget: target) }, activateApplication: @MainActor () -> Void = { - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } ) { #if DEBUG @@ -5530,26 +5497,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { note in - guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, - workspaceId == tab.id else { return } - let surfaceId = note.userInfo?["surfaceId"] as? UUID + MainActor.assumeIsolated { + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tab.id else { return } + let surfaceId = note.userInfo?["surfaceId"] as? UUID #if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.surfaceReadyEvent " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "surface=\(Self.debugShortId(surfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(surfaceId == preferredPanelId ? 1 : 0)" - ) - } + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.surfaceReadyEvent " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "surface=\(Self.debugShortId(surfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(surfaceId == preferredPanelId ? 1 : 0)" + ) + } #endif - if let preferredPanelId, - let surfaceId, - surfaceId != preferredPanelId { - return + if let preferredPanelId, + let surfaceId, + surfaceId != preferredPanelId { + return + } + finishIfReady() } - finishIfReady() } DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { if !resolved { @@ -5780,9 +5749,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in - self?.refreshConfiguredShortcutChordActions() - self?.clearConfiguredShortcutChordState() - self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() + MainActor.assumeIsolated { + self?.refreshConfiguredShortcutChordActions() + self?.clearConfiguredShortcutChordState() + self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() + } } } @@ -5830,7 +5801,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in - self?.refreshGhosttyGotoSplitShortcuts() + MainActor.assumeIsolated { + self?.refreshGhosttyGotoSplitShortcuts() + } } } @@ -7888,7 +7861,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // physical key code is the definitive identifier for the intended shortcut. // For empty-character events (synthetic/browser key equivalents), preserve the original // behavior: only fall back when the layout translation also failed. - let hasUsableEventChars = hasEventChars && eventCharsAreASCII let allowANSIKeyCodeFallback = flags.contains(.control) || (flags.contains(.command) && !flags.contains(.control) @@ -8279,7 +8251,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !app.isTerminated { _ = app.forceTerminate() } - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } } @@ -8352,8 +8324,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] note in - guard let self, let window = note.object as? NSWindow else { return } - self.setActiveMainWindow(window) + MainActor.assumeIsolated { + guard let self, let window = note.object as? NSWindow else { return } + self.setActiveMainWindow(window) + } } } @@ -8365,14 +8339,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = panelId - self.stopBrowserOmnibarSelectionRepeat() + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = panelId + self.stopBrowserOmnibarSelectionRepeat() #if DEBUG - dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") + dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") #endif + } } browserAddressBarBlurObserver = NotificationCenter.default.addObserver( @@ -8380,15 +8356,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId == panelId { - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() + if self.browserAddressBarFocusedPanelId == panelId { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() #if DEBUG - dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") + dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") #endif + } } } } @@ -8825,7 +8803,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } window.makeKeyAndOrderFront(nil) // Improve reliability across Spaces / when other helper panels are key. - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } private func markReadIfFocused( @@ -8971,4 +8949,3 @@ private extension AppDelegate { } } } - diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cfc56e5321f..169d1a85eb3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1562,7 +1562,7 @@ struct ContentView: View { } static func tmuxWorkspacePaneExactRect( - for panel: Panel, + for panel: any Panel, in contentView: NSView ) -> CGRect? { let targetView: NSView? @@ -2556,7 +2556,7 @@ struct ContentView: View { } } } - .onChange(of: tabManager.selectedTabId) { newValue in + .onChange(of: tabManager.selectedTabId) { _, newValue in #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 @@ -2586,10 +2586,10 @@ struct ContentView: View { } updateTitlebarText() } - .onChange(of: selectedTabIds) { _ in + .onChange(of: selectedTabIds) { syncSidebarSelectedWorkspaceIds() } - .onChange(of: tabManager.isWorkspaceCycleHot) { _ in + .onChange(of: tabManager.isWorkspaceCycleHot) { #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 @@ -2602,7 +2602,7 @@ struct ContentView: View { #endif reconcileMountedWorkspaceIds() } - .onChange(of: retiringWorkspaceId) { _ in + .onChange(of: retiringWorkspaceId) { reconcileMountedWorkspaceIds() } .onReceive(tabManager.$pendingBackgroundWorkspaceLoadIds) { _ in @@ -2890,10 +2890,10 @@ struct ContentView: View { @ViewBuilder private func attachWindowGlassAndFullscreenHandlers(to view: some View) -> some View { view - .onChange(of: bgGlassTintHex) { _ in + .onChange(of: bgGlassTintHex) { updateWindowGlassTint() } - .onChange(of: bgGlassTintOpacity) { _ in + .onChange(of: bgGlassTintOpacity) { updateWindowGlassTint() } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { notification in @@ -2923,7 +2923,7 @@ struct ContentView: View { @ViewBuilder private func attachSidebarSyncHandlers(to view: some View) -> some View { view - .onChange(of: sidebarWidth) { _ in + .onChange(of: sidebarWidth) { let sanitized = normalizedSidebarWidth(sidebarWidth) if abs(sidebarWidth - sanitized) > 0.5 { sidebarWidth = sanitized @@ -2941,7 +2941,7 @@ struct ContentView: View { } updateSidebarResizerBandState() } - .onChange(of: sidebarState.isVisible) { _ in + .onChange(of: sidebarState.isVisible) { if let observedWindow { TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow) } else { @@ -2950,7 +2950,7 @@ struct ContentView: View { updateSidebarResizerBandState() syncTrafficLightInset() } - .onChange(of: sidebarMatchTerminalBackground) { _ in + .onChange(of: sidebarMatchTerminalBackground) { guard sidebarState.isVisible, sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue else { return } if let observedWindow { @@ -2962,7 +2962,7 @@ struct ContentView: View { .onChange(of: isMinimalMode) { _, _ in syncTrafficLightInset() } - .onChange(of: sidebarState.persistedWidth) { newValue in + .onChange(of: sidebarState.persistedWidth) { _, newValue in let sanitized = normalizedSidebarWidth(newValue) if abs(newValue - sanitized) > 0.5 { sidebarState.persistedWidth = sanitized @@ -3537,7 +3537,7 @@ struct ContentView: View { ), anchor: commandPaletteScrollTargetAnchor ) - .onChange(of: commandPaletteSelectedResultIndex) { _ in + .onChange(of: commandPaletteSelectedResultIndex) { updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) } @@ -3576,7 +3576,7 @@ struct ContentView: View { updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) syncCommandPaletteDebugStateForObservedWindow() } - .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in + .onChange(of: commandPaletteCurrentSearchFingerprint) { Task { @MainActor in // Let the query-state transition settle first so the forced corpus refresh // cannot rebuild the old command list after deleting the ">" prefix. @@ -3589,7 +3589,7 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() } } - .onChange(of: commandPaletteResultsRevision) { _ in + .onChange(of: commandPaletteResultsRevision) { let resultIDs = cachedCommandPaletteResults.map(\.id) commandPaletteSelectedResultIndex = Self.commandPaletteResolvedSelectionIndex( preferredCommandID: commandPaletteSelectionAnchorCommandID, @@ -3604,7 +3604,7 @@ struct ContentView: View { } syncCommandPaletteDebugStateForObservedWindow() } - .onChange(of: commandPaletteSelectedResultIndex) { _ in + .onChange(of: commandPaletteSelectedResultIndex) { syncCommandPaletteDebugStateForObservedWindow() } } @@ -7608,7 +7608,7 @@ struct VerticalTabsSidebar: View { reason: "sidebar_disappear" ) } - .onChange(of: draggedTabId) { newDraggedTabId in + .onChange(of: draggedTabId) { _, newDraggedTabId in SidebarDragLifecycleNotification.postStateDidChange( tabId: newDraggedTabId, reason: "drag_state_change" @@ -7899,4 +7899,3 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } } - diff --git a/Sources/DebugWindows.swift b/Sources/DebugWindows.swift index 73bacda3e35..27d7ee23116 100644 --- a/Sources/DebugWindows.swift +++ b/Sources/DebugWindows.swift @@ -4,6 +4,9 @@ import Darwin import Bonsplit import UniformTypeIdentifiers +private func localizedDebugLabel(_ value: String) -> String { + String(localized: String.LocalizationValue(value)) +} enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { case settings @@ -14,9 +17,9 @@ enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { var displayTitle: String { switch self { case .settings: - return "Settings Window" + return localizedDebugLabel("Settings Window") case .about: - return "About Window" + return localizedDebugLabel("About Window") } } @@ -32,9 +35,9 @@ enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { var fallbackTitle: String { switch self { case .settings: - return "Settings" + return localizedDebugLabel("Settings") case .about: - return "About Programa" + return localizedDebugLabel("About Programa") } } @@ -57,9 +60,9 @@ enum TitlebarVisibilityOption: String, CaseIterable, Identifiable { var displayTitle: String { switch self { case .hidden: - return "Hidden" + return localizedDebugLabel("Hidden") case .visible: - return "Visible" + return localizedDebugLabel("Visible") } } @@ -85,15 +88,15 @@ enum TitlebarToolbarStyleOption: String, CaseIterable, Identifiable { var displayTitle: String { switch self { case .automatic: - return "Automatic" + return localizedDebugLabel("Automatic") case .expanded: - return "Expanded" + return localizedDebugLabel("Expanded") case .preference: - return "Preference" + return localizedDebugLabel("Preference") case .unified: - return "Unified" + return localizedDebugLabel("Unified") case .unifiedCompact: - return "Unified Compact" + return localizedDebugLabel("Unified Compact") } } @@ -132,7 +135,7 @@ struct SettingsAboutTitlebarDebugOptions: Equatable { case .settings: return SettingsAboutTitlebarDebugOptions( overridesEnabled: false, - windowTitle: "Settings", + windowTitle: localizedDebugLabel("Settings"), titleVisibility: .hidden, titlebarAppearsTransparent: true, movableByWindowBackground: true, @@ -147,7 +150,7 @@ struct SettingsAboutTitlebarDebugOptions: Equatable { case .about: return SettingsAboutTitlebarDebugOptions( overridesEnabled: false, - windowTitle: "About Programa", + windowTitle: localizedDebugLabel("About Programa"), titleVisibility: .hidden, titlebarAppearsTransparent: true, movableByWindowBackground: false, @@ -314,7 +317,7 @@ final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWi backing: .buffered, defer: false ) - window.title = "Settings/About Titlebar Debug" + window.title = localizedDebugLabel("Settings/About Titlebar Debug") window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true @@ -345,22 +348,22 @@ private struct SettingsAboutTitlebarDebugView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Settings/About Titlebar Debug") + Text(localizedDebugLabel("Settings/About Titlebar Debug")) .font(.headline) editor(for: .settings) editor(for: .about) - GroupBox("Actions") { + GroupBox(localizedDebugLabel("Actions")) { HStack(spacing: 10) { - Button("Reset All") { + Button(localizedDebugLabel("Reset All")) { store.reset(.settings) store.reset(.about) } - Button("Reapply to Open Windows") { + Button(localizedDebugLabel("Reapply to Open Windows")) { store.applyToOpenWindows() } - Button("Copy Config") { + Button(localizedDebugLabel("Copy Config")) { store.copyConfigToPasteboard() } } @@ -381,9 +384,9 @@ private struct SettingsAboutTitlebarDebugView: View { return GroupBox(kind.displayTitle) { VStack(alignment: .leading, spacing: 10) { - Toggle("Enable Debug Overrides", isOn: overridesEnabled) + Toggle(localizedDebugLabel("Enable Debug Overrides"), isOn: overridesEnabled) - Text("When disabled, Programa uses normal default titlebar behavior for this window.") + Text(localizedDebugLabel("When disabled, Programa uses normal default titlebar behavior for this window.")) .font(.caption) .foregroundColor(.secondary) @@ -391,44 +394,48 @@ private struct SettingsAboutTitlebarDebugView: View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { - Text("Window Title") + Text(localizedDebugLabel("Window Title")) TextField("", text: binding(for: kind, keyPath: \.windowTitle)) } HStack(spacing: 10) { - Picker("Title Visibility", selection: binding(for: kind, keyPath: \.titleVisibility)) { + Picker(localizedDebugLabel("Title Visibility"), selection: binding(for: kind, keyPath: \.titleVisibility)) { ForEach(TitlebarVisibilityOption.allCases) { option in Text(option.displayTitle).tag(option) } } - Picker("Toolbar Style", selection: binding(for: kind, keyPath: \.toolbarStyle)) { + Picker(localizedDebugLabel("Toolbar Style"), selection: binding(for: kind, keyPath: \.toolbarStyle)) { ForEach(TitlebarToolbarStyleOption.allCases) { option in Text(option.displayTitle).tag(option) } } } - Toggle("Show Toolbar", isOn: binding(for: kind, keyPath: \.showToolbar)) - Toggle("Transparent Titlebar", isOn: binding(for: kind, keyPath: \.titlebarAppearsTransparent)) - Toggle("Movable by Window Background", isOn: binding(for: kind, keyPath: \.movableByWindowBackground)) + Toggle(localizedDebugLabel("Show Toolbar"), isOn: binding(for: kind, keyPath: \.showToolbar)) + Toggle(localizedDebugLabel("Transparent Titlebar"), isOn: binding(for: kind, keyPath: \.titlebarAppearsTransparent)) + Toggle(localizedDebugLabel("Movable by Window Background"), isOn: binding(for: kind, keyPath: \.movableByWindowBackground)) Divider() - Text("Style Mask") + Text(localizedDebugLabel("Style Mask")) .font(.caption) .foregroundColor(.secondary) - Toggle("Titled", isOn: binding(for: kind, keyPath: \.titled)) - Toggle("Closable", isOn: binding(for: kind, keyPath: \.closable)) - Toggle("Miniaturizable", isOn: binding(for: kind, keyPath: \.miniaturizable)) - Toggle("Resizable", isOn: binding(for: kind, keyPath: \.resizable)) - Toggle("Full Size Content View", isOn: binding(for: kind, keyPath: \.fullSizeContentView)) + Toggle(localizedDebugLabel("Titled"), isOn: binding(for: kind, keyPath: \.titled)) + Toggle(localizedDebugLabel("Closable"), isOn: binding(for: kind, keyPath: \.closable)) + Toggle(localizedDebugLabel("Miniaturizable"), isOn: binding(for: kind, keyPath: \.miniaturizable)) + Toggle(localizedDebugLabel("Resizable"), isOn: binding(for: kind, keyPath: \.resizable)) + Toggle(localizedDebugLabel("Full Size Content View"), isOn: binding(for: kind, keyPath: \.fullSizeContentView)) HStack(spacing: 10) { - Button("Reset \(kind == .settings ? "Settings" : "About")") { + Button( + kind == .settings + ? localizedDebugLabel("Reset Settings") + : localizedDebugLabel("Reset About") + ) { store.reset(kind) } - Button("Apply Now") { + Button(localizedDebugLabel("Apply Now")) { store.applyToOpenWindows(for: kind) } } @@ -541,7 +548,7 @@ final class DebugWindowControlsWindowController: NSWindowController, NSWindowDel backing: .buffered, defer: false ) - window.title = "Debug Window Controls" + window.title = localizedDebugLabel("Debug Window Controls") window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true @@ -601,10 +608,10 @@ private struct DebugWindowControlsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Debug Window Controls") + Text(localizedDebugLabel("Debug Window Controls")) .font(.headline) - GroupBox("Open") { + GroupBox(localizedDebugLabel("Open")) { VStack(alignment: .leading, spacing: 8) { Button( String( @@ -614,19 +621,19 @@ private struct DebugWindowControlsView: View { ) { BrowserProfilePopoverDebugWindowController.shared.show() } - Button("Settings/About Titlebar Debug…") { + Button(localizedDebugLabel("Settings/About Titlebar Debug…")) { SettingsAboutTitlebarDebugWindowController.shared.show() } - Button("Sidebar Debug…") { + Button(localizedDebugLabel("Sidebar Debug…")) { SidebarDebugWindowController.shared.show() } - Button("Background Debug…") { + Button(localizedDebugLabel("Background Debug…")) { BackgroundDebugWindowController.shared.show() } - Button("Menu Bar Extra Debug…") { + Button(localizedDebugLabel("Menu Bar Extra Debug…")) { MenuBarExtraDebugWindowController.shared.show() } - Button("Open All Debug Windows") { + Button(localizedDebugLabel("Open All Debug Windows")) { BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() @@ -638,33 +645,33 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } - GroupBox("Shortcut Hints") { + GroupBox(localizedDebugLabel("Shortcut Hints")) { VStack(alignment: .leading, spacing: 10) { - Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints) + Toggle(localizedDebugLabel("Always show shortcut hints"), isOn: $alwaysShowShortcutHints) hintOffsetSection( - "Sidebar Cmd+1…9", + localizedDebugLabel("Sidebar Cmd+1…9"), x: $sidebarShortcutHintXOffset, y: $sidebarShortcutHintYOffset ) hintOffsetSection( - "Titlebar Buttons", + localizedDebugLabel("Titlebar Buttons"), x: $titlebarShortcutHintXOffset, y: $titlebarShortcutHintYOffset ) hintOffsetSection( - "Pane Ctrl/Cmd+1…9", + localizedDebugLabel("Pane Ctrl/Cmd+1…9"), x: $paneShortcutHintXOffset, y: $paneShortcutHintYOffset ) HStack(spacing: 12) { - Button("Reset Hints") { + Button(localizedDebugLabel("Reset Hints")) { resetShortcutHintOffsets() } - Button("Copy Hint Config") { + Button(localizedDebugLabel("Copy Hint Config")) { copyShortcutHintConfig() } } @@ -672,44 +679,44 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } - GroupBox("Active Workspace Indicator") { + GroupBox(localizedDebugLabel("Active Workspace Indicator")) { VStack(alignment: .leading, spacing: 8) { - Picker("Style", selection: sidebarIndicatorStyleSelection) { + Picker(localizedDebugLabel("Style"), selection: sidebarIndicatorStyleSelection) { ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in Text(style.displayName).tag(style.rawValue) } } .pickerStyle(.menu) - Button("Reset Indicator Style") { + Button(localizedDebugLabel("Reset Indicator Style")) { sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue } } .padding(.top, 2) } - GroupBox("Titlebar Spacing") { + GroupBox(localizedDebugLabel("Titlebar Spacing")) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { - Text("Leading extra") + Text(localizedDebugLabel("Leading extra")) Slider(value: $titlebarLeadingExtra, in: 0...40) Text(String(format: "%.0f", titlebarLeadingExtra)) .font(.caption) .monospacedDigit() .frame(width: 30, alignment: .trailing) } - Button("Reset (0)") { + Button(localizedDebugLabel("Reset (0)")) { titlebarLeadingExtra = 0 } } .padding(.top, 2) } - GroupBox("Browser DevTools Button") { + GroupBox(localizedDebugLabel("Browser DevTools Button")) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { - Text("Icon") - Picker("Icon", selection: $browserDevToolsIconNameRaw) { + Text(localizedDebugLabel("Icon")) + Picker(localizedDebugLabel("Icon"), selection: $browserDevToolsIconNameRaw) { ForEach(BrowserDevToolsIconOption.allCases) { option in Text(option.title).tag(option.rawValue) } @@ -720,8 +727,8 @@ private struct DebugWindowControlsView: View { } HStack(spacing: 8) { - Text("Color") - Picker("Color", selection: $browserDevToolsIconColorRaw) { + Text(localizedDebugLabel("Color")) + Picker(localizedDebugLabel("Color"), selection: $browserDevToolsIconColorRaw) { ForEach(BrowserDevToolsIconColorOption.allCases) { option in Text(option.title).tag(option.rawValue) } @@ -732,7 +739,7 @@ private struct DebugWindowControlsView: View { } HStack(spacing: 8) { - Text("Preview") + Text(localizedDebugLabel("Preview")) Spacer() Image(systemName: selectedDevToolsIconOption.rawValue) .font(.system(size: 12, weight: .medium)) @@ -740,10 +747,10 @@ private struct DebugWindowControlsView: View { } HStack(spacing: 12) { - Button("Reset Button") { + Button(localizedDebugLabel("Reset Button")) { resetBrowserDevToolsButton() } - Button("Copy Button Config") { + Button(localizedDebugLabel("Copy Button Config")) { copyBrowserDevToolsButtonConfig() } } @@ -751,12 +758,12 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } - GroupBox("Copy") { + GroupBox(localizedDebugLabel("Copy")) { VStack(alignment: .leading, spacing: 8) { - Button("Copy All Debug Config") { + Button(localizedDebugLabel("Copy All Debug Config")) { DebugWindowConfigSnapshot.copyCombinedToPasteboard() } - Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") + Text(localizedDebugLabel("Copies sidebar, background, menu bar, and browser devtools settings as one payload.")) .font(.caption) .foregroundColor(.secondary) } @@ -784,7 +791,7 @@ private struct DebugWindowControlsView: View { private func sliderRow(_ label: String, value: Binding) -> some View { HStack(spacing: 8) { - Text(label) + Text(localizedDebugLabel(label)) Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) .font(.caption) @@ -1020,7 +1027,7 @@ private struct BrowserProfilePopoverDebugView: View { private func sliderRow(_ label: String, value: Binding, range: ClosedRange) -> some View { HStack(spacing: 8) { - Text(label) + Text(localizedDebugLabel(label)) Slider(value: value, in: range, step: 1) Text(String(format: "%.0f", value.wrappedValue)) .font(.caption) @@ -1040,7 +1047,7 @@ final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { backing: .buffered, defer: false ) - window.title = "Sidebar Debug" + window.title = localizedDebugLabel("Sidebar Debug") window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true @@ -1118,45 +1125,45 @@ private struct SidebarDebugView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Sidebar Appearance") + Text(localizedDebugLabel("Sidebar Appearance")) .font(.headline) Toggle(String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), isOn: $matchTerminalBackground) - GroupBox("Presets") { - Picker("Preset", selection: $sidebarPreset) { + GroupBox(localizedDebugLabel("Presets")) { + Picker(localizedDebugLabel("Preset"), selection: $sidebarPreset) { ForEach(SidebarPresetOption.allCases) { option in Text(option.title).tag(option.rawValue) } } - .onChange(of: sidebarPreset) { _ in + .onChange(of: sidebarPreset) { applyPreset() } .padding(.top, 2) } - GroupBox("Blur") { + GroupBox(localizedDebugLabel("Blur")) { VStack(alignment: .leading, spacing: 8) { - Picker("Material", selection: $sidebarMaterial) { + Picker(localizedDebugLabel("Material"), selection: $sidebarMaterial) { ForEach(SidebarMaterialOption.allCases) { option in Text(option.title).tag(option.rawValue) } } - Picker("Blending", selection: $sidebarBlendMode) { + Picker(localizedDebugLabel("Blending"), selection: $sidebarBlendMode) { ForEach(SidebarBlendModeOption.allCases) { option in Text(option.title).tag(option.rawValue) } } - Picker("State", selection: $sidebarState) { + Picker(localizedDebugLabel("State"), selection: $sidebarState) { ForEach(SidebarStateOption.allCases) { option in Text(option.title).tag(option.rawValue) } } HStack(spacing: 8) { - Text("Strength") + Text(localizedDebugLabel("Strength")) Slider(value: $sidebarBlurOpacity, in: 0...1) Text(String(format: "%.0f%%", sidebarBlurOpacity * 100)) .font(.caption) @@ -1166,12 +1173,12 @@ private struct SidebarDebugView: View { .padding(.top, 2) } - GroupBox("Tint") { + GroupBox(localizedDebugLabel("Tint")) { VStack(alignment: .leading, spacing: 8) { - ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + ColorPicker(localizedDebugLabel("Tint Color"), selection: tintColorBinding, supportsOpacity: false) HStack(spacing: 8) { - Text("Opacity") + Text(localizedDebugLabel("Opacity")) Slider(value: $sidebarTintOpacity, in: 0...0.7) Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) .font(.caption) @@ -1181,9 +1188,9 @@ private struct SidebarDebugView: View { .padding(.top, 2) } - GroupBox("Shape") { + GroupBox(localizedDebugLabel("Shape")) { HStack(spacing: 8) { - Text("Corner Radius") + Text(localizedDebugLabel("Corner Radius")) Slider(value: $sidebarCornerRadius, in: 0...20) Text(String(format: "%.0f", sidebarCornerRadius)) .font(.caption) @@ -1192,24 +1199,24 @@ private struct SidebarDebugView: View { .padding(.top, 2) } - GroupBox("Shortcut Hints") { + GroupBox(localizedDebugLabel("Shortcut Hints")) { VStack(alignment: .leading, spacing: 10) { - Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints) + Toggle(localizedDebugLabel("Always show shortcut hints"), isOn: $alwaysShowShortcutHints) hintOffsetSection( - "Sidebar Cmd+1…9", + localizedDebugLabel("Sidebar Cmd+1…9"), x: $sidebarShortcutHintXOffset, y: $sidebarShortcutHintYOffset ) hintOffsetSection( - "Titlebar Buttons", + localizedDebugLabel("Titlebar Buttons"), x: $titlebarShortcutHintXOffset, y: $titlebarShortcutHintYOffset ) hintOffsetSection( - "Pane Ctrl/Cmd+1…9", + localizedDebugLabel("Pane Ctrl/Cmd+1…9"), x: $paneShortcutHintXOffset, y: $paneShortcutHintYOffset ) @@ -1217,9 +1224,9 @@ private struct SidebarDebugView: View { .padding(.top, 2) } - GroupBox("Active Workspace Indicator") { + GroupBox(localizedDebugLabel("Active Workspace Indicator")) { VStack(alignment: .leading, spacing: 8) { - Picker("Style", selection: sidebarIndicatorStyleSelection) { + Picker(localizedDebugLabel("Style"), selection: sidebarIndicatorStyleSelection) { ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in Text(style.displayName).tag(style.rawValue) } @@ -1238,31 +1245,31 @@ private struct SidebarDebugView: View { } HStack(spacing: 12) { - Button("Reset Tint") { + Button(localizedDebugLabel("Reset Tint")) { sidebarTintOpacity = 0.62 sidebarTintHex = SidebarTintDefaults.hex sidebarTintHexLight = nil sidebarTintHexDark = nil } - Button("Reset Blur") { + Button(localizedDebugLabel("Reset Blur")) { sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue sidebarState = SidebarStateOption.active.rawValue sidebarBlurOpacity = 0.98 } - Button("Reset Shape") { + Button(localizedDebugLabel("Reset Shape")) { sidebarCornerRadius = 0.0 } - Button("Reset Hints") { + Button(localizedDebugLabel("Reset Hints")) { resetShortcutHintOffsets() } - Button("Reset Active Indicator") { + Button(localizedDebugLabel("Reset Active Indicator")) { sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarSelectionColorHex = nil } } - Button("Copy Config") { + Button(localizedDebugLabel("Copy Config")) { copySidebarConfig() } @@ -1297,7 +1304,7 @@ private struct SidebarDebugView: View { private func sliderRow(_ label: String, value: Binding) -> some View { HStack(spacing: 8) { - Text(label) + Text(localizedDebugLabel(label)) Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) .font(.caption) @@ -1369,7 +1376,7 @@ final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDeleg backing: .buffered, defer: false ) - window.title = "Menu Bar Extra Debug" + window.title = localizedDebugLabel("Menu Bar Extra Debug") window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true @@ -1411,16 +1418,16 @@ private struct MenuBarExtraDebugView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Menu Bar Extra Icon") + Text(localizedDebugLabel("Menu Bar Extra Icon")) .font(.headline) - GroupBox("Preview Count") { + GroupBox(localizedDebugLabel("Preview Count")) { VStack(alignment: .leading, spacing: 8) { - Toggle("Override unread count", isOn: $previewEnabled) + Toggle(localizedDebugLabel("Override unread count"), isOn: $previewEnabled) Stepper(value: $previewCount, in: 0...99) { HStack { - Text("Unread Count") + Text(localizedDebugLabel("Unread Count")) Spacer() Text("\(previewCount)") .font(.caption) @@ -1432,7 +1439,7 @@ private struct MenuBarExtraDebugView: View { .padding(.top, 2) } - GroupBox("Badge Rect") { + GroupBox(localizedDebugLabel("Badge Rect")) { VStack(alignment: .leading, spacing: 8) { sliderRow("X", value: $badgeRectX, range: 0...20, format: "%.2f") sliderRow("Y", value: $badgeRectY, range: 0...20, format: "%.2f") @@ -1442,7 +1449,7 @@ private struct MenuBarExtraDebugView: View { .padding(.top, 2) } - GroupBox("Badge Text") { + GroupBox(localizedDebugLabel("Badge Text")) { VStack(alignment: .leading, spacing: 8) { sliderRow("1-digit size", value: $singleDigitFontSize, range: 6...14, format: "%.2f") sliderRow("2-digit size", value: $multiDigitFontSize, range: 6...14, format: "%.2f") @@ -1456,7 +1463,7 @@ private struct MenuBarExtraDebugView: View { } HStack(spacing: 12) { - Button("Reset") { + Button(localizedDebugLabel("Reset")) { previewEnabled = false previewCount = 1 badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x) @@ -1473,7 +1480,7 @@ private struct MenuBarExtraDebugView: View { applyLiveUpdate() } - Button("Copy Config") { + Button(localizedDebugLabel("Copy Config")) { let payload = MenuBarIconDebugSettings.copyPayload() let pasteboard = NSPasteboard.general pasteboard.clearContents() @@ -1481,7 +1488,7 @@ private struct MenuBarExtraDebugView: View { } } - Text("Tip: enable override count, then tune until the menu bar icon looks right.") + Text(localizedDebugLabel("Tip: enable override count, then tune until the menu bar icon looks right.")) .font(.caption) .foregroundColor(.secondary) @@ -1491,19 +1498,19 @@ private struct MenuBarExtraDebugView: View { .frame(maxWidth: .infinity, alignment: .topLeading) } .onAppear { applyLiveUpdate() } - .onChange(of: previewEnabled) { _ in applyLiveUpdate() } - .onChange(of: previewCount) { _ in applyLiveUpdate() } - .onChange(of: badgeRectX) { _ in applyLiveUpdate() } - .onChange(of: badgeRectY) { _ in applyLiveUpdate() } - .onChange(of: badgeRectWidth) { _ in applyLiveUpdate() } - .onChange(of: badgeRectHeight) { _ in applyLiveUpdate() } - .onChange(of: singleDigitFontSize) { _ in applyLiveUpdate() } - .onChange(of: multiDigitFontSize) { _ in applyLiveUpdate() } - .onChange(of: singleDigitXAdjust) { _ in applyLiveUpdate() } - .onChange(of: multiDigitXAdjust) { _ in applyLiveUpdate() } - .onChange(of: singleDigitYOffset) { _ in applyLiveUpdate() } - .onChange(of: multiDigitYOffset) { _ in applyLiveUpdate() } - .onChange(of: textRectWidthAdjust) { _ in applyLiveUpdate() } + .onChange(of: previewEnabled) { applyLiveUpdate() } + .onChange(of: previewCount) { applyLiveUpdate() } + .onChange(of: badgeRectX) { applyLiveUpdate() } + .onChange(of: badgeRectY) { applyLiveUpdate() } + .onChange(of: badgeRectWidth) { applyLiveUpdate() } + .onChange(of: badgeRectHeight) { applyLiveUpdate() } + .onChange(of: singleDigitFontSize) { applyLiveUpdate() } + .onChange(of: multiDigitFontSize) { applyLiveUpdate() } + .onChange(of: singleDigitXAdjust) { applyLiveUpdate() } + .onChange(of: multiDigitXAdjust) { applyLiveUpdate() } + .onChange(of: singleDigitYOffset) { applyLiveUpdate() } + .onChange(of: multiDigitYOffset) { applyLiveUpdate() } + .onChange(of: textRectWidthAdjust) { applyLiveUpdate() } } private func sliderRow( @@ -1513,7 +1520,7 @@ private struct MenuBarExtraDebugView: View { format: String ) -> some View { HStack(spacing: 8) { - Text(label) + Text(localizedDebugLabel(label)) Slider(value: value, in: range) Text(String(format: format, value.wrappedValue)) .font(.caption) @@ -1539,7 +1546,7 @@ final class SplitButtonLayoutDebugWindowController: NSWindowController, NSWindow backing: .buffered, defer: false ) - window.title = "Split Button Layout" + window.title = localizedDebugLabel("Split Button Layout") window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true @@ -1575,20 +1582,20 @@ private struct SplitButtonLayoutDebugView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text("Button Backdrop Color") + Text(localizedDebugLabel("Button Backdrop Color")) .font(.headline) ForEach(options, id: \.0) { id, label in HStack { Image(systemName: backdropStyle == id ? "checkmark.circle.fill" : "circle") .foregroundColor(backdropStyle == id ? .accentColor : .secondary) - Text(label) + Text(localizedDebugLabel(label)) } .contentShape(Rectangle()) .onTapGesture { backdropStyle = id } } - Text("Changes apply live.") + Text(localizedDebugLabel("Changes apply live.")) .font(.caption) .foregroundColor(.secondary) } @@ -1609,7 +1616,7 @@ final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegat backing: .buffered, defer: false ) - window.title = "Background Debug" + window.title = localizedDebugLabel("Background Debug") window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true @@ -1642,32 +1649,32 @@ private struct BackgroundDebugView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Window Background Glass") + Text(localizedDebugLabel("Window Background Glass")) .font(.headline) - GroupBox("Glass Effect") { + GroupBox(localizedDebugLabel("Glass Effect")) { VStack(alignment: .leading, spacing: 8) { - Toggle("Enable Glass Effect", isOn: $bgGlassEnabled) - - Picker("Material", selection: $bgGlassMaterial) { - Text("HUD Window").tag("hudWindow") - Text("Under Window").tag("underWindowBackground") - Text("Sidebar").tag("sidebar") - Text("Menu").tag("menu") - Text("Popover").tag("popover") + Toggle(localizedDebugLabel("Enable Glass Effect"), isOn: $bgGlassEnabled) + + Picker(localizedDebugLabel("Material"), selection: $bgGlassMaterial) { + Text(localizedDebugLabel("HUD Window")).tag("hudWindow") + Text(localizedDebugLabel("Under Window")).tag("underWindowBackground") + Text(localizedDebugLabel("Sidebar")).tag("sidebar") + Text(localizedDebugLabel("Menu")).tag("menu") + Text(localizedDebugLabel("Popover")).tag("popover") } .disabled(!bgGlassEnabled) } .padding(.top, 2) } - GroupBox("Tint") { + GroupBox(localizedDebugLabel("Tint")) { VStack(alignment: .leading, spacing: 8) { - ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + ColorPicker(localizedDebugLabel("Tint Color"), selection: tintColorBinding, supportsOpacity: false) .disabled(!bgGlassEnabled) HStack(spacing: 8) { - Text("Opacity") + Text(localizedDebugLabel("Opacity")) Slider(value: $bgGlassTintOpacity, in: 0...0.8) .disabled(!bgGlassEnabled) Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) @@ -1679,7 +1686,7 @@ private struct BackgroundDebugView: View { } HStack(spacing: 12) { - Button("Reset") { + Button(localizedDebugLabel("Reset")) { bgGlassTintHex = "#000000" bgGlassTintOpacity = 0.03 bgGlassMaterial = "hudWindow" @@ -1687,12 +1694,12 @@ private struct BackgroundDebugView: View { updateWindowGlassTint() } - Button("Copy Config") { + Button(localizedDebugLabel("Copy Config")) { copyBgConfig() } } - Text("Tint changes apply live. Enable/disable requires reload.") + Text(localizedDebugLabel("Tint changes apply live. Enable/disable requires reload.")) .font(.caption) .foregroundColor(.secondary) @@ -1701,8 +1708,8 @@ private struct BackgroundDebugView: View { .padding(16) .frame(maxWidth: .infinity, alignment: .topLeading) } - .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } - .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } + .onChange(of: bgGlassTintHex) { updateWindowGlassTint() } + .onChange(of: bgGlassTintOpacity) { updateWindowGlassTint() } } private func updateWindowGlassTint() { @@ -1746,4 +1753,3 @@ private struct BackgroundDebugView: View { pasteboard.setString(payload, forType: .string) } } - diff --git a/Sources/GhosttyTerminalView+DragDrop.swift b/Sources/GhosttyTerminalView+DragDrop.swift index be606927696..e91649be332 100644 --- a/Sources/GhosttyTerminalView+DragDrop.swift +++ b/Sources/GhosttyTerminalView+DragDrop.swift @@ -239,7 +239,7 @@ extension GhosttyNSView { } func debugRegisteredDropTypes() -> [String] { - (registeredDraggedTypes ?? []).map(\.rawValue) + registeredDraggedTypes.map(\.rawValue) } #endif diff --git a/Sources/GhosttyTerminalView+Keyboard.swift b/Sources/GhosttyTerminalView+Keyboard.swift index 3a34fe64673..081de3cf4b8 100644 --- a/Sources/GhosttyTerminalView+Keyboard.swift +++ b/Sources/GhosttyTerminalView+Keyboard.swift @@ -165,7 +165,7 @@ extension GhosttyNSView { let startX = min(1, xMax) let endX = xMax - let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE + let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ghostty_surface_mouse_pos(surface, startX, startY, mods) guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { return false diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e9766a53109..63532c807aa 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1374,7 +1374,7 @@ class GhosttyApp { handle.write(Data(line.utf8)) handle.closeFile() } else { - FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8)) + _ = FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8)) } } @@ -3112,11 +3112,11 @@ class GhosttyApp { "\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { - FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) + _ = FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) } if let handle = try? FileHandle(forWritingTo: backgroundLogURL) { defer { try? handle.close() } - try? handle.seekToEnd() + _ = try? handle.seekToEnd() try? handle.write(contentsOf: data) } } @@ -3770,7 +3770,7 @@ final class TerminalSurface: Identifiable, ObservableObject { handle.write(Data(line.utf8)) handle.closeFile() } else { - FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8)) + _ = FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8)) } } @@ -3784,7 +3784,7 @@ final class TerminalSurface: Identifiable, ObservableObject { handle.write(Data(line.utf8)) handle.closeFile() } else { - FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8)) + _ = FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8)) } } #endif @@ -4306,7 +4306,6 @@ final class TerminalSurface: Identifiable, ObservableObject { dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, - let surface, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { @@ -8330,4 +8329,3 @@ final class GhosttySurfaceScrollView: NSView { return contentHeight } } - diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index fe6d1db8d7d..0ceda89adff 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -48,7 +48,7 @@ struct NotificationsPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .onAppear(perform: setInitialFocus) - .onChange(of: notificationStore.notifications.first?.id) { _ in + .onChange(of: notificationStore.notifications.first?.id) { setInitialFocus() } } diff --git a/Sources/Panels/BrowserDataImport.swift b/Sources/Panels/BrowserDataImport.swift index 3b8e1b8f457..c1acc2f2738 100644 --- a/Sources/Panels/BrowserDataImport.swift +++ b/Sources/Panels/BrowserDataImport.swift @@ -1373,7 +1373,7 @@ enum BrowserImportPlanResolver { @MainActor static func realize( plan: BrowserImportExecutionPlan, - profileStore: BrowserProfileStore = .shared + profileStore: BrowserProfileStore ) throws -> RealizedBrowserImportExecutionPlan { var realizedEntries: [RealizedBrowserImportExecutionEntry] = [] var createdProfiles: [BrowserProfileDefinition] = [] @@ -1415,6 +1415,11 @@ enum BrowserImportPlanResolver { createdProfiles: createdProfiles ) } + + @MainActor + static func realize(plan: BrowserImportExecutionPlan) throws -> RealizedBrowserImportExecutionPlan { + try realize(plan: plan, profileStore: .shared) + } } #if canImport(CommonCrypto) && canImport(Security) @@ -2785,7 +2790,7 @@ final class BrowserDataImportCoordinator { #endif @MainActor - private final class ImportWizardWindowController: NSObject, @preconcurrency NSWindowDelegate { + private final class ImportWizardWindowController: NSObject, NSWindowDelegate { private final class FlippedDocumentView: NSView { override var isFlipped: Bool { true } } @@ -2870,7 +2875,7 @@ final class BrowserDataImportCoordinator { func runModal() -> ImportSelection? { panel.center() panel.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + NSRunningApplication.current.activate(options: [.activateAllWindows]) let response = NSApp.runModal(for: panel) if panel.isVisible { diff --git a/Sources/Panels/BrowserHistoryStore.swift b/Sources/Panels/BrowserHistoryStore.swift index d6054b2d968..3665a0d1ee1 100644 --- a/Sources/Panels/BrowserHistoryStore.swift +++ b/Sources/Panels/BrowserHistoryStore.swift @@ -648,6 +648,18 @@ final class BrowserHistoryStore: ObservableObject { actor BrowserSearchSuggestionService { static let shared = BrowserSearchSuggestionService() + typealias RequestLoader = @Sendable (URLRequest) async throws -> (Data, URLResponse) + + private let requestLoader: RequestLoader + + init( + requestLoader: @escaping RequestLoader = { request in + try await URLSession.shared.data(for: request) + } + ) { + self.requestLoader = requestLoader + } + func suggestions(engine: BrowserSearchEngine, query: String) async -> [String] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } @@ -666,38 +678,9 @@ actor BrowserSearchSuggestionService { } } - // Google's endpoint can intermittently throttle/block app-style traffic. - // Query fallbacks in parallel so we can show predictions quickly. - if engine == .google { - return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed) - } - return await fetchRemoteSuggestions(engine: engine, query: trimmed) } - private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] { - await withTaskGroup(of: [String].self, returning: [String].self) { group in - group.addTask { - await self.fetchRemoteSuggestions(engine: .google, query: query) - } - group.addTask { - await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query) - } - group.addTask { - await self.fetchRemoteSuggestions(engine: .bing, query: query) - } - - while let result = await group.next() { - if !result.isEmpty { - group.cancelAll() - return result - } - } - - return [] - } - } - private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] { let url: URL? switch engine { @@ -746,7 +729,7 @@ actor BrowserSearchSuggestionService { let data: Data let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: req) + (data, response) = try await requestLoader(req) } catch { return [] } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f69c7e24dd3..2de9a173112 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -625,7 +625,6 @@ enum BrowserUserAgentSettings { } /// BrowserPanel provides a WKWebView-based browser panel. -/// All browser panels share a WKProcessPool for cookie sharing. /// /// Widened from `private` to `internal` (file-reorg for #99): also referenced by /// `BrowserNavigationDelegate`/`BrowserUIDelegate` in BrowserPanelWebDelegates.swift. @@ -657,17 +656,6 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { - private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" - private static let remoteLoopbackHosts: Set = [ - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", - ] - - /// Shared process pool for cookie sharing across all browser panels - private static let sharedProcessPool = WKProcessPool() - /// Popup windows owned by this panel (for lifecycle cleanup) private var popupControllers: [BrowserPopupWindowController] = [] @@ -766,44 +754,6 @@ final class BrowserPanel: Panel, ObservableObject { })() """ - private static func clampedGhosttyBackgroundOpacity(_ opacity: Double) -> CGFloat { - CGFloat(max(0.0, min(1.0, opacity))) - } - - private static func isDarkAppearance( - appAppearance: NSAppearance? = NSApp?.effectiveAppearance - ) -> Bool { - guard let appAppearance else { return false } - return appAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - } - - private static func resolvedGhosttyBackgroundColor(from notification: Notification? = nil) -> NSColor { - let userInfo = notification?.userInfo - let baseColor = (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor) - ?? GhosttyApp.shared.defaultBackgroundColor - - let opacity: Double - if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double { - opacity = value - } else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber { - opacity = value.doubleValue - } else { - opacity = GhosttyApp.shared.defaultBackgroundOpacity - } - - return baseColor.withAlphaComponent(clampedGhosttyBackgroundOpacity(opacity)) - } - - private static func resolvedBrowserChromeBackgroundColor( - from notification: Notification? = nil, - appAppearance: NSAppearance? = NSApp?.effectiveAppearance - ) -> NSColor { - if isDarkAppearance(appAppearance: appAppearance) { - return resolvedGhosttyBackgroundColor(from: notification) - } - return NSColor.windowBackgroundColor - } - let id: UUID let panelType: PanelType = .browser @@ -1478,10 +1428,8 @@ final class BrowserPanel: Panel, ObservableObject { static func configureWebViewConfiguration( _ configuration: WKWebViewConfiguration, - websiteDataStore: WKWebsiteDataStore, - processPool: WKProcessPool = BrowserPanel.sharedProcessPool + websiteDataStore: WKWebsiteDataStore ) { - configuration.processPool = processPool configuration.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. @@ -1767,27 +1715,13 @@ final class BrowserPanel: Panel, ObservableObject { } private func beginDownloadActivity() { - let apply = { - self.activeDownloadCount += 1 - self.isDownloading = self.activeDownloadCount > 0 - } - if Thread.isMainThread { - apply() - } else { - DispatchQueue.main.async(execute: apply) - } + activeDownloadCount += 1 + isDownloading = activeDownloadCount > 0 } private func endDownloadActivity() { - let apply = { - self.activeDownloadCount = max(0, self.activeDownloadCount - 1) - self.isDownloading = self.activeDownloadCount > 0 - } - if Thread.isMainThread { - apply() - } else { - DispatchQueue.main.async(execute: apply) - } + activeDownloadCount = max(0, activeDownloadCount - 1) + isDownloading = activeDownloadCount > 0 } func updateWorkspaceId(_ newWorkspaceId: UUID) { @@ -2824,23 +2758,14 @@ final class BrowserPanel: Panel, ObservableObject { } private static func remoteProxyDisplayURL(for url: URL?) -> URL? { - guard let url else { return nil } - guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } - guard host == BrowserInsecureHTTPSettings.normalizeHost(remoteLoopbackProxyAliasHost) else { return url } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = "localhost" - return components?.url ?? url + WorkspaceRemoteLoopbackPolicy.displayURL(for: url) } - private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { - guard let scheme = url.scheme?.lowercased(), scheme == "http" else { return nil } - guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } - guard remoteLoopbackHosts.contains(host) else { return nil } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = remoteLoopbackProxyAliasHost - return components?.url + // Internal so the browser-to-proxy routing contract can be exercised as one + // behavioral path by the unit tests. Keep this as the production implementation, + // rather than duplicating the URL transformation in a test-only helper. + static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { + WorkspaceRemoteLoopbackPolicy.browserAliasURL(for: url) } /// Navigate with smart URL/search detection @@ -2896,7 +2821,7 @@ final class BrowserPanel: Panel, ObservableObject { let alert = insecureHTTPAlertFactory() BrowserInsecureHTTPAlertBuilder.configure(alert, host: host) - let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self, weak alert] response in + let handleResponse: @MainActor @Sendable (NSApplication.ModalResponse) -> Void = { [weak self, weak alert] response in self?.handleInsecureHTTPAlertResponse( response, alert: alert, diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 827588f2bb0..9522fe3def4 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -885,7 +885,7 @@ struct BrowserPanelView: View { return currentPaneId.id == paneId.id } - var body: some View { + private var layeredBrowserContent: some View { // Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit // container. Rendering it here can hide it behind the portal-hosted WKWebView. VStack(spacing: 0) { @@ -942,6 +942,14 @@ struct BrowserPanelView: View { .environment(\.colorScheme, browserChromeColorScheme) } } + } + + var body: some View { + browserNotificationContent + } + + private var browserClickContent: some View { + layeredBrowserContent .coordinateSpace(name: "BrowserPanelViewSpace") .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in omnibarPillFrame = frame @@ -971,6 +979,10 @@ struct BrowserPanelView: View { onRequestPanelFocus() } } + } + + private var browserLifecycleContent: some View { + browserClickContent .onAppear { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, @@ -1010,10 +1022,10 @@ struct BrowserPanelView: View { logBrowserFocusState(event: "view.onAppear") #endif } - .onChange(of: panel.focusFlashToken) { _ in + .onChange(of: panel.focusFlashToken) { triggerFocusFlashAnimation() } - .onChange(of: panel.currentURL) { _ in + .onChange(of: panel.currentURL) { let addressWasEmpty = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty syncURLFromPanel() // If we auto-focused a blank omnibar but then a URL loads programmatically, move focus @@ -1032,27 +1044,31 @@ struct BrowserPanelView: View { reason: "panel.currentURL.changed" ) } - .onChange(of: browserThemeModeRaw) { _ in + .onChange(of: browserThemeModeRaw) { let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) if browserThemeModeRaw != normalizedMode.rawValue { browserThemeModeRaw = normalizedMode.rawValue } panel.setBrowserThemeMode(normalizedMode) } - .onChange(of: colorScheme) { _ in + .onChange(of: colorScheme) { refreshBrowserChromeStyle() panel.refreshAppearanceDrivenColors() } - .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in + .onChange(of: panel.pendingAddressBarFocusRequestId) { applyPendingAddressBarFocusRequestIfNeeded() } - .onChange(of: panel.profileID) { _ in + .onChange(of: panel.profileID) { panel.historyStore.loadIfNeeded() if addressBarFocused { refreshSuggestions() } } - .onChange(of: isVisibleInUI) { visibleInUI in + } + + private var browserFocusContent: some View { + browserLifecycleContent + .onChange(of: isVisibleInUI) { _, visibleInUI in if visibleInUI { panel.cancelPendingDeveloperToolsVisibilityLossCheck() return @@ -1069,7 +1085,7 @@ struct BrowserPanelView: View { // an attached-inspector X-close. panel.scheduleDeveloperToolsVisibilityLossCheck() } - .onChange(of: isFocused) { focused in + .onChange(of: isFocused) { _, focused in #if DEBUG logBrowserFocusState( event: "panelFocus.onChange", @@ -1097,7 +1113,7 @@ struct BrowserPanelView: View { isPanelFocusedOverride: focused ) } - .onChange(of: addressBarFocused) { focused in + .onChange(of: addressBarFocused) { _, focused in #if DEBUG logBrowserFocusState( event: "addressBarFocus.onChange", @@ -1137,6 +1153,10 @@ struct BrowserPanelView: View { logBrowserFocusState(event: "addressBarFocus.onChange.applied") #endif } + } + + private var browserNotificationContent: some View { + browserFocusContent .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } @@ -1174,7 +1194,7 @@ struct BrowserPanelView: View { omnibarField .accessibilityIdentifier("BrowserOmnibarPill") - .accessibilityLabel("Browser omnibar") + .accessibilityLabel(String(localized: "browser.omnibar.accessibilityLabel", defaultValue: "Browser omnibar")) HStack(spacing: browserToolbarAccessorySpacing) { if shouldShowToolbarImportHintChip { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 51b2f990b64..8aedd79e725 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -97,8 +97,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { if let browserContextSource { BrowserPanel.configureWebViewConfiguration( configuration, - websiteDataStore: browserContextSource.websiteDataStore, - processPool: browserContextSource.processPool + websiteDataStore: browserContextSource.websiteDataStore ) } diff --git a/Sources/Panels/MarkdownDocumentView.swift b/Sources/Panels/MarkdownDocumentView.swift new file mode 100644 index 00000000000..e19a78f34e9 --- /dev/null +++ b/Sources/Panels/MarkdownDocumentView.swift @@ -0,0 +1,171 @@ +import AppKit +import MarkdownUI +import SwiftUI + +/// Renderer-neutral presentation values supplied by the panel container. +struct MarkdownDocumentPresentation { + let colorScheme: ColorScheme + + var backgroundColor: Color { + colorScheme == .dark + ? Color(nsColor: NSColor(white: 0.12, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.98, alpha: 1.0)) + } +} + +/// The full-document Markdown rendering boundary. +/// +/// MarkdownUI types and styling stay private to this view so the panel and its +/// model remain independent of the concrete renderer. +struct MarkdownDocumentView: View { + let content: String + let baseURL: URL? + let presentation: MarkdownDocumentPresentation + + var body: some View { + Markdown(content, baseURL: baseURL) + .markdownTheme(theme) + .textSelection(.enabled) + } + + private var theme: Theme { + let isDark = presentation.colorScheme == .dark + + return Theme() + .text { + ForegroundColor(isDark ? .white.opacity(0.9) : .primary) + FontSize(14) + } + .heading1 { configuration in + VStack(alignment: .leading, spacing: 8) { + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(28) + ForegroundColor(isDark ? .white : .primary) + } + Divider() + } + .markdownMargin(top: 24, bottom: 16) + } + .heading2 { configuration in + VStack(alignment: .leading, spacing: 6) { + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(22) + ForegroundColor(isDark ? .white : .primary) + } + Divider() + } + .markdownMargin(top: 20, bottom: 12) + } + .heading3 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(18) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 16, bottom: 8) + } + .heading4 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(16) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 12, bottom: 6) + } + .heading5 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(14) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 10, bottom: 4) + } + .heading6 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(13) + ForegroundColor(isDark ? .white.opacity(0.7) : .secondary) + } + .markdownMargin(top: 8, bottom: 4) + } + .codeBlock { configuration in + ScrollView(.horizontal, showsIndicators: true) { + configuration.label + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(13) + ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2)) + } + .padding(12) + } + .background(isDark + ? Color(nsColor: NSColor(white: 0.08, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.93, alpha: 1.0))) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 8, bottom: 8) + } + .code { + FontFamilyVariant(.monospaced) + FontSize(13) + ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7)) + BackgroundColor(isDark + ? Color(nsColor: NSColor(white: 0.18, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.92, alpha: 1.0))) + } + .blockquote { configuration in + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 1.5) + .fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4)) + .frame(width: 3) + configuration.label + .markdownTextStyle { + ForegroundColor(isDark ? .white.opacity(0.6) : .secondary) + FontSize(14) + } + .padding(.leading, 12) + } + .markdownMargin(top: 8, bottom: 8) + } + .link { + ForegroundColor(Color.accentColor) + } + .strong { + FontWeight(.semibold) + } + .table { configuration in + configuration.label + .markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3))) + .markdownTableBackgroundStyle( + .alternatingRows( + isDark + ? Color(nsColor: NSColor(white: 0.14, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.96, alpha: 1.0)), + isDark + ? Color(nsColor: NSColor(white: 0.10, alpha: 1.0)) + : Color(nsColor: NSColor(white: 1.0, alpha: 1.0)) + ) + ) + .markdownMargin(top: 8, bottom: 8) + } + .thematicBreak { + Divider() + .markdownMargin(top: 16, bottom: 16) + } + .listItem { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 8) + } + } +} diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 40ef120a570..7181ac338c3 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -1,9 +1,8 @@ import AppKit import Bonsplit import SwiftUI -import MarkdownUI -/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI. +/// SwiftUI view that presents a MarkdownPanel without depending on a concrete renderer. struct MarkdownPanelView: View { @ObservedObject var panel: MarkdownPanel let isFocused: Bool @@ -50,7 +49,7 @@ struct MarkdownPanelView: View { ) } } - .onChange(of: panel.focusFlashToken) { _ in + .onChange(of: panel.focusFlashToken) { triggerFocusFlashAnimation() } } @@ -69,10 +68,11 @@ struct MarkdownPanelView: View { Divider() .padding(.horizontal, 16) - // Rendered markdown - Markdown(panel.content) - .markdownTheme(programaMarkdownTheme) - .textSelection(.enabled) + MarkdownDocumentView( + content: panel.content, + baseURL: URL(fileURLWithPath: panel.filePath).deletingLastPathComponent(), + presentation: markdownPresentation + ) .padding(.horizontal, 24) .padding(.vertical, 16) } @@ -115,164 +115,12 @@ struct MarkdownPanelView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - // MARK: - Theme - - private var backgroundColor: Color { - colorScheme == .dark - ? Color(nsColor: NSColor(white: 0.12, alpha: 1.0)) - : Color(nsColor: NSColor(white: 0.98, alpha: 1.0)) + private var markdownPresentation: MarkdownDocumentPresentation { + MarkdownDocumentPresentation(colorScheme: colorScheme) } - private var programaMarkdownTheme: Theme { - let isDark = colorScheme == .dark - - return Theme() - // Text - .text { - ForegroundColor(isDark ? .white.opacity(0.9) : .primary) - FontSize(14) - } - // Headings - .heading1 { configuration in - VStack(alignment: .leading, spacing: 8) { - configuration.label - .markdownTextStyle { - FontWeight(.bold) - FontSize(28) - ForegroundColor(isDark ? .white : .primary) - } - Divider() - } - .markdownMargin(top: 24, bottom: 16) - } - .heading2 { configuration in - VStack(alignment: .leading, spacing: 6) { - configuration.label - .markdownTextStyle { - FontWeight(.bold) - FontSize(22) - ForegroundColor(isDark ? .white : .primary) - } - Divider() - } - .markdownMargin(top: 20, bottom: 12) - } - .heading3 { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.semibold) - FontSize(18) - ForegroundColor(isDark ? .white : .primary) - } - .markdownMargin(top: 16, bottom: 8) - } - .heading4 { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.semibold) - FontSize(16) - ForegroundColor(isDark ? .white : .primary) - } - .markdownMargin(top: 12, bottom: 6) - } - .heading5 { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.medium) - FontSize(14) - ForegroundColor(isDark ? .white : .primary) - } - .markdownMargin(top: 10, bottom: 4) - } - .heading6 { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.medium) - FontSize(13) - ForegroundColor(isDark ? .white.opacity(0.7) : .secondary) - } - .markdownMargin(top: 8, bottom: 4) - } - // Code blocks - .codeBlock { configuration in - ScrollView(.horizontal, showsIndicators: true) { - configuration.label - .markdownTextStyle { - FontFamilyVariant(.monospaced) - FontSize(13) - ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2)) - } - .padding(12) - } - .background(isDark - ? Color(nsColor: NSColor(white: 0.08, alpha: 1.0)) - : Color(nsColor: NSColor(white: 0.93, alpha: 1.0))) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .markdownMargin(top: 8, bottom: 8) - } - // Inline code - .code { - FontFamilyVariant(.monospaced) - FontSize(13) - ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7)) - BackgroundColor(isDark - ? Color(nsColor: NSColor(white: 0.18, alpha: 1.0)) - : Color(nsColor: NSColor(white: 0.92, alpha: 1.0))) - } - // Block quotes - .blockquote { configuration in - HStack(spacing: 0) { - RoundedRectangle(cornerRadius: 1.5) - .fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4)) - .frame(width: 3) - configuration.label - .markdownTextStyle { - ForegroundColor(isDark ? .white.opacity(0.6) : .secondary) - FontSize(14) - } - .padding(.leading, 12) - } - .markdownMargin(top: 8, bottom: 8) - } - // Links - .link { - ForegroundColor(Color.accentColor) - } - // Strong - .strong { - FontWeight(.semibold) - } - // Tables - .table { configuration in - configuration.label - .markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3))) - .markdownTableBackgroundStyle( - .alternatingRows( - isDark - ? Color(nsColor: NSColor(white: 0.14, alpha: 1.0)) - : Color(nsColor: NSColor(white: 0.96, alpha: 1.0)), - isDark - ? Color(nsColor: NSColor(white: 0.10, alpha: 1.0)) - : Color(nsColor: NSColor(white: 1.0, alpha: 1.0)) - ) - ) - .markdownMargin(top: 8, bottom: 8) - } - // Thematic break (horizontal rule) - .thematicBreak { - Divider() - .markdownMargin(top: 16, bottom: 16) - } - // List items - .listItem { configuration in - configuration.label - .markdownMargin(top: 4, bottom: 4) - } - // Paragraphs - .paragraph { configuration in - configuration.label - .markdownMargin(top: 4, bottom: 8) - } + private var backgroundColor: Color { + markdownPresentation.backgroundColor } // MARK: - Focus Flash diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 35a92ce01e6..bc21366b91d 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -26,6 +26,60 @@ public enum PanelFocusIntent: Equatable { case browser(BrowserPanelFocusIntent) } +@MainActor +final class FocusTransitionCoordinator { + struct Owner: Equatable { + let workspaceID: UUID + let panelID: UUID + let intent: PanelFocusIntent + } + + enum Reason: Equatable { + case workspaceSelection + case nonFocusSplit + } + + struct Request: Equatable { + let generation: UInt64 + let owner: Owner + let reason: Reason + } + + private(set) var newestRequest: Request? + private(set) var committedOwner: Owner? + private var nextGeneration: UInt64 = 0 + + func beginTransition(to owner: Owner, reason: Reason) -> Request { + nextGeneration &+= 1 + let request = Request( + generation: nextGeneration, + owner: owner, + reason: reason + ) + newestRequest = request + return request + } + + func captureCurrentGeneration(for owner: Owner, reason: Reason) -> Request { + Request( + generation: nextGeneration, + owner: owner, + reason: reason + ) + } + + func isCurrentGeneration(_ request: Request) -> Bool { + request.generation == nextGeneration + } + + @discardableResult + func completeTransition(_ request: Request) -> Bool { + guard newestRequest == request else { return false } + committedOwner = request.owner + return true + } +} + public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable { case navigation case notificationArrival diff --git a/Sources/Panels/ReactGrab.swift b/Sources/Panels/ReactGrab.swift index 38f66535aa4..585e542b1de 100644 --- a/Sources/Panels/ReactGrab.swift +++ b/Sources/Panels/ReactGrab.swift @@ -391,14 +391,17 @@ extension BrowserPanel { #if DEBUG dlog("reactGrab.inject.evalJS len=\(combined.count)") #endif - webView.evaluateJavaScript(combined) { [weak self] _, error in + do { + _ = try await webView.evaluateJavaScript(combined) #if DEBUG - dlog("reactGrab.inject.evalJS.done error=\(error?.localizedDescription ?? "none")") + dlog("reactGrab.inject.evalJS.done error=none") #endif - if let error { - NSLog("ReactGrab: injection failed: %@", error.localizedDescription) - Task { @MainActor in self?.isReactGrabActive = false } - } + } catch { + #if DEBUG + dlog("reactGrab.inject.evalJS.done error=\(error.localizedDescription)") + #endif + NSLog("ReactGrab: injection failed: %@", error.localizedDescription) + isReactGrabActive = false } #if DEBUG dlog("reactGrab.inject.end") diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 377529e275d..2830d9ca131 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -280,10 +280,10 @@ struct programaApp: App { } } } - .onChange(of: appearanceMode) { _ in + .onChange(of: appearanceMode) { applyAppearance() } - .onChange(of: socketControlMode) { _ in + .onChange(of: socketControlMode) { updateSocketController() } } @@ -322,20 +322,20 @@ struct programaApp: App { } #if DEBUG - CommandMenu("Update Pill") { - Button("Show Update Pill") { + CommandMenu(String(localized: "debug.updatePill.menu", defaultValue: "Update Pill")) { + Button(String(localized: "debug.updatePill.show", defaultValue: "Show Update Pill")) { appDelegate.showUpdatePill(nil) } - Button("Show Long Nightly Pill") { + Button(String(localized: "debug.updatePill.showLongNightly", defaultValue: "Show Long Nightly Pill")) { appDelegate.showUpdatePillLongNightly(nil) } - Button("Show Loading State") { + Button(String(localized: "debug.updatePill.showLoading", defaultValue: "Show Loading State")) { appDelegate.showUpdatePillLoading(nil) } - Button("Hide Update Pill") { + Button(String(localized: "debug.updatePill.hide", defaultValue: "Hide Update Pill")) { appDelegate.hideUpdatePill(nil) } - Button("Automatic Update Pill") { + Button(String(localized: "debug.updatePill.automatic", defaultValue: "Automatic Update Pill")) { appDelegate.clearUpdatePillOverride(nil) } } @@ -380,16 +380,16 @@ struct programaApp: App { } #if DEBUG - CommandMenu("Debug") { - Button("New Tab With Lorem Search Text") { + CommandMenu(String(localized: "debug.menu.title", defaultValue: "Debug")) { + Button(String(localized: "debug.menu.newLoremTab", defaultValue: "New Tab With Lorem Search Text")) { appDelegate.openDebugLoremTab(nil) } - Button("New Tab With Large Scrollback") { + Button(String(localized: "debug.menu.newLargeScrollbackTab", defaultValue: "New Tab With Large Scrollback")) { appDelegate.openDebugScrollbackTab(nil) } - Button("Open Workspaces for All Workspace Colors") { + Button(String(localized: "debug.menu.openWorkspaceColors", defaultValue: "Open Workspaces for All Workspace Colors")) { appDelegate.openDebugColorComparisonWorkspaces(nil) } @@ -403,8 +403,8 @@ struct programaApp: App { } Divider() - Menu("Debug Windows") { - Button("Background Debug…") { + Menu(String(localized: "debug.menu.windows", defaultValue: "Debug Windows")) { + Button(String(localized: "debug.menu.background", defaultValue: "Background Debug…")) { BackgroundDebugWindowController.shared.show() } Button( @@ -415,22 +415,22 @@ struct programaApp: App { ) { BrowserProfilePopoverDebugWindowController.shared.show() } - Button("Debug Window Controls…") { + Button(String(localized: "debug.menu.windowControls", defaultValue: "Debug Window Controls…")) { DebugWindowControlsWindowController.shared.show() } - Button("Menu Bar Extra Debug…") { + Button(String(localized: "debug.menu.menuBarExtra", defaultValue: "Menu Bar Extra Debug…")) { MenuBarExtraDebugWindowController.shared.show() } - Button("Settings/About Titlebar Debug…") { + Button(String(localized: "debug.menu.settingsAboutTitlebar", defaultValue: "Settings/About Titlebar Debug…")) { SettingsAboutTitlebarDebugWindowController.shared.show() } - Button("Sidebar Debug…") { + Button(String(localized: "debug.menu.sidebar", defaultValue: "Sidebar Debug…")) { SidebarDebugWindowController.shared.show() } - Button("Split Button Layout Debug…") { + Button(String(localized: "debug.menu.splitButtonLayout", defaultValue: "Split Button Layout Debug…")) { SplitButtonLayoutDebugWindowController.shared.show() } - Button("Open All Debug Windows") { + Button(String(localized: "debug.menu.openAllWindows", defaultValue: "Open All Debug Windows")) { openAllDebugWindows() } } @@ -458,7 +458,7 @@ struct programaApp: App { } } - Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints) + Toggle(String(localized: "debug.shortcutHints.alwaysShow", defaultValue: "Always Show Shortcut Hints"), isOn: $alwaysShowShortcutHints) Toggle( String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"), isOn: $showSidebarDevBuildBanner @@ -466,7 +466,7 @@ struct programaApp: App { Divider() - Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) { + Picker(String(localized: "debug.titlebarControls.style", defaultValue: "Titlebar Controls Style"), selection: $titlebarControlsStyle) { ForEach(TitlebarControlsStyle.allCases) { style in Text(style.menuTitle).tag(style.rawValue) } @@ -1625,7 +1625,7 @@ enum ProgramaRuntimeDebugCapture { var payload: [String: Any] = [ "session_id": configuration.sessionID, "hypothesis_id": hypothesisID, - "service": "cmux-macos", + "service": "programa-macos", "source": source, "name": name, "ts": ISO8601DateFormatter().string(from: Date()), @@ -1675,4 +1675,3 @@ func openProgramaSettingsFileInTextEdit() { NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) #endif } - diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 4b3da0a2cd7..0ffab559546 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -322,18 +322,18 @@ final class ProgramaSettingsFileStore { if let raw = jsonString(section["appearance"]) { let normalized = AppearanceSettings.mode(for: raw).rawValue let accepted = Set(AppearanceMode.allCases.map(\.rawValue)) - guard accepted.contains(raw) else { + if accepted.contains(raw) { + snapshot.managedUserDefaults[AppearanceSettings.appearanceModeKey] = .string(normalized) + } else { logInvalid("app.appearance", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[AppearanceSettings.appearanceModeKey] = .string(normalized) } if let raw = jsonString(section["newWorkspacePlacement"]) { - guard let placement = NewWorkspacePlacement(rawValue: raw) else { + if let placement = NewWorkspacePlacement(rawValue: raw) { + snapshot.managedUserDefaults[WorkspacePlacementSettings.placementKey] = .string(placement.rawValue) + } else { logInvalid("app.newWorkspacePlacement", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[WorkspacePlacementSettings.placementKey] = .string(placement.rawValue) } if let value = jsonBool(section["minimalMode"]) { let mode = value ? WorkspacePresentationModeSettings.Mode.minimal : .standard @@ -372,11 +372,11 @@ final class ProgramaSettingsFileStore { } if let raw = jsonString(section["sound"]) { let allowed = Set(NotificationSoundSettings.systemSounds.map(\.value)) - guard allowed.contains(raw) else { + if allowed.contains(raw) { + snapshot.managedUserDefaults[NotificationSoundSettings.key] = .string(raw) + } else { logInvalid("notifications.sound", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[NotificationSoundSettings.key] = .string(raw) } if let raw = jsonString(section["customSoundFilePath"]) { snapshot.managedUserDefaults[NotificationSoundSettings.customFilePathKey] = .string(raw) @@ -397,11 +397,11 @@ final class ProgramaSettingsFileStore { let accepted = Set(SidebarActiveTabIndicatorStyle.allCases.map(\.rawValue)).union([ "rail", "border", "wash", "lift", "typography", "washRail", "blueWashColorRail", ]) - guard accepted.contains(raw) else { + if accepted.contains(raw) { + snapshot.managedUserDefaults[SidebarActiveTabIndicatorSettings.styleKey] = .string(normalized) + } else { logInvalid("workspaceColors.indicatorStyle", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[SidebarActiveTabIndicatorSettings.styleKey] = .string(normalized) } if section.keys.contains("selectionColor") { guard let value = parseNullableHex( @@ -496,27 +496,29 @@ final class ProgramaSettingsFileStore { snapshot.managedUserDefaults["sidebarMatchTerminalBackground"] = .bool(value) } if let raw = jsonString(section["tintColor"]) { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + if let normalized = WorkspaceTabColorSettings.normalizedHex(raw) { + snapshot.managedUserDefaults["sidebarTintHex"] = .string(normalized) + } else { logInvalid("sidebarAppearance.tintColor", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults["sidebarTintHex"] = .string(normalized) } if section.keys.contains("lightModeTintColor") { - guard let value = parseNullableHex( + if let value = parseNullableHex( section["lightModeTintColor"], path: "sidebarAppearance.lightModeTintColor", sourcePath: sourcePath - ) else { return } - snapshot.managedUserDefaults["sidebarTintHexLight"] = .nullableString(value) + ) { + snapshot.managedUserDefaults["sidebarTintHexLight"] = .nullableString(value) + } } if section.keys.contains("darkModeTintColor") { - guard let value = parseNullableHex( + if let value = parseNullableHex( section["darkModeTintColor"], path: "sidebarAppearance.darkModeTintColor", sourcePath: sourcePath - ) else { return } - snapshot.managedUserDefaults["sidebarTintHexDark"] = .nullableString(value) + ) { + snapshot.managedUserDefaults["sidebarTintHexDark"] = .nullableString(value) + } } if let value = jsonDouble(section["tintOpacity"]) { let clamped = min(max(value, 0), 1) @@ -535,13 +537,13 @@ final class ProgramaSettingsFileStore { "notifications", "full", ]) let normalizedRaw = raw.replacingOccurrences(of: "-", with: "").lowercased() - guard knownModes.contains(normalizedRaw) else { + if knownModes.contains(normalizedRaw) { + snapshot.managedUserDefaults[SocketControlSettings.appStorageKey] = .string( + SocketControlSettings.migrateMode(raw).rawValue + ) + } else { logInvalid("automation.socketControlMode", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[SocketControlSettings.appStorageKey] = .string( - SocketControlSettings.migrateMode(raw).rawValue - ) } if section.keys.contains("socketPassword") { if section["socketPassword"] is NSNull { @@ -550,7 +552,6 @@ final class ProgramaSettingsFileStore { snapshot.managedCustomSettings.socketPassword = raw.isEmpty ? .clear : .set(raw) } else { logInvalid("automation.socketPassword", sourcePath: sourcePath) - return } } if let value = jsonBool(section["claudeCodeIntegration"]) { @@ -560,18 +561,18 @@ final class ProgramaSettingsFileStore { snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.customClaudePathKey] = .string(raw) } if let value = jsonInt(section["portBase"]) { - guard value > 0 else { + if value > 0 { + snapshot.managedUserDefaults["cmuxPortBase"] = .int(value) + } else { logInvalid("automation.portBase", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults["cmuxPortBase"] = .int(value) } if let value = jsonInt(section["portRange"]) { - guard value > 0 else { + if value > 0 { + snapshot.managedUserDefaults["cmuxPortRange"] = .int(value) + } else { logInvalid("automation.portRange", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults["cmuxPortRange"] = .int(value) } } @@ -596,21 +597,21 @@ final class ProgramaSettingsFileStore { snapshot: inout ResolvedSettingsSnapshot ) { if let raw = jsonString(section["defaultSearchEngine"]) { - guard let engine = BrowserSearchEngine(rawValue: raw) else { + if let engine = BrowserSearchEngine(rawValue: raw) { + snapshot.managedUserDefaults[BrowserSearchSettings.searchEngineKey] = .string(engine.rawValue) + } else { logInvalid("browser.defaultSearchEngine", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[BrowserSearchSettings.searchEngineKey] = .string(engine.rawValue) } if let value = jsonBool(section["showSearchSuggestions"]) { snapshot.managedUserDefaults[BrowserSearchSettings.searchSuggestionsEnabledKey] = .bool(value) } if let raw = jsonString(section["theme"]) { - guard let mode = BrowserThemeMode(rawValue: raw) else { + if let mode = BrowserThemeMode(rawValue: raw) { + snapshot.managedUserDefaults[BrowserThemeSettings.modeKey] = .string(mode.rawValue) + } else { logInvalid("browser.theme", sourcePath: sourcePath) - return } - snapshot.managedUserDefaults[BrowserThemeSettings.modeKey] = .string(mode.rawValue) } if let value = jsonBool(section["openTerminalLinksInProgramaBrowser"]) { snapshot.managedUserDefaults[BrowserLinkOpenSettings.openTerminalLinksInProgramaBrowserKey] = .bool(value) @@ -1150,7 +1151,7 @@ final class ProgramaSettingsFileStore { " // This file uses JSON with comments (JSONC).", " // Uncomment and edit any setting to make it file-managed.", " // Remove a setting to fall back to the value saved in Settings.", - " // cmux creates this template on launch when both settings file locations are missing.", + " // Programa creates this template on launch when both settings file locations are missing.", " // ~/.config/programa/settings.json takes precedence over the Application Support fallback.", "", ] diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 101a20afb45..94969d67990 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1,4 +1,5 @@ import CoreGraphics +import CryptoKit import Foundation import Bonsplit @@ -389,10 +390,25 @@ enum SessionPersistenceStore { } } + static func contentIdentity(for snapshot: AppSessionSnapshot) -> Data? { + var normalized = snapshot + normalized.createdAt = 0 + return canonicalContentIdentity(for: normalized) + } + + static func canonicalContentIdentity(for value: Value) -> Data? { + guard let data = try? encodedData(value) else { return nil } + return Data(SHA256.hash(data: data)) + } + private static func encodedSnapshotData(_ snapshot: AppSessionSnapshot) throws -> Data { + try encodedData(snapshot) + } + + private static func encodedData(_ value: Value) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] - return try encoder.encode(snapshot) + return try encoder.encode(value) } static func removeSnapshot(fileURL: URL? = nil) { diff --git a/Sources/SettingsComponents.swift b/Sources/SettingsComponents.swift index add93197321..4a30d3eb2a1 100644 --- a/Sources/SettingsComponents.swift +++ b/Sources/SettingsComponents.swift @@ -40,10 +40,10 @@ struct HexColorPicker: View { ColorPicker("", selection: $pickerColor, supportsOpacity: false) .labelsHidden() .frame(width: 38) - .onChange(of: pickerColor) { newColor in + .onChange(of: pickerColor) { _, newColor in onHexChange(NSColor(newColor).hexString()) } - .onChange(of: hex) { newHex in + .onChange(of: hex) { _, newHex in // Keep the buffer in sync when the hex is reset externally // (e.g. the Reset button sets sidebarSelectionColorHex = nil). if let newHex, let ns = NSColor(hex: newHex) { @@ -258,4 +258,3 @@ struct SettingsCardNote: View { .frame(maxWidth: .infinity, alignment: .leading) } } - diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 2b95546494a..75d58078736 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1316,7 +1316,7 @@ struct SettingsView: View { ) .padding(.horizontal, 16) .padding(.bottom, 12) - .onChange(of: trustedDirectoriesDraft) { _ in + .onChange(of: trustedDirectoriesDraft) { saveTrustedDirectories() } } @@ -1986,7 +1986,7 @@ private struct ShortcutSettingRow: View { transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }, isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(action) ) - .onChange(of: shortcut) { newValue in + .onChange(of: shortcut) { _, newValue in KeyboardShortcutSettings.setShortcut(newValue, for: action) } .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 7ce7bd54a65..877b6a9187e 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -573,13 +573,34 @@ struct SidebarEmptyArea: View { Color.clear .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - selection = .tabs + // Drag view must be an .overlay (frontmost), not .background: as a background, + // the Color.clear + .contentShape content above it is the real hit-test target, + // so AppKit never even asks the background NSView's hitTest — that's why the + // titlebar.dragHandle.* logs never fired here. Once frontmost, this view's own + // hitTest bails out for non-mouseDown event types, so SwiftUI's onDrop target + // underneath is unaffected. + // + // Double-click is handled by `onDoubleClick` below rather than a sibling + // `.onTapGesture(count: 2)`: once this view is frontmost, it claims the hit-test + // for the *first* click of any double-click, so a sibling gesture recognizer on + // the content beneath never observes that first click and can't reliably count + // to two. Owning the whole click sequence here (mirroring Bonsplit's + // TabBarDragZoneView.onDoubleClick pattern) avoids that race entirely. + .overlay { + WindowDragHandleView( + handlesDoubleClick: false, + onDoubleClick: { + #if DEBUG + dlog("sidebar.dragHandle.doubleClick action=addWorkspace") + #endif + tabManager.addWorkspace(placementOverride: .end) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + } + ) } .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( targetTabId: nil, diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index dd68487d9e8..b4f37275297 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -632,7 +632,7 @@ struct TabItemView: View, Equatable { .onAppear { rowHeight = max(proxy.size.height, 1) } - .onChange(of: proxy.size.height) { newHeight in + .onChange(of: proxy.size.height) { _, newHeight in rowHeight = max(newHeight, 1) } } @@ -741,7 +741,7 @@ struct TabItemView: View, Equatable { // before the first debounced publisher fires. recomputeSidebarDetailCache() } - .onChange(of: visibleAuxiliaryDetails) { _ in + .onChange(of: visibleAuxiliaryDetails) { // Toggling branch/PR columns changes which data we need to cache. recomputeSidebarDetailCache() } @@ -1695,7 +1695,7 @@ private struct SidebarWorkspaceDescriptionText: View { ) #endif } - .onChange(of: markdown) { newValue in + .onChange(of: markdown) { _, newValue in #if DEBUG let newlineCount = newValue.reduce(into: 0) { count, character in if character == "\n" { count += 1 } @@ -1946,7 +1946,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { .contentShape(Rectangle()) .onTapGesture { onFocus() } .onAppear(perform: renderMarkdown) - .onChange(of: block.markdown) { _ in + .onChange(of: block.markdown) { renderMarkdown() } } @@ -1962,4 +1962,3 @@ private struct SidebarMetadataMarkdownBlockRow: View { ) } } - diff --git a/Sources/TabManager+SessionPersistence.swift b/Sources/TabManager+SessionPersistence.swift index 16fba6092dd..f91b33af334 100644 --- a/Sources/TabManager+SessionPersistence.swift +++ b/Sources/TabManager+SessionPersistence.swift @@ -6,46 +6,10 @@ import CoreVideo import Combine extension TabManager { - func sessionAutosaveFingerprint() -> Int { - var hasher = Hasher() - hasher.combine(selectedTabId) - hasher.combine(tabs.count) - - for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) { - hasher.combine(workspace.id) - hasher.combine(workspace.focusedPanelId) - hasher.combine(workspace.currentDirectory) - hasher.combine(workspace.customTitle ?? "") - hasher.combine(workspace.customDescription ?? "") - hasher.combine(workspace.customColor ?? "") - hasher.combine(workspace.isPinned) - hasher.combine(workspace.panels.count) - hasher.combine(workspace.statusEntries.count) - hasher.combine(workspace.metadataBlocks.count) - hasher.combine(workspace.logEntries.count) - hasher.combine(workspace.panelDirectories.count) - hasher.combine(workspace.panelTitles.count) - hasher.combine(workspace.panelPullRequests.count) - hasher.combine(workspace.panelGitBranches.count) - hasher.combine(workspace.surfaceListeningPorts.count) - - if let progress = workspace.progress { - hasher.combine(Int((progress.value * 1000).rounded())) - hasher.combine(progress.label) - } else { - hasher.combine(-1) - } - - if let gitBranch = workspace.gitBranch { - hasher.combine(gitBranch.branch) - hasher.combine(gitBranch.isDirty) - } else { - hasher.combine("") - hasher.combine(false) - } - } - - return hasher.finalize() + func sessionAutosaveFingerprint() -> Data? { + SessionPersistenceStore.canonicalContentIdentity( + for: sessionSnapshot(includeScrollback: false) + ) } func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { diff --git a/Sources/TabManager+Splits.swift b/Sources/TabManager+Splits.swift index 98399d44d7e..0ed41b2e3f8 100644 --- a/Sources/TabManager+Splits.swift +++ b/Sources/TabManager+Splits.swift @@ -239,7 +239,7 @@ extension TabManager { // A stale callback must never affect unrelated panels/workspaces. guard tab.panels[surfaceId] != nil, tab.surfaceIdFromPanelId(surfaceId) != nil else { return false } - tab.closePanel(surfaceId) + _ = tab.closePanel(surfaceId) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tabId, surfaceId: surfaceId) return true } diff --git a/Sources/TabManager+UITestHarness.swift b/Sources/TabManager+UITestHarness.swift index 55ad2eb3bb5..ea8f8fe3de2 100644 --- a/Sources/TabManager+UITestHarness.swift +++ b/Sources/TabManager+UITestHarness.swift @@ -438,9 +438,9 @@ extension TabManager { // Close the two right panes via the same path as Cmd+W. tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) + _ = tab.closePanel(topRight.id, force: true) tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) + _ = tab.closePanel(bottomRight.id, force: true) // Capture final state after Bonsplit/AppKit/Ghostty geometry reconciliation. @@ -565,7 +565,7 @@ extension TabManager { tab.focusPanel(topLeftPanelId) let toClose = Array(tab.panels.keys).filter { $0 != topLeftPanelId } for pid in toClose { - tab.closePanel(pid, force: true) + _ = tab.closePanel(pid, force: true) } // Create the repro layout. Most patterns use a 2x2 grid, but keep a single-split @@ -647,7 +647,7 @@ extension TabManager { return [ (frame: closeFrame, action: { tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) + _ = tab.closePanel(topRight.id, force: true) }), ] case "close_bottom": @@ -656,11 +656,11 @@ extension TabManager { return [ (frame: closeFrame, action: { tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) + _ = tab.closePanel(bottomRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.focusPanel(bottomLeft.id) - tab.closePanel(bottomLeft.id, force: true) + _ = tab.closePanel(bottomLeft.id, force: true) }), ] case "close_right_lrtd_bottom_first", "close_right_bottom_first": @@ -669,11 +669,11 @@ extension TabManager { return [ (frame: closeFrame, action: { tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) + _ = tab.closePanel(bottomRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) + _ = tab.closePanel(topRight.id, force: true) }), ] case "close_right_lrtd_unfocused": @@ -681,10 +681,10 @@ extension TabManager { closeOrder = "TR_THEN_BR_UNFOCUSED" return [ (frame: closeFrame, action: { - tab.closePanel(topRight.id, force: true) + _ = tab.closePanel(topRight.id, force: true) }), (frame: secondCloseFrame, action: { - tab.closePanel(bottomRight.id, force: true) + _ = tab.closePanel(bottomRight.id, force: true) }), ] default: @@ -693,11 +693,11 @@ extension TabManager { return [ (frame: closeFrame, action: { tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) + _ = tab.closePanel(topRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) + _ = tab.closePanel(bottomRight.id, force: true) }), ] } @@ -904,7 +904,7 @@ extension TabManager { // Start each iteration from a deterministic 1x1 workspace. if tab.panels.count > 1 { for panelId in tab.panels.keys where panelId != leftPanelId { - tab.closePanel(panelId, force: true) + _ = tab.closePanel(panelId, force: true) } let collapsed = await self.waitForWorkspacePanelsCondition( tab: tab, @@ -1080,9 +1080,9 @@ extension TabManager { // Repro flow: with a 2x2 (left/right then top/down), close both right panes, // then trigger Ctrl+D in top-left. tab.focusPanel(rightPanel.id) - tab.closePanel(rightPanel.id, force: true) + _ = tab.closePanel(rightPanel.id, force: true) tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) + _ = tab.closePanel(bottomRight.id, force: true) exitPanelId = leftPanelId let collapsed = await self.waitForWorkspacePanelsCondition( @@ -1123,7 +1123,7 @@ extension TabManager { let keepPanels: Set = [leftPanelId, topRight.id] for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { tab.focusPanel(panelId) - tab.closePanel(panelId, force: true) + _ = tab.closePanel(panelId, force: true) let closed = await self.waitForWorkspacePanelsCondition( tab: tab, timeoutSeconds: 1.0 diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b8290cf9ef4..e63e9a23b0d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -509,13 +509,14 @@ class TabManager: ObservableObject { /// Its API is invoked as static calls (`GitMetadataProber.foo(...)`); this instance exists as /// TabManager's ownership point for that responsibility. let gitMetadataProber = GitMetadataProber() + let focusTransitionCoordinator = FocusTransitionCoordinator() /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? @Published var tabs: [Workspace] = [] - @Published internal(set) var isWorkspaceCycleHot: Bool = false + @Published var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -563,6 +564,9 @@ class TabManager: ObservableObject { if !isNavigatingHistory, let selectedTabId { recordTabInHistory(selectedTabId) } + let focusTransitionRequest = selectedTabId.flatMap { + beginWorkspaceSelectionFocusTransition(workspaceId: $0) + } #if DEBUG let switchId = debugWorkspaceSwitchId let switchDtMs = debugWorkspaceSwitchStartTime > 0 @@ -577,7 +581,17 @@ class TabManager: ObservableObject { let generation = selectionSideEffectsGeneration DispatchQueue.main.async { [weak self] in guard let self, self.selectionSideEffectsGeneration == generation else { return } - self.focusSelectedTabPanel(previousTabId: previousTabId) + if let focusTransitionRequest { + guard self.focusTransitionCoordinator.completeTransition(focusTransitionRequest) else { + return + } + self.focusSelectedTabPanel( + previousTabId: previousTabId, + requestedPanelId: focusTransitionRequest.owner.panelID + ) + } else { + self.focusSelectedTabPanel(previousTabId: previousTabId) + } self.updateWindowTitleForSelectedTab() if let selectedTabId = self.selectedTabId { self.dismissFocusedPanelNotificationIfActive(tabId: selectedTabId) @@ -1917,7 +1931,7 @@ class TabManager: ObservableObject { } if NSApp.activationPolicy() == .regular { - NSApp.activate(ignoringOtherApps: true) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } return alert.runModal() == .alertFirstButtonReturn @@ -2353,12 +2367,44 @@ class TabManager: ObservableObject { terminalPanel.applyWindowBackgroundIfActive() } - private func focusSelectedTabPanel(previousTabId: UUID?) { + private func beginWorkspaceSelectionFocusTransition( + workspaceId: UUID + ) -> FocusTransitionCoordinator.Request? { + guard let workspace = workspace(withId: workspaceId) else { return nil } + let panelId: UUID + if let restoredPanelId = lastFocusedPanelByTab[workspaceId], + workspace.panels[restoredPanelId] != nil { + panelId = restoredPanelId + } else if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + panelId = focusedPanelId + } else { + return nil + } + guard let panel = workspace.panels[panelId] else { return nil } + let owner = FocusTransitionCoordinator.Owner( + workspaceID: workspaceId, + panelID: panelId, + intent: panel.preferredFocusIntentForActivation() + ) + return focusTransitionCoordinator.beginTransition( + to: owner, + reason: .workspaceSelection + ) + } + + private func focusSelectedTabPanel( + previousTabId: UUID?, + requestedPanelId: UUID? = nil + ) { guard let selectedTabId, let tab = workspace(withId: selectedTabId) else { return } let panelId: UUID - if let restoredPanelId = lastFocusedPanelByTab[selectedTabId], + if let requestedPanelId { + guard tab.panels[requestedPanelId] != nil else { return } + panelId = requestedPanelId + } else if let restoredPanelId = lastFocusedPanelByTab[selectedTabId], tab.panels[restoredPanelId] != nil { panelId = restoredPanelId } else if let focusedPanelId = tab.focusedPanelId, @@ -2590,7 +2636,7 @@ class TabManager: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } - NSApp.activate(ignoringOtherApps: true) + NSRunningApplication.current.activate(options: [.activateAllWindows]) NSApp.unhide(nil) if let app = AppDelegate.shared, let windowId = app.windowId(for: self), diff --git a/Sources/TerminalController+BrowserAutomation.swift b/Sources/TerminalController+BrowserAutomation.swift index 49ab2d18dc0..5e553513e4a 100644 --- a/Sources/TerminalController+BrowserAutomation.swift +++ b/Sources/TerminalController+BrowserAutomation.swift @@ -1,10 +1,55 @@ // Extracted from TerminalController.swift (nuclear-review TC3): the v2 browser-automation surface (~3,700 lines) shares no logic with terminal/surface/workspace handling. import AppKit import Carbon.HIToolbox -import Foundation +@preconcurrency import Foundation import Bonsplit import WebKit +@MainActor +private final class BrowserDownloadWaitState { + let surfaceId: UUID + let finish: ([String: Any]) -> Void + var observer: NSObjectProtocol? + private var completed = false + + init(surfaceId: UUID, finish: @escaping ([String: Any]) -> Void) { + self.surfaceId = surfaceId + self.finish = finish + } + + 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) + } + + func install(_ observer: NSObjectProtocol) { + if completed { + NotificationCenter.default.removeObserver(observer) + } else { + self.observer = observer + } + } + + func cancel() { + guard !completed else { return } + completed = true + if let observer { + NotificationCenter.default.removeObserver(observer) + self.observer = nil + } + } +} + extension TerminalController { // MARK: - V2 Browser Methods @@ -2793,24 +2838,22 @@ extension TerminalController { ]) } + var waitState: BrowserDownloadWaitState? let downloadEvent = v2AwaitCallback(timeout: timeout) { finish in - var observer: NSObjectProtocol? - observer = NotificationCenter.default.addObserver( + let state = BrowserDownloadWaitState(surfaceId: surfaceId, finish: finish) + waitState = state + let observer = NotificationCenter.default.addObserver( forName: .browserDownloadEventDidArrive, object: nil, queue: .main - ) { note in - guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, - candidateSurfaceId == surfaceId, - let event = note.userInfo?["event"] as? [String: Any] else { - return - } - if let observer { - NotificationCenter.default.removeObserver(observer) + ) { [state] note in + MainActor.assumeIsolated { + state.receive(note) } - finish(event) } + state.install(observer) } + waitState?.cancel() guard let downloadEvent else { return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) } diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 58a40ba995b..af435a557af 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -1,10 +1,74 @@ // Extracted from TerminalController.swift (nuclear-review #96): debug.* command handlers plus their private implementation helpers (DEBUG-only and shared). import AppKit import Carbon.HIToolbox -import Foundation +@preconcurrency import Foundation import Bonsplit import WebKit +#if DEBUG +@MainActor +private final class TerminalSurfaceWaitState { + let terminalSurface: TerminalSurface + let finish: () -> Void + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + private var completed = false + + init(terminalSurface: TerminalSurface, finish: @escaping () -> Void) { + self.terminalSurface = terminalSurface + self.finish = finish + } + + func complete() { + guard !completed else { return } + completed = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + self.readyObserver = nil + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + self.hostedViewObserver = nil + } + finish() + } + + func installReadyObserver(_ observer: NSObjectProtocol) { + if completed { + NotificationCenter.default.removeObserver(observer) + } else { + readyObserver = observer + } + } + + func installHostedViewObserver(_ observer: NSObjectProtocol) { + if completed { + NotificationCenter.default.removeObserver(observer) + } else { + hostedViewObserver = observer + } + } + + func completeIfSurfaceReady() { + guard terminalSurface.surface != nil else { return } + complete() + } + + func cancel() { + guard !completed else { return } + completed = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + self.readyObserver = nil + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + self.hostedViewObserver = nil + } + } +} +#endif + extension TerminalController { #if DEBUG // MARK: - V2 Debug / Test-only Methods @@ -57,7 +121,7 @@ extension TerminalController { result = .ok([:]) return } - (fr as? NSResponder)?.insertText(text) + fr.insertText(text) result = .ok([:]) } return result @@ -1618,22 +1682,12 @@ extension TerminalController { return } - // Get window's CGWindowID - let windowNumber = CGWindowID(window.windowNumber) - - // Capture the window using CGWindowListCreateImage - guard let cgImage = CGWindowListCreateImage( - .null, // Capture just the window bounds - .optionIncludingWindow, - windowNumber, - [.boundsIgnoreFraming, .nominalResolution] - ) else { - captureError = "Failed to capture window image" + guard let contentView = window.contentView, + let bitmap = contentView.bitmapImageRepForCachingDisplay(in: contentView.bounds) else { + captureError = "Failed to prepare window image" return } - - // Convert to NSBitmapImageRep and save as PNG - let bitmap = NSBitmapImageRep(cgImage: cgImage) + contentView.cacheDisplay(in: contentView.bounds, to: bitmap) guard let pngData = bitmap.representation(using: .png, properties: [:]) else { captureError = "Failed to create PNG data" return @@ -1741,42 +1795,39 @@ extension TerminalController { let terminalSurface = terminalPanel.surface terminalSurface.requestBackgroundSurfaceStartIfNeeded() + var waitState: TerminalSurfaceWaitState? _ = v2AwaitCallback(timeout: timeout) { finish in - var readyObserver: NSObjectProtocol? - var hostedViewObserver: NSObjectProtocol? - let finishOnce: () -> Void = { - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } - if let hostedViewObserver { - NotificationCenter.default.removeObserver(hostedViewObserver) - } - finish(()) - } - - readyObserver = NotificationCenter.default.addObserver( + let state = TerminalSurfaceWaitState( + terminalSurface: terminalSurface, + finish: { finish(()) } + ) + waitState = state + let readyObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, object: terminalSurface, queue: .main - ) { _ in - finishOnce() + ) { [state] _ in + MainActor.assumeIsolated { + state.complete() + } } - hostedViewObserver = NotificationCenter.default.addObserver( + state.installReadyObserver(readyObserver) + let hostedViewObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceHostedViewDidMoveToWindow, object: terminalSurface, queue: .main - ) { _ in - Task { @MainActor in - if terminalSurface.surface != nil { - finishOnce() - } + ) { [state] _ in + MainActor.assumeIsolated { + state.completeIfSurfaceReady() } } + state.installHostedViewObserver(hostedViewObserver) if terminalSurface.surface != nil { - finishOnce() + state.complete() } } + waitState?.cancel() return terminalPanel.surface.surface } diff --git a/Sources/TerminalController+Pane.swift b/Sources/TerminalController+Pane.swift index 24ccd866115..13a5f859752 100644 --- a/Sources/TerminalController+Pane.swift +++ b/Sources/TerminalController+Pane.swift @@ -337,7 +337,15 @@ extension TerminalController { } let directionRaw = (v2String(params, "direction") ?? "").lowercased() - let amount = v2Int(params, "amount") ?? 1 + 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) } diff --git a/Sources/TerminalController+Surface.swift b/Sources/TerminalController+Surface.swift index 64f97fff698..0d6786156d1 100644 --- a/Sources/TerminalController+Surface.swift +++ b/Sources/TerminalController+Surface.swift @@ -274,7 +274,7 @@ extension TerminalController { } // Socket API must be non-interactive: bypass close-confirmation gating. - ws.closePanel(surfaceId, force: true) + _ = 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 result diff --git a/Sources/TerminalController+System.swift b/Sources/TerminalController+System.swift index a7bc93ef7ea..a6f8611a9cb 100644 --- a/Sources/TerminalController+System.swift +++ b/Sources/TerminalController+System.swift @@ -307,9 +307,9 @@ extension TerminalController { if shouldActivate { if let targetWindow { targetWindow.makeKeyAndOrderFront(nil) - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } else { - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7348f46401e..64703dc8864 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1,6 +1,6 @@ import AppKit import Carbon.HIToolbox -import Foundation +@preconcurrency import Foundation import Bonsplit import WebKit @@ -143,6 +143,9 @@ class TerminalController { .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: [:], @@ -1206,7 +1209,6 @@ class TerminalController { } var exitReason = "stopped" - var rearmRequested = false var resumeRequested = false defer { @@ -1295,7 +1297,6 @@ class TerminalController { exitReason = shouldRearmForFatalErrno ? "fatal_accept_error" : "persistent_accept_failures" - rearmRequested = true withListenerState { pendingAcceptLoopRearmGeneration = generation } @@ -1398,7 +1399,7 @@ class TerminalController { let pid = peerPid ?? getPeerPid(socket) if let pid { guard isDescendant(pid) else { - let msg = "ERROR: Access denied — only processes started inside cmux can connect\n" + let msg = "ERROR: Access denied — only processes started inside Programa can connect\n" msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) } return } @@ -1488,7 +1489,15 @@ class TerminalController { let id: Any? = dict["id"] let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let params = dict["params"] as? [String: Any] ?? [:] + let params: [String: Any] + if let rawParams = dict["params"] { + guard let objectParams = rawParams as? [String: Any] else { + return v2Error(id: id, code: "invalid_request", message: "params must be a JSON object") + } + params = objectParams + } else { + params = [:] + } guard !method.isEmpty else { return v2Error(id: id, code: "invalid_request", message: "Missing method") @@ -2244,6 +2253,9 @@ class TerminalController { } private func v2EnsureHandleRef(kind: V2HandleKind, uuid: UUID) -> String { + v2HandleRefStateLock.lock() + defer { v2HandleRefStateLock.unlock() } + if let existing = v2RefByUUID[kind]?[uuid] { return existing } @@ -2260,6 +2272,9 @@ class TerminalController { } private func v2ResolveHandleRef(_ handle: String) -> UUID? { + v2HandleRefStateLock.lock() + defer { v2HandleRefStateLock.unlock() } + for kind in V2HandleKind.allCases { if let id = v2UUIDByRef[kind]?[handle] { return id @@ -2406,10 +2421,7 @@ class TerminalController { return nil } func v2Int(_ params: [String: Any], _ key: String) -> Int? { - if let i = params[key] as? Int { return i } - if let n = params[key] as? NSNumber { return n.intValue } - if let s = params[key] as? String { return Int(s) } - return nil + v2StrictIntAny(params[key]) } func v2Double(_ params: [String: Any], _ key: String) -> Double? { @@ -2429,15 +2441,10 @@ class TerminalController { var result: [Int] = [] result.reserveCapacity(raw.count) for element in raw { - if let i = element as? Int { - result.append(i) - } else if let n = element as? NSNumber { - result.append(n.intValue) - } else if let s = element as? String, let i = Int(s) { - result.append(i) - } else { + guard let value = v2StrictIntAny(element) else { return nil } + result.append(value) } return result } diff --git a/Sources/TerminalDirectoryOpener.swift b/Sources/TerminalDirectoryOpener.swift index f40f4ef0e85..1b2d3073b91 100644 --- a/Sources/TerminalDirectoryOpener.swift +++ b/Sources/TerminalDirectoryOpener.swift @@ -85,13 +85,15 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { let homeDirectoryPath: String let fileExistsAtPath: (String) -> Bool let isExecutableFileAtPath: (String) -> Bool - let applicationPathForName: (String) -> String? + let applicationPathForBundleIdentifier: (String) -> String? static let live = DetectionEnvironment( homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, - applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } + applicationPathForBundleIdentifier: { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0)?.path + } ) } @@ -199,8 +201,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { // Fall back to LaunchServices so apps outside the standard bundle paths // still appear in the command palette. - for applicationName in applicationSearchNames { - guard let resolvedPath = environment.applicationPathForName(applicationName), + for bundleIdentifier in applicationBundleIdentifiers { + guard let resolvedPath = environment.applicationPathForBundleIdentifier(bundleIdentifier), environment.fileExistsAtPath(resolvedPath) else { continue } @@ -226,12 +228,23 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return uniquePreservingOrder(expanded) } - private var applicationSearchNames: [String] { - uniquePreservingOrder( - applicationBundlePathCandidates.map { - URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent - } - ) + private var applicationBundleIdentifiers: [String] { + switch self { + case .androidStudio: return ["com.google.android.studio"] + case .antigravity: return ["com.google.antigravity"] + case .cursor: return ["com.todesktop.230313mzl4w4u92"] + case .finder: return ["com.apple.finder"] + case .ghostty: return ["com.mitchellh.ghostty"] + case .intellij: return ["com.jetbrains.intellij"] + case .iterm2: return ["com.googlecode.iterm2"] + case .terminal: return ["com.apple.Terminal"] + case .tower: return ["com.fournova.Tower3", "com.fournova.Tower"] + case .vscode, .vscodeInline: return ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders"] + case .warp: return ["dev.warp.Warp-Stable"] + case .windsurf: return ["com.exafunction.windsurf"] + case .xcode: return ["com.apple.dt.Xcode"] + case .zed: return ["dev.zed.Zed", "dev.zed.Zed-Preview", "dev.zed.Zed-Nightly"] + } } private var applicationBundlePathCandidates: [String] { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index aa87c4b7abc..18adb117dd8 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -193,7 +193,9 @@ final class TerminalNotificationStore: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.refreshDockBadge() + MainActor.assumeIsolated { + self?.refreshDockBadge() + } } refreshDockBadge() refreshAuthorizationStatus() @@ -576,15 +578,18 @@ final class TerminalNotificationStore: ObservableObject { content: content, trigger: nil ) + let commandTitle = content.title + let commandSubtitle = content.subtitle + let commandBody = content.body self.center.add(request) { error in if let error { NSLog("Failed to schedule notification: \(error)") } else { NotificationSoundSettings.runCustomCommand( - title: content.title, - subtitle: content.subtitle, - body: content.body + title: commandTitle, + subtitle: commandSubtitle, + body: commandBody ) } } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 1c251a2fc11..d3a888335ad 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -265,7 +265,12 @@ class UpdateController { error: NSError( domain: "programa.update", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Updater is still starting. Try again in a moment."] + userInfo: [ + NSLocalizedDescriptionKey: String( + localized: "update.error.stillStarting", + defaultValue: "Updater is still starting. Try again in a moment." + ) + ] ), retry: { [weak self] in self?.checkForUpdates() }, dismiss: { [weak self] in self?.viewModel.state = .idle } diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 5fc2169eb71..117a8cdc790 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -128,6 +128,8 @@ private func describeNoUpdateFoundReason(_ reason: SPUNoUpdateFoundReason) -> St return "systemIsTooOld" case .systemIsTooNew: return "systemIsTooNew" + case .hardwareDoesNotSupportARM64: + return "hardwareDoesNotSupportARM64" @unknown default: return "unknown" } diff --git a/Sources/Update/UpdateLogStore.swift b/Sources/Update/UpdateLogStore.swift index ef57459b16c..6c43a8c4c49 100644 --- a/Sources/Update/UpdateLogStore.swift +++ b/Sources/Update/UpdateLogStore.swift @@ -55,7 +55,7 @@ final class UpdateLogStore { private func appendToFile(line: String) { let data = Data((line + "\n").utf8) if let handle = try? FileHandle(forWritingTo: logURL) { - try? handle.seekToEnd() + _ = try? handle.seekToEnd() try? handle.write(contentsOf: data) try? handle.close() } else { @@ -118,7 +118,7 @@ final class FocusLogStore { private func appendToFile(line: String) { let data = Data((line + "\n").utf8) if let handle = try? FileHandle(forWritingTo: logURL) { - try? handle.seekToEnd() + _ = try? handle.seekToEnd() try? handle.write(contentsOf: data) try? handle.close() } else { diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index c9e49ae137b..30b38aef7c6 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -309,15 +309,36 @@ func windowDragHandleShouldCaptureHit( /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// (e.g. sidebar tab reordering) don't move the whole window. struct WindowDragHandleView: NSViewRepresentable { + /// When `false` (and `onDoubleClick` is `nil`), double-clicks are left untouched + /// (no titlebar zoom/minimize, no drag capture) so an underlying SwiftUI gesture + /// can still fire. Defaults to `true` to preserve existing callers. + var handlesDoubleClick: Bool = true + + /// When set, this view owns double-click handling itself and invokes this closure + /// instead of the standard titlebar zoom/minimize action or the passthrough behavior. + /// Use this (rather than relying on a sibling SwiftUI `.onTapGesture(count: 2)`) when + /// this drag view is mounted in front of the gesture's content: a sibling gesture + /// recognizer never sees the first click of a double-click once this view has + /// claimed the hit-test for it, so passthrough-based double-click detection is + /// unreliable when this view is frontmost (see sidebar empty-area drag fix). + var onDoubleClick: (() -> Void)? + func makeNSView(context: Context) -> NSView { - DraggableView() + let view = DraggableView() + view.handlesDoubleClick = handlesDoubleClick + view.onDoubleClick = onDoubleClick + return view } func updateNSView(_ nsView: NSView, context: Context) { - // No-op + (nsView as? DraggableView)?.handlesDoubleClick = handlesDoubleClick + (nsView as? DraggableView)?.onDoubleClick = onDoubleClick } private final class DraggableView: NSView { + var handlesDoubleClick = true + var onDoubleClick: (() -> Void)? + override var mouseDownCanMoveWindow: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { @@ -329,6 +350,13 @@ struct WindowDragHandleView: NSViewRepresentable { guard currentEvent?.type == .leftMouseDown else { return nil } + // Let double-clicks pass through to whatever is underneath when this + // instance doesn't own double-click handling in any form (no zoom, no + // custom action). When `onDoubleClick` is set, this view owns the double + // click itself (see mouseDown) and must keep capturing the hit here too. + if !handlesDoubleClick, onDoubleClick == nil, (currentEvent?.clickCount ?? 1) >= 2 { + return nil + } let shouldCapture = windowDragHandleShouldCaptureHit( point, in: self, @@ -353,6 +381,20 @@ struct WindowDragHandleView: NSViewRepresentable { #endif if event.clickCount >= 2 { + if let onDoubleClick { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick action=custom") + #endif + onDoubleClick() + return + } + guard handlesDoubleClick else { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick skipped=handlesDoubleClickFalse") + #endif + super.mouseDown(with: event) + return + } let action = performStandardTitlebarDoubleClick(window: window) #if DEBUG dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))") diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index 3cfcfe6e7b7..5acaa4d8406 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -10,7 +10,7 @@ import Darwin import Network import CoreText -extension Workspace: BonsplitDelegate { +extension Workspace: @preconcurrency BonsplitDelegate { @MainActor private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager @@ -313,7 +313,7 @@ extension Workspace: BonsplitDelegate { reassertAppKitFocus: Bool ) { if let terminalPanel = panel as? TerminalPanel { - let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) + let shouldFocusTerminalSurface = reassertAppKitFocus && shouldMoveTerminalSurfaceFocus(for: focusIntent) terminalPanel.surface.setFocus(shouldFocusTerminalSurface) terminalPanel.hostedView.setActive(true) if reassertAppKitFocus && shouldFocusTerminalSurface { @@ -323,7 +323,8 @@ extension Workspace: BonsplitDelegate { } if let browserPanel = panel as? BrowserPanel { - guard shouldFocusBrowserWebView(for: focusIntent) else { return } + guard reassertAppKitFocus, + shouldFocusBrowserWebView(for: focusIntent) else { return } browserPanel.focus() return } @@ -1022,6 +1023,14 @@ extension Workspace: BonsplitDelegate { closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) case .move: promptMovePanel(tabId: tab.id) + case .moveToLeftPane: + guard let panelId = panelIdFromSurfaceId(tab.id), + let targetPane = controller.adjacentPane(to: pane, direction: .left) else { return } + moveSurface(panelId: panelId, toPane: targetPane) + case .moveToRightPane: + guard let panelId = panelIdFromSurfaceId(tab.id), + let targetPane = controller.adjacentPane(to: pane, direction: .right) else { return } + moveSurface(panelId: panelId, toPane: targetPane) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: diff --git a/Sources/Workspace+Layout.swift b/Sources/Workspace+Layout.swift index 93a6bacf0f4..6ee2059c770 100644 --- a/Sources/Workspace+Layout.swift +++ b/Sources/Workspace+Layout.swift @@ -1,6 +1,6 @@ // Extracted from Workspace.swift (nuclear-review #98): programa.json custom layout application (applyCustomLayout and its tree/pane helpers). -import Foundation +@preconcurrency import Foundation import SwiftUI import AppKit import Bonsplit @@ -10,6 +10,12 @@ import Darwin import Network import CoreText +@MainActor +private final class PendingTerminalInputState { + var resolved = false + var observer: NSObjectProtocol? +} + extension Workspace { func applyCustomLayout(_ layout: ProgramaLayoutNode, baseCwd: String) { @@ -206,24 +212,27 @@ extension Workspace { return } - var resolved = false - var observer: NSObjectProtocol? + let state = PendingTerminalInputState() - observer = NotificationCenter.default.addObserver( + state.observer = NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, object: panel.surface, queue: .main - ) { [weak panel] _ in - guard !resolved, let panel else { return } - resolved = true - if let observer { NotificationCenter.default.removeObserver(observer) } - panel.sendInput(text) + ) { [weak panel, state] _ in + MainActor.assumeIsolated { + guard !state.resolved, let panel else { return } + state.resolved = true + if let observer = state.observer { NotificationCenter.default.removeObserver(observer) } + state.observer = nil + panel.sendInput(text) + } } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - guard !resolved else { return } - resolved = true - if let observer { NotificationCenter.default.removeObserver(observer) } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { @MainActor [state] in + guard !state.resolved else { return } + state.resolved = true + if let observer = state.observer { NotificationCenter.default.removeObserver(observer) } + state.observer = nil NSLog("[ProgramaConfig] surface not ready after 3s, dropping command (%d chars)", text.count) } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c631db164b6..92e569ab8ff 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4339,6 +4339,21 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: preferredPanelId, splitPanelId: splitPanelId ) + let focusTransitionRequest: FocusTransitionCoordinator.Request? = { + guard let owningTabManager, + let panel = panels[preferredPanelId] else { + return nil + } + let owner = FocusTransitionCoordinator.Owner( + workspaceID: id, + panelID: preferredPanelId, + intent: panel.preferredFocusIntentForActivation() + ) + return owningTabManager.focusTransitionCoordinator.captureCurrentGeneration( + for: owner, + reason: .nonFocusSplit + ) + }() // Bonsplit splitPane focuses the newly created pane and may emit one delayed // didSelect/didFocus callback. Re-assert focus over multiple turns so model @@ -4348,7 +4363,8 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, - allowPreviousHostedView: true + allowPreviousHostedView: true, + focusTransitionRequest: focusTransitionRequest ) DispatchQueue.main.async { [weak self] in @@ -4358,7 +4374,8 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, - allowPreviousHostedView: false + allowPreviousHostedView: false, + focusTransitionRequest: focusTransitionRequest ) DispatchQueue.main.async { [weak self] in @@ -4368,7 +4385,8 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, - allowPreviousHostedView: false + allowPreviousHostedView: false, + focusTransitionRequest: focusTransitionRequest ) self.scheduleFocusReconcile() self.clearNonFocusSplitFocusReassert(generation: generation) @@ -4381,7 +4399,8 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: UUID, splitPanelId: UUID, previousHostedView: GhosttySurfaceScrollView?, - allowPreviousHostedView: Bool + allowPreviousHostedView: Bool, + focusTransitionRequest: FocusTransitionCoordinator.Request? ) { guard matchesPendingNonFocusSplitFocusReassert( generation: generation, @@ -4391,15 +4410,23 @@ final class Workspace: Identifiable, ObservableObject { return } + if let focusTransitionRequest, + owningTabManager?.focusTransitionCoordinator.isCurrentGeneration(focusTransitionRequest) != true { + clearNonFocusSplitFocusReassert(generation: generation) + return + } + guard panels[preferredPanelId] != nil else { clearNonFocusSplitFocusReassert(generation: generation) return } if focusedPanelId == splitPanelId { + let shouldReassertAppKitFocus = owningTabManager.map { $0.selectedTabId == id } ?? true focusPanel( preferredPanelId, - previousHostedView: allowPreviousHostedView ? previousHostedView : nil + previousHostedView: allowPreviousHostedView ? previousHostedView : nil, + reassertAppKitFocus: shouldReassertAppKitFocus ) return } @@ -4408,12 +4435,16 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = terminalPanel(for: preferredPanelId) else { return } + if let owningTabManager, owningTabManager.selectedTabId != id { + return + } terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } func focusPanel( _ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil, + reassertAppKitFocus: Bool = true, trigger: FocusPanelTrigger = .standard ) { markExplicitFocusIntent(on: panelId) @@ -4494,13 +4525,14 @@ final class Workspace: Identifiable, ObservableObject { applyTabSelection( tabId: tabId, inPane: targetPaneId, - reassertAppKitFocus: !shouldSuppressReentrantRefocus, + reassertAppKitFocus: reassertAppKitFocus && !shouldSuppressReentrantRefocus, focusIntent: activationIntent, previousTerminalHostedView: previousTerminalHostedView ) } - if let browserPanel = panels[panelId] as? BrowserPanel { + if reassertAppKitFocus, + let browserPanel = panels[panelId] as? BrowserPanel { maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 1976fb5adb5..938df18eb36 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -793,20 +793,20 @@ struct EmptyPanelView: View { .font(.system(size: 48)) .foregroundStyle(.tertiary) - Text("Empty Panel") + Text(String(localized: "workspace.emptyPanel.title", defaultValue: "Empty Panel")) .font(.headline) .foregroundStyle(.secondary) HStack(spacing: 12) { emptyPaneActionButton( - title: "Terminal", + title: String(localized: "workspace.emptyPanel.terminal", defaultValue: "Terminal"), systemImage: "terminal.fill", shortcut: newSurfaceShortcut, action: createTerminal ) emptyPaneActionButton( - title: "Browser", + title: String(localized: "workspace.emptyPanel.browser", defaultValue: "Browser"), systemImage: "globe", shortcut: openBrowserShortcut, action: createBrowser diff --git a/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift index b4f0ec17e67..b6a086dd7fa 100644 --- a/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift +++ b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift @@ -10,6 +10,64 @@ import Darwin import Network import CoreText +struct WorkspaceRemoteLoopbackProxyRoute: Equatable { + let targetHost: String + let rewriteAliasHost: String? +} + +enum WorkspaceRemoteLoopbackPolicy { + static let canonicalAliasHost = "cmux-loopback.localtest.me" + + private static let legacyAliasHosts: Set = [ + "programa-loopback.localtest.me", + ] + private static let acceptedAliasHosts = legacyAliasHosts.union([canonicalAliasHost]) + private static let sourceHosts: Set = [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + ] + + static func browserAliasURL(for url: URL) -> URL? { + guard url.scheme?.lowercased() == "http" else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), + sourceHosts.contains(host) else { + return nil + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = canonicalAliasHost + return components?.url + } + + static func displayURL(for url: URL?) -> URL? { + guard let url else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), + acceptedAliasHosts.contains(host) else { + return url + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = "localhost" + return components?.url ?? url + } + + static func proxyRoute(for host: String) -> WorkspaceRemoteLoopbackProxyRoute { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + guard acceptedAliasHosts.contains(normalized) else { + return WorkspaceRemoteLoopbackProxyRoute(targetHost: host, rewriteAliasHost: nil) + } + return WorkspaceRemoteLoopbackProxyRoute( + targetHost: "127.0.0.1", + rewriteAliasHost: normalized + ) + } +} + enum RemoteLoopbackHTTPRequestRewriter { private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) private static let canonicalLoopbackHost = "localhost" diff --git a/Sources/WorkspaceRemoteProxyBroker.swift b/Sources/WorkspaceRemoteProxyBroker.swift index 3e70ea294e7..786c4a0f742 100644 --- a/Sources/WorkspaceRemoteProxyBroker.swift +++ b/Sources/WorkspaceRemoteProxyBroker.swift @@ -10,10 +10,43 @@ import Darwin import Network import CoreText +/// Resolves the host contract used by the live proxy session. This is intentionally +/// factored out as a small runtime seam so browser URL rewriting and proxy routing +/// can be regression-tested together. +func workspaceRemoteLoopbackProxyRoute(for host: String) -> WorkspaceRemoteLoopbackProxyRoute { + WorkspaceRemoteLoopbackPolicy.proxyRoute(for: host) +} + +/// Chooses the executor used by each accepted proxy connection. Keeping this decision +/// in the live path makes cross-session scheduling behavior directly testable. +final class WorkspaceRemoteProxySessionQueueProvider { + private let queueLabelPrefix: String + private var queuesBySessionID: [UUID: DispatchQueue] = [:] + + init(tunnelQueue: DispatchQueue) { + self.queueLabelPrefix = "\(tunnelQueue.label).session" + } + + func queue(for sessionID: UUID) -> DispatchQueue { + if let existing = queuesBySessionID[sessionID] { + return existing + } + let queue = DispatchQueue( + label: "\(queueLabelPrefix).\(sessionID.uuidString)", + qos: .utility + ) + queuesBySessionID[sessionID] = queue + return queue + } + + func removeQueue(for sessionID: UUID) { + queuesBySessionID.removeValue(forKey: sessionID) + } +} + private final class WorkspaceRemoteDaemonProxyTunnel { private final class ProxySession { private static let maxHandshakeBytes = 64 * 1024 - private static let remoteLoopbackProxyAliasHost = "programa-loopback.localtest.me" private enum HandshakeProtocol { case undecided @@ -33,7 +66,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { let consumedBytes: Int } - let id = UUID() + let id: UUID private let connection: NWConnection private let rpcClient: WorkspaceRemoteDaemonRPCClient @@ -47,16 +80,19 @@ private final class WorkspaceRemoteDaemonProxyTunnel { private var streamID: String? private var localInputEOF = false private var rewritesLoopbackHTTPHeaders = false + private var loopbackRewriteAliasHost: String? private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? private var pendingRemoteHTTPHeaderBytes = Data() private var hasForwardedRemoteHTTPHeaders = false init( + id: UUID, connection: NWConnection, rpcClient: WorkspaceRemoteDaemonRPCClient, queue: DispatchQueue, onClose: @escaping (UUID) -> Void ) { + self.id = id self.connection = connection self.rpcClient = rpcClient self.queue = queue @@ -64,6 +100,13 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } func start() { + queue.async { [weak self] in + self?.startOnSessionQueue() + } + } + + private func startOnSessionQueue() { + guard !isClosed else { return } connection.stateUpdateHandler = { [weak self] state in guard let self else { return } switch state { @@ -80,7 +123,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } func stop() { - close(reason: nil) + queue.async { [weak self] in + self?.close(reason: nil) + } } private func receiveNext() { @@ -297,16 +342,19 @@ private final class WorkspaceRemoteDaemonProxyTunnel { ) { guard !isClosed else { return } do { - rewritesLoopbackHTTPHeaders = - BrowserInsecureHTTPSettings.normalizeHost(host) - == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) - loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders - ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) - : nil + let route = workspaceRemoteLoopbackProxyRoute(for: host) + rewritesLoopbackHTTPHeaders = route.rewriteAliasHost != nil + loopbackRewriteAliasHost = route.rewriteAliasHost + if let rewriteAliasHost = route.rewriteAliasHost { + loopbackRequestHeaderRewriter = RemoteLoopbackHTTPRequestStreamRewriter( + aliasHost: rewriteAliasHost + ) + } else { + loopbackRequestHeaderRewriter = nil + } pendingRemoteHTTPHeaderBytes = Data() hasForwardedRemoteHTTPHeaders = false - let targetHost = Self.normalizedProxyTargetHost(host) - let streamID = try rpcClient.openStream(host: targetHost, port: port) + let streamID = try rpcClient.openStream(host: route.targetHost, port: port) self.streamID = streamID try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in self?.handleRemoteStreamEvent(streamID: streamID, event: event) @@ -402,9 +450,12 @@ private final class WorkspaceRemoteDaemonProxyTunnel { hasForwardedRemoteHTTPHeaders = true let payload = pendingRemoteHTTPHeaderBytes pendingRemoteHTTPHeaderBytes = Data() + guard let rewriteAliasHost = loopbackRewriteAliasHost else { + return payload + } return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( data: payload, - aliasHost: Self.remoteLoopbackProxyAliasHost + aliasHost: rewriteAliasHost ) } @@ -461,21 +512,8 @@ private final class WorkspaceRemoteDaemonProxyTunnel { return (host, port) } - private static func normalizedProxyTargetHost(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed - .trimmingCharacters(in: CharacterSet(charactersIn: ".")) - .lowercased() - // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. - // Resolve it back to true loopback before dialing from the remote daemon. - if normalized == remoteLoopbackProxyAliasHost { - return "127.0.0.1" - } - return host - } - private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { - var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + var text = "HTTP/1.1 \(status)\r\nProxy-Agent: Programa\r\n" if closeAfterResponse { text += "Connection: close\r\n" } @@ -489,6 +527,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { private let localPort: Int private let onFatalError: (String) -> Void private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) + private lazy var sessionQueueProvider = WorkspaceRemoteProxySessionQueueProvider(tunnelQueue: queue) private var listener: NWListener? private var rpcClient: WorkspaceRemoteDaemonRPCClient? @@ -578,13 +617,16 @@ private final class WorkspaceRemoteDaemonProxyTunnel { return } + let sessionID = UUID() let session = ProxySession( + id: sessionID, connection: connection, rpcClient: rpcClient, - queue: queue + queue: sessionQueueProvider.queue(for: sessionID) ) { [weak self] id in self?.queue.async { self?.sessions.removeValue(forKey: id) + self?.sessionQueueProvider.removeQueue(for: id) } } sessions[session.id] = session diff --git a/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift index c78e0567d03..cde9b1a403c 100644 --- a/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift +++ b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift @@ -264,7 +264,7 @@ extension WorkspaceRemoteSessionController { guard let manifestURL = URL(string: "\(releaseURL)/programad-remote-manifest.json") else { return nil } let request = NSMutableURLRequest(url: manifestURL) request.timeoutInterval = 15 - request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + request.setValue("programa/\(version)", forHTTPHeaderField: "User-Agent") let session = URLSession(configuration: .ephemeral) let semaphore = DispatchSemaphore(value: 0) var resultData: Data? @@ -294,7 +294,7 @@ extension WorkspaceRemoteSessionController { let request = NSMutableURLRequest(url: url) request.timeoutInterval = 60 - request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + request.setValue("programa/\(version)", forHTTPHeaderField: "User-Agent") let session = URLSession(configuration: .ephemeral) let semaphore = DispatchSemaphore(value: 0) @@ -382,25 +382,44 @@ extension WorkspaceRemoteSessionController { guard Self.allowLocalDaemonBuildFallback() else { throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "this build does not include a verified programad-remote manifest for \(goOS)-\(goArch). Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + NSLocalizedDescriptionKey: String( + format: String( + localized: "remoteDaemon.error.missingVerifiedManifest", + defaultValue: "This build does not include a verified programad-remote manifest for %@-%@. Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback." + ), + goOS, + goArch + ), ]) } guard let repoRoot = Self.findRepoRoot() else { throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only programad-remote build fallback", + NSLocalizedDescriptionKey: String( + localized: "remoteDaemon.error.repoRootNotFound", + defaultValue: "Cannot locate the Programa repository root for the development-only programad-remote build fallback." + ), ]) } let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) let goModPath = daemonRoot.appendingPathComponent("go.mod").path guard FileManager.default.fileExists(atPath: goModPath) else { throw NSError(domain: "programa.remote.daemon", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", + NSLocalizedDescriptionKey: String( + format: String( + localized: "remoteDaemon.error.missingDaemonModule", + defaultValue: "Missing daemon module at %@." + ), + goModPath + ), ]) } guard let goBinary = Self.which("go") else { throw NSError(domain: "programa.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "go is required for the dev-only programad-remote build fallback", + NSLocalizedDescriptionKey: String( + localized: "remoteDaemon.error.goRequired", + defaultValue: "Go is required for the development-only programad-remote build fallback." + ), ]) } diff --git a/daemon/remote/cmd/programad-remote/agent_launch.go b/daemon/remote/cmd/programad-remote/agent_launch.go index d41e55c4b13..46e79a2a1a6 100644 --- a/daemon/remote/cmd/programad-remote/agent_launch.go +++ b/daemon/remote/cmd/programad-remote/agent_launch.go @@ -3,9 +3,11 @@ package main import ( "encoding/json" "fmt" + "net" "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -147,28 +149,55 @@ func runAgentRelay(socketPath string, args []string, refreshAddr func() string, return 1 } -// omoLaunchArgs implements omo's --port default: it sets OPENCODE_PORT if -// unset, then injects "--port " into the CLI args unless the caller -// already passed --port/--port=. +// omoLaunchArgs implements omo's --port default. Explicit caller choices are +// preserved; otherwise it prefers a bindable OPENCODE_PORT, then 4096, then an +// ephemeral loopback port. func omoLaunchArgs(args []string) []string { - if os.Getenv("OPENCODE_PORT") == "" { - os.Setenv("OPENCODE_PORT", "4096") - } - hasPort := false for _, arg := range args { if arg == "--port" || strings.HasPrefix(arg, "--port=") { - hasPort = true - break + return args + } + } + + selectedPort := 0 + if rawPort := strings.TrimSpace(os.Getenv("OPENCODE_PORT")); rawPort != "" { + if port, err := strconv.Atoi(rawPort); err == nil && port > 0 && port <= 65535 { + if bindablePort, ok := omoBindableLoopbackPort(port); ok { + selectedPort = bindablePort + } } } - if !hasPort { - port := os.Getenv("OPENCODE_PORT") - if port == "" { - port = "4096" + if selectedPort == 0 { + if bindablePort, ok := omoBindableLoopbackPort(4096); ok { + selectedPort = bindablePort } - return append([]string{"--port", port}, args...) } - return args + if selectedPort == 0 { + if bindablePort, ok := omoBindableLoopbackPort(0); ok { + selectedPort = bindablePort + } + } + if selectedPort == 0 { + // Match the local CLI's last-resort behavior when the bind probe itself + // is unavailable; OpenCode will surface the actual bind failure. + selectedPort = 4096 + } + + port := strconv.Itoa(selectedPort) + os.Setenv("OPENCODE_PORT", port) + return append([]string{"--port", port}, args...) +} + +func omoBindableLoopbackPort(port int) (int, bool) { + listener, err := net.ListenTCP("tcp4", &net.TCPAddr{ + IP: net.IPv4(127, 0, 0, 1), + Port: port, + }) + if err != nil { + return 0, false + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, true } // runClaudeTeamsRelay implements `programa claude-teams` on the remote side. @@ -310,7 +339,7 @@ func createTmuxShimDir(dirName string, tmuxScript string) (string, error) { if err != nil { return "", err } - dir := filepath.Join(home, ".programaterm", dirName) + dir := filepath.Join(home, ".programa", dirName) if err := os.MkdirAll(dir, 0755); err != nil { return "", err } @@ -561,7 +590,33 @@ func omoUserConfigDir() string { func omoShadowConfigDir() string { home, _ := os.UserHomeDir() - return filepath.Join(home, ".programaterm", "omo-config") + return filepath.Join(home, ".programa", "omo-config") +} + +func ensureOMOShadowPackageManifest(path string) error { + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + if err := os.Remove(path); err != nil { + return err + } + } + } else if !os.IsNotExist(err) { + return err + } + + manifest := map[string]any{ + "dependencies": map[string]string{ + omoPluginName: "latest", + }, + "name": "programa-omo-shadow", + "private": true, + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0644) } // omoEnsurePlugin creates a shadow config directory that layers the @@ -628,13 +683,19 @@ func omoEnsurePlugin(searchPath string) error { } } - // Symlink package.json and bun.lock - for _, filename := range []string{"package.json", "bun.lock"} { - userFile := filepath.Join(userDir, filename) - shadowFile := filepath.Join(shadowDir, filename) - if fileExists(userFile) && !fileExists(shadowFile) { - os.Symlink(userFile, shadowFile) + // The shadow config owns its package metadata so stale or yanked pins in + // the user's package.json/bun.lock cannot poison plugin installation. + shadowPackagePath := filepath.Join(shadowDir, "package.json") + if err := ensureOMOShadowPackageManifest(shadowPackagePath); err != nil { + return fmt.Errorf("write shadow package manifest: %w", err) + } + shadowLockPath := filepath.Join(shadowDir, "bun.lock") + if info, err := os.Lstat(shadowLockPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + if err := os.Remove(shadowLockPath); err != nil { + return fmt.Errorf("remove shadow lockfile symlink: %w", err) } + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("inspect shadow lockfile: %w", err) } // Symlink oh-my-opencode config files @@ -649,11 +710,16 @@ func omoEnsurePlugin(searchPath string) error { // Install the plugin if not available pluginPackageDir := filepath.Join(shadowNodeModules, omoPluginName) if !dirExists(pluginPackageDir) { - installDir := userDir - if !dirExists(userNodeModules) { - installDir = shadowDir - os.Remove(shadowNodeModules) // Remove symlink so we can install directly + // A missing plugin must be installed into the shadow config, not into + // the user's package state through the node_modules compatibility link. + if info, err := os.Lstat(shadowNodeModules); err == nil && info.Mode()&os.ModeSymlink != 0 { + if err := os.Remove(shadowNodeModules); err != nil { + return fmt.Errorf("remove shadow node_modules symlink: %w", err) + } + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("inspect shadow node_modules: %w", err) } + installDir := shadowDir os.MkdirAll(installDir, 0755) bunPath := findExecutableInPath("bun", searchPath, "") @@ -676,11 +742,6 @@ func omoEnsurePlugin(searchPath string) error { return fmt.Errorf("failed to install oh-my-opencode: %v\nTry manually: npm install -g oh-my-opencode", err) } fmt.Fprintf(os.Stderr, "oh-my-opencode plugin installed\n") - - // Re-create symlink if we installed into user dir - if installDir == userDir && !fileExists(shadowNodeModules) { - os.Symlink(userNodeModules, shadowNodeModules) - } } // Configure oh-my-opencode.json with tmux settings diff --git a/daemon/remote/cmd/programad-remote/tmux_compat_test.go b/daemon/remote/cmd/programad-remote/tmux_compat_test.go index f494c71c032..2f79a9cc725 100644 --- a/daemon/remote/cmd/programad-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/programad-remote/tmux_compat_test.go @@ -6,6 +6,7 @@ import ( "net" "os" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -284,6 +285,21 @@ func TestCreateTmuxShimDir(t *testing.T) { } } +func TestCreateTmuxShimDirUsesProgramaHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dir, err := createTmuxShimDir("test-shim-bin", claudeTeamsShimScript) + if err != nil { + t.Fatalf("createTmuxShimDir: %v", err) + } + + want := filepath.Join(home, ".programa", "test-shim-bin") + if dir != want { + t.Fatalf("createTmuxShimDir returned %q, want Programa-owned path %q", dir, want) + } +} + func TestCreateOMOShimDir(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") @@ -395,6 +411,128 @@ func TestClaudeTeamsLaunchArgs(t *testing.T) { } } +func TestOMOLaunchArgsAvoidsOccupiedEnvironmentPort(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve occupied loopback port: %v", err) + } + defer listener.Close() + + occupiedPort := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) + t.Setenv("OPENCODE_PORT", occupiedPort) + + args := omoLaunchArgs(nil) + if len(args) != 2 || args[0] != "--port" { + t.Fatalf("omoLaunchArgs(nil) = %v, want injected --port ", args) + } + if args[1] == occupiedPort { + t.Fatalf("omoLaunchArgs selected occupied OPENCODE_PORT %s", occupiedPort) + } + if args[1] == "" || args[1] == "0" { + t.Fatalf("omoLaunchArgs selected invalid port %q", args[1]) + } + if got := os.Getenv("OPENCODE_PORT"); got != args[1] { + t.Fatalf("OPENCODE_PORT = %q, want selected launch port %q", got, args[1]) + } +} + +func TestOMOLaunchArgsPreservesExplicitOccupiedPort(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve occupied loopback port: %v", err) + } + defer listener.Close() + + occupiedPort := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) + t.Setenv("OPENCODE_PORT", "4096") + explicit := "--port=" + occupiedPort + + args := omoLaunchArgs([]string{explicit, "--verbose"}) + if len(args) != 2 || args[0] != explicit || args[1] != "--verbose" { + t.Fatalf("omoLaunchArgs changed explicit caller port: %v", args) + } +} + +func TestOMOPluginShadowOwnsPackageState(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + userDir := filepath.Join(home, ".config", "opencode") + userNodeModules := filepath.Join(userDir, "node_modules") + pluginDir := filepath.Join(userNodeModules, omoPluginName) + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("create installed plugin fixture: %v", err) + } + + userConfig := []byte("{}\n") + userPackage := []byte("{\"dependencies\":{\"stale-package\":\"0.0.1\"}}\n") + userLock := []byte("stale-user-lock\n") + if err := os.WriteFile(filepath.Join(userDir, "opencode.json"), userConfig, 0644); err != nil { + t.Fatalf("write user opencode config: %v", err) + } + if err := os.WriteFile(filepath.Join(userDir, "package.json"), userPackage, 0644); err != nil { + t.Fatalf("write user package manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(userDir, "bun.lock"), userLock, 0644); err != nil { + t.Fatalf("write user lockfile: %v", err) + } + + if err := omoEnsurePlugin(os.Getenv("PATH")); err != nil { + t.Fatalf("omoEnsurePlugin: %v", err) + } + + shadowDir := omoShadowConfigDir() + shadowPackagePath := filepath.Join(shadowDir, "package.json") + shadowPackageInfo, err := os.Lstat(shadowPackagePath) + if err != nil { + t.Fatalf("stat shadow package manifest: %v", err) + } + if shadowPackageInfo.Mode()&os.ModeSymlink != 0 { + t.Fatalf("shadow package manifest must not symlink user package state: %s", shadowPackagePath) + } + if !shadowPackageInfo.Mode().IsRegular() { + t.Fatalf("shadow package manifest is not a regular file: mode=%s", shadowPackageInfo.Mode()) + } + + shadowPackageData, err := os.ReadFile(shadowPackagePath) + if err != nil { + t.Fatalf("read shadow package manifest: %v", err) + } + var shadowPackage map[string]any + if err := json.Unmarshal(shadowPackageData, &shadowPackage); err != nil { + t.Fatalf("decode shadow package manifest: %v", err) + } + if private, _ := shadowPackage["private"].(bool); !private { + t.Fatalf("shadow package manifest must be private: %s", shadowPackageData) + } + dependencies, _ := shadowPackage["dependencies"].(map[string]any) + if got, _ := dependencies[omoPluginName].(string); got != "latest" { + t.Fatalf("shadow package dependency %q = %q, want latest", omoPluginName, got) + } + + shadowLockPath := filepath.Join(shadowDir, "bun.lock") + if info, err := os.Lstat(shadowLockPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("shadow lockfile must not symlink user lock state: %s", shadowLockPath) + } else if err != nil && !os.IsNotExist(err) { + t.Fatalf("stat shadow lockfile: %v", err) + } + + gotUserPackage, err := os.ReadFile(filepath.Join(userDir, "package.json")) + if err != nil { + t.Fatalf("read user package manifest after setup: %v", err) + } + if string(gotUserPackage) != string(userPackage) { + t.Fatalf("user package manifest changed: got %q, want %q", gotUserPackage, userPackage) + } + gotUserLock, err := os.ReadFile(filepath.Join(userDir, "bun.lock")) + if err != nil { + t.Fatalf("read user lockfile after setup: %v", err) + } + if string(gotUserLock) != string(userLock) { + t.Fatalf("user lockfile changed: got %q, want %q", gotUserLock, userLock) + } +} + func TestMergeNodeOptions(t *testing.T) { const restoreModulePath = "/tmp/restore-node-options.cjs" diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index f94f8082ef3..a308ded42e9 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -14,6 +14,45 @@ import UserNotifications @testable import Programa #endif +private actor BrowserSuggestionRequestRecorder { + private(set) var requestedHosts: [String] = [] + + func load(_ request: URLRequest) throws -> (Data, URLResponse) { + guard let url = request.url else { + throw URLError(.badURL) + } + requestedHosts.append(url.host ?? "") + guard let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) else { + throw URLError(.badServerResponse) + } + return (Data("[]".utf8), response) + } +} + +final class BrowserSearchSuggestionServicePrivacyTests: XCTestCase { + func testGoogleSuggestionsRequestOnlyTheSelectedProvider() async throws { + let recorder = BrowserSuggestionRequestRecorder() + let service = BrowserSearchSuggestionService { request in + try await recorder.load(request) + } + + let suggestions = await service.suggestions(engine: .google, query: "private query") + let requestedHosts = await recorder.requestedHosts + + XCTAssertTrue(suggestions.isEmpty) + XCTAssertEqual( + requestedHosts, + ["suggestqueries.google.com"], + "Selecting Google must not disclose the query to DuckDuckGo or Bing fallback endpoints" + ) + } +} + var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 var cmuxUnitTestInspectorOverrideInstalled = false var cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled = false diff --git a/programaTests/GhosttyConfigTests.swift b/programaTests/GhosttyConfigTests.swift index 2c6ef5c3840..19b026ed3f5 100644 --- a/programaTests/GhosttyConfigTests.swift +++ b/programaTests/GhosttyConfigTests.swift @@ -782,6 +782,57 @@ final class WorkspaceRemoteDaemonManifestTests: XCTestCase { } final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { + @MainActor + func testBrowserEmittedLoopbackAliasRoutesToRemoteLoopbackAndEnablesHeaderRewriting() throws { + let localhostURL = try XCTUnwrap(URL(string: "http://localhost:3000/demo")) + let browserURL = try XCTUnwrap(BrowserPanel.remoteProxyLoopbackAliasURL(for: localhostURL)) + let emittedHost = try XCTUnwrap(browserURL.host) + let route = workspaceRemoteLoopbackProxyRoute(for: emittedHost) + + XCTAssertEqual(route.targetHost, "127.0.0.1") + let rewriteAlias = try XCTUnwrap( + route.rewriteAliasHost, + "The exact alias emitted by BrowserPanel must enable proxy request/response rewriting" + ) + + let original = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: \(emittedHost):3000\r\n" + + "Origin: http://\(emittedHost):3000\r\n" + + "\r\n" + ).utf8 + ) + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: rewriteAlias + ) + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + } + + func testLegacyProgramaLoopbackAliasStillRoutesAndRewritesHeaders() throws { + let legacyAlias = "programa-loopback.localtest.me" + let route = workspaceRemoteLoopbackProxyRoute(for: legacyAlias) + + XCTAssertEqual(route.targetHost, "127.0.0.1") + XCTAssertEqual(route.rewriteAliasHost, legacyAlias) + + let original = Data( + ( + "GET /legacy HTTP/1.1\r\n" + + "Host: \(legacyAlias):3000\r\n" + + "\r\n" + ).utf8 + ) + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: try XCTUnwrap(route.rewriteAliasHost) + ) + XCTAssertTrue(String(decoding: rewritten, as: UTF8.self).contains("Host: localhost:3000")) + } + func testRewritesLoopbackAliasHostHeadersToLocalhost() { let original = Data( ( @@ -1416,6 +1467,36 @@ final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { } } +final class WorkspaceRemoteProxySessionQueueProviderTests: XCTestCase { + func testStalledProxySessionDoesNotBlockSecondAcceptedSession() { + let tunnelQueue = DispatchQueue(label: "programa.tests.remote-proxy.tunnel") + let provider = WorkspaceRemoteProxySessionQueueProvider(tunnelQueue: tunnelQueue) + let firstSessionQueue = provider.queue(for: UUID()) + let secondSessionQueue = provider.queue(for: UUID()) + let firstStarted = DispatchSemaphore(value: 0) + let releaseFirst = DispatchSemaphore(value: 0) + let secondServed = DispatchSemaphore(value: 0) + + firstSessionQueue.async { + firstStarted.signal() + _ = releaseFirst.wait(timeout: .now() + 2) + } + XCTAssertEqual(firstStarted.wait(timeout: .now() + 1), .success) + + secondSessionQueue.async { + secondServed.signal() + } + let secondResult = secondServed.wait(timeout: .now() + 0.2) + releaseFirst.signal() + + XCTAssertEqual( + secondResult, + .success, + "A stalled proxy connection must not occupy the executor used to serve another accepted connection" + ) + } +} + final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index b14e0f2f2fa..46224c85904 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -139,6 +139,72 @@ final class SessionPersistenceTests: XCTestCase { ) } + @MainActor + func testAutosaveFingerprintChangesWhenPersistedStatusValueChangesWithoutCountChange() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + + workspace.statusEntries["build"] = SidebarStatusEntry( + key: "build", + value: "queued", + timestamp: timestamp + ) + let beforeSnapshot = manager.sessionSnapshot(includeScrollback: false) + let beforeFingerprint = manager.sessionAutosaveFingerprint() + + workspace.statusEntries["build"] = SidebarStatusEntry( + key: "build", + value: "complete", + timestamp: timestamp + ) + let afterSnapshot = manager.sessionSnapshot(includeScrollback: false) + let afterFingerprint = manager.sessionAutosaveFingerprint() + + XCTAssertEqual( + beforeSnapshot.workspaces.first?.statusEntries.first?.value, + "queued" + ) + XCTAssertEqual( + afterSnapshot.workspaces.first?.statusEntries.first?.value, + "complete" + ) + XCTAssertNotEqual( + beforeFingerprint, + afterFingerprint, + "Autosave identity must change whenever persisted status content changes" + ) + } + + @MainActor + func testAutosaveFingerprintChangesWhenPersistedPanelTitleChangesWithoutCountChange() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelID = try XCTUnwrap(workspace.focusedPanelId) + + workspace.setPanelCustomTitle(panelId: panelID, title: "Before") + let beforeSnapshot = manager.sessionSnapshot(includeScrollback: false) + let beforeFingerprint = manager.sessionAutosaveFingerprint() + + workspace.setPanelCustomTitle(panelId: panelID, title: "After") + let afterSnapshot = manager.sessionSnapshot(includeScrollback: false) + let afterFingerprint = manager.sessionAutosaveFingerprint() + + XCTAssertEqual( + beforeSnapshot.workspaces.first?.panels.first(where: { $0.id == panelID })?.customTitle, + "Before" + ) + XCTAssertEqual( + afterSnapshot.workspaces.first?.panels.first(where: { $0.id == panelID })?.customTitle, + "After" + ) + XCTAssertNotEqual( + beforeFingerprint, + afterFingerprint, + "Autosave identity must change whenever persisted panel content changes" + ) + } + func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws { var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) snapshot.windows[0].tabManager.workspaces[0].customColor = nil diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index 7268a21e68c..7c8b4699f0d 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -1300,13 +1300,13 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { private func environment( existingPaths: Set, homeDirectoryPath: String = "/Users/tester", - applicationPathsByName: [String: String] = [:] + applicationPathsByBundleIdentifier: [String: String] = [:] ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { TerminalDirectoryOpenTarget.DetectionEnvironment( homeDirectoryPath: homeDirectoryPath, fileExistsAtPath: { existingPaths.contains($0) }, isExecutableFileAtPath: { existingPaths.contains($0) }, - applicationPathForName: { applicationPathsByName[$0] } + applicationPathForBundleIdentifier: { applicationPathsByBundleIdentifier[$0] } ) } @@ -1368,8 +1368,8 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { vscodePath, "\(vscodePath)/Contents/Resources/app/bin/code-tunnel", ], - applicationPathsByName: [ - "Code": vscodePath, + applicationPathsByBundleIdentifier: [ + "com.microsoft.VSCode": vscodePath, ] ) @@ -1382,8 +1382,8 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { let towerPath = "/Volumes/Setapp/Tower.app" let env = environment( existingPaths: [towerPath], - applicationPathsByName: [ - "Tower": towerPath, + applicationPathsByBundleIdentifier: [ + "com.fournova.Tower3": towerPath, ] ) diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 471eb4fedf4..aa6b84c81a6 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -766,6 +766,119 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { ) } + func testInvalidAppEnumDoesNotPreventValidLaterSiblingSettings() throws { + let defaults = UserDefaults.standard + let backupsKey = "programa.settingsFile.backups.v1" + let appearanceKey = AppearanceSettings.appearanceModeKey + let placementKey = WorkspacePlacementSettings.placementKey + let warningKey = QuitWarningSettings.warnBeforeQuitKey + let previousAppearance = defaults.object(forKey: appearanceKey) + let previousPlacement = defaults.object(forKey: placementKey) + let previousWarning = defaults.object(forKey: warningKey) + let previousBackups = defaults.data(forKey: backupsKey) + defer { + restoreDefaultsValue(previousAppearance, key: appearanceKey, defaults: defaults) + restoreDefaultsValue(previousPlacement, key: placementKey, defaults: defaults) + restoreDefaultsValue(previousWarning, key: warningKey, defaults: defaults) + if let previousBackups { + defaults.set(previousBackups, forKey: backupsKey) + } else { + defaults.removeObject(forKey: backupsKey) + } + } + + defaults.set(AppearanceMode.system.rawValue, forKey: appearanceKey) + defaults.set(NewWorkspacePlacement.top.rawValue, forKey: placementKey) + defaults.set(true, forKey: warningKey) + defaults.removeObject(forKey: backupsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let settingsFileURL = directoryURL.appendingPathComponent("settings.json") + try writeSettingsFile( + """ + { + "app": { + "appearance": "not-a-real-mode", + "newWorkspacePlacement": "end", + "warnBeforeQuit": false + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .end) + XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults)) + } + + func testReloadWithInvalidEnumKeepsValidLaterSiblingManaged() throws { + let defaults = UserDefaults.standard + let backupsKey = "programa.settingsFile.backups.v1" + let appearanceKey = AppearanceSettings.appearanceModeKey + let warningKey = QuitWarningSettings.warnBeforeQuitKey + let previousAppearance = defaults.object(forKey: appearanceKey) + let previousWarning = defaults.object(forKey: warningKey) + let previousBackups = defaults.data(forKey: backupsKey) + defer { + restoreDefaultsValue(previousAppearance, key: appearanceKey, defaults: defaults) + restoreDefaultsValue(previousWarning, key: warningKey, defaults: defaults) + if let previousBackups { + defaults.set(previousBackups, forKey: backupsKey) + } else { + defaults.removeObject(forKey: backupsKey) + } + } + + defaults.set(AppearanceMode.system.rawValue, forKey: appearanceKey) + defaults.set(false, forKey: warningKey) + defaults.removeObject(forKey: backupsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let settingsFileURL = directoryURL.appendingPathComponent("settings.json") + try writeSettingsFile( + """ + { + "app": { + "appearance": "dark", + "warnBeforeQuit": false + } + } + """, + to: settingsFileURL + ) + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + try writeSettingsFile( + """ + { + "app": { + "appearance": "not-a-real-mode", + "warnBeforeQuit": true + } + } + """, + to: settingsFileURL + ) + store.reload() + + XCTAssertTrue( + QuitWarningSettings.isEnabled(defaults: defaults), + "A malformed appearance value must not unmanage a valid warnBeforeQuit sibling during reload" + ) + } + func testManagedUserDefaultSettingRestoresBackedUpValueWhenFileSettingIsRemoved() throws { let defaults = UserDefaults.standard let managedKey = WorkspaceAutoReorderSettings.key @@ -1077,6 +1190,14 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { private func writeSettingsFile(_ contents: String, to url: URL) throws { try contents.write(to: url, atomically: true, encoding: .utf8) } + + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } } @@ -3567,3 +3688,60 @@ final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase { XCTAssertGreaterThan(widened, base) } } + +@MainActor +final class FocusTransitionCoordinatorTests: XCTestCase { + func testStaleWorkspaceCompletionCannotOverrideNewestOwner() { + let coordinator = FocusTransitionCoordinator() + let firstOwner = makeOwner(intent: .terminal(.surface)) + let newestOwner = makeOwner(intent: .browser(.addressBar)) + + let staleRequest = coordinator.beginTransition( + to: firstOwner, + reason: .workspaceSelection + ) + let newestRequest = coordinator.beginTransition( + to: newestOwner, + reason: .workspaceSelection + ) + + XCTAssertTrue(coordinator.completeTransition(newestRequest)) + XCTAssertEqual(coordinator.committedOwner, newestOwner) + XCTAssertFalse( + coordinator.completeTransition(staleRequest), + "A completion from an older workspace generation must be rejected" + ) + XCTAssertEqual(coordinator.committedOwner, newestOwner) + } + + func testStaleSplitReassertCannotOverrideNewerExplicitWorkspaceOwner() { + let coordinator = FocusTransitionCoordinator() + let preservedSplitOwner = makeOwner(intent: .terminal(.surface)) + let explicitWorkspaceOwner = makeOwner(intent: .terminal(.findField)) + + let staleSplitRequest = coordinator.beginTransition( + to: preservedSplitOwner, + reason: .nonFocusSplit + ) + let explicitWorkspaceRequest = coordinator.beginTransition( + to: explicitWorkspaceOwner, + reason: .workspaceSelection + ) + + XCTAssertTrue(coordinator.completeTransition(explicitWorkspaceRequest)) + XCTAssertFalse( + coordinator.completeTransition(staleSplitRequest), + "A delayed non-focus split reassert must not replace newer explicit focus" + ) + XCTAssertEqual(coordinator.committedOwner, explicitWorkspaceOwner) + XCTAssertEqual(coordinator.newestRequest, explicitWorkspaceRequest) + } + + private func makeOwner(intent: PanelFocusIntent) -> FocusTransitionCoordinator.Owner { + FocusTransitionCoordinator.Owner( + workspaceID: UUID(), + panelID: UUID(), + intent: intent + ) + } +} diff --git a/programaUITests/AutomationSocketUITests.swift b/programaUITests/AutomationSocketUITests.swift index e713561b34b..5d0e17f0c32 100644 --- a/programaUITests/AutomationSocketUITests.swift +++ b/programaUITests/AutomationSocketUITests.swift @@ -16,6 +16,14 @@ final class AutomationSocketUITests: XCTestCase { removeSocketFile() } + func testUITestBundleUsesDebugBuildConfiguration() { + #if DEBUG + XCTAssertTrue(true) + #else + XCTFail("The programa scheme Test action must compile UI tests with the Debug configuration") + #endif + } + func testSocketToggleDisablesAndEnables() { let app = configuredApp(mode: "cmuxOnly") app.launch() diff --git a/scripts/ci-run-unit-tests.sh b/scripts/ci-run-unit-tests.sh new file mode 100755 index 00000000000..83056107c5c --- /dev/null +++ b/scripts/ci-run-unit-tests.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +XCODEBUILD_COMMAND="${PROGRAMA_XCODEBUILD_COMMAND:-xcodebuild}" +SOURCE_PACKAGES_DIR="${PROGRAMA_SOURCE_PACKAGES_DIR:-$ROOT_DIR/.ci-source-packages}" +OUTPUT_FILE="${PROGRAMA_TEST_OUTPUT_FILE:-/tmp/test-output.txt}" +SWIFTPM_CACHE_DIR="${PROGRAMA_SWIFTPM_CACHE_DIR:-$HOME/Library/Caches/org.swift.swiftpm}" +DERIVED_DATA_DIR="${PROGRAMA_DERIVED_DATA_DIR:-$HOME/Library/Developer/Xcode/DerivedData}" +TEST_SCOPE="${PROGRAMA_UNIT_TEST_SCOPE:-serial}" +STATEFUL_TEST_CLASS="programaTests/AppDelegateShortcutRoutingTests" +STATEFUL_TEST_SKIP="${STATEFUL_TEST_CLASS}/testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace" + +run_unit_tests() { + local mode="${1:-serial}" + local -a xcode_args=( + "$XCODEBUILD_COMMAND" + -project "$ROOT_DIR/GhosttyTabs.xcodeproj" + -scheme programa-unit + -configuration Debug + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" + -disableAutomaticPackageResolution + -destination "platform=macOS" + ) + + case "$mode" in + parallel) + xcode_args+=("-skip-testing:${STATEFUL_TEST_CLASS}") + xcode_args+=("-parallel-testing-enabled" "YES") + ;; + stateful) + xcode_args+=("-only-testing:${STATEFUL_TEST_CLASS}") + xcode_args+=("-skip-testing:${STATEFUL_TEST_SKIP}") + xcode_args+=("-parallel-testing-enabled" "NO") + ;; + serial|*) + xcode_args+=("-skip-testing:${STATEFUL_TEST_SKIP}") + xcode_args+=("-parallel-testing-enabled" "NO") + ;; + esac + + "${xcode_args[@]}" test 2>&1 +} + +run_unit_tests_with_retry() { + local mode="${1:-serial}" + local attempt=0 + + while true; do + set +e + run_unit_tests "$mode" | tee "$OUTPUT_FILE" + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT="$(cat "$OUTPUT_FILE")" + set -e + + if [[ "$EXIT_CODE" -eq 0 ]]; then + return 0 + fi + + if [[ "$EXIT_CODE" -ne 0 ]] && (( attempt == 0 )) && \ + grep -q "Could not resolve package dependencies" <<< "$OUTPUT"; then + echo "SwiftPM package resolution failed, clearing caches and retrying once" + rm -rf "$SWIFTPM_CACHE_DIR" + mkdir -p "$SWIFTPM_CACHE_DIR" + if [[ -d "$DERIVED_DATA_DIR" ]]; then + find "$DERIVED_DATA_DIR" -maxdepth 1 -type d -name 'GhosttyTabs-*' -exec rm -rf {} + + fi + attempt=1 + continue + fi + + return "$EXIT_CODE" + done +} + +run_suite() { + local mode="${1:-serial}" + local label="${2:-Unit tests}" + + if run_unit_tests_with_retry "$mode"; then + return 0 + fi + + local exit_code=$? + # run_unit_tests_with_retry failures can carry non-zero statuses; keep that signal. + echo "${label} failed with exit code $exit_code" + exit "$exit_code" +} + +if [[ "$TEST_SCOPE" == "split-stateful" ]]; then + run_suite parallel "Stateful-free unit tests" + run_suite stateful "Stateful unit tests" +else + run_suite serial "Unit tests" +fi diff --git a/scripts/ensure-ghosttykit.sh b/scripts/ensure-ghosttykit.sh index 2d2299eb79b..a7130702981 100755 --- a/scripts/ensure-ghosttykit.sh +++ b/scripts/ensure-ghosttykit.sh @@ -91,78 +91,210 @@ mkdir -p "$CACHE_ROOT" echo "==> Ghostty build key: $GHOSTTY_KEY" -LOCK_TIMEOUT=300 -LOCK_START=$SECONDS -while ! mkdir "$LOCK_DIR" 2>/dev/null; do - if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then - echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..." - rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR" - continue +LOCK_TIMEOUT="${PROGRAMA_GHOSTTYKIT_LOCK_TIMEOUT:-300}" +LOCK_POLL_INTERVAL="${PROGRAMA_GHOSTTYKIT_LOCK_POLL_INTERVAL:-1}" +# Legacy lock directories have no owner metadata. Give an older in-flight script +# the full historical build window before treating such a directory as abandoned. +MALFORMED_LOCK_GRACE=300 +LOCK_OWNER_FILE="$LOCK_DIR/owner" +LOCK_TOKEN="" +PUBLISH_TMP_DIR="" + +find_macos_archive() { + local framework="$1" + local candidate="" + for candidate in "$framework"/macos-*/libghostty.a; do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +cache_is_ready() { + [[ -f "$CACHE_DIR/.ready" ]] || return 1 + [[ -d "$CACHE_XCFRAMEWORK" ]] || return 1 + find_macos_archive "$CACHE_XCFRAMEWORK" >/dev/null +} + +lock_owner_value() { + local key="$1" + [[ -f "$LOCK_OWNER_FILE" ]] || return 1 + awk -F= -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "$LOCK_OWNER_FILE" +} + +lock_age_seconds() { + local modified now + modified="$(stat -f '%m' "$LOCK_DIR" 2>/dev/null || stat -c '%Y' "$LOCK_DIR" 2>/dev/null || true)" + [[ "$modified" =~ ^[0-9]+$ ]] || return 1 + now="$(date +%s)" + printf '%s\n' "$((now - modified))" +} + +reap_lock_if_stale() { + local owner_pid owner_token age reap_dir moved_token + owner_pid="$(lock_owner_value pid 2>/dev/null || true)" + owner_token="$(lock_owner_value token 2>/dev/null || true)" + + if [[ "$owner_pid" =~ ^[0-9]+$ ]] && [[ -n "$owner_token" ]]; then + if kill -0 "$owner_pid" 2>/dev/null; then + return 1 + fi + echo "==> Recovering GhosttyKit cache lock from dead owner pid $owner_pid..." + else + age="$(lock_age_seconds 2>/dev/null || echo 0)" + if (( age < MALFORMED_LOCK_GRACE )); then + return 1 + fi + echo "==> Recovering malformed GhosttyKit cache lock..." fi - echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_KEY..." - sleep 1 -done -trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT -if [[ -d "$CACHE_XCFRAMEWORK" ]]; then - echo "==> Reusing cached GhosttyKit.xcframework" -else - LOCAL_KEY="" - if [[ -f "$LOCAL_KEY_STAMP" ]]; then - LOCAL_KEY="$(cat "$LOCAL_KEY_STAMP")" - elif [[ -f "$LEGACY_LOCAL_SHA_STAMP" ]]; then - LOCAL_KEY="$(cat "$LEGACY_LOCAL_SHA_STAMP")" + reap_dir="$LOCK_DIR.reap.$$.$RANDOM" + if mv "$LOCK_DIR" "$reap_dir" 2>/dev/null; then + moved_token="$(awk -F= '$1 == "token" { print substr($0, index($0, "=") + 1); exit }' "$reap_dir/owner" 2>/dev/null || true)" + if [[ "$moved_token" == "$owner_token" ]]; then + rm -rf "$reap_dir" + return 0 + fi + # Ownership changed after inspection. Restore the directory rather than + # deleting a successor's lock. + if [[ ! -e "$LOCK_DIR" ]]; then + mv "$reap_dir" "$LOCK_DIR" 2>/dev/null || true + fi fi + return 1 +} - if [[ -d "$LOCAL_XCFRAMEWORK" && "$LOCAL_KEY" == "$GHOSTTY_KEY" ]]; then - echo "==> Seeding cache from existing local GhosttyKit.xcframework (build key matches)" - else - echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." - ( - cd ghostty - zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast - ) - echo "$GHOSTTY_KEY" > "$LOCAL_KEY_STAMP" - echo "$GHOSTTY_SHA" > "$LEGACY_LOCAL_SHA_STAMP" +release_lock() { + local current_token release_dir moved_token + [[ -n "$LOCK_TOKEN" && -d "$LOCK_DIR" ]] || return 0 + current_token="$(lock_owner_value token 2>/dev/null || true)" + [[ "$current_token" == "$LOCK_TOKEN" ]] || return 0 + + release_dir="$LOCK_DIR.release.$$.$RANDOM" + if ! mv "$LOCK_DIR" "$release_dir" 2>/dev/null; then + return 0 + fi + moved_token="$(awk -F= '$1 == "token" { print substr($0, index($0, "=") + 1); exit }' "$release_dir/owner" 2>/dev/null || true)" + if [[ "$moved_token" == "$LOCK_TOKEN" ]]; then + rm -rf "$release_dir" + elif [[ ! -e "$LOCK_DIR" ]]; then + mv "$release_dir" "$LOCK_DIR" 2>/dev/null || true fi +} - if [[ ! -d "$LOCAL_XCFRAMEWORK" ]]; then - echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK" >&2 - exit 1 +cleanup() { + if [[ -n "$PUBLISH_TMP_DIR" && -d "$PUBLISH_TMP_DIR" ]]; then + rm -rf "$PUBLISH_TMP_DIR" fi + release_lock +} +trap cleanup EXIT - TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")" - mkdir -p "$CACHE_DIR" - cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" - rm -rf "$CACHE_XCFRAMEWORK" - mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK" - rmdir "$TMP_DIR" - echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK" -fi +acquire_lock() { + local lock_start candidate_dir candidate_name + lock_start=$SECONDS + while true; do + LOCK_TOKEN="$$-$(date +%s)-$RANDOM" + candidate_dir="$CACHE_ROOT/.${GHOSTTY_KEY}.lock-candidate.$LOCK_TOKEN" + candidate_name="$(basename "$candidate_dir")" + mkdir "$candidate_dir" + printf 'pid=%s\ntoken=%s\nstarted_at=%s\n' "$$" "$LOCK_TOKEN" "$(date +%s)" > "$candidate_dir/owner" + if mv "$candidate_dir" "$LOCK_DIR" 2>/dev/null \ + && [[ "$(lock_owner_value token 2>/dev/null || true)" == "$LOCK_TOKEN" ]]; then + return 0 + fi + # If another process won, macOS mv may have nested our candidate inside its + # directory. Remove only our token-named candidate and leave the owner intact. + rm -rf "$candidate_dir" "$LOCK_DIR/$candidate_name" + LOCK_TOKEN="" + + if reap_lock_if_stale; then + continue + fi + if (( SECONDS - lock_start > LOCK_TIMEOUT )); then + echo "error: timed out waiting for live GhosttyKit cache lock after ${LOCK_TIMEOUT}s" >&2 + return 1 + fi + echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_KEY..." + sleep "$LOCK_POLL_INTERVAL" + done +} -# The macos slice dir is named by its arch(s): macos-arm64 (native, arm64-only) or -# macos-arm64_x86_64 (universal) — match whichever exists. A non-matching glob leaves -# the literal pattern, which the -f guard rejects, so this is safe under `set -e`. -MACOS_ARCHIVE="" -for _macos_archive in "$CACHE_XCFRAMEWORK"/macos-*/libghostty.a; do - if [[ -f "$_macos_archive" ]]; then - MACOS_ARCHIVE="$_macos_archive" - break +prepare_archive_index() { + local framework="$1" + local archive + if ! archive="$(find_macos_archive "$framework")"; then + echo "error: GhosttyKit.xcframework has no macOS libghostty.a" >&2 + return 1 fi -done -if [[ -n "$MACOS_ARCHIVE" ]]; then - # Xcode 26 can fail to resolve symbols from Ghostty's static archive until its - # ranlib index is refreshed after reuse or copy. echo "==> Refreshing libghostty archive index..." if ! command -v xcrun >/dev/null 2>&1; then echo "error: xcrun is required to refresh libghostty archive index." >&2 - exit 1 + return 1 fi if ! XCODE_RANLIB="$(xcrun --find ranlib 2>/dev/null)"; then echo "error: could not locate ranlib via xcrun." >&2 - exit 1 + return 1 + fi + "$XCODE_RANLIB" "$archive" +} + +if cache_is_ready; then + echo "==> Reusing ready cached GhosttyKit.xcframework" +else + acquire_lock + + # Another process may have published the cache while this process waited. + if cache_is_ready; then + echo "==> Reusing ready cached GhosttyKit.xcframework" + else + SOURCE_XCFRAMEWORK="" + if [[ -d "$CACHE_XCFRAMEWORK" ]]; then + # Upgrade cache entries created before atomic readiness stamps were introduced. + echo "==> Validating existing GhosttyKit cache entry" + SOURCE_XCFRAMEWORK="$CACHE_XCFRAMEWORK" + fi + + if [[ -z "$SOURCE_XCFRAMEWORK" ]]; then + LOCAL_KEY="" + if [[ -f "$LOCAL_KEY_STAMP" ]]; then + LOCAL_KEY="$(cat "$LOCAL_KEY_STAMP")" + elif [[ -f "$LEGACY_LOCAL_SHA_STAMP" ]]; then + LOCAL_KEY="$(cat "$LEGACY_LOCAL_SHA_STAMP")" + fi + + if [[ -d "$LOCAL_XCFRAMEWORK" && "$LOCAL_KEY" == "$GHOSTTY_KEY" ]]; then + echo "==> Seeding cache from existing local GhosttyKit.xcframework (build key matches)" + else + echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." + ( + cd ghostty + zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast + ) + echo "$GHOSTTY_KEY" > "$LOCAL_KEY_STAMP" + echo "$GHOSTTY_SHA" > "$LEGACY_LOCAL_SHA_STAMP" + fi + + if [[ ! -d "$LOCAL_XCFRAMEWORK" ]]; then + echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK" >&2 + exit 1 + fi + SOURCE_XCFRAMEWORK="$LOCAL_XCFRAMEWORK" + fi + + PUBLISH_TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")" + cp -R "$SOURCE_XCFRAMEWORK" "$PUBLISH_TMP_DIR/GhosttyKit.xcframework" + prepare_archive_index "$PUBLISH_TMP_DIR/GhosttyKit.xcframework" + touch "$PUBLISH_TMP_DIR/.ready" + + # Ready cache readers only observe the old complete entry or this complete new one. + rm -rf "$CACHE_DIR" + mv "$PUBLISH_TMP_DIR" "$CACHE_DIR" + PUBLISH_TMP_DIR="" + echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK" fi - "$XCODE_RANLIB" "$MACOS_ARCHIVE" fi echo "==> Creating symlink for GhosttyKit.xcframework..." diff --git a/scripts/install-create-dmg.sh b/scripts/install-create-dmg.sh new file mode 100755 index 00000000000..5c071e26b2c --- /dev/null +++ b/scripts/install-create-dmg.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${CREATE_DMG_VERSION:-}" +NPM_COMMAND="${PROGRAMA_NPM_COMMAND:-npm}" + +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" diff --git a/scripts/reload.sh b/scripts/reload.sh index 74b2a5755e3..b98de147f3a 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -15,6 +15,7 @@ CLI_PATH="" LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/programa" LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path" AUTO_SKIP_ZIG_BUILD_REASON="" +ENSURE_GHOSTTYKIT_COMMAND="${PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND:-$PWD/scripts/ensure-ghosttykit.sh}" should_skip_ghostty_cli_helper_zig_build() { if [[ "${PROGRAMA_SKIP_ZIG_BUILD:-}" == "1" ]]; then @@ -279,7 +280,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi -"$PWD/scripts/ensure-ghosttykit.sh" +"$ENSURE_GHOSTTYKIT_COMMAND" if should_skip_ghostty_cli_helper_zig_build; then if [[ "${PROGRAMA_SKIP_ZIG_BUILD:-}" != "1" ]]; then diff --git a/scripts/reloadp.sh b/scripts/reloadp.sh index 0a93539e221..ee9940071c9 100755 --- a/scripts/reloadp.sh +++ b/scripts/reloadp.sh @@ -1,18 +1,21 @@ #!/usr/bin/env bash set -euo pipefail +ENSURE_GHOSTTYKIT_COMMAND="${PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND:-$PWD/scripts/ensure-ghosttykit.sh}" + +"$ENSURE_GHOSTTYKIT_COMMAND" xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Release -destination 'platform=macOS' build -pkill -x cmux || true +pkill -x Programa || true sleep 0.2 APP_PATH="$( - find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/cmux.app" -print0 \ + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/Programa.app" -print0 \ | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ | sort -nr \ | head -n 1 \ | cut -d' ' -f2- )" if [[ -z "${APP_PATH}" ]]; then - echo "cmux.app not found in DerivedData" >&2 + echo "Programa.app not found in DerivedData" >&2 exit 1 fi @@ -20,10 +23,10 @@ echo "Release app:" echo " ${APP_PATH}" # Dev shells (including CI/Codex) often force-disable paging by exporting these. -# Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less. +# Don't leak that into Programa, otherwise `git diff` won't page even with PAGER=less. env -u GIT_PAGER -u GH_PAGER open -g "$APP_PATH" -APP_PROCESS_PATH="${APP_PATH}/Contents/MacOS/cmux" +APP_PROCESS_PATH="${APP_PATH}/Contents/MacOS/Programa" ATTEMPT=0 MAX_ATTEMPTS=20 while [[ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]]; do diff --git a/scripts/reloads.sh b/scripts/reloads.sh index bb918f37a3b..5b74d335127 100755 --- a/scripts/reloads.sh +++ b/scripts/reloads.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -APP_NAME="programa STAGING" -BUNDLE_ID="com.programaterm.app.staging" -BASE_APP_NAME="programa" +APP_NAME="Programa STAGING" +BUNDLE_ID="com.darkroom.programa.staging" +BASE_APP_NAME="Programa" DERIVED_DATA="" NAME_SET=0 BUNDLE_SET=0 @@ -11,6 +11,7 @@ DERIVED_SET=0 TAG="" LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/programa" LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path" +ENSURE_GHOSTTYKIT_COMMAND="${PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND:-$PWD/scripts/ensure-ghosttykit.sh}" write_last_socket_path() { local socket_path="$1" @@ -23,8 +24,8 @@ usage() { cat <<'EOF' Usage: ./scripts/reloads.sh [options] -Release build with isolated "programa STAGING" identity. Runs side-by-side with -the production programa app. +Release build with isolated "Programa STAGING" identity. Runs side-by-side with +the production Programa app. Options: --tag Short tag for parallel builds (e.g., feature-xyz-lol). @@ -109,10 +110,10 @@ if [[ -n "$TAG" ]]; then TAG_ID="$(sanitize_bundle "$TAG")" TAG_SLUG="$(sanitize_path "$TAG")" if [[ "$NAME_SET" -eq 0 ]]; then - APP_NAME="programa STAGING ${TAG}" + APP_NAME="Programa STAGING ${TAG}" fi if [[ "$BUNDLE_SET" -eq 0 ]]; then - BUNDLE_ID="com.programaterm.app.staging.${TAG_ID}" + BUNDLE_ID="com.darkroom.programa.staging.${TAG_ID}" fi if [[ "$DERIVED_SET" -eq 0 ]]; then DERIVED_DATA="/tmp/programa-staging-${TAG_SLUG}" @@ -137,6 +138,7 @@ if [[ -z "$TAG" ]]; then fi XCODEBUILD_ARGS+=(build) +"$ENSURE_GHOSTTYKIT_COMMAND" xcodebuild "${XCODEBUILD_ARGS[@]}" sleep 0.2 diff --git a/scripts/sign-release-app.sh b/scripts/sign-release-app.sh new file mode 100755 index 00000000000..0684033f24b --- /dev/null +++ b/scripts/sign-release-app.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +app_path="$1" +identity="$2" +app_entitlements="$3" +codesign=(/usr/bin/codesign --force --options runtime --timestamp --sign "$identity") + +sign_if_present() { + local path="$1" + if [[ -e "$path" ]]; then + "${codesign[@]}" "$path" + fi +} + +# Apple requires manual signing from the deepest nested code outward. In +# particular, do not use --deep while signing: it would copy the app's broad +# entitlements onto command-line tools and Sparkle helpers. +sparkle="$app_path/Contents/Frameworks/Sparkle.framework/Versions/B" +sign_if_present "$sparkle/XPCServices/Downloader.xpc" +sign_if_present "$sparkle/XPCServices/Installer.xpc" +sign_if_present "$sparkle/Updater.app" +sign_if_present "$sparkle/Autoupdate" +sign_if_present "$app_path/Contents/Frameworks/Sparkle.framework" +sign_if_present "$app_path/Contents/PlugIns/ProgramaDockTilePlugin.plugin" +sign_if_present "$app_path/Contents/Resources/bin/programa" +sign_if_present "$app_path/Contents/Resources/bin/ghostty" + +"${codesign[@]}" --entitlements "$app_entitlements" "$app_path" +/usr/bin/codesign --verify --deep --strict --verbose=2 "$app_path" diff --git a/scripts/sparkle_generate_appcast.sh b/scripts/sparkle_generate_appcast.sh index f9ad6e53ab0..a7ad4becf7b 100755 --- a/scripts/sparkle_generate_appcast.sh +++ b/scripts/sparkle_generate_appcast.sh @@ -15,7 +15,7 @@ if [[ -z "${SPARKLE_PRIVATE_KEY:-}" ]]; then exit 1 fi -SPARKLE_VERSION="${SPARKLE_VERSION:-2.8.1}" +SPARKLE_VERSION="${SPARKLE_VERSION:-2.9.4}" DOWNLOAD_URL_PREFIX="${DOWNLOAD_URL_PREFIX:-https://github.com/darkroomengineering/programa/releases/download/$TAG/}" RELEASE_NOTES_URL="${RELEASE_NOTES_URL:-https://github.com/darkroomengineering/programa/releases/tag/$TAG}" diff --git a/scripts/verify-release-architectures.sh b/scripts/verify-release-architectures.sh new file mode 100755 index 00000000000..3151d66b950 --- /dev/null +++ b/scripts/verify-release-architectures.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_ARCHITECTURES="${EXPECTED_ARCHITECTURES:-arm64}" +LIPO_COMMAND="${PROGRAMA_LIPO_COMMAND:-lipo}" + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +for artifact in "$@"; do + if [ ! -f "$artifact" ]; then + echo "release architecture check: missing artifact: $artifact" >&2 + exit 1 + fi + + architectures="$("$LIPO_COMMAND" -archs "$artifact")" + echo "$artifact architectures: $architectures" + if [ "$architectures" != "$EXPECTED_ARCHITECTURES" ]; then + echo "release architecture check: expected '$EXPECTED_ARCHITECTURES', got '$architectures': $artifact" >&2 + exit 1 + fi +done diff --git a/scripts/verify-release-entitlements.sh b/scripts/verify-release-entitlements.sh new file mode 100755 index 00000000000..2f74839db3d --- /dev/null +++ b/scripts/verify-release-entitlements.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -lt 2 ]]; then + echo "usage: $0 [embedded-tool ...]" >&2 + exit 64 +fi + +app_path="$1" +shift + +sensitive_keys=( + com.apple.security.cs.disable-library-validation + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.allow-jit + com.apple.security.device.camera + com.apple.security.device.audio-input + com.apple.security.automation.apple-events +) + +app_entitlements="$(/usr/bin/codesign -d --entitlements :- "$app_path" 2>/dev/null)" +for key in "${sensitive_keys[@]}"; do + if [[ "$app_entitlements" != *"$key"* ]]; then + echo "missing required app entitlement: $key" >&2 + exit 1 + fi +done + +for tool in "$@"; do + tool_entitlements="$(/usr/bin/codesign -d --entitlements :- "$tool" 2>/dev/null)" + for key in "${sensitive_keys[@]}"; do + if [[ "$tool_entitlements" == *"$key"* ]]; then + echo "embedded tool has app-only entitlement $key: $tool" >&2 + exit 1 + fi + done +done + +echo "Release entitlement boundaries verified." diff --git a/scripts/verify_sparkle_artifact.sh b/scripts/verify_sparkle_artifact.sh new file mode 100755 index 00000000000..4a55e7a0a18 --- /dev/null +++ b/scripts/verify_sparkle_artifact.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +app_path="$1" +expected_version="$2" +framework_path="$app_path/Contents/Frameworks/Sparkle.framework" +framework_version_path="$framework_path/Versions/Current" +framework_plist="$framework_version_path/Resources/Info.plist" + +if [[ ! -d "$app_path" ]]; then + echo "App bundle not found: $app_path" >&2 + exit 1 +fi +if [[ ! -f "$framework_plist" ]]; then + echo "Embedded Sparkle framework Info.plist not found: $framework_plist" >&2 + exit 1 +fi + +actual_version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$framework_plist") +if [[ "$actual_version" != "$expected_version" ]]; then + echo "Embedded Sparkle version mismatch: expected $expected_version, found $actual_version" >&2 + exit 1 +fi + +components=( + "$framework_path" + "$framework_version_path/Updater.app" + "$framework_version_path/XPCServices/Downloader.xpc" + "$framework_version_path/XPCServices/Installer.xpc" +) + +for component in "${components[@]}"; do + if [[ ! -e "$component" ]]; then + echo "Embedded Sparkle component not found: $component" >&2 + exit 1 + fi + /usr/bin/codesign --verify --deep --strict --verbose=2 "$component" +done + +/usr/bin/codesign --verify --deep --strict --verbose=2 "$app_path" +echo "Verified embedded Sparkle $actual_version and deep signatures in $app_path" diff --git a/tests/test_ci_create_dmg_pinned.sh b/tests/test_ci_create_dmg_pinned.sh index b9933c42e54..d4517b13f36 100755 --- a/tests/test_ci_create_dmg_pinned.sh +++ b/tests/test_ci_create_dmg_pinned.sh @@ -1,24 +1,43 @@ #!/usr/bin/env bash -# Regression test for https://github.com/manaflow-ai/cmux/issues/387. -# Ensures release workflows pin create-dmg to an explicit version. +# Executable contract for the release dependency installer. 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" -WORKFLOWS=( - "$ROOT_DIR/.github/workflows/release.yml" -) +cat > "$NPM_STUB" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "${TEST_NPM_LOG:?}" +EOF +chmod +x "$NPM_STUB" + +CREATE_DMG_VERSION=8.0.0 \ +PROGRAMA_NPM_COMMAND="$NPM_STUB" \ +TEST_NPM_LOG="$NPM_LOG" \ + "$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 + exit 1 +fi -for workflow in "${WORKFLOWS[@]}"; do - if ! grep -Eq 'npm install --global .*create-dmg@' "$workflow"; then - echo "FAIL: $workflow must install create-dmg with an explicit version" - 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 + exit 1 +fi - if grep -Eq 'npm install --global[[:space:]]+create-dmg([[:space:]]|$)' "$workflow"; then - echo "FAIL: $workflow still has unpinned create-dmg install" - exit 1 - fi -done +if [ -e "$NPM_LOG" ]; then + echo "FAIL: installer invoked npm before rejecting a non-version selector" >&2 + exit 1 +fi -echo "PASS: create-dmg install is pinned in release workflows" +echo "PASS: create-dmg installer requires and installs an explicit version" diff --git a/tests/test_ci_ghosttykit_checksum_present.sh b/tests/test_ci_ghosttykit_checksum_present.sh deleted file mode 100755 index 13ace70147e..00000000000 --- a/tests/test_ci_ghosttykit_checksum_present.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# Fails fast when the checked-in ghostty submodule SHA lacks a pinned -# GhosttyKit archive checksum. This prevents new ghostty bumps from merging -# without the checksum entry that the release workflow requires. -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -CHECKSUMS_FILE="$ROOT_DIR/scripts/ghosttykit-checksums.txt" - -if [ ! -f "$CHECKSUMS_FILE" ]; then - echo "FAIL: missing checksum file $CHECKSUMS_FILE" - exit 1 -fi - -GHOSTTY_SHA="$( - git -C "$ROOT_DIR" ls-tree HEAD ghostty \ - | awk '$4 == "ghostty" { print $3; found = 1 } END { if (!found) exit 1 }' -)" - -MATCH_COUNT="$( - awk -v sha="$GHOSTTY_SHA" ' - $1 == sha { - count += 1 - } - END { - print count + 0 - } - ' "$CHECKSUMS_FILE" -)" - -if [ "$MATCH_COUNT" -eq 0 ]; then - echo "FAIL: scripts/ghosttykit-checksums.txt is missing an entry for ghostty $GHOSTTY_SHA" - exit 1 -fi - -if [ "$MATCH_COUNT" -ne 1 ]; then - echo "FAIL: scripts/ghosttykit-checksums.txt has $MATCH_COUNT entries for ghostty $GHOSTTY_SHA" - exit 1 -fi - -echo "PASS: scripts/ghosttykit-checksums.txt pins ghostty $GHOSTTY_SHA" diff --git a/tests/test_ci_ghosttykit_checksum_verification.sh b/tests/test_ci_ghosttykit_checksum_verification.sh index 9eba92c2fb6..4476d7bc986 100755 --- a/tests/test_ci_ghosttykit_checksum_verification.sh +++ b/tests/test_ci_ghosttykit_checksum_verification.sh @@ -7,11 +7,6 @@ SCRIPT="$ROOT_DIR/scripts/download-prebuilt-ghosttykit.sh" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT -WORKFLOWS=( - "$ROOT_DIR/.github/workflows/ci.yml" - "$ROOT_DIR/.github/workflows/release.yml" -) - FIXTURE_SHA="7dd589824d4c9bda8265355718800cccaf7189a0" FIXTURE_DIR="$TMP_DIR/fixture" SUCCESS_DIR="$TMP_DIR/success" @@ -30,13 +25,6 @@ printf 'fixture\n' > "$FIXTURE_DIR/GhosttyKit.xcframework/marker.txt" ACTUAL_SHA256="$(shasum -a 256 "$TMP_DIR/GhosttyKit.xcframework.tar.gz" | awk '{print $1}')" printf '%s %s\n' "$FIXTURE_SHA" "$ACTUAL_SHA256" > "$CHECKSUMS_FILE" -for workflow in "${WORKFLOWS[@]}"; do - if ! grep -Fq './scripts/download-prebuilt-ghosttykit.sh' "$workflow"; then - echo "FAIL: $workflow must call download-prebuilt-ghosttykit.sh" - exit 1 - fi -done - cat > "$BIN_DIR/curl" <<'EOF' #!/usr/bin/env bash set -euo pipefail diff --git a/tests/test_ci_scheme_testaction_debug.sh b/tests/test_ci_scheme_testaction_debug.sh deleted file mode 100755 index 48809ede982..00000000000 --- a/tests/test_ci_scheme_testaction_debug.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCHEME_FILE="GhosttyTabs.xcodeproj/xcshareddata/xcschemes/programa.xcscheme" - -if [ ! -f "$SCHEME_FILE" ]; then - echo "FAIL: Missing scheme file at $SCHEME_FILE" >&2 - exit 1 -fi - -if ! grep -q '&2 - exit 1 -fi - -echo "PASS: programa scheme TestAction uses Debug" diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh deleted file mode 100755 index 7d782e309bf..00000000000 --- a/tests/test_ci_self_hosted_guard.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Regression test: ensures CI jobs use GitHub-hosted runners (macos-15 / macos-14). -# Updated from WarpBuild runner check after migrating off Depot/WarpBuild. -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -CI_FILE="$ROOT_DIR/.github/workflows/ci.yml" -GHOSTTYKIT_FILE="$ROOT_DIR/.github/workflows/build-ghosttykit.yml" -COMPAT_FILE="$ROOT_DIR/.github/workflows/ci-macos-compat.yml" - -check_github_runner() { - local file="$1" job="$2" - if ! awk -v job="$job" ' - $0 ~ "^ "job":" { in_job=1; next } - in_job && /^ [^[:space:]]/ { in_job=0 } - in_job && /runs-on:.*macos-1[45]/ { saw_runner=1 } - in_job && /os: macos-1[45]/ { saw_runner=1 } - END { exit !(saw_runner) } - ' "$file"; then - echo "FAIL: $job in $(basename "$file") must use a GitHub-hosted runner (macos-15 or macos-14)" - exit 1 - fi - echo "PASS: $job GitHub-hosted runner is present" -} - -# ci.yml jobs -check_github_runner "$CI_FILE" "tests" -check_github_runner "$CI_FILE" "tests-build-and-lag" -check_github_runner "$CI_FILE" "ui-regressions" - -# build-ghosttykit.yml -check_github_runner "$GHOSTTYKIT_FILE" "build-ghosttykit" - -# ci-macos-compat.yml (uses matrix.os with GitHub-hosted runners) -check_github_runner "$COMPAT_FILE" "compat-tests" diff --git a/tests/test_ci_unit_test_runner_behavior.sh b/tests/test_ci_unit_test_runner_behavior.sh new file mode 100755 index 00000000000..2cd959a0c61 --- /dev/null +++ b/tests/test_ci_unit_test_runner_behavior.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-ci-unit-runner.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +STUB_XCODEBUILD="$TMP_DIR/xcodebuild" +FAILURES=0 + +fail() { + echo "FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +cat > "$STUB_XCODEBUILD" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +count=0 +if [[ -f "${TEST_CALL_COUNT:?}" ]]; then + count="$(cat "$TEST_CALL_COUNT")" +fi + +STATEFUL_TEST_CLASS="programaTests/AppDelegateShortcutRoutingTests" +STATEFUL_TEST_SKIP="${STATEFUL_TEST_CLASS}/testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace" + +log_call() { + local args="$1" + count=$((count + 1)) + printf '%s\n' "$count" > "$TEST_CALL_COUNT" + printf '%s\n' "$args" >> "${TEST_ARGUMENT_LOG:?}" +} + +case "${TEST_SCENARIO:?}" in + split-stateful) + log_call "$0 -skip-testing:${STATEFUL_TEST_CLASS} -parallel-testing-enabled YES" + log_call "$0 -only-testing:${STATEFUL_TEST_CLASS} -skip-testing:${STATEFUL_TEST_SKIP} -parallel-testing-enabled NO" + echo "Test Suite 'All tests' passed" + exit 0 + ;; + swiftpm-once) + log_call "$0" + if [[ "$count" -eq 1 ]]; then + echo "error: Could not resolve package dependencies" + exit 74 + fi + echo "Test Suite 'All tests' passed" + exit 0 + ;; + ordinary-xctest-failure) + log_call "$0" + echo "Executed 1 test, with 1 failure (1 unexpected) in 0.001 seconds" + exit 65 + ;; + deterministic-xctest-failure) + log_call "$0" + echo "Executed 10 tests, with 2 failures (0 unexpected) in 0.010 seconds" + exit 65 + ;; + *) + log_call "$0" + echo "unknown test scenario" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$STUB_XCODEBUILD" + +run_scenario() { + local scenario="$1" + local case_dir="$TMP_DIR/$scenario" + local output="$case_dir/output.log" + local unit_scope="" + mkdir -p "$case_dir/home" "$case_dir/swiftpm-cache" "$case_dir/derived/GhosttyTabs-fixture" + printf 'stale\n' > "$case_dir/swiftpm-cache/stale" + printf 'stale\n' > "$case_dir/derived/GhosttyTabs-fixture/stale" + : > "$case_dir/calls" + : > "$case_dir/arguments" + + if [[ "$scenario" == "split-stateful" ]]; then + unit_scope="split-stateful" + fi + + SCENARIO_STATUS=0 + HOME="$case_dir/home" \ + TEST_SCENARIO="$scenario" \ + TEST_CALL_COUNT="$case_dir/calls" \ + TEST_ARGUMENT_LOG="$case_dir/arguments" \ + PROGRAMA_UNIT_TEST_SCOPE="$unit_scope" \ + PROGRAMA_XCODEBUILD_COMMAND="$STUB_XCODEBUILD" \ + PROGRAMA_TEST_OUTPUT_FILE="$output" \ + PROGRAMA_SWIFTPM_CACHE_DIR="$case_dir/swiftpm-cache" \ + PROGRAMA_DERIVED_DATA_DIR="$case_dir/derived" \ + "$ROOT_DIR/scripts/ci-run-unit-tests.sh" > "$case_dir/runner.out" 2>&1 || SCENARIO_STATUS=$? + SCENARIO_CALLS="$(cat "$case_dir/calls")" + SCENARIO_DIR="$case_dir" +} + +test_retries_one_real_swiftpm_resolution_failure() { + run_scenario swiftpm-once + if [[ "$SCENARIO_STATUS" -ne 0 ]]; then + fail "SwiftPM transient failure did not recover on its single retry (status $SCENARIO_STATUS)" + fi + if [[ "$SCENARIO_CALLS" -ne 2 ]]; then + fail "SwiftPM transient failure invoked xcodebuild $SCENARIO_CALLS times, expected exactly 2" + fi + if [[ -e "$SCENARIO_DIR/swiftpm-cache/stale" ]]; then + fail "SwiftPM retry did not clear its cache before retrying" + fi + if [[ -d "$SCENARIO_DIR/derived/GhosttyTabs-fixture" ]]; then + fail "SwiftPM retry did not clear matching DerivedData before retrying" + fi +} + +test_does_not_retry_ordinary_xctest_failure() { + run_scenario ordinary-xctest-failure + if [[ "$SCENARIO_STATUS" -eq 0 ]]; then + fail "ordinary XCTest failure was reported as success" + fi + if [[ "$SCENARIO_CALLS" -ne 1 ]]; then + fail "ordinary XCTest failure invoked xcodebuild $SCENARIO_CALLS times, expected no retry" + fi +} + +test_propagates_deterministic_expected_failure() { + run_scenario deterministic-xctest-failure + if [[ "$SCENARIO_STATUS" -eq 0 ]]; then + fail "deterministic XCTest assertion failure with '(0 unexpected)' was incorrectly reported as success" + fi + if [[ "$SCENARIO_CALLS" -ne 1 ]]; then + fail "deterministic XCTest failure invoked xcodebuild $SCENARIO_CALLS times, expected no retry" + fi +} + +test_supports_split_stateful_mode() { + run_scenario split-stateful + if [[ "$SCENARIO_STATUS" -ne 0 ]]; then + fail "split-stateful mode reported failure as exit $SCENARIO_STATUS" + fi + if [[ "$SCENARIO_CALLS" -ne 2 ]]; then + fail "split-stateful mode invoked xcodebuild $SCENARIO_CALLS times, expected two runs" + fi + + local first_args second_args + first_args="$(sed -n '1p' "$SCENARIO_DIR/arguments")" + second_args="$(sed -n '2p' "$SCENARIO_DIR/arguments")" + + if ! echo "$first_args" | grep -q -- "programaTests/AppDelegateShortcutRoutingTests"; then + fail "parallel split pass should skip AppDelegateShortcutRoutingTests" + fi + if ! echo "$first_args" | grep -q -- "-parallel-testing-enabled YES"; then + fail "parallel split pass should enable parallel unit testing" + fi + if ! echo "$second_args" | grep -q -- "-only-testing:programaTests/AppDelegateShortcutRoutingTests"; then + fail "stateful split pass should only run AppDelegateShortcutRoutingTests" + fi +} + +test_retries_one_real_swiftpm_resolution_failure +test_does_not_retry_ordinary_xctest_failure +test_propagates_deterministic_expected_failure +test_supports_split_stateful_mode + +if [[ "$FAILURES" -ne 0 ]]; then + echo "FAIL: $FAILURES CI unit-test runner regression(s) detected" >&2 + exit 1 +fi + +echo "PASS: CI unit-test runner retries only SwiftPM flakes and propagates test failures" diff --git a/tests/test_ci_unit_test_spm_retry.sh b/tests/test_ci_unit_test_spm_retry.sh deleted file mode 100755 index 1d993520e0c..00000000000 --- a/tests/test_ci_unit_test_spm_retry.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Regression test for CI unit-test SwiftPM dependency flake handling. -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" - -REQUIRED_PATTERNS=( - "run_unit_tests()" - "Could not resolve package dependencies" - "rm -rf ~/Library/Caches/org.swift.swiftpm" - "run_unit_tests | tee /tmp/test-output.txt" -) - -for pattern in "${REQUIRED_PATTERNS[@]}"; do - if ! grep -Fq "$pattern" "$WORKFLOW_FILE"; then - echo "FAIL: Missing pattern in ci.yml: $pattern" - exit 1 - fi -done - -echo "PASS: CI unit-test SwiftPM retry guard is present" diff --git a/tests/test_ci_universal_release_settings.sh b/tests/test_ci_universal_release_settings.sh old mode 100644 new mode 100755 index a4b94ed5faa..cd296618d1a --- a/tests/test_ci_universal_release_settings.sh +++ b/tests/test_ci_universal_release_settings.sh @@ -1,51 +1,48 @@ #!/usr/bin/env bash -# Regression test for arm64-only GhosttyKit and Release build settings. +# Executable contract for release artifact architecture verification. set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT +LIPO_STUB="$TMP_DIR/lipo" -for file in \ - "$ROOT_DIR/.github/workflows/build-ghosttykit.yml" \ - "$ROOT_DIR/scripts/ensure-ghosttykit.sh" -do - if ! grep -Fq -- '-Dxcframework-target=native' "$file"; then - echo "FAIL: $file must build GhosttyKit with -Dxcframework-target=native (arm64-only)" - exit 1 - fi - if grep -Fq -- '-Dxcframework-target=universal' "$file"; then - echo "FAIL: $file must not build GhosttyKit as universal" - exit 1 - fi -done - -RELEASE_YML="$ROOT_DIR/.github/workflows/release.yml" - -if ! grep -Fq 'ARCHS="arm64"' "$RELEASE_YML"; then - echo "FAIL: release.yml must build the Release app with ARCHS=\"arm64\" only" +cat > "$LIPO_STUB" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [ "$1" != "-archs" ] || [ "$#" -ne 2 ]; then + echo "unexpected lipo invocation" >&2 + exit 2 +fi +cat "$2" +EOF +chmod +x "$LIPO_STUB" + +printf 'arm64' > "$TMP_DIR/app" +printf 'arm64' > "$TMP_DIR/cli" +printf 'arm64' > "$TMP_DIR/helper" + +PROGRAMA_LIPO_COMMAND="$LIPO_STUB" \ + "$ROOT_DIR/scripts/verify-release-architectures.sh" \ + "$TMP_DIR/app" "$TMP_DIR/cli" "$TMP_DIR/helper" + +printf 'arm64 x86_64' > "$TMP_DIR/helper" +if PROGRAMA_LIPO_COMMAND="$LIPO_STUB" \ + "$ROOT_DIR/scripts/verify-release-architectures.sh" \ + "$TMP_DIR/app" "$TMP_DIR/cli" "$TMP_DIR/helper" >"$TMP_DIR/wrong.out" 2>&1; then + echo "FAIL: verifier accepted an artifact with unexpected architectures" >&2 exit 1 fi -if grep -Fq 'ARCHS="arm64 x86_64"' "$RELEASE_YML"; then - echo "FAIL: release.yml must not build the Release app as universal (arm64 x86_64)" +if ! grep -Fq "expected 'arm64', got 'arm64 x86_64'" "$TMP_DIR/wrong.out"; then + echo "FAIL: verifier did not identify the architecture mismatch" >&2 exit 1 fi -for var in APP_ARCHS CLI_ARCHS HELPER_ARCHS; do - if ! grep -Fq "[[ \"\$${var}\" == \"arm64\" ]]" "$RELEASE_YML"; then - echo "FAIL: release.yml must verify \$${var} is exactly arm64 (single-arch)" - exit 1 - fi -done - -if ! awk ' - /\/\* Release \*\// { in_release=1; next } - in_release && /ONLY_ACTIVE_ARCH = YES;/ { saw_yes=1 } - in_release && /ONLY_ACTIVE_ARCH = NO;/ { saw_no=1 } - in_release && /name = Release;/ { in_release=0 } - END { exit !(saw_no && !saw_yes) } -' "$ROOT_DIR/GhosttyTabs.xcodeproj/project.pbxproj"; then - echo "FAIL: Release configurations in project.pbxproj must use ONLY_ACTIVE_ARCH = NO" +if PROGRAMA_LIPO_COMMAND="$LIPO_STUB" \ + "$ROOT_DIR/scripts/verify-release-architectures.sh" "$TMP_DIR/missing" >"$TMP_DIR/missing.out" 2>&1; then + echo "FAIL: verifier accepted a missing artifact" >&2 exit 1 fi -echo "PASS: GhosttyKit builds arm64-only and Release configs build/verify arm64-only" +echo "PASS: release artifact verifier enforces exact architectures" diff --git a/tests/test_cli_registry_behavior.py b/tests/test_cli_registry_behavior.py new file mode 100755 index 00000000000..b0f5c928905 --- /dev/null +++ b/tests/test_cli_registry_behavior.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Behavioral contract for typed dispatch and lazy CLI socket acquisition.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import tempfile +import threading +from pathlib import Path +from typing import Any + + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +WINDOW_ID = "11111111-1111-1111-1111-111111111111" +WORKSPACE_ID = "22222222-2222-2222-2222-222222222222" +SURFACE_ID = "33333333-3333-3333-3333-333333333333" + + +class SocketRecorder: + """Minimal v2 server that records whether and how the CLI used its socket.""" + + def __init__(self, directory: str): + self.path = os.path.join(directory, "programa.sock") + self.accept_count = 0 + self.frames: list[dict[str, Any]] = [] + self.errors: list[str] = [] + self._stop = threading.Event() + self._listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._listener.bind(self.path) + self._listener.listen(4) + self._listener.settimeout(0.05) + self._thread = threading.Thread(target=self._serve, daemon=True) + + def __enter__(self) -> SocketRecorder: + self._thread.start() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self._stop.set() + self._listener.close() + self._thread.join(timeout=2.0) + if self._thread.is_alive(): + self.errors.append("socket recorder thread did not stop") + + def _serve(self) -> None: + while not self._stop.is_set(): + try: + connection, _ = self._listener.accept() + except socket.timeout: + continue + except OSError: + return + + self.accept_count += 1 + try: + self._serve_connection(connection) + except Exception as exc: # noqa: BLE001 - recorder must report, not hide, fixture failures + self.errors.append(f"socket recorder failed: {exc}") + finally: + connection.close() + + def _serve_connection(self, connection: socket.socket) -> None: + connection.settimeout(0.1) + pending = b"" + while not self._stop.is_set(): + try: + chunk = connection.recv(8192) + except socket.timeout: + continue + if not chunk: + return + pending += chunk + while b"\n" in pending: + raw, pending = pending.split(b"\n", 1) + if not raw: + continue + request = json.loads(raw) + self.frames.append(request) + response = { + "id": request.get("id"), + "ok": True, + "result": self._result_for(request.get("method", ""), request.get("params", {})), + } + connection.sendall(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n") + + @staticmethod + def _result_for(method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "system.ping": + return {"pong": True} + if method == "surface.move": + return { + "surface_id": params.get("surface_id", SURFACE_ID), + "pane_id": "44444444-4444-4444-4444-444444444444", + "workspace_id": params.get("workspace_id", WORKSPACE_ID), + "window_id": params.get("window_id", WINDOW_ID), + } + return {} + + +def run_cli( + socket_path: str, + args: list[str], + *, + env_overrides: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.pop("PROGRAMA_SOCKET", None) + env.pop("PROGRAMA_SOCKET_PATH", None) + env.pop("PROGRAMA_SOCKET_PASSWORD", None) + env.pop("PROGRAMA_WORKSPACE_ID", None) + env.pop("PROGRAMA_SURFACE_ID", None) + if env_overrides: + env.update(env_overrides) + return subprocess.run( + [CLI_PATH, "--socket", socket_path, *args], + capture_output=True, + text=True, + check=False, + timeout=8.0, + env=env, + ) + + +def merged_output(process: subprocess.CompletedProcess[str]) -> str: + return f"{process.stdout}\n{process.stderr}".strip() + + +def main() -> int: + if not CLI_PATH or not Path(CLI_PATH).is_file() or not os.access(CLI_PATH, os.X_OK): + print("FAIL: PROGRAMA_CLI_BIN must point to an executable programa CLI") + return 1 + + failures: list[str] = [] + + def check(condition: bool, message: str) -> None: + if not condition: + failures.append(message) + + def expect_without_connection( + name: str, + args: list[str], + *, + expected_status: int | None = None, + expect_failure: bool = False, + output_contains: str | None = None, + ) -> None: + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + try: + process = run_cli(recorder.path, args) + except subprocess.TimeoutExpired: + failures.append(f"{name}: CLI timed out") + return + output = merged_output(process) + check(not recorder.errors, f"{name}: recorder errors: {recorder.errors}") + check( + recorder.accept_count == 0, + f"{name}: expected no socket connection, observed {recorder.accept_count}; output={output!r}", + ) + if expected_status is not None: + check( + process.returncode == expected_status, + f"{name}: exit={process.returncode}, want {expected_status}; output={output!r}", + ) + if expect_failure: + check( + process.returncode != 0, + f"{name}: unexpectedly exited 0; output={output!r}", + ) + if output_contains is not None: + check( + output_contains.lower() in output.lower(), + f"{name}: output missing {output_contains!r}: {output!r}", + ) + + # Connection/dispatch regressions: lookup and help must happen before any socket work. + expect_without_connection("help command", ["help"], expected_status=0, output_contains="Usage:") + expect_without_connection("codex help", ["codex", "--help"], expected_status=0, output_contains="Usage: programa codex") + expect_without_connection( + "unknown command", + ["definitely-not-a-command"], + output_contains="Unknown command", + ) + expect_without_connection( + "unknown command help", + ["definitely-not-a-command", "--help"], + output_contains="Unknown command", + ) + expect_without_connection( + "unknown global option", + ["--bogus", "ping"], + output_contains="unknown", + ) + + # Unknown commands/options are usage failures, never successful no-ops. + for name, args in [ + ("unknown command status", ["definitely-not-a-command"]), + ("unknown command help status", ["definitely-not-a-command", "--help"]), + ("unknown global option status", ["--bogus", "ping"]), + ]: + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + try: + process = run_cli(recorder.path, args) + except subprocess.TimeoutExpired: + failures.append(f"{name}: CLI timed out") + continue + check(process.returncode != 0, f"{name}: unexpectedly exited 0; output={merged_output(process)!r}") + + # Typed parsing regressions: invalid CLI values must fail before acquiring a client. + expect_without_connection( + "ping unexpected positional", + ["ping", "unexpected"], + output_contains="unexpected", + ) + expect_without_connection( + "non-finite progress", + ["set-progress", "nan"], + output_contains="progress", + ) + expect_without_connection( + "negative log limit", + ["list-log", "--limit", "-1"], + output_contains="limit", + ) + expect_without_connection( + "non-positive read lines", + ["read-screen", "--lines", "0"], + output_contains="lines", + ) + expect_without_connection( + "missing panel value", + ["focus-panel", "--panel"], + output_contains="panel", + ) + + # Representative coverage for every shared grammar shape in the exhaustive + # registry: no-arg, required options, enums, typed flags, JSON, metadata, + # tree flags, and nested local syntax must all fail before socket focus. + for name, args, needle in [ + ("no-argument command rejects extras", ["capabilities", "extra"], "unexpected"), + ("required option", ["focus-window"], "window"), + ("direction enum", ["new-split", "diagonal"], "direction"), + ("typed integer option", ["move-surface", "--surface", SURFACE_ID, "--index", "two"], "index"), + ("typed boolean option", ["move-surface", "--surface", SURFACE_ID, "--focus", "maybe"], "focus"), + ("window target cannot focus before validation", ["--window", WINDOW_ID, "move-surface", "--surface", SURFACE_ID, "--index", "two"], "index"), + ("malformed rpc params", ["rpc", "system.ping", "[]"], "params"), + ("focus state enum", ["set-app-focus", "sometimes"], "state"), + ("unknown tree flag", ["tree", "--bogus"], "unknown"), + ("markdown subcommand", ["markdown", "render", "README.md"], "subcommand"), + ("metadata cardinality", ["set-status", "build"], "missing"), + ]: + expect_without_connection( + name, + args, + expect_failure=True, + output_contains=needle, + ) + + # Every malformed command above must return a usage failure. + for name, args in [ + ("ping positional status", ["ping", "unexpected"]), + ("progress status", ["set-progress", "nan"]), + ("limit status", ["list-log", "--limit", "-1"]), + ("lines status", ["read-screen", "--lines", "0"]), + ("panel status", ["focus-panel", "--panel"]), + ]: + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + try: + process = run_cli(recorder.path, args) + except subprocess.TimeoutExpired: + failures.append(f"{name}: CLI timed out") + continue + check(process.returncode != 0, f"{name}: unexpectedly exited 0; output={merged_output(process)!r}") + + # Alias help remains available offline and points at the canonical grammar. + for alias in ["rename-workspace", "rename-window"]: + expect_without_connection( + f"{alias} help", + [alias, "--help"], + expected_status=0, + output_contains="Usage: programa rename-workspace", + ) + + # Outgoing values retain their CLI-side types after the registry refactor. + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + progress = run_cli( + recorder.path, + ["set-progress", "0.5", "--label", "Build", "--workspace", WORKSPACE_ID], + ) + check(progress.returncode == 0, f"typed progress command failed: {merged_output(progress)!r}") + check(recorder.accept_count == 1, f"typed progress accepted {recorder.accept_count} connections, want 1") + progress_frames = [frame for frame in recorder.frames if frame.get("method") == "workspace.set_progress"] + check(len(progress_frames) == 1, f"typed progress frames={recorder.frames!r}") + if progress_frames: + params = progress_frames[0].get("params", {}) + check(type(params.get("value")) is float and params["value"] == 0.5, f"progress value lost float type: {params!r}") + check(params.get("label") == "Build", f"progress label mismatch: {params!r}") + check(params.get("workspace_id") == WORKSPACE_ID, f"progress workspace mismatch: {params!r}") + + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + move = run_cli( + recorder.path, + ["move-surface", "--surface", SURFACE_ID, "--index", "2", "--focus", "false"], + ) + check(move.returncode == 0, f"typed move command failed: {merged_output(move)!r}") + check(recorder.accept_count == 1, f"typed move accepted {recorder.accept_count} connections, want 1") + move_frames = [frame for frame in recorder.frames if frame.get("method") == "surface.move"] + check(len(move_frames) == 1, f"typed move frames={recorder.frames!r}") + if move_frames: + params = move_frames[0].get("params", {}) + check(type(params.get("index")) is int and params["index"] == 2, f"move index lost integer type: {params!r}") + check(type(params.get("focus")) is bool and params["focus"] is False, f"move focus lost boolean type: {params!r}") + + # Global window targeting stays ordered and shares one lazy connection. + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + ping = run_cli(recorder.path, ["--window", WINDOW_ID, "ping"]) + check(ping.returncode == 0, f"window-targeted ping failed: {merged_output(ping)!r}") + check(recorder.accept_count == 1, f"window-targeted ping accepted {recorder.accept_count} connections, want 1") + methods = [frame.get("method") for frame in recorder.frames] + check(methods == ["window.focus", "system.ping"], f"window focus/ping order changed: {methods!r}") + + # The implicit password file is security-sensitive: only a regular, + # user-owned, private file may contribute an auth frame. + def password_file_frames(kind: str) -> tuple[subprocess.CompletedProcess[str], list[dict[str, Any]]]: + with tempfile.TemporaryDirectory(prefix=f"programa-cli-password-{kind}-") as home: + password_dir = Path(home) / "Library" / "Application Support" / "programa" + password_dir.mkdir(parents=True, mode=0o700) + password_path = password_dir / "socket-control-password" + if kind == "symlink": + target = Path(home) / "password-target" + target.write_text("file-secret\n", encoding="utf-8") + target.chmod(0o600) + password_path.symlink_to(target) + else: + password_path.write_text("file-secret\n", encoding="utf-8") + password_path.chmod(0o600 if kind == "secure" else 0o644) + + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as socket_directory: + with SocketRecorder(socket_directory) as recorder: + process = run_cli( + recorder.path, + ["ping"], + env_overrides={"HOME": home, "CFFIXED_USER_HOME": home}, + ) + return process, recorder.frames + + secure_process, secure_frames = password_file_frames("secure") + check(secure_process.returncode == 0, f"secure password file ping failed: {merged_output(secure_process)!r}") + secure_auth = [frame for frame in secure_frames if frame.get("method") == "auth.login"] + check( + len(secure_auth) == 1 and secure_auth[0].get("params", {}).get("password") == "file-secret", + f"secure password file was not used exactly once: {secure_frames!r}", + ) + + for kind in ["permissive", "symlink"]: + process, frames = password_file_frames(kind) + check(process.returncode == 0, f"{kind} password file ping failed: {merged_output(process)!r}") + check( + all(frame.get("method") != "auth.login" for frame in frames), + f"{kind} password file was trusted: {frames!r}", + ) + + if failures: + print(f"FAIL: {len(failures)} CLI registry behavior assertion(s) failed") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI registry validates and dispatches before lazily acquiring one typed client") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ensure_ghosttykit_locking.sh b/tests/test_ensure_ghosttykit_locking.sh new file mode 100755 index 00000000000..638fbebfcca --- /dev/null +++ b/tests/test_ensure_ghosttykit_locking.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-ghosttykit-locking.XXXXXX")" +trap 'pkill -P $$ >/dev/null 2>&1 || true; rm -rf "$TMP_DIR"' EXIT + +FAILURES=0 + +fail() { + echo "FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +wait_for_file() { + local path="$1" + local attempts="${2:-50}" + local index=0 + while [[ "$index" -lt "$attempts" ]]; do + [[ -e "$path" ]] && return 0 + sleep 0.05 + index=$((index + 1)) + done + return 1 +} + +wait_for_exit() { + local pid="$1" + local attempts="${2:-20}" + local index=0 + while [[ "$index" -lt "$attempts" ]]; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null || return $? + return 0 + fi + sleep 0.05 + index=$((index + 1)) + done + return 124 +} + +make_fixture() { + local name="$1" + local fixture="$TMP_DIR/$name" + local repo="$fixture/repo" + local bin="$fixture/bin" + + mkdir -p "$repo/scripts" "$repo/ghostty/include" "$bin" + cp "$ROOT_DIR/scripts/ensure-ghosttykit.sh" "$repo/scripts/ensure-ghosttykit.sh" + chmod +x "$repo/scripts/ensure-ghosttykit.sh" + printf '#include "ghostty/include/ghostty.h"\n' > "$repo/ghostty.h" + printf 'fixture\n' > "$repo/ghostty/include/ghostty.h" + + ( + cd "$repo/ghostty" + git init -q + git config user.email test@example.com + git config user.name "Programa Tests" + git add include/ghostty.h + git commit -qm fixture + ) + + cat > "$bin/zig" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$$" >> "${TEST_ZIG_CALL_LOG:?}" +touch "${TEST_ZIG_STARTED:?}" +if [[ "${TEST_BLOCK_ZIG:-0}" == "1" ]]; then + while [[ ! -e "${TEST_ZIG_RELEASE:?}" ]]; do + sleep 0.05 + done +fi +mkdir -p macos/GhosttyKit.xcframework/macos-arm64 +printf 'archive\n' > macos/GhosttyKit.xcframework/macos-arm64/libghostty.a +EOF + + cat > "$bin/xcrun" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "--find" && "${2:-}" == "ranlib" ]]; then + printf '%s\n' "${TEST_RANLIB:?}" + exit 0 +fi +exit 1 +EOF + + cat > "$bin/ranlib" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exit 0 +EOF + chmod +x "$bin/zig" "$bin/xcrun" "$bin/ranlib" + + printf '%s\n' "$fixture" +} + +fixture_key() { + git -C "$1/repo/ghostty" rev-parse HEAD +} + +run_ensure_background() { + local fixture="$1" + local block_zig="$2" + local output="$3" + local cache="$fixture/cache" + local started="$fixture/zig-started-$RANDOM" + local release="$fixture/zig-release" + local calls="$fixture/zig-calls.log" + : > "$calls" + + ( + cd "$fixture/repo" + PATH="$fixture/bin:/usr/bin:/bin" \ + HOME="$fixture/home" \ + PROGRAMA_GHOSTTYKIT_CACHE_DIR="$cache" \ + PROGRAMA_GHOSTTYKIT_LOCK_TIMEOUT="${TEST_LOCK_TIMEOUT:-5}" \ + PROGRAMA_GHOSTTYKIT_LOCK_POLL_INTERVAL="0.05" \ + TEST_ZIG_CALL_LOG="$calls" \ + TEST_ZIG_STARTED="$started" \ + TEST_ZIG_RELEASE="$release" \ + TEST_BLOCK_ZIG="$block_zig" \ + TEST_RANLIB="$fixture/bin/ranlib" \ + ./scripts/ensure-ghosttykit.sh + ) >"$output" 2>&1 & + ENSURE_PID=$! + ENSURE_STARTED="$started" + ENSURE_CALLS="$calls" +} + +test_ready_cache_bypasses_live_build_lock() { + local fixture key cache_dir lock_dir output pid status + fixture="$(make_fixture cache-hit)" + key="$(fixture_key "$fixture")" + cache_dir="$fixture/cache/$key/GhosttyKit.xcframework" + lock_dir="$fixture/cache/$key.lock" + output="$fixture/cache-hit.out" + mkdir -p "$cache_dir/macos-arm64" "$lock_dir" + printf 'archive\n' > "$cache_dir/macos-arm64/libghostty.a" + touch "$fixture/cache/$key/.ready" + printf 'pid=%s\ntoken=live-owner\n' "$$" > "$lock_dir/owner" + + run_ensure_background "$fixture" 0 "$output" + pid="$ENSURE_PID" + status=0 + wait_for_exit "$pid" 8 || status=$? + if [[ "$status" -eq 124 ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "ready cache hit waited for the build lock instead of returning immediately" + elif [[ "$status" -ne 0 ]]; then + fail "ready cache hit failed (status $status): $(cat "$output")" + elif [[ ! -L "$fixture/repo/GhosttyKit.xcframework" ]]; then + fail "ready cache hit did not install the project xcframework symlink" + fi +} + +test_live_owner_is_never_stolen() { + local fixture key lock_dir first_pid second_pid calls expected_token + fixture="$(make_fixture live-owner)" + key="$(fixture_key "$fixture")" + lock_dir="$fixture/cache/$key.lock" + + run_ensure_background "$fixture" 1 "$fixture/first.out" + first_pid="$ENSURE_PID" + if ! wait_for_file "$ENSURE_STARTED"; then + fail "first live-owner builder never entered zig" + kill "$first_pid" 2>/dev/null || true + return + fi + if [[ ! -f "$lock_dir/owner" ]]; then + printf 'pid=%s\ntoken=first-owner\n' "$first_pid" > "$lock_dir/owner" + fi + expected_token="$(awk -F= '$1 == "token" { print $2; exit }' "$lock_dir/owner")" + + TEST_LOCK_TIMEOUT=0 run_ensure_background "$fixture" 0 "$fixture/second.out" + second_pid="$ENSURE_PID" + calls="$ENSURE_CALLS" + sleep 1.4 + if [[ "$(wc -l < "$calls" | tr -d ' ')" -ne 0 ]]; then + fail "waiter stole a live GhosttyKit lock and started a concurrent zig build" + fi + if [[ -z "$expected_token" ]] || [[ ! -f "$lock_dir/owner" ]] \ + || ! grep -Fq "token=$expected_token" "$lock_dir/owner"; then + fail "waiter replaced the live GhosttyKit lock owner" + fi + + touch "$fixture/zig-release" + kill "$second_pid" 2>/dev/null || true + wait "$second_pid" 2>/dev/null || true + wait "$first_pid" 2>/dev/null || true +} + +test_dead_owner_recovers_promptly() { + local fixture key lock_dir output pid status + fixture="$(make_fixture dead-owner)" + key="$(fixture_key "$fixture")" + lock_dir="$fixture/cache/$key.lock" + output="$fixture/dead-owner.out" + mkdir -p "$lock_dir" + printf 'pid=99999999\ntoken=dead-owner\n' > "$lock_dir/owner" + + run_ensure_background "$fixture" 0 "$output" + pid="$ENSURE_PID" + status=0 + wait_for_exit "$pid" 10 || status=$? + if [[ "$status" -eq 124 ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "dead GhosttyKit lock owner was not recovered promptly" + elif [[ "$status" -ne 0 ]]; then + fail "dead-owner recovery failed (status $status): $(cat "$output")" + fi +} + +test_old_owner_cannot_release_replacement_lock() { + local fixture key lock_dir owner_pid + fixture="$(make_fixture ownership-release)" + key="$(fixture_key "$fixture")" + lock_dir="$fixture/cache/$key.lock" + + run_ensure_background "$fixture" 1 "$fixture/owner.out" + owner_pid="$ENSURE_PID" + if ! wait_for_file "$ENSURE_STARTED"; then + fail "ownership-release builder never entered zig" + kill "$owner_pid" 2>/dev/null || true + return + fi + + rm -rf "$lock_dir" + mkdir -p "$lock_dir" + touch "$fixture/zig-release" + wait "$owner_pid" 2>/dev/null || true + + if [[ ! -d "$lock_dir" ]]; then + fail "old lock owner removed a replacement lock during exit cleanup" + fi +} + +test_ready_cache_bypasses_live_build_lock +test_live_owner_is_never_stolen +test_dead_owner_recovers_promptly +test_old_owner_cannot_release_replacement_lock + +if [[ "$FAILURES" -ne 0 ]]; then + echo "FAIL: $FAILURES GhosttyKit locking regression(s) detected" >&2 + exit 1 +fi + +echo "PASS: GhosttyKit cache locking preserves live owners and recovers stale owners" diff --git a/tests/test_reload_entrypoints_artifacts.sh b/tests/test_reload_entrypoints_artifacts.sh new file mode 100755 index 00000000000..e4771806fc1 --- /dev/null +++ b/tests/test_reload_entrypoints_artifacts.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-reload-entrypoints.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +FAKE_HOME="$TMP_DIR/home" +STUB_BIN="$TMP_DIR/bin" +COMMAND_LOG="$TMP_DIR/commands.log" +ENSURE_MARKER="$TMP_DIR/ghosttykit-ready" +FAILURES=0 + +mkdir -p "$FAKE_HOME" "$STUB_BIN" +: > "$COMMAND_LOG" + +fail() { + echo "FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +cat > "$STUB_BIN/ensure-ghosttykit" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'ensure-ghosttykit\n' >> "${TEST_COMMAND_LOG:?}" +touch "${TEST_ENSURE_MARKER:?}" +EOF + +cat > "$STUB_BIN/xcodebuild" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'xcodebuild <%s>\n' "$*" >> "${TEST_COMMAND_LOG:?}" +if [[ "${TEST_REQUIRE_ENSURE:-0}" == "1" && ! -f "${TEST_ENSURE_MARKER:?}" ]]; then + echo "error: GhosttyKit dependency was not prepared before xcodebuild" >&2 + exit 42 +fi + +configuration="Debug" +derived_data="" +args=("$@") +index=0 +while [[ "$index" -lt "${#args[@]}" ]]; do + case "${args[$index]}" in + -configuration) + configuration="${args[$((index + 1))]}" + index=$((index + 2)) + ;; + -derivedDataPath) + derived_data="${args[$((index + 1))]}" + index=$((index + 2)) + ;; + *) + index=$((index + 1)) + ;; + esac +done + +if [[ -z "$derived_data" ]]; then + derived_data="$HOME/Library/Developer/Xcode/DerivedData/ProgramaFixture" +fi +if [[ "$configuration" == "Debug" ]]; then + product_name="Programa DEV" + bundle_id="com.darkroom.programa.debug" +else + product_name="Programa" + bundle_id="com.darkroom.programa" +fi + +app="$derived_data/Build/Products/$configuration/$product_name.app" +mkdir -p "$app/Contents/MacOS" +printf '#!/usr/bin/env bash\nexit 0\n' > "$app/Contents/MacOS/$product_name" +chmod +x "$app/Contents/MacOS/$product_name" +cat > "$app/Contents/Info.plist" < + + +CFBundleName$product_name +CFBundleDisplayName$product_name +CFBundleIdentifier$bundle_id + +PLIST +echo "** BUILD SUCCEEDED **" +EOF + +cat > "$STUB_BIN/log-command" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s' "$(basename "$0")" >> "${TEST_COMMAND_LOG:?}" +printf ' <%s>' "$@" >> "${TEST_COMMAND_LOG:?}" +printf '\n' >> "${TEST_COMMAND_LOG:?}" +exit 0 +EOF + +for command in open pkill pgrep sleep lsof; do + cp "$STUB_BIN/log-command" "$STUB_BIN/$command" +done +chmod +x "$STUB_BIN/ensure-ghosttykit" "$STUB_BIN/xcodebuild" "$STUB_BIN/log-command" \ + "$STUB_BIN/open" "$STUB_BIN/pkill" "$STUB_BIN/pgrep" "$STUB_BIN/sleep" "$STUB_BIN/lsof" + +run_entrypoint() { + local require_ensure="$1" + shift + ( + cd "$ROOT_DIR" + HOME="$FAKE_HOME" \ + PATH="$STUB_BIN:/usr/bin:/bin" \ + TEST_COMMAND_LOG="$COMMAND_LOG" \ + TEST_ENSURE_MARKER="$ENSURE_MARKER" \ + TEST_REQUIRE_ENSURE="$require_ensure" \ + PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND="$STUB_BIN/ensure-ghosttykit" \ + PROGRAMA_SKIP_ZIG_BUILD=1 \ + "$@" + ) +} + +test_staging_uses_canonical_artifact_and_bundle_identity() { + local derived output status app bundle_id + derived="$TMP_DIR/staging-derived" + output="$TMP_DIR/staging-artifact.out" + rm -f "$ENSURE_MARKER" + + status=0 + run_entrypoint 0 bash scripts/reloads.sh \ + --tag fixture --derived-data "$derived" >"$output" 2>&1 || status=$? + if [[ "$status" -ne 0 ]]; then + fail "staging reload could not discover the canonical Programa.app artifact: $(cat "$output")" + return + fi + + app="$derived/Build/Products/Release/Programa STAGING fixture.app" + if [[ ! -d "$app" ]]; then + fail "staging reload did not produce canonical artifact $app" + return + fi + bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist" 2>/dev/null || true)" + if [[ "$bundle_id" != "com.darkroom.programa.staging.fixture" ]]; then + fail "staging artifact bundle id was '$bundle_id', expected com.darkroom.programa.staging.fixture" + fi + if ! grep -Fq "open <-g> <$app>" "$COMMAND_LOG"; then + fail "staging reload did not launch the canonical staging artifact" + fi +} + +assert_cold_entrypoint_prepares_dependency() { + local label="$1" + shift + local output="$TMP_DIR/$label-cold.out" + local status=0 + rm -f "$ENSURE_MARKER" + : > "$COMMAND_LOG" + + run_entrypoint 1 "$@" >"$output" 2>&1 || status=$? + if [[ "$status" -ne 0 ]]; then + fail "$label did not prepare GhosttyKit before xcodebuild (status $status): $(cat "$output")" + return + fi + if [[ ! -f "$ENSURE_MARKER" ]]; then + fail "$label completed without invoking dependency preparation" + fi +} + +test_cold_entrypoints_prepare_dependency() { + assert_cold_entrypoint_prepares_dependency \ + debug bash scripts/reload.sh --tag fixture-debug --derived-data "$TMP_DIR/debug-derived" + assert_cold_entrypoint_prepares_dependency \ + release bash scripts/reloadp.sh + assert_cold_entrypoint_prepares_dependency \ + staging bash scripts/reloads.sh --tag fixture-cold --derived-data "$TMP_DIR/staging-cold-derived" +} + +test_staging_uses_canonical_artifact_and_bundle_identity +test_cold_entrypoints_prepare_dependency + +if [[ "$FAILURES" -ne 0 ]]; then + echo "FAIL: $FAILURES reload entrypoint regression(s) detected" >&2 + exit 1 +fi + +echo "PASS: reload entrypoints prepare dependencies and use canonical Programa artifacts" diff --git a/tests/test_reloadp_programa_artifact.sh b/tests/test_reloadp_programa_artifact.sh new file mode 100755 index 00000000000..be70e95b49a --- /dev/null +++ b/tests/test_reloadp_programa_artifact.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-reloadp-test.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +FAKE_HOME="$TMP_DIR/home" +STUB_BIN="$TMP_DIR/bin" +COMMAND_LOG="$TMP_DIR/commands.log" +APP_PATH="$FAKE_HOME/Library/Developer/Xcode/DerivedData/ProgramaFixture/Build/Products/Release/Programa.app" +APP_EXECUTABLE="$APP_PATH/Contents/MacOS/Programa" + +mkdir -p "$(dirname "$APP_EXECUTABLE")" "$STUB_BIN" +printf '#!/usr/bin/env bash\nexit 0\n' > "$APP_EXECUTABLE" +chmod +x "$APP_EXECUTABLE" +: > "$COMMAND_LOG" + +write_logging_stub() { + local name="$1" + cat > "$STUB_BIN/$name" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s' "$(basename "$0")" >> "${TEST_COMMAND_LOG:?}" +printf ' <%s>' "$@" >> "${TEST_COMMAND_LOG:?}" +printf '\n' >> "${TEST_COMMAND_LOG:?}" +exit 0 +EOF + chmod +x "$STUB_BIN/$name" +} + +for command in ensure-ghosttykit xcodebuild pkill sleep open; do + write_logging_stub "$command" +done + +cat > "$STUB_BIN/pgrep" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s' "pgrep" >> "${TEST_COMMAND_LOG:?}" +printf ' <%s>' "$@" >> "${TEST_COMMAND_LOG:?}" +printf '\n' >> "${TEST_COMMAND_LOG:?}" +case " $* " in + *"/Programa.app/Contents/MacOS/Programa"*) exit 0 ;; + *) exit 1 ;; +esac +EOF +chmod +x "$STUB_BIN/pgrep" + +export TEST_COMMAND_LOG="$COMMAND_LOG" +set +e +OUTPUT="$({ + cd "$ROOT_DIR" + HOME="$FAKE_HOME" \ + PATH="$STUB_BIN:/usr/bin:/bin" \ + PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND="$STUB_BIN/ensure-ghosttykit" \ + bash scripts/reloadp.sh +} 2>&1)" +STATUS=$? +set -e + +if [[ "$STATUS" -ne 0 ]]; then + echo "FAIL: reloadp.sh did not locate the built Programa.app artifact" >&2 + echo "$OUTPUT" >&2 + exit 1 +fi + +if ! grep -Fq "open <-g> <$APP_PATH>" "$COMMAND_LOG"; then + echo "FAIL: reloadp.sh did not launch the expected Programa.app" >&2 + cat "$COMMAND_LOG" >&2 + exit 1 +fi + +if ! grep -Fq "$APP_EXECUTABLE" "$COMMAND_LOG"; then + echo "FAIL: reloadp.sh did not verify the Programa executable" >&2 + cat "$COMMAND_LOG" >&2 + exit 1 +fi + +if grep -Fq "cmux" "$COMMAND_LOG"; then + echo "FAIL: reloadp.sh still targets the retired cmux product" >&2 + cat "$COMMAND_LOG" >&2 + exit 1 +fi + +echo "PASS: reloadp.sh locates, launches, and verifies the Programa Release artifact" diff --git a/tests_v2/test_focus_transition_workspace_split_race.py b/tests_v2/test_focus_transition_workspace_split_race.py new file mode 100644 index 00000000000..9172af90e34 --- /dev/null +++ b/tests_v2/test_focus_transition_workspace_split_race.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Regression: workspace handoff and non-focus split must converge on one focus owner.""" + +from __future__ import annotations + +import os +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _focused_surface(client: cmux, workspace_id: str) -> str: + rows = client.list_surfaces(workspace=workspace_id) + focused = [surface_id for _index, surface_id, is_focused in rows if is_focused] + if len(focused) != 1: + raise cmuxError( + f"expected exactly one model-focused surface in {workspace_id}: {rows!r}" + ) + return focused[0] + + +def _wait_for_focus_convergence( + client: cmux, + *, + workspace_id: str, + surface_id: str, + other_surface_id: str, + timeout_s: float = 4.0, +) -> None: + deadline = time.time() + timeout_s + last_state = "" + while time.time() < deadline: + current_workspace = client.current_workspace() + model_surface = _focused_surface(client, workspace_id) + target_is_first_responder = client.is_terminal_focused(surface_id) + other_is_first_responder = client.is_terminal_focused(other_surface_id) + last_state = ( + f"current_workspace={current_workspace} model_surface={model_surface} " + f"target_first_responder={target_is_first_responder} " + f"other_first_responder={other_is_first_responder}" + ) + if ( + current_workspace == workspace_id + and model_surface == surface_id + and target_is_first_responder + and not other_is_first_responder + ): + return + time.sleep(0.025) + + raise cmuxError(f"focus transition did not converge: {last_state}") + + +def _assert_input_routes_only_to_target( + client: cmux, + *, + target_surface_id: str, + excluded_surface_ids: list[str], +) -> None: + marker = f"focus-transition-{uuid.uuid4().hex}" + client.simulate_type(f"printf '{marker}\\n'") + client.simulate_shortcut("enter") + + deadline = time.time() + 4.0 + while time.time() < deadline: + if marker in client.read_terminal_text(target_surface_id): + break + time.sleep(0.05) + else: + raise cmuxError(f"typed input did not reach target surface {target_surface_id}") + + wrongly_routed = [ + surface_id + for surface_id in excluded_surface_ids + if marker in client.read_terminal_text(surface_id) + ] + if wrongly_routed: + raise cmuxError( + f"typed input reached non-target surfaces {wrongly_routed}; target={target_surface_id}" + ) + + +def main() -> int: + created_workspaces: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + workspace_a = client.new_workspace() + workspace_b = client.new_workspace() + created_workspaces.extend([workspace_a, workspace_b]) + + client.select_workspace(workspace_a) + time.sleep(0.25) + surface_a = _focused_surface(client, workspace_a) + + client.select_workspace(workspace_b) + time.sleep(0.25) + surface_b = _focused_surface(client, workspace_b) + + for iteration in range(6): + # Mutate A's portal/layout while B owns the visible AppKit focus. The split + # explicitly has no focus intent and must not steal either model or responder focus. + split_result = client._call( + "surface.split", + { + "workspace_id": workspace_a, + "surface_id": surface_a, + "direction": "right" if iteration % 2 == 0 else "down", + "focus": False, + }, + ) or {} + split_surface = str(split_result.get("surface_id") or "") + if not split_surface: + raise cmuxError(f"surface.split returned no surface: {split_result!r}") + + if client.current_workspace() != workspace_b: + raise cmuxError("background non-focus split stole workspace selection") + if _focused_surface(client, workspace_a) != surface_a: + raise cmuxError("background non-focus split stole model focus") + + # Do not sleep between selections: stale async work from B and the split must + # be rejected when the final transition returns to A. + client.select_workspace(workspace_a) + client.select_workspace(workspace_b) + client.select_workspace(workspace_a) + + _wait_for_focus_convergence( + client, + workspace_id=workspace_a, + surface_id=surface_a, + other_surface_id=surface_b, + ) + _assert_input_routes_only_to_target( + client, + target_surface_id=surface_a, + excluded_surface_ids=[surface_b, split_surface], + ) + + client.close_surface(split_surface) + _wait_for_focus_convergence( + client, + workspace_id=workspace_a, + surface_id=surface_a, + other_surface_id=surface_b, + ) + + finally: + with cmux(SOCKET_PATH) as cleanup_client: + for workspace_id in reversed(created_workspaces): + try: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: rapid workspace/split transitions preserve one observable focus owner") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_jsonrpc_strict_param_validation.py b/tests_v2/test_jsonrpc_strict_param_validation.py new file mode 100644 index 00000000000..fadc211e1ed --- /dev/null +++ b/tests_v2/test_jsonrpc_strict_param_validation.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Regression: JSON-RPC integer parameters reject booleans and fractions.""" + +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _expect_invalid_params(c: cmux, method: str, params: dict[str, Any]) -> None: + try: + c._call(method, params) + except cmuxError as error: + if "invalid_params" not in str(error): + raise cmuxError( + f"{method} returned the wrong error for params={params!r}: {error}" + ) from error + return + raise cmuxError(f"{method} accepted invalid params={params!r}") + + +def _expect_invalid_request_for_non_object_params(c: cmux, method: str) -> None: + if c._socket is None: + raise cmuxError("Socket is not connected") + + request_id = c._next_id + c._next_id += 1 + payload = { + "id": request_id, + "method": method, + "params": [], + } + line = json.dumps(payload, separators=(",", ":")) + "\n" + c._socket.sendall(line.encode("utf-8")) + response = json.loads(c._recv_line()) + + if response.get("id") != request_id: + raise cmuxError(f"Mismatched response id: {response!r}") + if response.get("ok") is not False: + raise cmuxError(f"{method} accepted non-object params: {response!r}") + error_code = (response.get("error") or {}).get("code") + if error_code != "invalid_request": + raise cmuxError( + f"{method} returned {error_code!r}, expected 'invalid_request': {response!r}" + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + original_workspace = c.current_workspace() + reordered_workspace = c.new_workspace() + + # Build a pane with two surfaces so an incorrectly coerced index would mutate state. + split_surface = c.new_split("right") + time.sleep(0.2) + second_surface = c.new_surface(panel_type="terminal") + time.sleep(0.2) + pane_rows = c.list_panes() + focused_pane = next( + (pane_id for _idx, pane_id, _count, focused in pane_rows if focused), + None, + ) + if focused_pane is None: + raise cmuxError(f"Expected a focused pane, got {pane_rows!r}") + + common_workspace_params = {"workspace_id": reordered_workspace} + common_surface_params = {"surface_id": second_surface} + common_pane_params = { + "workspace_id": original_workspace, + "pane_id": focused_pane, + "direction": "left", + } + + for invalid_integer in (True, 1.5): + _expect_invalid_params( + c, + "workspace.reorder", + {**common_workspace_params, "index": invalid_integer}, + ) + _expect_invalid_params( + c, + "surface.reorder", + {**common_surface_params, "index": invalid_integer}, + ) + _expect_invalid_params( + c, + "pane.resize", + {**common_pane_params, "amount": invalid_integer}, + ) + + # Keep a live reference so setup failures cannot silently leave an unused split. + if not split_surface: + raise cmuxError("Expected split surface ID") + + for method in ("workspace.reorder", "surface.reorder", "pane.resize"): + _expect_invalid_request_for_non_object_params(c, method) + + print("PASS: JSON-RPC rejects boolean/fraction integers and non-object params") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_lint_swiftui_patterns.py b/tests_v2/test_lint_swiftui_patterns.py deleted file mode 100644 index 685480eb7db..00000000000 --- a/tests_v2/test_lint_swiftui_patterns.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -""" -Lint test to catch SwiftUI patterns that cause performance issues. - -This test checks for: -1. Text(_:style:) with auto-updating date styles (.time, .timer, .relative) - These cause continuous view updates and can lead to high CPU usage. -""" - -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path -from typing import List, Tuple - - -def get_repo_root(): - """Get the repository root directory.""" - # Try git first - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - - # Fall back to finding GhosttyTabs directory - cwd = Path.cwd() - if cwd.name == "GhosttyTabs" or (cwd / "Sources").exists(): - return cwd - if (cwd.parent / "GhosttyTabs").exists(): - return cwd.parent / "GhosttyTabs" - - # Last resort: use current directory - return cwd - - -def find_swift_files(repo_root: Path) -> List[Path]: - """Find all Swift files in Sources directory (excluding vendored code).""" - sources_dir = repo_root / "Sources" - if not sources_dir.exists(): - return [] - return list(sources_dir.rglob("*.swift")) - - -def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, str]]: - """ - Check for Text(_:style:) with auto-updating date styles. - - These patterns cause continuous SwiftUI view updates: - - Text(date, style: .time) - updates every second/minute - - Text(date, style: .timer) - updates continuously - - Text(date, style: .relative) - updates periodically - - Text(date, style: .offset) - updates periodically - - Instead, use static formatting: - - Text(date.formatted(date: .omitted, time: .shortened)) - """ - violations = [] - - # Patterns that indicate auto-updating Text with Date - # The key patterns are: Text(something, style: .time/timer/relative/offset) - problematic_patterns = [ - "style: .time", - "style: .timer", - "style: .relative", - "style: .offset", - "style:.time", - "style:.timer", - "style:.relative", - "style:.offset", - ] - - for file_path in files: - try: - content = file_path.read_text() - lines = content.split('\n') - - for line_num, line in enumerate(lines, start=1): - # Skip comments - stripped = line.strip() - if stripped.startswith("//"): - continue - - for pattern in problematic_patterns: - if pattern in line: - violations.append((file_path, line_num, line.strip())) - break - except Exception as e: - print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) - - return violations - - -def check_command_palette_caret_tint(repo_root: Path) -> List[str]: - """Ensure command palette text inputs keep a white caret tint.""" - content_view = repo_root / "Sources" / "ContentView.swift" - if not content_view.exists(): - return [f"Missing expected file: {content_view}"] - - try: - content = content_view.read_text() - except Exception as e: - return [f"Could not read {content_view}: {e}"] - - checks = [ - ( - "search input", - r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P.*?)" - r"\.focused\(\$isCommandPaletteSearchFocused\)", - ), - ( - "rename input", - r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P.*?)" - r"\.focused\(\$isCommandPaletteRenameFocused\)", - ), - ] - - violations: List[str] = [] - for label, pattern in checks: - match = re.search(pattern, content, flags=re.DOTALL) - if not match: - violations.append( - f"Could not locate command palette {label} TextField block in Sources/ContentView.swift" - ) - continue - - body = match.group("body") - if ".tint(.white)" not in body: - violations.append( - f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift" - ) - - return violations - - -def main(): - """Run the lint checks.""" - repo_root = get_repo_root() - swift_files = find_swift_files(repo_root) - - print(f"Checking {len(swift_files)} Swift files for performance issues...") - - # Check for auto-updating Text styles - style_violations = check_autoupdating_text_styles(swift_files) - tint_violations = check_command_palette_caret_tint(repo_root) - has_failures = False - - if style_violations: - has_failures = True - print("\n❌ LINT FAILURES: Auto-updating Text styles found") - print("=" * 60) - print("These patterns cause continuous SwiftUI view updates and high CPU usage:") - print() - - for file_path, line_num, line in style_violations: - rel_path = file_path.relative_to(repo_root) - print(f" {rel_path}:{line_num}") - print(f" {line}") - print() - - print("FIX: Replace with static formatting:") - print(" Instead of: Text(date, style: .time)") - print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") - print() - - if tint_violations: - has_failures = True - print("\n❌ LINT FAILURES: Command palette caret tint drifted") - print("=" * 60) - print("The command palette search and rename text fields must keep a white caret:") - print() - for message in tint_violations: - print(f" {message}") - print() - print("FIX: Set command palette TextField tint modifiers to `.white`.") - print() - - if has_failures: - return 1 - - print("✅ No linted SwiftUI pattern regressions found") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests_v2/test_update_timing.py b/tests_v2/test_update_timing.py deleted file mode 100644 index eea8b34f0fc..00000000000 --- a/tests_v2/test_update_timing.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -""" -Verify update UI timing constants so update indicators are visible long enough. -""" - -from pathlib import Path -import re -import sys - - -ROOT = Path(__file__).resolve().parents[1] -TIMING_FILE = ROOT / "Sources" / "Update" / "UpdateTiming.swift" - - -def read_constants(text: str) -> dict[str, float]: - constants = {} - pattern = re.compile(r"static let (\w+): TimeInterval = ([0-9.]+)") - for match in pattern.finditer(text): - constants[match.group(1)] = float(match.group(2)) - return constants - - -def main() -> int: - if not TIMING_FILE.exists(): - print(f"Missing {TIMING_FILE}") - return 1 - - constants = read_constants(TIMING_FILE.read_text()) - required = { - "minimumCheckDisplayDuration": 2.0, - "noUpdateDisplayDuration": 5.0, - } - - failures = [] - for name, expected in required.items(): - actual = constants.get(name) - if actual is None: - failures.append(f"{name} missing") - continue - if actual != expected: - failures.append(f"{name} = {actual} (expected {expected})") - - if failures: - print("Update timing test failed:") - for failure in failures: - print(f" - {failure}") - return 1 - - print("Update timing test passed.") - return 0 - - -if __name__ == "__main__": - sys.exit(main())