diff --git a/.claude/skills/asc-iap-attach/SKILL.md b/.claude/skills/asc-iap-attach/SKILL.md index 8501358..a5b7723 100644 --- a/.claude/skills/asc-iap-attach/SKILL.md +++ b/.claude/skills/asc-iap-attach/SKILL.md @@ -22,7 +22,7 @@ This skill uses Apple's internal iris API (`/iris/v1/subscriptionSubmissions`) v ## Preconditions -- Web session cached in macOS Keychain. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. - Know your app ID. - IAPs and/or subscriptions already exist and are in **Ready to Submit** state. - A build is uploaded and attached to the current app version. @@ -32,7 +32,7 @@ This skill uses Apple's internal iris API (`/iris/v1/subscriptionSubmissions`) v ### 1. Check for an existing web session ```bash -security find-generic-password -s "asc-web-session" -a "asc:web-session:store" -w > /dev/null 2>&1 && echo "SESSION_EXISTS" || echo "NO_SESSION" +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" ``` - If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. @@ -44,20 +44,16 @@ Use the iris API to list subscription groups (with subscriptions) and in-app pur ```bash python3 -c " -import json, subprocess, urllib.request, sys +import json, os, urllib.request, sys APP_ID = 'APP_ID_HERE' -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -118,18 +114,14 @@ Use the following script to attach subscriptions. **Do not print or log the cook ```bash python3 -c " -import json, subprocess, urllib.request, sys - -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -178,18 +170,14 @@ For in-app purchases (non-subscription), change the type and relationship: ```bash python3 -c " -import json, subprocess, urllib.request, sys - -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -243,7 +231,7 @@ After attachment, call `get_tab_state` for `ascOverview` to refresh the submissi The subscription is already attached — this is safe to ignore. HTTP 409 with this message means the item was previously attached. ### 401 Not Authorized (iris API) -The web session has expired. Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz — this captures a fresh session and saves it to the keychain automatically. The user will need to complete Apple ID login + 2FA in the popup. After the tool returns success, retry the iris API calls. +The web session has expired. Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz — this captures a fresh session and refreshes `~/.blitz/asc-agent/web-session.json` automatically. The user will need to complete Apple ID login + 2FA in the popup. After the tool returns success, retry the iris API calls. ## Agent Behavior diff --git a/.claude/skills/asc-team-key-create/SKILL.md b/.claude/skills/asc-team-key-create/SKILL.md index 3fc2697..1d7daa9 100644 --- a/.claude/skills/asc-team-key-create/SKILL.md +++ b/.claude/skills/asc-team-key-create/SKILL.md @@ -15,17 +15,17 @@ Use this skill to create a new App Store Connect API Key with Admin permissions ## Preconditions -- Web session cached in macOS Keychain. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. - The authenticated Apple ID must have Account Holder or Admin role. ## Workflow ### 1. Check for an existing web session -Before anything else, check if a web session already exists in the macOS Keychain: +Before anything else, check if a web session file already exists: ```bash -security find-generic-password -s "asc-web-session" -a "asc:web-session:store" -w > /dev/null 2>&1 && echo "SESSION_EXISTS" || echo "NO_SESSION" +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" ``` - If `NO_SESSION`: call the `asc_web_auth` MCP tool first to open the Apple ID login window in Blitz. Wait for it to complete before proceeding. @@ -41,22 +41,17 @@ Use the following self-contained script. Replace `KEY_NAME` with the user's chos ```bash python3 -c " -import json, subprocess, urllib.request, base64, os, sys, time +import json, urllib.request, base64, os, sys, time KEY_NAME = 'KEY_NAME_HERE' -# Extract cookies from keychain (silent — never print these) -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: - print('ERROR: No web session found. User must authenticate first.') - print('Run: asc web auth login --apple-id EMAIL') +# Read web session file (silent — never print these) +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -191,7 +186,7 @@ After the script runs, report: ## Common Errors ### 401 Not Authorized -The web session has expired or doesn't exist. Call the `asc_web_auth` MCP tool — this opens the Apple ID login window in Blitz and captures the session to the macOS Keychain automatically. Then retry the key creation script. +The web session has expired or doesn't exist. Call the `asc_web_auth` MCP tool — this opens the Apple ID login window in Blitz and refreshes `~/.blitz/asc-agent/web-session.json` automatically. Then retry the key creation script. ### 409 Conflict A key with the same name may already exist, or another conflict occurred. Try a different name. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2c54d7..0e63e72 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,13 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer @@ -45,6 +52,13 @@ jobs: runs-on: macos-15-intel steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer @@ -82,6 +96,13 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Setup Node.js uses: actions/setup-node@v4 @@ -119,21 +140,40 @@ jobs: # Add to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + - name: Validate production signing inputs + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + [ -n "$APPLE_INSTALLER_IDENTITY" ] || { echo "Missing APPLE_INSTALLER_IDENTITY"; exit 1; } + [ -n "$APPLE_API_KEY" ] || { echo "Missing APPLE_API_KEY"; exit 1; } + [ -n "$APPLE_API_ISSUER" ] || { echo "Missing APPLE_API_ISSUER"; exit 1; } + [ -n "$APPLE_API_KEY_BASE64" ] || { echo "Missing APPLE_API_KEY_BASE64"; exit 1; } + - name: Build release .app env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + BLITZ_REQUIRE_SIGNED_RELEASE: "1" run: | swift build -c release bash scripts/bundle.sh release - name: Build .pkg env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} - APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY || '' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + BLITZ_REQUIRE_SIGNED_RELEASE: "1" run: bash scripts/build-pkg.sh - name: Notarize .pkg - if: env.APPLE_API_KEY != '' env: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 @@ -165,7 +205,9 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e "process.stdout.write(require('./package.json').version)")" >> "$GITHUB_OUTPUT" + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Extract changelog notes id: changelog @@ -224,6 +266,13 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Setup Node.js uses: actions/setup-node@v4 @@ -260,11 +309,24 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e "process.stdout.write(require('./package.json').version)")" >> "$GITHUB_OUTPUT" + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Validate x86_64 signing inputs + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } - name: Build x86_64 .app artifact env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + BLITZ_REQUIRE_SIGNED_RELEASE: "1" run: | swift build -c release bash scripts/bundle.sh release diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 0000000..06b9343 --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,239 @@ +name: Release Smoke Test + +on: + pull_request: + branches: [master, main] + workflow_dispatch: + inputs: + notarize_pkg: + description: "Notarize the arm64 pkg when Apple notary secrets are available" + required: false + default: true + type: boolean + +run-name: "Release Smoke Test (${{ github.ref_name }}) #${{ github.run_number }}" + +jobs: + smoke_arm64: + runs-on: macos-15 + permissions: + contents: read + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + + - name: Import signing certificate + if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Validate production signing inputs + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + [ -n "$APPLE_INSTALLER_IDENTITY" ] || { echo "Missing APPLE_INSTALLER_IDENTITY"; exit 1; } + [ -n "$APPLE_API_KEY" ] || { echo "Missing APPLE_API_KEY"; exit 1; } + [ -n "$APPLE_API_ISSUER" ] || { echo "Missing APPLE_API_ISSUER"; exit 1; } + [ -n "$APPLE_API_KEY_BASE64" ] || { echo "Missing APPLE_API_KEY_BASE64"; exit 1; } + + - name: Get version + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build release .app + run: | + swift build -c release + BLITZ_REQUIRE_SIGNED_RELEASE=1 bash scripts/bundle.sh release + + - name: Build .pkg + run: BLITZ_REQUIRE_SIGNED_RELEASE=1 bash scripts/build-pkg.sh + + - name: Notarize .pkg + env: + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 + run: | + echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$APPLE_API_KEY_PATH" + VERSION="${{ steps.version.outputs.version }}" + xcrun notarytool submit "build/Blitz-$VERSION.pkg" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY" \ + --issuer "$APPLE_API_ISSUER" \ + --wait + xcrun stapler staple "build/Blitz-$VERSION.pkg" + + - name: Create smoke artifacts + run: | + cd .build + ditto -c -k --sequesterRsrc --keepParent Blitz.app Blitz.app.zip + shasum -a 256 Blitz.app.zip > SHA256SUMS.txt + find Blitz.app/Contents/MacOS -type f -perm +111 -exec shasum -a 256 {} + >> SHA256SUMS.txt + PKG_PATH="../build/Blitz-${{ steps.version.outputs.version }}.pkg" + if [ -f "$PKG_PATH" ]; then + shasum -a 256 "$PKG_PATH" >> SHA256SUMS.txt + fi + cat SHA256SUMS.txt + + - name: Verify arm64 smoke outputs + run: | + test -f .build/Blitz.app.zip + test -f .build/SHA256SUMS.txt + test -f "build/Blitz-${{ steps.version.outputs.version }}.pkg" + ls -lh .build/Blitz.app.zip .build/SHA256SUMS.txt "build/Blitz-${{ steps.version.outputs.version }}.pkg" + + - name: Upload arm64 smoke artifacts + uses: actions/upload-artifact@v4 + with: + name: Blitz-smoke-arm64-${{ steps.version.outputs.version }}-${{ github.run_number }} + path: | + .build/Blitz.app.zip + .build/SHA256SUMS.txt + build/Blitz-${{ steps.version.outputs.version }}.pkg + retention-days: 14 + if-no-files-found: error + + - name: Write summary + run: | + { + echo "## arm64 smoke artifacts" + echo "" + echo "- Version: ${{ steps.version.outputs.version }}" + echo "- Bundled app zip: .build/Blitz.app.zip" + echo "- Pkg: build/Blitz-${{ steps.version.outputs.version }}.pkg" + echo "- Checksums: .build/SHA256SUMS.txt" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true + + smoke_x86_64: + runs-on: macos-15-intel + permissions: + contents: read + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + + - name: Validate x86_64 signing inputs + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + + - name: Import signing certificate + if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Get version + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build x86_64 .app artifact + run: | + swift build -c release + BLITZ_REQUIRE_SIGNED_RELEASE=1 bash scripts/bundle.sh release + mkdir -p build + ditto -c -k --sequesterRsrc --keepParent .build/Blitz.app "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" + shasum -a 256 "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" > "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + + - name: Verify x86_64 smoke outputs + run: | + test -f "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" + test -f "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + ls -lh "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + + - name: Upload x86_64 smoke artifacts + uses: actions/upload-artifact@v4 + with: + name: Blitz-smoke-x86_64-${{ steps.version.outputs.version }}-${{ github.run_number }} + path: | + build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip + build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256 + retention-days: 14 + if-no-files-found: error + + - name: Write summary + run: | + { + echo "## x86_64 smoke artifacts" + echo "" + echo "- Version: ${{ steps.version.outputs.version }}" + echo "- App zip: build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" + echo "- Checksum: build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6b16a90 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "deps/App-Store-Connect-CLI-helper"] + path = deps/App-Store-Connect-CLI-helper + url = https://github.com/pythonlearner1025/App-Store-Connect-CLI.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 471d4d4..3346f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.30 +- New built-in terminal +- New Dashboard and App navigation +- Project switching performance improvements +- Share ASC auth between asc-cli and Blitz MCP tools +- Improved release/update reliability and simulator behavior +- Localization fixes (screenshot, store listing, overview tab) + ## 1.0.29 - Faster App Store Connect setup with improved onboarding, credential entry, and bundle ID guidance - Better review workflows with rejection feedback in Overview and Review, plus a submission history timeline diff --git a/CLAUDE.md b/CLAUDE.md index 8e20eee..1ddb28e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,9 @@ swift build # Release build swift build -c release +# Fetch pinned helper dependency +git submodule update --init --recursive + # Bundle as macOS .app (signs with Developer ID) bash scripts/bundle.sh release @@ -29,7 +32,7 @@ bash scripts/build-pkg.sh ## Architecture -**Blitz** is a native macOS SwiftUI app (requires macOS 14+) for iOS development. It provides simulator management, screen capture, database browsing, App Store Connect integration, and an MCP server for Claude Code integration. Built with Swift Package Manager (no Xcode project). +**Blitz** is a native macOS SwiftUI app (requires macOS 14+) for iOS development. It provides simulator management, screen capture, database browsing, App Store Connect integration, and an MCP server for Claude Code integration. Built with Swift Package Manager (no Xcode project). Source bundling also depends on the pinned ASC helper submodule in `deps/App-Store-Connect-CLI-helper` and a local Go toolchain. ### Single-target structure diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..209cd05 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "f47d0cb895ca8245ce9a43115105f5d3639a1454cf83268f88e98f5fa3c0fc57", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm.git", + "state" : { + "revision" : "3c45fdcfcf4395c72d2a4ee23c0bce79017b5391", + "version" : "1.12.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 55a7cae..f3d9ecb 100644 --- a/Package.swift +++ b/Package.swift @@ -8,13 +8,23 @@ let package = Package( .macOS(.v14) ], products: [ + .library(name: "BlitzMCPCommon", targets: ["BlitzMCPCommon"]), .executable(name: "Blitz", targets: ["Blitz"]), + .executable(name: "blitz-macos-mcp", targets: ["BlitzMCPHelper"]), + ], + dependencies: [ + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), ], targets: [ + .target( + name: "BlitzMCPCommon", + path: "Sources/BlitzMCPCommon" + ), .executableTarget( name: "Blitz", + dependencies: ["SwiftTerm", "BlitzMCPCommon"], path: "src", - exclude: ["metal"], + exclude: ["metal", "resources/skills"], resources: [.process("resources"), .copy("templates")], linkerSettings: [ .linkedFramework("ScreenCaptureKit"), @@ -27,6 +37,11 @@ let package = Package( .linkedFramework("WebKit"), ] ), + .executableTarget( + name: "BlitzMCPHelper", + dependencies: ["BlitzMCPCommon"], + path: "Sources/BlitzMCPHelper" + ), .testTarget( name: "BlitzTests", dependencies: ["Blitz"], diff --git a/README.md b/README.md index 568ad1a..8516abc 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ https://github.com/user-attachments/assets/07364d9f-f6a7-4375-acc8-b7ab46dcc60e - macOS 14+ (Sonoma) - Xcode 16+ (Swift 5.10+) - Node.js 18+ (for build scripts and sidecar) +- Go 1.26+ (for source builds that bundle the pinned `ascd` helper) ## Download @@ -43,6 +44,9 @@ https://github.com/user-attachments/assets/07364d9f-f6a7-4375-acc8-b7ab46dcc60e git clone https://github.com/blitzdotdev/blitz-mac.git cd blitz-mac +# Fetch the pinned App Store Connect helper fork +git submodule update --init --recursive + # Debug build swift build @@ -62,6 +66,8 @@ For signed builds, copy `.env.example` to `.env` and fill in your Apple Develope bash scripts/bundle.sh release ``` +The ASC helper binary bundled into the app is built from the pinned submodule at `deps/App-Store-Connect-CLI-helper`. If you need to override that source during development or CI, set `BLITZ_ASCD_SOURCE_DIR` or point `BLITZ_ASCD_PATH` at a prebuilt compatible helper binary. + ## Verify a release binary Every GitHub release includes `SHA256SUMS.txt` with checksums of the CI-built binary. To verify: diff --git a/Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift b/Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift new file mode 100644 index 0000000..9c14dd5 --- /dev/null +++ b/Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift @@ -0,0 +1,22 @@ +import Darwin +import Foundation + +public enum BlitzMCPTransportPaths { + public static var root: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz") + } + + public static var helper: URL { + root.appendingPathComponent("blitz-macos-mcp") + } + + public static var bridgeScript: URL { + root.appendingPathComponent("blitz-mcp-bridge.sh") + } + + public static var socket: URL { + URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("blitz-mcp-\(getuid()).sock") + } +} diff --git a/Sources/BlitzMCPHelper/main.swift b/Sources/BlitzMCPHelper/main.swift new file mode 100644 index 0000000..10b4a23 --- /dev/null +++ b/Sources/BlitzMCPHelper/main.swift @@ -0,0 +1,251 @@ +import BlitzMCPCommon +import Darwin +import Foundation + +@main +struct BlitzMCPHelper { + private struct RequestMetadata { + let expectsResponse: Bool + let id: Any + let startupTimeout: TimeInterval + let responseTimeout: TimeInterval + } + + private enum HelperError: LocalizedError { + case bridgeUnavailable + case responseTimeout + case emptyResponse + case invalidSocketPath + case socketCreateFailed + case writeFailed + + var errorDescription: String? { + switch self { + case .bridgeUnavailable: + return "Cannot connect to Blitz. Is it running?" + case .responseTimeout: + return "Timed out waiting for a response from Blitz." + case .emptyResponse: + return "Blitz returned an empty MCP response." + case .invalidSocketPath: + return "Blitz MCP socket path is invalid." + case .socketCreateFailed: + return "Failed to open the Blitz MCP socket." + case .writeFailed: + return "Failed to send the MCP request to Blitz." + } + } + } + + static func main() async { + do { + for try await line in FileHandle.standardInput.bytes.lines { + try handleLine(String(line)) + } + } catch { + log("MCP helper stopped: \(error.localizedDescription)") + exit(1) + } + } + + private static func handleLine(_ line: String) throws { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let metadata = parseRequestMetadata(trimmed) + + do { + if let response = try sendRequest(trimmed, metadata: metadata) { + try writeLine(response, to: STDOUT_FILENO) + } + } catch { + if metadata.expectsResponse { + let response = errorResponse(id: metadata.id, message: error.localizedDescription) + try writeLine(response, to: STDOUT_FILENO) + } else { + log("Notification failed: \(error.localizedDescription)") + } + } + } + + private static func parseRequestMetadata(_ line: String) -> RequestMetadata { + guard let data = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return RequestMetadata(expectsResponse: true, id: NSNull(), startupTimeout: 10, responseTimeout: 30) + } + + let method = json["method"] as? String ?? "" + return RequestMetadata( + expectsResponse: json["id"] != nil, + id: json["id"] ?? NSNull(), + startupTimeout: method == "initialize" ? 30 : 10, + responseTimeout: method == "initialize" ? 30 : 300 + ) + } + + private static func sendRequest(_ line: String, metadata: RequestMetadata) throws -> String? { + let deadline = Date().addingTimeInterval(metadata.startupTimeout) + let socketPath = BlitzMCPTransportPaths.socket.path + + while true { + do { + return try sendRequestOnce( + line, + expectsResponse: metadata.expectsResponse, + responseTimeout: metadata.responseTimeout, + socketPath: socketPath + ) + } catch HelperError.bridgeUnavailable { + guard Date() < deadline else { throw HelperError.bridgeUnavailable } + usleep(250_000) + } catch let error as POSIXError where shouldRetry(error.code) { + guard Date() < deadline else { throw HelperError.bridgeUnavailable } + usleep(250_000) + } catch { + throw error + } + } + } + + private static func sendRequestOnce( + _ line: String, + expectsResponse: Bool, + responseTimeout: TimeInterval, + socketPath: String + ) throws -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw HelperError.socketCreateFailed } + defer { Darwin.close(fd) } + + var timeout = timeval( + tv_sec: Int(responseTimeout.rounded(.up)), + tv_usec: 0 + ) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + pointer, + socklen_t(MemoryLayout.size) + ) + } + + var address = try makeSocketAddress(path: socketPath) + let addressLength = socklen_t(address.sun_len) + let connectResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(fd, sockaddrPointer, addressLength) + } + } + guard connectResult == 0 else { + throw mapConnectError(errno) + } + + try writeLine(line, to: fd) + guard expectsResponse else { return nil } + + guard let response = try readLine(from: fd) else { + throw HelperError.emptyResponse + } + return response + } + + private static func shouldRetry(_ code: POSIXErrorCode) -> Bool { + switch code { + case .ENOENT, .ECONNREFUSED, .EAGAIN, .ETIMEDOUT, .ECONNRESET: + return true + default: + return false + } + } + + private static func mapConnectError(_ code: Int32) -> Error { + guard let posixCode = POSIXErrorCode(rawValue: code) else { + return HelperError.bridgeUnavailable + } + if shouldRetry(posixCode) { + return POSIXError(posixCode) + } + return HelperError.bridgeUnavailable + } + + private static func makeSocketAddress(path: String) throws -> sockaddr_un { + var address = sockaddr_un() + let pathLength = path.utf8.count + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) - 1 + + guard pathLength <= maxPathLength else { + throw HelperError.invalidSocketPath + } + + address.sun_len = UInt8(MemoryLayout.size) + address.sun_family = sa_family_t(AF_UNIX) + + withUnsafeMutableBytes(of: &address.sun_path) { destination in + path.withCString { source in + destination.copyBytes(from: UnsafeRawBufferPointer(start: source, count: pathLength + 1)) + } + } + + return address + } + + private static func readLine(from fd: Int32) throws -> String? { + var data = Data() + var byte: UInt8 = 0 + + while true { + let count = Darwin.read(fd, &byte, 1) + if count == 0 { + return data.isEmpty ? nil : String(data: data, encoding: .utf8) + } + if count < 0 { + if errno == EWOULDBLOCK || errno == EAGAIN { + throw HelperError.responseTimeout + } + throw HelperError.bridgeUnavailable + } + if byte == 0x0A { + return String(data: data, encoding: .utf8) + } + data.append(byte) + } + } + + private static func errorResponse(id: Any, message: String) -> String { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "error": [ + "code": -32000, + "message": message + ] as [String: Any] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return #"{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Unknown Blitz MCP error."}}"# + } + return json + } + + private static func log(_ message: String) { + try? writeLine(message, to: STDERR_FILENO) + } + + private static func writeLine(_ line: String, to fd: Int32) throws { + let data = Data((line + "\n").utf8) + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesWritten = 0 + while bytesWritten < rawBuffer.count { + let nextPointer = baseAddress.advanced(by: bytesWritten) + let result = Darwin.write(fd, nextPointer, rawBuffer.count - bytesWritten) + if result < 0 { + throw HelperError.writeFailed + } + bytesWritten += result + } + } + } +} diff --git a/Tests/blitz_tests/ASCAuthBridgeTests.swift b/Tests/blitz_tests/ASCAuthBridgeTests.swift new file mode 100644 index 0000000..9eba96d --- /dev/null +++ b/Tests/blitz_tests/ASCAuthBridgeTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func testASCAuthBridgeWritesManagedConfigForAgentSessions() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("asc-auth-bridge-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundledASCD = root.appendingPathComponent("Blitz.app/Contents/Helpers/ascd") + try fileManager.createDirectory( + at: bundledASCD.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\nexit 0\n".write(to: bundledASCD, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bundledASCD.path) + + let bridge = ASCAuthBridge( + blitzRoot: root, + fileManager: fileManager, + bundledASCDPathProvider: { bundledASCD.path } + ) + let credentials = ASCCredentials( + issuerId: "ISSUER-123", + keyId: "KEY-123", + privateKey: """ + -----BEGIN PRIVATE KEY----- + TESTKEY + -----END PRIVATE KEY----- + """ + ) + + try bridge.syncCredentials(credentials) + + let configData = try Data(contentsOf: bridge.configURL) + let configJSON = try JSONSerialization.jsonObject(with: configData) as? [String: Any] + + #expect(configJSON?["default_key_name"] as? String == "BlitzKey") + #expect(configJSON?["key_id"] as? String == "KEY-123") + #expect(configJSON?["issuer_id"] as? String == "ISSUER-123") + #expect(configJSON?["private_key_path"] as? String == bridge.privateKeyURL.path) + + let keys = configJSON?["keys"] as? [[String: Any]] + #expect(keys?.count == 1) + #expect(keys?.first?["name"] as? String == "BlitzKey") + + let persistedPrivateKey = try String(contentsOf: bridge.privateKeyURL, encoding: .utf8) + #expect(persistedPrivateKey.contains("BEGIN PRIVATE KEY")) + + let managedLaunchPath = root.appendingPathComponent("projects/demo").path + let env = bridge.environmentOverrides(forLaunchPath: managedLaunchPath) + #expect(env["PATH"]?.hasPrefix(bridge.binDirectory.path + ":") == true) + #expect(FileManager.default.isExecutableFile(atPath: bridge.ascWrapperURL.path)) + #expect(FileManager.default.isExecutableFile(atPath: bridge.ascdShimURL.path)) + + let wrapper = try String(contentsOf: bridge.ascWrapperURL, encoding: .utf8) + #expect(wrapper.contains("__ascd_run_cli__")) + #expect(wrapper.contains("ASC_CONFIG_PATH")) + #expect(wrapper.contains(bridge.configURL.path)) + #expect(wrapper.contains("${SELF_DIR}/ascd")) + + let shellExports = bridge.shellExportCommands(forLaunchPath: managedLaunchPath) + #expect(shellExports.contains { $0.contains("export PATH=") && $0.contains(bridge.binDirectory.path) }) + #expect(shellExports.count == 1) + + let unrelatedEnv = bridge.environmentOverrides(forLaunchPath: "/tmp/not-managed") + #expect(unrelatedEnv.isEmpty) +} + +@Test func testASCWebSessionStoreMatchesASCCacheShapeAndPreservesSessions() throws { + let firstSession = IrisSession( + cookies: [ + .init(name: "DES123", value: "alpha", domain: ".apple.com", path: "/"), + .init(name: "itctx", value: "beta", domain: ".appstoreconnect.apple.com", path: "/"), + ], + email: "first@example.com", + capturedAt: Date(timeIntervalSince1970: 1) + ) + let secondSession = IrisSession( + cookies: [ + .init(name: "myacinfo", value: "gamma", domain: ".apple.com", path: "/"), + ], + email: "second@example.com", + capturedAt: Date(timeIntervalSince1970: 2) + ) + + let firstData = try ASCWebSessionStore.mergedData( + storing: firstSession, + into: nil, + now: Date(timeIntervalSince1970: 10) + ) + let mergedData = try ASCWebSessionStore.mergedData( + storing: secondSession, + into: firstData, + now: Date(timeIntervalSince1970: 20) + ) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let mergedStore = try decoder.decode(ASCWebSessionStore.self, from: mergedData) + + #expect(mergedStore.version == 1) + #expect(mergedStore.sessions.count == 2) + #expect(mergedStore.lastKey != nil) + + let storedEmails = Set(mergedStore.sessions.values.compactMap(\.userEmail)) + #expect(storedEmails == Set(["first@example.com", "second@example.com"])) + + let firstStoredSession = mergedStore.sessions.values.first { $0.userEmail == "first@example.com" } + #expect(firstStoredSession?.cookies["https://appstoreconnect.apple.com/"]?.count == 2) + #expect(firstStoredSession?.cookies["https://idmsa.apple.com/"]?.count == 1) + #expect(firstStoredSession?.cookies["https://gsa.apple.com/"]?.count == 1) + + let removedData = try ASCWebSessionStore.removingSession( + email: "second@example.com", + from: mergedData + ) + #expect(removedData != nil) + + let removedStore = try decoder.decode(ASCWebSessionStore.self, from: try #require(removedData)) + #expect(removedStore.sessions.count == 1) + #expect(removedStore.sessions.values.first?.userEmail == "first@example.com") + #expect(removedStore.lastKey == removedStore.sessions.keys.first) +} diff --git a/Tests/blitz_tests/ASCDaemonClientTests.swift b/Tests/blitz_tests/ASCDaemonClientTests.swift new file mode 100644 index 0000000..e54b84d --- /dev/null +++ b/Tests/blitz_tests/ASCDaemonClientTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func testSequencedPipeBufferReassemblesOutOfOrderChunks() { + var buffer = SequencedPipeBuffer() + + let delayed = buffer.append(Data("\"result\":{\"statusCode\":200}}\n".utf8), sequence: 1) + #expect(delayed.isEmpty) + + let lines = buffer.append(Data("{\"id\":\"ascd-39\",".utf8), sequence: 0) + #expect(lines.count == 1) + #expect(String(data: lines[0], encoding: .utf8) == "{\"id\":\"ascd-39\",\"result\":{\"statusCode\":200}}") +} + +@Test func testSequencedPipeBufferFlushesTrailingChunkAtEOF() { + var buffer = SequencedPipeBuffer() + + let partial = buffer.append(Data("partial stderr".utf8), sequence: 0) + #expect(partial.isEmpty) + + let flushed = buffer.append(Data(), sequence: 1) + #expect(flushed.count == 1) + #expect(String(data: flushed[0], encoding: .utf8) == "partial stderr") +} diff --git a/Tests/blitz_tests/ASCReleaseStatusTests.swift b/Tests/blitz_tests/ASCReleaseStatusTests.swift new file mode 100644 index 0000000..caf7f4b --- /dev/null +++ b/Tests/blitz_tests/ASCReleaseStatusTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func dashboardStatusIgnoresOlderRejectedVersionAfterLiveRelease() { + let status = ASCDashboardProjectStatus(versions: [ + makeVersion(id: "live", state: "READY_FOR_SALE", createdDate: "2026-03-20T00:00:00Z"), + makeVersion(id: "rejected", state: "REJECTED", createdDate: "2026-03-01T00:00:00Z"), + ]) + + #expect(status.isLiveOnStore) + #expect(!status.isPendingReview) + #expect(!status.isRejected) +} + +@Test func dashboardStatusCountsRejectedUpdateAlongsideLiveRelease() { + let status = ASCDashboardProjectStatus(versions: [ + makeVersion(id: "rejected", state: "REJECTED", createdDate: "2026-03-21T00:00:00Z"), + makeVersion(id: "live", state: "READY_FOR_SALE", createdDate: "2026-03-20T00:00:00Z"), + ]) + + #expect(status.isLiveOnStore) + #expect(!status.isPendingReview) + #expect(status.isRejected) +} + +@Test func dashboardStatusCountsPendingReviewUpdateAlongsideLiveRelease() { + let status = ASCDashboardProjectStatus(versions: [ + makeVersion(id: "review", state: "WAITING_FOR_REVIEW", createdDate: "2026-03-21T00:00:00Z"), + makeVersion(id: "live", state: "READY_FOR_SALE", createdDate: "2026-03-20T00:00:00Z"), + ]) + + #expect(status.isLiveOnStore) + #expect(status.isPendingReview) + #expect(!status.isRejected) +} + +@Test func releaseStatusSortsVersionsByNewestCreatedDateFirst() { + let sorted = ASCReleaseStatus.sortedVersionsByRecency([ + makeVersion(id: "old", state: "READY_FOR_SALE", createdDate: "2026-03-01T00:00:00Z"), + makeVersion(id: "new", state: "WAITING_FOR_REVIEW", createdDate: "2026-03-21T00:00:00Z"), + makeVersion(id: "middle", state: "REJECTED", createdDate: "2026-03-10T00:00:00Z"), + ]) + + #expect(sorted.map(\.id) == ["new", "middle", "old"]) +} + +@Test func submissionHistoryMapsInvalidBinaryToSubmissionError() { + #expect(ASCReleaseStatus.submissionHistoryEventType(forVersionState: "INVALID_BINARY") == .submissionError) + #expect(ASCReleaseStatus.reviewSubmissionEventType(forVersionState: "INVALID_BINARY") == .submissionError) +} + +@Test func reviewSubmissionStaysSubmittedForWaitingForReview() { + #expect(ASCReleaseStatus.reviewSubmissionEventType(forVersionState: "WAITING_FOR_REVIEW") == .submitted) +} + +private func makeVersion(id: String, state: String, createdDate: String) -> ASCAppStoreVersion { + ASCAppStoreVersion( + id: id, + attributes: ASCAppStoreVersion.Attributes( + versionString: id, + appStoreState: state, + releaseType: nil, + createdDate: createdDate, + copyright: nil + ) + ) +} diff --git a/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift b/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift new file mode 100644 index 0000000..a535d81 --- /dev/null +++ b/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import Blitz + +@MainActor +@Test func loadTrackFromASCPreservesUnsavedLocaleTrack() { + let manager = ASCManager() + let locale = "en-US" + let displayType = "APP_IPHONE_67" + let set = makeScreenshotSet(id: "set-us", displayType: displayType, count: 1) + + manager.updateScreenshotCache( + locale: locale, + sets: [set], + screenshots: [set.id: [makeScreenshot(id: "remote-1", fileName: "remote-1.png")]] + ) + manager.loadTrackFromASC(displayType: displayType, locale: locale) + + let trackKey = manager.screenshotTrackKey(displayType: displayType, locale: locale) + manager.trackSlots[trackKey] = [ + TrackSlot( + id: "local-1", + localPath: "/tmp/local-1.png", + localImage: nil, + ascScreenshot: nil, + isFromASC: false + ) + ] + Array(repeating: nil, count: 9) + + #expect(manager.hasUnsavedChanges(displayType: displayType, locale: locale)) + + manager.updateScreenshotCache( + locale: locale, + sets: [set], + screenshots: [set.id: [makeScreenshot(id: "remote-2", fileName: "remote-2.png")]] + ) + manager.loadTrackFromASC(displayType: displayType, locale: locale) + + let slots = manager.trackSlotsForDisplayType(displayType, locale: locale) + #expect(slots[0]?.id == "local-1") + #expect(manager.hasUnsavedChanges(displayType: displayType, locale: locale)) +} + +@MainActor +@Test func submissionReadinessUsesPrimaryLocaleScreenshotCache() { + let manager = ASCManager() + manager.app = makeApp(primaryLocale: "en-US") + manager.localizations = [ + makeLocalization(id: "loc-gb", locale: "en-GB"), + makeLocalization(id: "loc-us", locale: "en-US"), + ] + + let usSet = makeScreenshotSet(id: "set-us", displayType: "APP_IPHONE_67", count: 1) + manager.updateScreenshotCache( + locale: "en-US", + sets: [usSet], + screenshots: [usSet.id: [makeScreenshot(id: "shot-us", fileName: "us.png")]] + ) + + manager.selectedScreenshotsLocale = "en-GB" + + let readiness = manager.submissionReadiness + let iphoneField = readiness.fields.first { $0.label == "iPhone Screenshots" } + + #expect(iphoneField?.value == "1 screenshot(s)") +} + +@MainActor +@Test func submissionReadinessUsesPrimaryLocaleMetadataWhenAPIOrderDiffers() { + let manager = ASCManager() + manager.app = makeApp(primaryLocale: "en-US") + manager.localizations = [ + makeLocalization( + id: "loc-ja", + locale: "ja", + title: "Japanese Title", + description: "Japanese Description", + keywords: "japanese,keywords", + supportUrl: "https://example.com/ja/support" + ), + makeLocalization( + id: "loc-us", + locale: "en-US", + title: "English Title", + description: "English Description", + keywords: "english,keywords", + supportUrl: "https://example.com/en/support" + ), + ] + manager.appInfoLocalizationsByLocale = [ + "ja": makeAppInfoLocalization( + id: "info-ja", + locale: "ja", + name: "Japanese Name", + privacyPolicyUrl: "https://example.com/ja/privacy" + ), + "en-US": makeAppInfoLocalization( + id: "info-us", + locale: "en-US", + name: "English Name", + privacyPolicyUrl: "https://example.com/en/privacy" + ), + ] + manager.appInfoLocalization = manager.appInfoLocalizationsByLocale["ja"] + + func value(for label: String) -> String? { + manager.submissionReadiness.fields.first(where: { $0.label == label })?.value + } + + #expect(value(for: "App Name") == "English Name") + #expect(value(for: "Description") == "English Description") + #expect(value(for: "Keywords") == "english,keywords") + #expect(value(for: "Support URL") == "https://example.com/en/support") + #expect(value(for: "Privacy Policy URL") == "https://example.com/en/privacy") +} + +private func makeApp(primaryLocale: String?) -> ASCApp { + ASCApp( + id: "app-id", + attributes: ASCApp.Attributes( + bundleId: "com.example.blitz", + name: "Blitz", + primaryLocale: primaryLocale, + vendorNumber: nil, + contentRightsDeclaration: nil + ) + ) +} + +private func makeLocalization( + id: String, + locale: String, + title: String? = nil, + description: String? = nil, + keywords: String? = nil, + supportUrl: String? = nil +) -> ASCVersionLocalization { + ASCVersionLocalization( + id: id, + attributes: ASCVersionLocalization.Attributes( + locale: locale, + title: title, + subtitle: nil, + description: description, + keywords: keywords, + promotionalText: nil, + marketingUrl: nil, + supportUrl: supportUrl, + whatsNew: nil + ) + ) +} + +private func makeAppInfoLocalization( + id: String, + locale: String, + name: String? = nil, + privacyPolicyUrl: String? = nil +) -> ASCAppInfoLocalization { + ASCAppInfoLocalization( + id: id, + attributes: ASCAppInfoLocalization.Attributes( + locale: locale, + name: name, + subtitle: nil, + privacyPolicyUrl: privacyPolicyUrl, + privacyChoicesUrl: nil, + privacyPolicyText: nil + ) + ) +} + +private func makeScreenshotSet(id: String, displayType: String, count: Int?) -> ASCScreenshotSet { + ASCScreenshotSet( + id: id, + attributes: ASCScreenshotSet.Attributes( + screenshotDisplayType: displayType, + screenshotCount: count + ) + ) +} + +private func makeScreenshot(id: String, fileName: String) -> ASCScreenshot { + ASCScreenshot( + id: id, + attributes: ASCScreenshot.Attributes( + fileName: fileName, + fileSize: nil, + imageAsset: nil, + assetDeliveryState: nil + ) + ) +} diff --git a/Tests/blitz_tests/AppRelaunchServiceTests.swift b/Tests/blitz_tests/AppRelaunchServiceTests.swift new file mode 100644 index 0000000..b0c53c6 --- /dev/null +++ b/Tests/blitz_tests/AppRelaunchServiceTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import Blitz + +@MainActor +private func makeTestDefaults() -> UserDefaults { + let suiteName = "AppRelaunchServiceTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} + +@Test @MainActor func testScreenRecordingRelaunchSchedulesWhenPermissionWasGranted() { + let defaults = makeTestDefaults() + let appPath = "/Applications/Blitz's Test.app" + let start = Date(timeIntervalSince1970: 1_000) + let now = start + var launchedPath: String? + var launchedPID: Int32? + + let service = AppRelaunchService( + defaults: defaults, + now: { now }, + appURLProvider: { URL(fileURLWithPath: appPath) }, + screenRecordingAccessProvider: { true }, + launcher: { path, pid in + launchedPath = path + launchedPID = pid + return true + } + ) + + service.prepareForScreenRecordingPermissionRestart() + + let scheduled = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 4242) + + #expect(scheduled) + #expect(launchedPath == appPath) + #expect(launchedPID == 4242) + + launchedPath = nil + launchedPID = nil + let scheduledAgain = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 4242) + #expect(!scheduledAgain) + #expect(launchedPath == nil) + #expect(launchedPID == nil) +} + +@Test @MainActor func testScreenRecordingRelaunchDoesNotScheduleWhenRequestIsStale() { + let defaults = makeTestDefaults() + let appPath = "/Applications/Blitz.app" + let start = Date(timeIntervalSince1970: 2_000) + var now = start + var launched = false + + let service = AppRelaunchService( + defaults: defaults, + now: { now }, + appURLProvider: { URL(fileURLWithPath: appPath) }, + screenRecordingAccessProvider: { true }, + launcher: { _, _ in + launched = true + return true + } + ) + + service.prepareForScreenRecordingPermissionRestart() + now = start.addingTimeInterval(AppRelaunchService.pendingWindow + 1) + + let scheduled = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 111) + + #expect(!scheduled) + #expect(!launched) +} + +@Test @MainActor func testScreenRecordingRelaunchDoesNotScheduleWithoutGrantedPermission() { + let defaults = makeTestDefaults() + var launched = false + + let service = AppRelaunchService( + defaults: defaults, + now: { Date(timeIntervalSince1970: 3_000) }, + appURLProvider: { URL(fileURLWithPath: "/Applications/Blitz.app") }, + screenRecordingAccessProvider: { false }, + launcher: { _, _ in + launched = true + return true + } + ) + + service.prepareForScreenRecordingPermissionRestart() + let scheduled = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 222) + + #expect(!scheduled) + #expect(!launched) +} + +@Test func testRelaunchShellCommandQuotesAppPaths() { + let command = AppRelaunchService.relaunchShellCommand( + appPath: "/Applications/Blitz's Test.app", + pid: 9876 + ) + + #expect(command.contains("while kill -0 9876 2>/dev/null; do sleep 0.2; done;")) + #expect(command.contains("open '/Applications/Blitz'\\''s Test.app'")) +} diff --git a/Tests/blitz_tests/AutoUpdateServiceTests.swift b/Tests/blitz_tests/AutoUpdateServiceTests.swift new file mode 100644 index 0000000..1bd3232 --- /dev/null +++ b/Tests/blitz_tests/AutoUpdateServiceTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import Blitz + +@Test @MainActor func testAppUpdateInstallScriptKeepsBundleGuardsAndFailsOnScriptErrors() { + let zipPath = URL(fileURLWithPath: "/tmp/Blitz.app.zip") + let script = AutoUpdateManager.appUpdateInstallScript(zipPath: zipPath) + + #expect(!script.contains("/usr/bin/codesign --verify --deep --strict")) + #expect(!script.contains("/usr/sbin/spctl --assess --verbose=4")) + #expect(script.contains("CFBundleIdentifier")) + #expect(script.contains("Contents/Helpers/ascd")) + #expect(script.contains("BLITZ_UPDATE_CONTEXT='auto-update'")) + #expect(!script.contains("PREINSTALL\\\" '' '' '/' >> \\\"$UPDATE_LOG\\\" 2>&1 || true")) + #expect(!script.contains("POSTINSTALL\\\" '' '' '/' >> \\\"$UPDATE_LOG\\\" 2>&1 || true")) +} diff --git a/Tests/blitz_tests/ShellIntegrationServiceTests.swift b/Tests/blitz_tests/ShellIntegrationServiceTests.swift new file mode 100644 index 0000000..9736185 --- /dev/null +++ b/Tests/blitz_tests/ShellIntegrationServiceTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func testShellIntegrationInstallsAndRemovesManagedZshBlock() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory + .appendingPathComponent("shell-integration-home-\(UUID().uuidString)", isDirectory: true) + let blitzRoot = home.appendingPathComponent(".blitz", isDirectory: true) + + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + let bundledASCD = home.appendingPathComponent("Blitz.app/Contents/Helpers/ascd") + try fileManager.createDirectory( + at: bundledASCD.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\nexit 0\n".write(to: bundledASCD, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bundledASCD.path) + + let shellService = ShellIntegrationService( + homeDirectory: home, + blitzRoot: blitzRoot, + fileManager: fileManager, + bundledASCDPathProvider: { bundledASCD.path }, + loginShellPathProvider: { "/bin/zsh" } + ) + + try shellService.sync(enabled: true) + + let zshrc = home.appendingPathComponent(".zshrc") + let zshrcContents = try String(contentsOf: zshrc, encoding: .utf8) + #expect(zshrcContents.contains("Blitz shell integration")) + #expect(zshrcContents.contains(". \"$HOME/.blitz/shell/init.sh\"")) + + let initScript = try String(contentsOf: shellService.initScriptURL, encoding: .utf8) + #expect(initScript.contains("BLITZ_BIN")) + #expect(initScript.contains(".blitz/bin")) + #expect(FileManager.default.isExecutableFile(atPath: blitzRoot.appendingPathComponent("bin/ascd").path)) + #expect(FileManager.default.isExecutableFile(atPath: blitzRoot.appendingPathComponent("bin/asc").path)) + + try shellService.sync(enabled: false) + + let cleanedZshrc = try String(contentsOf: zshrc, encoding: .utf8) + #expect(!cleanedZshrc.contains("Blitz shell integration")) + #expect(!fileManager.fileExists(atPath: shellService.initScriptURL.path)) +} diff --git a/deps/App-Store-Connect-CLI-helper b/deps/App-Store-Connect-CLI-helper new file mode 160000 index 0000000..e60ce03 --- /dev/null +++ b/deps/App-Store-Connect-CLI-helper @@ -0,0 +1 @@ +Subproject commit e60ce037a3b36be3cbd05c3dfd82c3b28b61aa93 diff --git a/docs/codex-migration-prompt.md b/docs/codex-migration-prompt.md new file mode 100644 index 0000000..56a6178 --- /dev/null +++ b/docs/codex-migration-prompt.md @@ -0,0 +1,77 @@ +# Task: Replace blitz-macos's AppStoreConnectService with the ascd helper daemon + +## Goal + +Delete `src/services/AppStoreConnectService.swift` from blitz-macos. Replace it with a client that talks to the `ascd` helper daemon — a long-lived Go process that keeps App Store Connect auth and HTTP connections warm. + +The app must compile and run at every intermediate step. No big-bang rewrite. + +## Why + +`AppStoreConnectService.swift` is 1,716 lines of hand-rolled JWT generation, HTTP client code, and 70+ API methods with zero tests. It breaks, it's unmaintainable, and it duplicates work already done well by the Go App Store Connect CLI (2+ years, 3,054 commits). We want to stop maintaining the API layer entirely and focus on GUI/UX. + +## The two projects + +**blitz-macos** (`~/superapp/blitz-macos`): +- Native macOS SwiftUI app for iOS development +- `src/services/AppStoreConnectService.swift` — the thing being deleted (1,716 lines, 70+ API methods, manual JWT, no protocol, no tests) +- `src/models/ASCModels.swift` — 36 `Decodable` structs that decode JSON:API responses (770 lines). These should survive mostly unchanged. +- `src/services/ASCManager.swift` — `@Observable @MainActor` state holder. Calls `service.fetchX()` / `service.patchX()`. This gets rewired to use the new client. +- 23 view files consume `ASCManager` state via `.attributes.` access. These should be untouched. +- `src/services/MCPToolExecutor.swift` — MCP tools call ASCManager, not AppStoreConnectService. Should be untouched. +- `src/services/IrisService.swift` — private Apple API with cookie auth, completely separate from ASC JWT auth. Stays as-is. +- Credentials stored at `~/.blitz/asc-credentials.json` (fields: `issuerId`, `keyId`, `privateKey`) +- `CLAUDE.md` has the full architecture overview + +**ascd helper daemon** (`~/superapp/asc-cli/forks/App-Store-Connect-CLI-helper`): +- Fork of `github.com/rudrankriyam/App-Store-Connect-CLI` with one additive commit (`5c4feee0`) +- Binary at `cmd/ascd/main.go` — long-lived process, JSON-line protocol over stdin/stdout +- `docs/long-lived-helper-fork.md` — architecture and protocol reference +- `internal/helper/protocol.go` — all request/response types +- `internal/helper/service.go` — method dispatch, session management +- `internal/asc/raw_request.go` — generic authenticated HTTP through warm `asc.Client` + +### ascd protocol summary + +One JSON object per line in, one per line out. Five methods: + +- `ping` — health check +- `session.open` — resolves credentials, constructs warm HTTP client with cached JWT +- `session.close` — tears down session +- `session.request` — sends arbitrary ASC REST request (method, path, headers, body, timeoutMs) through the warm client. Returns raw HTTP response (statusCode, headers, contentType, body). **This is the fast path that replaces AppStoreConnectService.** +- `cli.exec` — runs the full upstream CLI as a child process. Returns exitCode, stdout, stderr. **Compatibility fallback.** + +The response body from `session.request` is the raw JSON:API payload from Apple — the same bytes `URLSession` would return. This means `ASCModels.swift` should decode it without changes. + +Auth: the Go CLI reads `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY_PATH` (or `ASC_PRIVATE_KEY`) env vars, or `~/.asc/credentials.json`, or macOS Keychain. + +## Requirements + +1. **Delete `AppStoreConnectService.swift` by the end.** Every API call it makes must be handled by `ascd` instead. +2. **`ASCModels.swift` survives with minimal changes.** The JSON:API format is identical. +3. **All 23 view files are untouched.** They consume `ASCManager`, not the service. +4. **`ASCManager` stays `@Observable @MainActor`.** It just calls a different backend. +5. **MCP tools keep working throughout.** +6. **Zero external Swift package dependencies.** `ascd` is a subprocess, not linked. +7. **Credential bridge.** blitz-macos stores creds at `~/.blitz/asc-credentials.json`; `ascd` reads env vars or `~/.asc/credentials.json`. Make them talk. +8. **The app compiles and runs at every intermediate step.** Old and new can coexist during migration. + +## What to read + +Read these files thoroughly before planning: + +**blitz-macos:** +- `src/services/AppStoreConnectService.swift` — every method, HTTP verb, endpoint, request body +- `src/services/ASCManager.swift` — every `service.` call site and the call chains per tab +- `src/models/ASCModels.swift` — every model type + +**ascd (`~/superapp/asc-cli/forks/App-Store-Connect-CLI-helper`):** +- `docs/long-lived-helper-fork.md` — architecture and protocol +- `internal/helper/protocol.go` — request/response types +- `internal/helper/service.go` — method dispatch +- `internal/asc/raw_request.go` — the fast-path HTTP layer +- `cmd/ascd/main.go` — entry point + +## What to produce + +A migration plan \ No newline at end of file diff --git a/docs/migration-to-shared-asc-package.md b/docs/migration-to-shared-asc-package.md new file mode 100644 index 0000000..684a58a --- /dev/null +++ b/docs/migration-to-shared-asc-package.md @@ -0,0 +1,567 @@ +# Migration: blitz-macos → Shared ASC Domain/Infrastructure Package + +## Goal + +Replace blitz-macos's custom App Store Connect models (`ASCModels.swift`, 770 lines) and API service (`AppStoreConnectService.swift`, 1,716 lines) with asc-cli's battle-tested Domain and Infrastructure layers, consumed as a Swift package dependency. + +## Why + +blitz-macos and asc-cli both implement the App Store Connect API independently. asc-cli's implementation is superior in every measurable way: + +- **226 domain types** (vs 36 flat structs in one file) with parent ID injection, semantic booleans, type-safe state enums, custom Codable, and CAEOAS affordances +- **54 `@Mockable` repository protocols** — blitz-macos has zero testable abstractions; `AppStoreConnectService` is a concrete 1,716-line class with no protocol +- **6,545 lines of domain logic** with full test coverage (Chicago School TDD) vs 0 tests in blitz-macos's ASC layer +- **Parent IDs on every model** — the ASC API doesn't return parent IDs; asc-cli's Infrastructure injects them at the mapper layer. blitz-macos works around this by relying on `ASCManager` knowing which app/version is selected, which breaks when passing models between contexts or serializing them + +Unifying means blitz-macos deletes ~2,500 lines of hand-rolled API code, gains testability, and automatically inherits every future asc-cli domain addition (Game Center, Xcode Cloud, diagnostics, etc.) for free. + +--- + +## Architecture Overview + +### Before + +``` +blitz-macos (zero external Swift dependencies) +├── ASCModels.swift — 36 flat Decodable structs, raw string states +├── AppStoreConnectService — 1,716 lines, manual JWT, 70+ methods, no protocol +├── ASCManager — @Observable, holds all state, string comparisons +└── 23 views — access .attributes.fieldName, hardcode state strings +``` + +### After + +``` +asc-cli repo +├── Sources/Domain/ — 165 files, pure value types, @Mockable protocols +├── Sources/Infrastructure/ — 69 files, SDK adapters with parent ID injection +└── Sources/ASCCommand/ — CLI (unchanged, still depends on Domain + Infra) + +blitz-macos (depends on asc-cli's Domain + Infrastructure via SPM) +├── ASCModels.swift — DELETED (replaced by Domain types) +├── AppStoreConnectService — DELETED (replaced by Infrastructure repositories) +├── ASCManager — REFACTORED: holds repository protocols, uses Domain types +├── BlitzAuthProvider — NEW: thin adapter bridging ~/.blitz/asc-credentials.json to AuthProvider protocol +├── 23 views — REFACTORED: .isLive instead of .attributes.appStoreState == "READY_FOR_SALE" +└── Iris/MCP/Simulator — UNCHANGED (blitz-specific, not in shared package) +``` + +--- + +## Prerequisites + +### Step 0: Make asc-cli's Domain and Infrastructure consumable as library products + +In `/Users/minjunes/superapp/asc-cli/Package.swift`, add library products so blitz-macos can depend on them: + +```swift +products: [ + .executable(name: "asc", targets: ["ASCCommand"]), + // NEW: library products for external consumers + .library(name: "ASCDomain", targets: ["Domain"]), + .library(name: "ASCInfrastructure", targets: ["Infrastructure"]), +], +``` + +No code changes needed in asc-cli — just exposing existing targets as libraries. + +In `/Users/minjunes/superapp/blitz-macos/Package.swift`, add the local dependency: + +```swift +dependencies: [ + .package(path: "../asc-cli"), +], +targets: [ + .executableTarget( + name: "blitz", + dependencies: [ + .product(name: "ASCDomain", package: "asc-swift"), + .product(name: "ASCInfrastructure", package: "asc-swift"), + ], + // ... + ), +] +``` + +> Note: `asc-swift` is the package name in asc-cli's Package.swift. Using a local `path:` dependency during development; switch to a git URL for release. + +This introduces transitive dependencies: `appstoreconnect-swift-sdk`, `Mockable`, `SweetCookieKit`. This is the cost of unification. blitz-macos's zero-dependency constraint is relaxed for its own sibling package only — no third-party code is imported directly. + +--- + +## Phase 1: Auth Bridge + +**Goal:** blitz-macos can construct asc-cli's Infrastructure repositories using its own credential store. + +### What exists + +- asc-cli defines `AuthProvider` protocol in Domain, with `FileAuthProvider`, `EnvironmentAuthProvider`, `CompositeAuthProvider` in Infrastructure +- asc-cli stores credentials at `~/.asc/credentials.json` +- blitz-macos stores credentials at `~/.blitz/asc-credentials.json` with a different JSON schema +- blitz-macos's `ASCCredentials` struct has `issuerId`, `keyId`, `privateKey` fields +- asc-cli's `AuthCredentials` struct has `keyID`, `issuerID`, `privateKeyPEM`, `vendorNumber` fields + +### What to do + +Create `src/services/BlitzAuthProvider.swift`: + +```swift +import Domain // from asc-cli + +/// Bridges blitz-macos credential storage to asc-cli's AuthProvider protocol. +struct BlitzAuthProvider: AuthProvider { + func resolve() throws -> AuthCredentials { + // Read from existing ~/.blitz/asc-credentials.json + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz/asc-credentials.json") + let data = try Data(contentsOf: url) + + struct BlitzCreds: Decodable { + let issuerId: String + let keyId: String + let privateKey: String + } + + let creds = try JSONDecoder().decode(BlitzCreds.self, from: data) + return AuthCredentials( + keyID: creds.keyId, + issuerID: creds.issuerId, + privateKeyPEM: creds.privateKey, + vendorNumber: nil + ) + } +} +``` + +### What to delete + +Nothing yet. This phase is additive — both auth paths coexist. + +### Verification + +Write a test that constructs `BlitzAuthProvider`, loads credentials from a temp file, and asserts the returned `AuthCredentials` fields match. This is your first blitz-macos test using asc-cli's domain types. + +--- + +## Phase 2: Repository Wiring + +**Goal:** blitz-macos can call asc-cli's repository implementations to fetch data, getting back rich Domain types. + +### What exists + +- asc-cli's `Infrastructure/Client/ClientFactory.swift` creates all SDK repository implementations given an `AuthProvider` +- Each repository (e.g., `SDKVersionRepository`) needs an `APIClient` (which wraps `AppStoreConnect_Swift_SDK.APIProvider`) + +### What to do + +Create `src/services/ASCClientFactory.swift` — a blitz-specific factory that: + +1. Takes `BlitzAuthProvider` +2. Constructs `AppStoreConnect_Swift_SDK.APIProvider` (JWT-based) +3. Returns typed repositories + +```swift +import Infrastructure // from asc-cli +import Domain + +struct ASCClientFactory { + private let authProvider: BlitzAuthProvider + + /// Returns all repositories blitz-macos needs. + func makeRepositories() throws -> ASCRepositories { + let credentials = try authProvider.resolve() + // Use asc-cli's ClientFactory or replicate its wiring + let config = APIConfiguration( + issuerID: credentials.issuerID, + privateKeyID: credentials.keyID, + privateKey: credentials.privateKeyPEM + ) + let provider = APIProvider(configuration: config) + return ASCRepositories( + apps: SDKAppRepository(client: provider), + versions: SDKVersionRepository(client: provider), + builds: OpenAPIBuildRepository(client: provider), + localizations: SDKLocalizationRepository(client: provider), + screenshots: OpenAPIScreenshotRepository(client: provider), + appInfo: SDKAppInfoRepository(client: provider), + reviews: SDKCustomerReviewRepository(client: provider), + submissions: OpenAPISubmissionRepository(client: provider), + testFlight: OpenAPITestFlightRepository(client: provider), + // ... add repositories as needed per phase + ) + } +} + +/// Container for all ASC repositories blitz-macos consumes. +struct ASCRepositories { + let apps: any AppRepository + let versions: any VersionRepository + let builds: any BuildRepository + let localizations: any VersionLocalizationRepository + let screenshots: any ScreenshotRepository + let appInfo: any AppInfoRepository + let reviews: any CustomerReviewRepository + let submissions: any SubmissionRepository + let testFlight: any TestFlightRepository +} +``` + +> Check asc-cli's `ClientFactory.swift` for exact constructor signatures — the repository implementations may require specific `APIClient` protocol conformance rather than raw `APIProvider`. + +### Verification + +In a test or debug build, call `factory.makeRepositories().apps.listApps(limit: 5)` and assert you get back `[Domain.App]` with `.name`, `.bundleId`, `.affordances`. + +--- + +## Phase 3: Migrate ASCManager (Incremental, One Repository at a Time) + +**Goal:** `ASCManager` uses asc-cli repository protocols instead of `AppStoreConnectService`. + +This is the core of the migration. Do it **one feature area at a time** so the app stays functional throughout. + +### Migration order (by blast radius, smallest first) + +#### 3a. Apps + +**Before (ASCManager):** +```swift +var app: ASCApp? +// ... +app = try await service.fetchApp(bundleId: bundleId) +// Views: app.name, app.bundleId, app.attributes.vendorNumber +``` + +**After:** +```swift +var app: Domain.App? +// ... +let response = try await repos.apps.listApps(limit: nil) +app = response.data.first { $0.bundleId == bundleId } +// Views: app.name, app.bundleId (same property names — minimal view changes) +``` + +**View changes:** `app.attributes.name` → `app.name`, `app.attributes.bundleId` → `app.bundleId`. The Domain model flattens `.attributes.` away. + +#### 3b. Versions + +**Before:** +```swift +var appStoreVersions: [ASCAppStoreVersion] = [] +appStoreVersions = try await service.fetchAppStoreVersions(appId: app!.id) +// Views: v.attributes.appStoreState == "READY_FOR_SALE" +``` + +**After:** +```swift +var appStoreVersions: [Domain.AppStoreVersion] = [] +appStoreVersions = try await repos.versions.listVersions(appId: app!.id) +// Views: v.isLive (semantic boolean) +``` + +**View changes — this is the big win.** Every string comparison becomes a boolean: + +| Before (23 view files) | After | +|---|---| +| `v.attributes.appStoreState == "READY_FOR_SALE"` | `v.isLive` | +| `v.attributes.appStoreState == "PREPARE_FOR_SUBMISSION"` | `v.state == .prepareForSubmission` | +| `!nonSubmittableStates.contains(v.attributes.appStoreState)` | `v.isEditable` | +| `v.attributes.appStoreState == "WAITING_FOR_REVIEW"` | `v.state == .waitingForReview` | +| `v.attributes.appStoreState == "REJECTED"` | `v.state == .rejected` | +| `v.attributes.appStoreState` (display) | `v.state.rawValue` (same strings) | + +**Parent ID bonus:** Every `AppStoreVersion` now carries `.appId` — no need to pass app context separately. + +#### 3c. Builds + +**Before:** +```swift +var builds: [ASCBuild] = [] +builds = try await service.fetchBuilds(appId: app!.id) +// Views: b.attributes.processingState == "VALID" && b.attributes.expired != true +``` + +**After:** +```swift +var builds: [Domain.Build] = [] +builds = try await repos.builds.listBuilds(appId: app!.id) +// Views: b.isUsable +``` + +**View changes:** + +| Before | After | +|---|---| +| `b.attributes.processingState == "VALID" && b.attributes.expired != true` | `b.isUsable` | +| `b.attributes.processingState` (badge) | `b.processingState.rawValue` | +| `b.attributes.expired == true` | `b.expired` | +| `b.attributes.version` | `b.version` | + +#### 3d. Version Localizations + +**Before:** +```swift +var localizations: [ASCVersionLocalization] = [] +localizations = try await service.fetchVersionLocalizations(versionId: versionId) +// Views: loc.attributes.description, loc.attributes.whatsNew +``` + +**After:** +```swift +var localizations: [Domain.AppStoreVersionLocalization] = [] +localizations = try await repos.localizations.listLocalizations(versionId: versionId) +// Views: loc.description, loc.whatsNew (flattened — no .attributes.) +``` + +#### 3e. Screenshots + +**Before:** +```swift +var screenshotSets: [ASCScreenshotSet] = [] +var screenshots: [String: [ASCScreenshot]] = [:] +screenshotSets = try await service.fetchScreenshotSets(localizationId: locId) +screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) +``` + +**After:** +```swift +var screenshotSets: [Domain.AppScreenshotSet] = [] +var screenshots: [String: [Domain.AppScreenshot]] = [:] +screenshotSets = try await repos.screenshots.listScreenshotSets(localizationId: locId) +screenshots[set.id] = try await repos.screenshots.listScreenshots(setId: set.id) +// Bonus: each screenshot carries .setId (parent ID) +// Bonus: screenshot.isComplete replaces assetDeliveryState string checks +``` + +#### 3f. App Info, Age Rating, Review Detail + +Same pattern. Replace `service.fetchAppInfo()` calls with `repos.appInfo.listAppInfos()`. Models gain parent IDs and affordances. + +#### 3g. Customer Reviews + +```swift +// Before +var customerReviews: [ASCCustomerReview] = [] +customerReviews = try await service.fetchReviews(appId: app!.id) + +// After +var customerReviews: [Domain.CustomerReview] = [] +let response = try await repos.reviews.listReviews(appId: app!.id) +customerReviews = response.data +``` + +#### 3h. TestFlight (Beta Groups, Testers) + +```swift +// Before +var betaGroups: [ASCBetaGroup] = [] +betaGroups = try await service.fetchBetaGroups(appId: app!.id) + +// After +var betaGroups: [Domain.BetaGroup] = [] +betaGroups = try await repos.testFlight.listBetaGroups(appId: app!.id) +// Bonus: each group carries .appId, .affordances +``` + +#### 3i. Submissions + +```swift +// Before: service.submitForReview(versionId:) +// After: repos.submissions.createSubmission(versionId:) +``` + +#### 3j. In-App Purchases & Subscriptions + +These have dedicated repositories in asc-cli (`InAppPurchaseRepository`, `SubscriptionRepository`, `SubscriptionGroupRepository`). Add them to `ASCRepositories` and wire into `ASCManager`. + +#### 3k. Write Operations (PATCH/POST/DELETE) + +asc-cli's repositories expose write methods too. For example: + +```swift +// VersionLocalizationRepository +func updateLocalization(id: String, whatsNew: String?, description: String?, ...) async throws -> AppStoreVersionLocalization + +// ScreenshotRepository +func uploadScreenshot(setId: String, fileName: String, fileData: Data) async throws -> AppScreenshot +func deleteScreenshot(id: String) async throws +``` + +Replace `service.patchLocalization(...)`, `service.uploadScreenshot(...)`, etc. with the corresponding repository method. + +--- + +## Phase 4: Delete Dead Code + +Once all `AppStoreConnectService` call sites are replaced: + +### Delete entirely +- `src/models/ASCModels.swift` — all 36 model types replaced by Domain imports +- `src/services/AppStoreConnectService.swift` — all 70+ methods replaced by repository calls + +### Keep but simplify +- `src/services/ASCManager.swift` — still needed as `@Observable` state holder, but now typed with `Domain.*` models and injected with repository protocols + +### Keep unchanged +- `src/services/IrisService.swift` — Iris private API is blitz-specific, not in asc-cli. Keep as-is. If Iris models overlap with Domain types (e.g., app creation), consider thin adapters later. +- `src/services/MCPToolExecutor.swift` — MCP tools read/write ASCManager state. Since ASCManager's public interface changes (new types), MCP tools need type updates but logic stays the same. +- `src/services/BuildPipelineService.swift` — uses xcodebuild, not ASC API. Unchanged. +- All simulator, database, project scaffolding code — unrelated to ASC. + +--- + +## Phase 5: View Refactoring Checklist + +Every view file that accesses `.attributes.` needs updating. The pattern is mechanical: + +### Property access flattening + +```swift +// BEFORE // AFTER +thing.attributes.fieldName thing.fieldName +thing.attributes.appStoreState thing.state.rawValue (for display) +thing.attributes.appStoreState == "X" thing.state == .x (for comparison) +thing.attributes.processingState == "VALID" thing.processingState == .valid +``` + +### Files to update (23 files) + +**Release views:** +- `src/views/release/ASCOverview.swift` — version state filtering, rejection display +- `src/views/release/StoreListingView.swift` — localization field access +- `src/views/release/ScreenshotsView.swift` — screenshot set/screenshot types +- `src/views/release/ReviewView.swift` — age rating, review detail, build selection, state checks +- `src/views/release/SubmitPreviewSheet.swift` — nonSubmittableStates → `!version.isEditable` +- `src/views/release/AppDetailsView.swift` — app info localization fields +- `src/views/release/PricingView.swift` — IAP/subscription state checks + +**TestFlight views:** +- `src/views/testflight/BuildsView.swift` — processingState badge, expired check +- `src/views/testflight/GroupsView.swift` — beta group fields +- `src/views/testflight/BetaInfoView.swift` — beta localization fields +- `src/views/testflight/FeedbackView.swift` — beta feedback fields + +**Insights views:** +- `src/views/insights/ReviewsView.swift` — customer review fields +- `src/views/insights/AnalyticsView.swift` — if it touches ASC models + +**Shared views:** +- `src/views/shared/asc/RejectionCardView.swift` — rejection reason display +- `src/views/shared/asc/BundleIDSetupView.swift` — bundle ID fields +- `src/views/shared/asc/ASCCredentialForm.swift` — credential entry +- `src/views/shared/asc/ASCTabContent.swift` — tab routing +- `src/views/shared/asc/ASCCredentialGate.swift` — auth state + +**Other:** +- `src/views/settings/SettingsView.swift` — credential display +- `src/views/OnboardingView.swift` — credential entry + +--- + +## Phase 6: MCP Tool Adaptation + +MCP tools in `MCPToolExecutor.swift` read and write `ASCManager` state. Since ASCManager's stored types change from `ASCApp` → `Domain.App`, etc., the MCP tool implementations need type updates. + +### Pattern + +```swift +// BEFORE +if let app = appState.ascManager.app { + return ["name": app.name, "bundleId": app.bundleId, + "state": app.attributes.appStoreState ?? "unknown"] +} + +// AFTER +if let app = appState.ascManager.app { + return ["name": app.name, "bundleId": app.bundleId] + // state is on the version, not the app — which is correct +} + +// BEFORE +let version = appState.ascManager.appStoreVersions.first { + $0.attributes.appStoreState != "READY_FOR_SALE" +} + +// AFTER +let version = appState.ascManager.appStoreVersions.first { !$0.isLive } +``` + +### Affordances in MCP responses + +New opportunity: MCP tool responses can now include `model.affordances` — giving the agent state-aware CLI commands alongside GUI actions. This is optional but powerful for hybrid workflows where Claude Code uses both MCP tools and `asc` CLI. + +```swift +// Optional enhancement: include affordances in MCP tool results +func getTabState() -> [String: Any] { + var result: [String: Any] = [...] + if let version = currentVersion { + result["affordances"] = version.affordances // free from Domain + } + return result +} +``` + +--- + +## Phase 7: Credential Unification (Optional, Future) + +Once Phase 1-6 are stable, consider whether blitz-macos should read from `~/.asc/credentials.json` (asc-cli's format) instead of `~/.blitz/asc-credentials.json`. Benefits: + +- Single credential store — `asc auth login` works for both tools +- `asc auth check` validates what blitz-macos will use +- Environment variable fallback via `CompositeAuthProvider` + +Cost: migration path for existing blitz-macos users who have credentials in `~/.blitz/`. Could do a one-time migration on first launch, or support both with a composite provider. + +--- + +## What NOT To Migrate + +| blitz-macos concern | Why it stays | +|---|---| +| `IrisService` + `IrisSession` + `IrisFeedbackCache` | Iris is a private Apple API using cookie auth. asc-cli has its own Iris implementation but the auth flows differ (browser cookies vs Keychain). Keep separate. | +| `ASCSubmissionHistoryCache` | Local persistence of version state transitions. Could move to shared package later, but not required — it's a UI convenience, not a domain concern. | +| `TrackSlot` model (screenshot tracks) | UI-specific: tracks local images vs ASC-sourced screenshots for the drag-reorder UI. Not a domain concept. | +| `SubmissionReadiness` (field checklist) | UI-specific readiness display. asc-cli has `VersionReadiness` in Domain which is richer — consider adopting it, but not required for migration. | +| `pendingFormValues` / `pendingCreateValues` | MCP form pre-fill state. Pure UI concern. | +| `BuildPipelineService` | Uses xcodebuild, not ASC API. | +| Simulator, Database, Project scaffolding | Unrelated to ASC. | + +--- + +## Verification Strategy + +After each phase, verify: + +1. **Build:** `swift build` succeeds for blitz-macos +2. **Manual test:** Launch the app, navigate to the affected tab, confirm data loads +3. **Type check:** No `ASCApp`, `ASCBuild`, etc. references remain for migrated types (grep for the old type name) +4. **New tests:** For each migrated area, write at least one test using `@Mockable` repository mocks to verify ASCManager state transitions + +### Final verification (after Phase 6) + +```bash +# No references to old ASC models should remain +cd /Users/minjunes/superapp/blitz-macos +grep -r "ASCApp\b" src/ --include="*.swift" # should return 0 +grep -r "ASCAppStoreVersion\b" src/ --include="*.swift" # should return 0 +grep -r "ASCBuild\b" src/ --include="*.swift" # should return 0 +grep -r "\.attributes\." src/ --include="*.swift" # should return 0 +grep -r "AppStoreConnectService" src/ --include="*.swift" # should return 0 + +# Old files should be gone +test ! -f src/models/ASCModels.swift +test ! -f src/services/AppStoreConnectService.swift +``` + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|---|---| +| asc-cli Domain types missing fields blitz-macos needs | Add fields to asc-cli's Domain types — they're the source of truth. PR to asc-cli. | +| `appstoreconnect-swift-sdk` version conflicts | blitz-macos inherits asc-cli's pinned version transitively. No conflict possible. | +| Sendable/concurrency mismatch | asc-cli Domain types are all `Sendable`. ASCManager is `@MainActor`. Repository calls are `async` — use `Task { }` from MainActor as blitz-macos already does. | +| Breaking change in asc-cli Domain | Pin to a specific asc-cli commit/tag during development. Update deliberately. | +| Migration takes too long | Each phase is independently shippable. Phase 3a-3k can be done one sub-phase per PR. The app works with a mix of old and new types during migration. | diff --git a/package-lock.json b/package-lock.json index d265e1d..9fcde8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blitz-macos", - "version": "1.0.29", + "version": "1.0.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blitz-macos", - "version": "1.0.29", + "version": "1.0.30", "devDependencies": { "@repalash/rclone.js": "*" } diff --git a/package.json b/package.json index 829b2a1..ae578bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blitz-macos", - "version": "1.0.29", + "version": "1.0.30", "type": "module", "private": true, "scripts": { diff --git a/scripts/blitz-mcp-bridge.sh b/scripts/blitz-mcp-bridge.sh old mode 100755 new mode 100644 index ba21441..326c0c0 --- a/scripts/blitz-mcp-bridge.sh +++ b/scripts/blitz-mcp-bridge.sh @@ -1,51 +1,12 @@ #!/bin/bash -# Blitz MCP Bridge: stdio → HTTP forwarder for Claude Code -# Reads JSON-RPC from stdin, POSTs to Blitz's MCP server, writes response to stdout -PORT_FILE="$HOME/.blitz/mcp-port" +# Compatibility shim for older Blitz MCP configs. +# New Codex and .mcp.json entries should launch ~/.blitz/blitz-macos-mcp directly. -# Wait up to 10 seconds for Blitz to start and write the port file -WAITED=0 -while [ ! -f "$PORT_FILE" ] && [ "$WAITED" -lt 10 ]; do - sleep 1 - WAITED=$((WAITED + 1)) -done +HELPER="$HOME/.blitz/blitz-macos-mcp" -if [ ! -f "$PORT_FILE" ]; then - echo '{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"Blitz is not running. Please start Blitz first."}}' >&2 +if [ ! -x "$HELPER" ]; then + echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Blitz MCP helper is not installed. Start Blitz first."}}' >&2 exit 1 fi -PORT=$(cat "$PORT_FILE") - -# Wait up to 5 more seconds for the HTTP server to accept connections -WAITED=0 -while ! curl -s -o /dev/null -w '' "http://127.0.0.1:${PORT}/mcp" 2>/dev/null && [ "$WAITED" -lt 5 ]; do - sleep 1 - WAITED=$((WAITED + 1)) -done - -while IFS= read -r line; do - [ -z "$line" ] && continue - - # Notifications (no "id" field) don't expect a response in MCP protocol. - # Still forward to server but discard the HTTP response to avoid - # injecting unexpected lines into the stdout stream. - case "$line" in - *'"id"'*) ;; # has id — normal request, will echo response below - *) - curl -s -o /dev/null -X POST "http://127.0.0.1:${PORT}/mcp" \ - -H "Content-Type: application/json" \ - --max-time 5 -d "$line" 2>/dev/null - continue - ;; - esac - - response=$(curl -s --max-time 120 -X POST "http://127.0.0.1:${PORT}/mcp" \ - -H "Content-Type: application/json" \ - -d "$line" 2>/dev/null) - if [ $? -ne 0 ]; then - echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Cannot connect to Blitz. Is it running?"}}' >&2 - exit 1 - fi - echo "$response" -done +exec "$HELPER" "$@" diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh index ca0110e..28c8685 100755 --- a/scripts/build-pkg.sh +++ b/scripts/build-pkg.sh @@ -22,6 +22,19 @@ PKG_SCRIPTS="$ROOT_DIR/scripts/pkg-scripts" ENTITLEMENTS="$ROOT_DIR/scripts/Entitlements.plist" BUILD_DIR="$ROOT_DIR/build/pkg" OUTPUT_PKG="$ROOT_DIR/build/$APP_NAME-$VERSION.pkg" +REQUIRE_SIGNED_RELEASE="${BLITZ_REQUIRE_SIGNED_RELEASE:-0}" + +# Require production signing inputs when strict mode is enabled. +if [ "$REQUIRE_SIGNED_RELEASE" = "1" ]; then + [ -n "${APPLE_SIGNING_IDENTITY:-}" ] || { + echo "ERROR: APPLE_SIGNING_IDENTITY is required for production pkg builds." >&2 + exit 1 + } + [ -n "${APPLE_INSTALLER_IDENTITY:-}" ] || { + echo "ERROR: APPLE_INSTALLER_IDENTITY is required for production pkg builds." >&2 + exit 1 + } +fi # Verify .app exists if [ ! -d "$SOURCE_APP" ]; then @@ -30,6 +43,12 @@ if [ ! -d "$SOURCE_APP" ]; then exit 1 fi +if [ ! -x "$SOURCE_APP/Contents/Helpers/ascd" ]; then + echo "ERROR: $SOURCE_APP does not contain a bundled ascd helper." + echo "Rebuild the app bundle after installing or building ascd." + exit 1 +fi + # Clean build dir and stale .pkg files rm -rf "$BUILD_DIR" rm -f "$ROOT_DIR/build/$APP_NAME-"*.pkg @@ -62,6 +81,18 @@ if [ -n "$APP_SIGNING_IDENTITY" ]; then --entitlements "$ENTITLEMENTS" \ "$f" done + if [ -f "$APP_PAYLOAD/Contents/Helpers/blitz-macos-mcp" ]; then + codesign --force --options runtime --timestamp \ + --sign "$APP_SIGNING_IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + "$APP_PAYLOAD/Contents/Helpers/blitz-macos-mcp" + fi + if [ -f "$APP_PAYLOAD/Contents/Helpers/ascd" ]; then + codesign --force --options runtime --timestamp \ + --sign "$APP_SIGNING_IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + "$APP_PAYLOAD/Contents/Helpers/ascd" + fi # Re-sign the main app bundle (must be last) codesign --force --options runtime --timestamp \ diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 3fd9255..f086530 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -14,11 +14,81 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:-}" ENTITLEMENTS="$ROOT_DIR/scripts/Entitlements.plist" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" +REQUIRE_SIGNED_RELEASE="${BLITZ_REQUIRE_SIGNED_RELEASE:-0}" + +resolve_ascd_path() { + local candidate="${BLITZ_ASCD_PATH:-}" + [ -n "$candidate" ] || return 1 + [ -x "$candidate" ] || return 1 + printf '%s\n' "$candidate" +} + +resolve_ascd_source_dir() { + local candidates=() + + if [ -n "${BLITZ_ASCD_SOURCE_DIR:-}" ]; then + candidates+=("$BLITZ_ASCD_SOURCE_DIR") + fi + + candidates+=( + "$ROOT_DIR/deps/App-Store-Connect-CLI-helper" + ) + + local candidate + for candidate in "${candidates[@]}"; do + [ -n "$candidate" ] || continue + if [ -f "$candidate/cmd/ascd/main.go" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +build_ascd_helper() { + local source_dir="$1" + local output_dir="$ROOT_DIR/.build/ascd-helper" + local output_path="$output_dir/ascd" + + if ! command -v go >/dev/null 2>&1; then + echo "ERROR: Go is required to build the bundled ascd helper." >&2 + echo " Install Go, or set BLITZ_ASCD_PATH to a prebuilt compatible helper binary." >&2 + return 1 + fi + + mkdir -p "$output_dir" + echo "Building ascd helper from $source_dir" >&2 + ( + cd "$source_dir" + go build -o "$output_path" ./cmd/ascd + ) + printf '%s\n' "$output_path" +} + +verify_ascd_helper() { + local helper_path="$1" + local response + + response="$(printf '{"id":"bundle-check","method":"ping"}\n' | "$helper_path" 2>/dev/null | head -1 || true)" + case "$response" in + *'"id":"bundle-check"'*'"result"'*) + return 0 + ;; + esac + + return 1 +} if [ "$CONFIG" = "debug" ] && [ "$TIMESTAMP_MODE" = "auto" ]; then TIMESTAMP_MODE="none" fi +if [ -z "$SIGNING_IDENTITY" ] && [ "$REQUIRE_SIGNED_RELEASE" = "1" ]; then + echo "ERROR: APPLE_SIGNING_IDENTITY is required for production release builds." >&2 + exit 1 +fi + if [ -z "$SIGNING_IDENTITY" ]; then echo "WARNING: APPLE_SIGNING_IDENTITY not set, falling back to ad-hoc signing." echo " TCC will require re-approval on every rebuild." @@ -31,15 +101,57 @@ VERSION=$(node -e "const p=JSON.parse(require('fs').readFileSync('$ROOT_DIR/pack echo "Building $APP_NAME.app v$VERSION ($CONFIG)..." # Build -swift build -c "$CONFIG" +swift build -c "$CONFIG" --product Blitz +swift build -c "$CONFIG" --product blitz-macos-mcp # Create .app structure +rm -rf "$BUNDLE_DIR" mkdir -p "$BUNDLE_DIR/Contents/MacOS" mkdir -p "$BUNDLE_DIR/Contents/Resources" +mkdir -p "$BUNDLE_DIR/Contents/Helpers" # Copy binary cp ".build/${CONFIG}/${APP_NAME}" "$BUNDLE_DIR/Contents/MacOS/${APP_NAME}" +# Copy the standalone MCP helper that Codex launches directly over stdio. +HELPER_BINARY=".build/${CONFIG}/blitz-macos-mcp" +if [ -f "$HELPER_BINARY" ]; then + cp "$HELPER_BINARY" "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + chmod 755 "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + echo "Copied blitz-macos-mcp helper into app bundle" +else + echo "WARNING: blitz-macos-mcp helper was not built; MCP integration will be unavailable." +fi + +ASC_HELPER_BINARY="$(resolve_ascd_path || true)" +if [ -z "$ASC_HELPER_BINARY" ]; then + ASC_HELPER_SOURCE_DIR="$(resolve_ascd_source_dir || true)" + if [ -n "$ASC_HELPER_SOURCE_DIR" ]; then + ASC_HELPER_BINARY="$(build_ascd_helper "$ASC_HELPER_SOURCE_DIR")" + fi +fi + +if [ -n "$ASC_HELPER_BINARY" ]; then + if ! verify_ascd_helper "$ASC_HELPER_BINARY"; then + echo "ERROR: ascd helper is not compatible with Blitz:" + echo " $ASC_HELPER_BINARY" + echo " Expected the forked helper binary with the JSON-line long-lived protocol." + exit 1 + fi + cp "$ASC_HELPER_BINARY" "$BUNDLE_DIR/Contents/Helpers/ascd" + chmod 755 "$BUNDLE_DIR/Contents/Helpers/ascd" + echo "Copied ascd helper into app bundle from $ASC_HELPER_BINARY" +else + echo "ERROR: ascd helper not found." + echo " Set BLITZ_ASCD_PATH to a built forked helper binary, or" + echo " set BLITZ_ASCD_SOURCE_DIR to the App-Store-Connect-CLI-helper fork checkout." + echo " Source builds can also place the fork at:" + echo " $ROOT_DIR/deps/App-Store-Connect-CLI-helper" + echo " If you cloned Blitz from git, run:" + echo " git submodule update --init --recursive" + exit 1 +fi + # Generate app icon (.icns) from PNG ICON_PNG="$ROOT_DIR/src/resources/blitz-icon.png" ICON_ICNS="$BUNDLE_DIR/Contents/Resources/AppIcon.icns" @@ -68,8 +180,8 @@ for bundle_dir in .build/${CONFIG}/*.bundle; do fi done -# Embed Claude skills in .app bundle (installed to ~/.claude/skills/ at app startup) -SKILLS_SRC="$ROOT_DIR/.claude/skills" +# Embed Claude skills in .app bundle +SKILLS_SRC="$ROOT_DIR/src/resources/skills" SKILLS_DST="$BUNDLE_DIR/Contents/Resources/claude-skills" if [ -d "$SKILLS_SRC" ]; then rm -rf "$SKILLS_DST" @@ -160,13 +272,19 @@ codesign_bundle_path() { fi } -# Sign nested native binaries first (inside-out — required for notarization) -if [ "$SIGNING_IDENTITY" != "-" ]; then - echo "Signing native dependencies..." - find "$BUNDLE_DIR/Contents/Resources" -type f \( -name "*.node" -o -name "*.dylib" \) 2>/dev/null | while read -r f; do - codesign_bundle_path "$f" 2>/dev/null || true - echo " Signed: $f" - done +# Sign nested native binaries first (inside-out — also required for ad-hoc CI bundle verification) +echo "Signing native dependencies..." +find "$BUNDLE_DIR/Contents/Resources" -type f \( -name "*.node" -o -name "*.dylib" \) 2>/dev/null | while read -r f; do + codesign_bundle_path "$f" 2>/dev/null || true + echo " Signed: $f" +done +if [ -f "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" ]; then + codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + echo " Signed: $BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" +fi +if [ -f "$BUNDLE_DIR/Contents/Helpers/ascd" ]; then + codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/ascd" + echo " Signed: $BUNDLE_DIR/Contents/Helpers/ascd" fi # Sign the .app bundle (must be after nested signing) diff --git a/scripts/pkg-scripts/postinstall b/scripts/pkg-scripts/postinstall index c1053e4..ef33b85 100755 --- a/scripts/pkg-scripts/postinstall +++ b/scripts/pkg-scripts/postinstall @@ -196,211 +196,221 @@ echo "TIMING: create user dirs took $(( $(date +%s) - T_STEP ))s" >> "$LOG" # ============================================================================ T_RUBY_START=$(date +%s) -RV_VERSION="v0.4.3" -BLITZ_BIN_DIR="$CONSOLE_HOME/.blitz/rubies" -RV_BIN="$BLITZ_BIN_DIR/rv" -BLITZ_RUBIES_DIR="$CONSOLE_HOME/.blitz/rubies" -POD_PATH="" - -# Clean up old Ruby install from previous Blitz versions -OLD_RUBY_DIR="$CONSOLE_HOME/.blitz/ruby" -if [ -d "$OLD_RUBY_DIR" ] && [ ! -L "$OLD_RUBY_DIR" ]; then - echo "Cleaning up old Ruby install at $OLD_RUBY_DIR..." >> "$LOG" - rm -rf "$OLD_RUBY_DIR" -fi - -# [1] Download rv binary if not present or wrong version -RV_NEEDED=true -if [ -x "$RV_BIN" ]; then - EXISTING_RV=$("$RV_BIN" --version 2>/dev/null || echo "") - if echo "$EXISTING_RV" | grep -q "${RV_VERSION#v}"; then - echo "OK: rv $RV_VERSION already installed at $RV_BIN" >> "$LOG" - RV_NEEDED=false - else - echo "rv version mismatch ($EXISTING_RV vs $RV_VERSION), updating..." >> "$LOG" +if [ "${BLITZ_UPDATE_CONTEXT:-}" = "auto-update" ]; then + echo "Skipping Ruby and CocoaPods bootstrap during auto-update" >> "$LOG" + echo "TIMING: ruby + cocoapods took 0s" >> "$LOG" +else + RV_VERSION="v0.4.3" + BLITZ_BIN_DIR="$CONSOLE_HOME/.blitz/rubies" + RV_BIN="$BLITZ_BIN_DIR/rv" + BLITZ_RUBIES_DIR="$CONSOLE_HOME/.blitz/rubies" + POD_PATH="" + + # Clean up old Ruby install from previous Blitz versions + OLD_RUBY_DIR="$CONSOLE_HOME/.blitz/ruby" + if [ -d "$OLD_RUBY_DIR" ] && [ ! -L "$OLD_RUBY_DIR" ]; then + echo "Cleaning up old Ruby install at $OLD_RUBY_DIR..." >> "$LOG" + rm -rf "$OLD_RUBY_DIR" fi -fi - -if [ "$RV_NEEDED" = true ]; then - echo "Installing rv $RV_VERSION..." >> "$LOG" - show_progress "Installing Ruby version manager..." - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-aarch64-apple-darwin.tar.xz" - else - RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-x86_64-apple-darwin.tar.xz" + # [1] Download rv binary if not present or wrong version + RV_NEEDED=true + if [ -x "$RV_BIN" ]; then + EXISTING_RV=$("$RV_BIN" --version 2>/dev/null || echo "") + if echo "$EXISTING_RV" | grep -q "${RV_VERSION#v}"; then + echo "OK: rv $RV_VERSION already installed at $RV_BIN" >> "$LOG" + RV_NEEDED=false + else + echo "rv version mismatch ($EXISTING_RV vs $RV_VERSION), updating..." >> "$LOG" + fi fi - RV_TMP="/tmp/blitz-rv-$$" - rm -rf "$RV_TMP" - mkdir -p "$RV_TMP" "$BLITZ_BIN_DIR" - - if curl -fsSL "$RV_URL" -o "$RV_TMP/rv.tar.xz" 2>> "$LOG"; then - tar -xJf "$RV_TMP/rv.tar.xz" -C "$RV_TMP" 2>> "$LOG" - cp "$RV_TMP"/rv-*/rv "$RV_BIN" 2>> "$LOG" - chmod 755 "$RV_BIN" - if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_BIN_DIR" "$RV_BIN"; fi - echo "rv installed to $RV_BIN" >> "$LOG" - else - fail "Failed to download rv from $RV_URL. Check /tmp/blitz_install.log for details." - fi + if [ "$RV_NEEDED" = true ]; then + echo "Installing rv $RV_VERSION..." >> "$LOG" + show_progress "Installing Ruby version manager..." - rm -rf "$RV_TMP" -fi + ARCH=$(uname -m) + if [ "$ARCH" = "arm64" ]; then + RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-aarch64-apple-darwin.tar.xz" + else + RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-x86_64-apple-darwin.tar.xz" + fi -# [2] Install Ruby via rv -RUBY_VERSION="3.4" -mkdir -p "$BLITZ_RUBIES_DIR" -if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_RUBIES_DIR" 2>/dev/null || true; fi + RV_TMP="/tmp/blitz-rv-$$" + rm -rf "$RV_TMP" + mkdir -p "$RV_TMP" "$BLITZ_BIN_DIR" -BLITZ_RUBY=$(run_as_user \ - env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ - "$RV_BIN" ruby find "$RUBY_VERSION" 2>/dev/null || true) + if curl -fsSL "$RV_URL" -o "$RV_TMP/rv.tar.xz" 2>> "$LOG"; then + tar -xJf "$RV_TMP/rv.tar.xz" -C "$RV_TMP" 2>> "$LOG" + cp "$RV_TMP"/rv-*/rv "$RV_BIN" 2>> "$LOG" + chmod 755 "$RV_BIN" + if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_BIN_DIR" "$RV_BIN"; fi + echo "rv installed to $RV_BIN" >> "$LOG" + else + fail "Failed to download rv from $RV_URL. Check /tmp/blitz_install.log for details." + fi -if [ -n "$BLITZ_RUBY" ] && [ -x "$BLITZ_RUBY" ]; then - echo "OK: Ruby already installed at $BLITZ_RUBY" >> "$LOG" -else - show_progress "Installing Ruby..." - echo "Installing Ruby $RUBY_VERSION via rv..." >> "$LOG" - if run_as_user \ - env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ - "$RV_BIN" ruby install "$RUBY_VERSION" >> "$LOG" 2>&1; then - echo "Ruby installed via rv" >> "$LOG" - else - fail "Failed to install Ruby via rv. Check /tmp/blitz_install.log for details." + rm -rf "$RV_TMP" fi + # [2] Install Ruby via rv + RUBY_VERSION="3.4" + mkdir -p "$BLITZ_RUBIES_DIR" + if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_RUBIES_DIR" 2>/dev/null || true; fi + BLITZ_RUBY=$(run_as_user \ env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ "$RV_BIN" ruby find "$RUBY_VERSION" 2>/dev/null || true) -fi - -if [ -z "$BLITZ_RUBY" ] || [ ! -x "$BLITZ_RUBY" ]; then - fail "Ruby was installed but binary not found. Check /tmp/blitz_install.log for details." -fi -echo "Ruby ready at: $BLITZ_RUBY ($($BLITZ_RUBY --version 2>/dev/null))" >> "$LOG" -# Create stable symlink: ~/.blitz/ruby → rv-managed Ruby dir -RUBY_INSTALL_DIR="$(dirname "$(dirname "$BLITZ_RUBY")")" -ln -sfn "$RUBY_INSTALL_DIR" "$CONSOLE_HOME/.blitz/ruby" -if [ "$IS_ROOT" = true ]; then chown -h "$CONSOLE_USER" "$CONSOLE_HOME/.blitz/ruby" 2>/dev/null || true; fi -echo "Symlinked $CONSOLE_HOME/.blitz/ruby -> $RUBY_INSTALL_DIR" >> "$LOG" + if [ -n "$BLITZ_RUBY" ] && [ -x "$BLITZ_RUBY" ]; then + echo "OK: Ruby already installed at $BLITZ_RUBY" >> "$LOG" + else + show_progress "Installing Ruby..." + echo "Installing Ruby $RUBY_VERSION via rv..." >> "$LOG" + if run_as_user \ + env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ + "$RV_BIN" ruby install "$RUBY_VERSION" >> "$LOG" 2>&1; then + echo "Ruby installed via rv" >> "$LOG" + else + fail "Failed to install Ruby via rv. Check /tmp/blitz_install.log for details." + fi -# [3] Install CocoaPods via gem -BLITZ_GEM="$(dirname "$BLITZ_RUBY")/gem" -BLITZ_GEM_BIN="$(dirname "$BLITZ_RUBY")" + BLITZ_RUBY=$(run_as_user \ + env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ + "$RV_BIN" ruby find "$RUBY_VERSION" 2>/dev/null || true) + fi -if [ -x "$BLITZ_GEM_BIN/pod" ]; then - POD_PATH="$BLITZ_GEM_BIN/pod" - echo "OK: CocoaPods already installed at $POD_PATH" >> "$LOG" -else - show_progress "Installing CocoaPods..." - echo "Installing CocoaPods..." >> "$LOG" - if run_as_user \ - "$BLITZ_GEM" install cocoapods --no-document > /dev/null 2>> "$LOG"; then - echo "CocoaPods installed successfully" >> "$LOG" - else - fail "Failed to install CocoaPods. Check /tmp/blitz_install.log for details." + if [ -z "$BLITZ_RUBY" ] || [ ! -x "$BLITZ_RUBY" ]; then + fail "Ruby was installed but binary not found. Check /tmp/blitz_install.log for details." fi + echo "Ruby ready at: $BLITZ_RUBY ($($BLITZ_RUBY --version 2>/dev/null))" >> "$LOG" + + # Create stable symlink: ~/.blitz/ruby → rv-managed Ruby dir + RUBY_INSTALL_DIR="$(dirname "$(dirname "$BLITZ_RUBY")")" + ln -sfn "$RUBY_INSTALL_DIR" "$CONSOLE_HOME/.blitz/ruby" + if [ "$IS_ROOT" = true ]; then chown -h "$CONSOLE_USER" "$CONSOLE_HOME/.blitz/ruby" 2>/dev/null || true; fi + echo "Symlinked $CONSOLE_HOME/.blitz/ruby -> $RUBY_INSTALL_DIR" >> "$LOG" + + # [3] Install CocoaPods via gem + BLITZ_GEM="$(dirname "$BLITZ_RUBY")/gem" + BLITZ_GEM_BIN="$(dirname "$BLITZ_RUBY")" if [ -x "$BLITZ_GEM_BIN/pod" ]; then POD_PATH="$BLITZ_GEM_BIN/pod" + echo "OK: CocoaPods already installed at $POD_PATH" >> "$LOG" else - fail "CocoaPods gem installed but pod binary not found. Check /tmp/blitz_install.log for details." + show_progress "Installing CocoaPods..." + echo "Installing CocoaPods..." >> "$LOG" + if run_as_user \ + "$BLITZ_GEM" install cocoapods --no-document > /dev/null 2>> "$LOG"; then + echo "CocoaPods installed successfully" >> "$LOG" + else + fail "Failed to install CocoaPods. Check /tmp/blitz_install.log for details." + fi + + if [ -x "$BLITZ_GEM_BIN/pod" ]; then + POD_PATH="$BLITZ_GEM_BIN/pod" + else + fail "CocoaPods gem installed but pod binary not found. Check /tmp/blitz_install.log for details." + fi fi -fi -echo "Pod ready at: $POD_PATH ($($POD_PATH --version 2>/dev/null))" >> "$LOG" -echo "TIMING: ruby + cocoapods took $(( $(date +%s) - T_RUBY_START ))s" >> "$LOG" + echo "Pod ready at: $POD_PATH ($($POD_PATH --version 2>/dev/null))" >> "$LOG" + echo "TIMING: ruby + cocoapods took $(( $(date +%s) - T_RUBY_START ))s" >> "$LOG" +fi # ============================================================================ # Install Python 3 + idb (Facebook iOS Development Bridge) # ============================================================================ T_IDB_START=$(date +%s) -BLITZ_PYTHON_DIR="$CONSOLE_HOME/.blitz/python" -BLITZ_IDB_COMPANION_DIR="$CONSOLE_HOME/.blitz/idb-companion" -IDB_COMPANION_BIN="$BLITZ_IDB_COMPANION_DIR/bin/idb_companion" -IDB_CLI_PATH="$BLITZ_PYTHON_DIR/bin/idb" - -if [ -x "$IDB_CLI_PATH" ] && "$IDB_CLI_PATH" --help >/dev/null 2>&1; then - echo "OK: idb already installed at $IDB_CLI_PATH" >> "$LOG" +if [ "${BLITZ_UPDATE_CONTEXT:-}" = "auto-update" ]; then + echo "Skipping Python and idb bootstrap during auto-update" >> "$LOG" + echo "TIMING: python + idb took 0s" >> "$LOG" else - echo "Installing Python 3 + idb..." >> "$LOG" - show_progress "Installing idb (iOS Development Bridge)..." + BLITZ_PYTHON_DIR="$CONSOLE_HOME/.blitz/python" + BLITZ_IDB_COMPANION_DIR="$CONSOLE_HOME/.blitz/idb-companion" + IDB_COMPANION_BIN="$BLITZ_IDB_COMPANION_DIR/bin/idb_companion" + IDB_CLI_PATH="$BLITZ_PYTHON_DIR/bin/idb" - # [1] Download pre-built Python 3 - if [ ! -x "$BLITZ_PYTHON_DIR/bin/python3" ]; then - echo "Downloading pre-built Python 3..." >> "$LOG" - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-aarch64-apple-darwin-install_only.tar.gz" + if [ -x "$IDB_CLI_PATH" ] && "$IDB_CLI_PATH" --help >/dev/null 2>&1; then + echo "OK: idb already installed at $IDB_CLI_PATH" >> "$LOG" + else + echo "Installing Python 3 + idb..." >> "$LOG" + show_progress "Installing idb (iOS Development Bridge)..." + + # [1] Download pre-built Python 3 + if [ ! -x "$BLITZ_PYTHON_DIR/bin/python3" ]; then + echo "Downloading pre-built Python 3..." >> "$LOG" + ARCH=$(uname -m) + if [ "$ARCH" = "arm64" ]; then + PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-aarch64-apple-darwin-install_only.tar.gz" + else + PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-x86_64-apple-darwin-install_only.tar.gz" + fi + + PYTHON_TMP="/tmp/blitz-python-$$" + rm -rf "$PYTHON_TMP" + mkdir -p "$PYTHON_TMP" + + if curl -fsSL "$PYTHON_URL" -o "$PYTHON_TMP/python.tar.gz" 2>> "$LOG"; then + mkdir -p "$BLITZ_PYTHON_DIR" + tar -xzf "$PYTHON_TMP/python.tar.gz" -C "$BLITZ_PYTHON_DIR" --strip-components=1 2>> "$LOG" + if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_PYTHON_DIR"; fi + echo "Python 3 installed to $BLITZ_PYTHON_DIR ($($BLITZ_PYTHON_DIR/bin/python3 --version 2>/dev/null))" >> "$LOG" + else + echo "WARNING: Failed to download Python 3" >> "$LOG" + fi + + rm -rf "$PYTHON_TMP" else - PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-x86_64-apple-darwin-install_only.tar.gz" + echo "OK: Python 3 already installed at $BLITZ_PYTHON_DIR/bin/python3" >> "$LOG" fi - PYTHON_TMP="/tmp/blitz-python-$$" - rm -rf "$PYTHON_TMP" - mkdir -p "$PYTHON_TMP" - - if curl -fsSL "$PYTHON_URL" -o "$PYTHON_TMP/python.tar.gz" 2>> "$LOG"; then - mkdir -p "$BLITZ_PYTHON_DIR" - tar -xzf "$PYTHON_TMP/python.tar.gz" -C "$BLITZ_PYTHON_DIR" --strip-components=1 2>> "$LOG" - if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_PYTHON_DIR"; fi - echo "Python 3 installed to $BLITZ_PYTHON_DIR ($($BLITZ_PYTHON_DIR/bin/python3 --version 2>/dev/null))" >> "$LOG" + # [2] Download pre-built idb-companion + if [ ! -x "$IDB_COMPANION_BIN" ]; then + echo "Downloading idb-companion..." >> "$LOG" + IDB_COMPANION_URL="https://github.com/facebook/idb/releases/download/v1.1.8/idb-companion.universal.tar.gz" + + IDB_TMP="/tmp/blitz-idb-companion-$$" + rm -rf "$IDB_TMP" + mkdir -p "$IDB_TMP" + + if curl -fsSL "$IDB_COMPANION_URL" -o "$IDB_TMP/idb-companion.tar.gz" 2>> "$LOG"; then + mkdir -p "$BLITZ_IDB_COMPANION_DIR" + tar -xzf "$IDB_TMP/idb-companion.tar.gz" -C "$BLITZ_IDB_COMPANION_DIR" --strip-components=1 2>> "$LOG" + if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_IDB_COMPANION_DIR"; fi + echo "idb-companion installed to $BLITZ_IDB_COMPANION_DIR" >> "$LOG" + else + echo "WARNING: Failed to download idb-companion" >> "$LOG" + fi + + rm -rf "$IDB_TMP" else - echo "WARNING: Failed to download Python 3" >> "$LOG" + echo "OK: idb-companion already installed at $IDB_COMPANION_BIN" >> "$LOG" fi - rm -rf "$PYTHON_TMP" - else - echo "OK: Python 3 already installed at $BLITZ_PYTHON_DIR/bin/python3" >> "$LOG" - fi - - # [2] Download pre-built idb-companion - if [ ! -x "$IDB_COMPANION_BIN" ]; then - echo "Downloading idb-companion..." >> "$LOG" - IDB_COMPANION_URL="https://github.com/facebook/idb/releases/download/v1.1.8/idb-companion.universal.tar.gz" - - IDB_TMP="/tmp/blitz-idb-companion-$$" - rm -rf "$IDB_TMP" - mkdir -p "$IDB_TMP" - - if curl -fsSL "$IDB_COMPANION_URL" -o "$IDB_TMP/idb-companion.tar.gz" 2>> "$LOG"; then - mkdir -p "$BLITZ_IDB_COMPANION_DIR" - tar -xzf "$IDB_TMP/idb-companion.tar.gz" -C "$BLITZ_IDB_COMPANION_DIR" --strip-components=1 2>> "$LOG" - if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_IDB_COMPANION_DIR"; fi - echo "idb-companion installed to $BLITZ_IDB_COMPANION_DIR" >> "$LOG" + # [3] pip install fb-idb + if [ -x "$BLITZ_PYTHON_DIR/bin/pip3" ]; then + echo "Installing fb-idb via pip..." >> "$LOG" + if run_as_user \ + "$BLITZ_PYTHON_DIR/bin/pip3" install fb-idb > /dev/null 2>> "$LOG"; then + echo "fb-idb installed successfully" >> "$LOG" + else + echo "WARNING: pip install fb-idb failed" >> "$LOG" + fi else - echo "WARNING: Failed to download idb-companion" >> "$LOG" + echo "WARNING: pip3 not found, cannot install fb-idb" >> "$LOG" fi - rm -rf "$IDB_TMP" - else - echo "OK: idb-companion already installed at $IDB_COMPANION_BIN" >> "$LOG" - fi - - # [3] pip install fb-idb - if [ -x "$BLITZ_PYTHON_DIR/bin/pip3" ]; then - echo "Installing fb-idb via pip..." >> "$LOG" - if run_as_user \ - "$BLITZ_PYTHON_DIR/bin/pip3" install fb-idb > /dev/null 2>> "$LOG"; then - echo "fb-idb installed successfully" >> "$LOG" + if [ -x "$IDB_CLI_PATH" ]; then + echo "OK: idb CLI ready at $IDB_CLI_PATH" >> "$LOG" else - echo "WARNING: pip install fb-idb failed" >> "$LOG" + echo "WARNING: idb CLI not found at $IDB_CLI_PATH after installation" >> "$LOG" fi - else - echo "WARNING: pip3 not found, cannot install fb-idb" >> "$LOG" - fi - - if [ -x "$IDB_CLI_PATH" ]; then - echo "OK: idb CLI ready at $IDB_CLI_PATH" >> "$LOG" - else - echo "WARNING: idb CLI not found at $IDB_CLI_PATH after installation" >> "$LOG" fi + echo "TIMING: python + idb took $(( $(date +%s) - T_IDB_START ))s" >> "$LOG" fi -echo "TIMING: python + idb took $(( $(date +%s) - T_IDB_START ))s" >> "$LOG" # ============================================================================ # Launch Blitz.app diff --git a/scripts/pkg-scripts/preinstall b/scripts/pkg-scripts/preinstall index c5eac06..982fb8b 100755 --- a/scripts/pkg-scripts/preinstall +++ b/scripts/pkg-scripts/preinstall @@ -285,10 +285,6 @@ Xcode -> Settings -> Platforms") fi echo "TIMING: simulator check took $(( $(date +%s) - T_STEP ))s" >> "$LOG" -# ============================================================================ -# Report results -# ============================================================================ - if [ ${#ERRORS[@]} -gt 0 ]; then MSG="Blitz requires the following to be fixed before installation:\n\n" for i in "${!ERRORS[@]}"; do diff --git a/src/AppCommands.swift b/src/AppCommands.swift index dc38b72..5491090 100644 --- a/src/AppCommands.swift +++ b/src/AppCommands.swift @@ -68,22 +68,35 @@ struct AppCommands: Commands { CommandGroup(after: .toolbar) { Divider() - Button("Simulator") { - appState.activeTab = .simulator + Button("Dashboard") { + appState.activeTab = .dashboard } .keyboardShortcut("1", modifiers: .command) - Button("Database") { - appState.activeTab = .database + Button("Simulator") { + appState.activeTab = .app + appState.activeAppSubTab = .simulator } .keyboardShortcut("2", modifiers: .command) - Button("Tests") { - appState.activeTab = .tests + Button("Database") { + appState.activeTab = .app + appState.activeAppSubTab = .database } .keyboardShortcut("3", modifiers: .command) } + // View > Terminal toggle + CommandGroup(after: .sidebar) { + Button(appState.showTerminal ? "Hide Terminal" : "Show Terminal") { + appState.showTerminal.toggle() + if appState.showTerminal && appState.terminalManager.sessions.isEmpty { + appState.terminalManager.createSession(projectPath: appState.activeProject?.path) + } + } + .keyboardShortcut("`", modifiers: .command) + } + // Build menu CommandMenu("Build") { Button("Run") { diff --git a/src/AppState.swift b/src/AppState.swift index 56eeeb7..30edae8 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -3,14 +3,11 @@ import SwiftUI /// All navigation tabs in the app enum AppTab: String, CaseIterable, Identifiable { - // Build group - case simulator - case database - case tests - case assets + // Top-level standalone tabs + case dashboard + case app // Release group (ASC) - case ascOverview case storeListing case screenshots case appDetails @@ -34,7 +31,7 @@ enum AppTab: String, CaseIterable, Identifiable { var isASCTab: Bool { switch self { - case .ascOverview, .storeListing, .screenshots, .appDetails, .monetization, .review, + case .storeListing, .screenshots, .appDetails, .monetization, .review, .analytics, .reviews, .builds, .groups, .betaInfo, .feedback: return true default: @@ -44,11 +41,8 @@ enum AppTab: String, CaseIterable, Identifiable { var label: String { switch self { - case .simulator: "Simulator" - case .database: "Database" - case .tests: "Tests" - case .assets: "Assets" - case .ascOverview: "Overview" + case .dashboard: "Dashboard" + case .app: "App" case .storeListing: "Store Listing" case .screenshots: "Screenshots" case .appDetails: "App Details" @@ -66,11 +60,8 @@ enum AppTab: String, CaseIterable, Identifiable { var icon: String { switch self { - case .simulator: "iphone" - case .database: "cylinder" - case .tests: "checkmark.circle" - case .assets: "photo.badge.plus" - case .ascOverview: "chart.bar" + case .dashboard: "square.grid.2x2" + case .app: "app" case .storeListing: "text.page" case .screenshots: "photo.on.rectangle" case .appDetails: "info.circle" @@ -87,15 +78,13 @@ enum AppTab: String, CaseIterable, Identifiable { } enum Group: String, CaseIterable { - case build = "Build" case release = "Release" case insights = "Insights" case testFlight = "TestFlight" var tabs: [AppTab] { switch self { - case .build: [.simulator, .database, .tests, .assets] - case .release: [.ascOverview, .storeListing, .screenshots, .appDetails, .monetization, .review] + case .release: [.storeListing, .screenshots, .appDetails, .monetization, .review] case .insights: [.analytics, .reviews] case .testFlight: [.builds, .groups, .betaInfo, .feedback] } @@ -103,23 +92,60 @@ enum AppTab: String, CaseIterable, Identifiable { } } +/// Sub-tabs within the App tab (top navbar) +enum AppSubTab: String, CaseIterable, Identifiable { + case overview + case simulator + case database + case tests + case icon + + var id: String { rawValue } + + var label: String { + switch self { + case .overview: "Overview" + case .simulator: "Simulator" + case .database: "Database" + case .tests: "Tests" + case .icon: "Icon" + } + } + + var systemImage: String { + switch self { + case .overview: "chart.bar" + case .simulator: "iphone" + case .database: "cylinder" + case .tests: "checkmark.circle" + case .icon: "photo.badge.plus" + } + } +} + /// Root observable state for the entire app @MainActor @Observable final class AppState { // Navigation var activeProjectId: String? - var activeTab: AppTab = .simulator + var activeTab: AppTab = .dashboard + var activeAppSubTab: AppSubTab = .overview // Child observable managers var projectManager = ProjectManager() var simulatorManager = SimulatorManager() var simulatorStream = SimulatorStreamManager() -var settingsStore = SettingsService.shared + var settingsStore = SettingsService.shared var databaseManager = DatabaseManager() var projectSetup = ProjectSetupManager() var ascManager = ASCManager() var autoUpdate = AutoUpdateManager() + var terminalManager = TerminalManager() + + // Terminal panel visibility (toggle only — does not affect session lifecycle) + var showTerminal = false + var terminalPanelSize: CGFloat = 250 // Sheet control (toggled by menu bar, observed by ContentView) var showNewProjectSheet = false @@ -128,12 +154,13 @@ var settingsStore = SettingsService.shared // MCP approval flow var pendingApproval: ApprovalRequest? var showApprovalAlert: Bool = false - var toolExecutor: MCPToolExecutor? + var toolExecutor: MCPExecutor? var mcpServer: MCPServerService? init() { // Boot MCP server eagerly — this runs before any SwiftUI view callback MCPBootstrap.shared.boot(appState: self) + ascManager.loadStoredCredentialsIfNeeded() } var activeProject: Project? { @@ -142,411 +169,5 @@ var settingsStore = SettingsService.shared } } -// MARK: - Observable Managers - -@MainActor -@Observable -final class ProjectManager { - var projects: [Project] = [] - var isLoading = false - - func loadProjects() async { - isLoading = true - defer { isLoading = false } - - let storage = ProjectStorage() - projects = await storage.listProjects() - } -} - -@MainActor -@Observable -final class SimulatorManager { - var simulators: [SimulatorInfo] = [] - var bootedDeviceId: String? - var isStreaming = false - var isBooting = false - var bootingDeviceName: String? - - func loadSimulators() async { - let client = SimctlClient() - do { - let devices = try await client.listDevices() - simulators = devices.map { device in - SimulatorInfo( - udid: device.udid, - name: device.name, - state: device.state, - deviceTypeIdentifier: device.deviceTypeIdentifier, - lastBootedAt: device.lastBootedAt - ) - } - // Only auto-select a booted device if it's supported - bootedDeviceId = simulators.first(where: { - $0.isBooted && SimulatorConfigDatabase.isSupported($0.name) - })?.udid - } catch { - print("Failed to load simulators: \(error)") - } - } - - /// Boot a simulator if none is currently running. Called when a project opens. - /// Prefers supported devices (iPhone 16/17); falls back to any iPhone. - func bootIfNeeded() async { - await loadSimulators() - - // If a supported device is already booted, keep it - if let bootedId = bootedDeviceId, - let booted = simulators.first(where: { $0.udid == bootedId }), - SimulatorConfigDatabase.isSupported(booted.name) { return } - - // Otherwise pick a supported device to boot (prefer shutdown ones to avoid conflicts) - guard let target = simulators.first(where: { - SimulatorConfigDatabase.isSupported($0.name) && !$0.isBooted - }) ?? simulators.first(where: { - SimulatorConfigDatabase.isSupported($0.name) - }) else { return } - - isBooting = true - defer { isBooting = false } - - let service = SimulatorService() - do { - try await service.boot(udid: target.udid) - bootedDeviceId = target.udid - await loadSimulators() - } catch { - print("Failed to auto-boot simulator: \(error)") - } - } - - /// Shutdown the booted simulator. Called on app quit. - func shutdownBooted() { - guard let udid = bootedDeviceId else { return } - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = ["simctl", "shutdown", udid] - try? process.run() - process.waitUntilExit() - } -} - -@MainActor -@Observable -final class SimulatorStreamManager { - let captureService = SimulatorCaptureService() - var renderer: MetalRenderer? - var isCapturing = false - var errorMessage: String? - var statusMessage: String? - /// True when the stream was paused by a tab switch (not manually stopped) - var isPaused = false - - private var rendererInitialized = false - - func ensureRenderer() { - guard !rendererInitialized else { return } - rendererInitialized = true - do { - renderer = try MetalRenderer() - } catch { - errorMessage = "Metal init failed: \(error.localizedDescription)" - } - } - - /// Full start: ensure renderer, open Simulator.app, connect SCStream. - func startStreaming(bootedDeviceId: String?) async { - guard !isCapturing else { return } - guard bootedDeviceId != nil else { - statusMessage = "No simulator booted" - return - } - - errorMessage = nil - isPaused = false - ensureRenderer() - - statusMessage = "Opening Simulator.app..." - let service = SimulatorService() - try? await service.openSimulatorApp() - - statusMessage = "Connecting to simulator..." - do { - try await captureService.startCapture(retryForWindow: true) - } catch { - errorMessage = error.localizedDescription - statusMessage = nil - return - } - - if captureService.isCapturing { - isCapturing = true - statusMessage = nil - } - } - - /// Full stop: stop SCStream, clear state. - func stopStreaming() async { - await captureService.stopCapture() - isCapturing = false - isPaused = false - } - - /// Pause: stop SCStream but keep simulator booted. Lightweight for tab switches. - func pauseStream() async { - guard isCapturing else { return } - isPaused = true - await captureService.stopCapture() - isCapturing = false - } - - /// Resume: restart SCStream after a pause. No window retry needed since sim is already running. - func resumeStream() async { - guard isPaused else { return } - isPaused = false - ensureRenderer() - - do { - try await captureService.startCapture(retryForWindow: false) - if captureService.isCapturing { - isCapturing = true - } - } catch { - errorMessage = error.localizedDescription - } - } -} - - - -@MainActor -@Observable -final class ProjectSetupManager { - var isSettingUp = false - var setupProjectId: String? - var currentStep: ProjectSetupService.SetupStep? - var errorMessage: String? - - /// Set by NewProjectSheet; consumed by ContentView to trigger setup. - var pendingSetupProjectId: String? - - /// Scaffold a project using the appropriate template for its type. - func setup(projectId: String, projectName: String, projectPath: String, projectType: ProjectType = .reactNative, platform: ProjectPlatform = .iOS) async { - isSettingUp = true - setupProjectId = projectId - currentStep = nil - errorMessage = nil - - do { - switch (projectType, platform) { - case (.swift, .macOS): - try await MacSwiftProjectSetupService.setup( - projectId: projectId, - projectName: projectName, - projectPath: projectPath, - onStep: { step in self.currentStep = step } - ) - case (.swift, .iOS): - try await SwiftProjectSetupService.setup( - projectId: projectId, - projectName: projectName, - projectPath: projectPath, - onStep: { step in self.currentStep = step } - ) - case (.reactNative, _): - try await ProjectSetupService.setup( - projectId: projectId, - projectName: projectName, - projectPath: projectPath, - onStep: { step in self.currentStep = step } - ) - case (.flutter, _): - throw ProjectSetupService.SetupError(message: "Flutter projects are not yet supported") - } - // Ensure .mcp.json, CLAUDE.md, .claude/settings.local.json exist - // (setup recreates the project dir, so these must be written after) - let storage = ProjectStorage() - storage.ensureMCPConfig(projectId: projectId) - storage.ensureClaudeFiles(projectId: projectId, projectType: projectType) - isSettingUp = false - } catch { - errorMessage = error.localizedDescription - isSettingUp = false - } - } - - var stepMessage: String { - if let error = errorMessage { return "Error: \(error)" } - return currentStep?.rawValue ?? "Preparing..." - } -} - -@MainActor -@Observable -final class DatabaseManager { - // Connection & data state - var connectionStatus: ConnectionStatus = .disconnected - var schema: TeenybaseSettingsResponse? - var selectedTable: TeenybaseTable? - var rows: [TableRow] = [] - var totalRows: Int = 0 - var currentPage: Int = 0 - var pageSize: Int = 50 - var sortField: String? - var sortAscending: Bool = true - var searchText: String = "" - var errorMessage: String? - - // Tracks which project we're connected to - private(set) var connectedProjectId: String? - - // Backend process - let backendProcess = TeenybaseProcessService() - let client = TeenybaseClient() - - /// Start the backend server for a project and connect to it. - func startAndConnect(projectId: String, projectPath: String) async { - // Already connected to this project - if connectedProjectId == projectId && connectionStatus == .connected { return } - // Already in progress for this project - if connectedProjectId == projectId && connectionStatus == .connecting { return } - - // Switching projects — tear down old connection - if connectedProjectId != nil && connectedProjectId != projectId { - disconnect() - } - - connectedProjectId = projectId - connectionStatus = .connecting - errorMessage = nil - - // Read admin token from .dev.vars - let token = readDevVar("ADMIN_SERVICE_TOKEN", projectPath: projectPath) - guard let token, !token.isEmpty else { - connectionStatus = .error - errorMessage = "No ADMIN_SERVICE_TOKEN in .dev.vars" - return - } - - // Start the backend process - await backendProcess.start(projectPath: projectPath) - - // Wait for it to be running - guard backendProcess.status == .running else { - connectionStatus = .error - errorMessage = backendProcess.errorMessage ?? "Backend failed to start" - return - } - - // Connect the API client - let baseURL = backendProcess.baseURL - await client.configure(baseURL: baseURL, token: token) - - do { - let settings = try await client.fetchSchema() - self.schema = settings - self.connectionStatus = .connected - self.errorMessage = nil - if self.selectedTable == nil, let first = settings.tables.first { - self.selectedTable = first - } - } catch { - self.connectionStatus = .error - self.errorMessage = "Connected but schema fetch failed: \(error.localizedDescription)" - } - } - - func loadRows() async { - guard let table = selectedTable else { return } - do { - var whereClause: String? = nil - if !searchText.isEmpty { - let textFields = table.fields.filter { ($0.type ?? "text") == "text" || ($0.sqlType ?? "") == "text" } - if !textFields.isEmpty { - let escaped = searchText - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "%", with: "\\%") - .replacingOccurrences(of: "_", with: "\\_") - let clauses = textFields.map { "\($0.name) LIKE '%\(escaped)%'" } - whereClause = clauses.joined(separator: " OR ") - } - } - - let result = try await client.listRecords( - table: table.name, - limit: pageSize, - offset: currentPage * pageSize, - orderBy: sortField, - ascending: sortAscending, - where: whereClause - ) - self.rows = result.items - self.totalRows = result.total - } catch { - self.errorMessage = error.localizedDescription - } - } - - func insertRecord(values: [String: Any]) async { - guard let table = selectedTable else { return } - do { - _ = try await client.insertRecord(table: table.name, values: values) - await loadRows() - } catch { - self.errorMessage = error.localizedDescription - } - } - - func updateRecord(id: String, values: [String: Any]) async { - guard let table = selectedTable else { return } - do { - _ = try await client.updateRecord(table: table.name, id: id, values: values) - await loadRows() - } catch { - self.errorMessage = error.localizedDescription - } - } - - func deleteRecord(id: String) async { - guard let table = selectedTable else { return } - do { - _ = try await client.deleteRecord(table: table.name, id: id) - await loadRows() - } catch { - self.errorMessage = error.localizedDescription - } - } - - func disconnect() { - backendProcess.stop() - connectedProjectId = nil - connectionStatus = .disconnected - schema = nil - selectedTable = nil - rows = [] - totalRows = 0 - currentPage = 0 - errorMessage = nil - } - - private func readDevVar(_ key: String, projectPath: String) -> String? { - let path = projectPath + "/.dev.vars" - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } - if trimmed.hasPrefix(key + "=") || trimmed.hasPrefix(key + " ") { - let parts = trimmed.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - return String(parts[1]).trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - } - } - } - return nil - } -} - // SettingsStore is SettingsService (defined in Services/SettingsService.swift) typealias SettingsStore = SettingsService diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index 33ee72a..ae5b2ea 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -1,5 +1,88 @@ import SwiftUI +final class BlitzAppDelegate: NSObject, NSApplicationDelegate { + var appState: AppState? + + func applicationDidFinishLaunching(_ notification: Notification) { + AppRelaunchService.shared.clearPendingRestart() + if let fileMenu = NSApp.mainMenu?.item(withTitle: "File") { + fileMenu.title = "Project" + } + // Set dock icon from bundled resource (needed for swift run / non-.app launches) + if let icon = Bundle.appResources.image(forResource: "blitz-icon") { + NSApp.applicationIconImage = icon + } + } + + func applicationDidBecomeActive(_ notification: Notification) { + AppRelaunchService.shared.clearPendingRestartAfterReturningToApp() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + func applicationWillTerminate(_ notification: Notification) { + _ = AppRelaunchService.shared.schedulePendingScreenRecordingRelaunchIfNeeded() + MCPBootstrap.shared.shutdown() + // Don't block termination with synchronous simctl shutdown — + // this prevents macOS TCC "Quit & Reopen" from relaunching the app. + // Fire-and-forget: let simctl handle cleanup in the background. + if let udid = appState?.simulatorManager.bootedDeviceId { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "shutdown", udid] + try? process.run() + // Do NOT call waitUntilExit() — let the app terminate immediately + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + if appState?.activeProjectId != nil { + for window in NSApp.windows where window.canBecomeMain { + window.makeKeyAndOrderFront(nil) + return false + } + } + for window in NSApp.windows { + window.makeKeyAndOrderFront(nil) + return false + } + } + return true + } +} + +@main +struct BlitzApp: App { + @NSApplicationDelegateAdaptor private var appDelegate: BlitzAppDelegate + @State private var appState = AppState() + + var body: some Scene { + Window("Welcome to Blitz", id: "welcome") { + WelcomeWindow(appState: appState) + .frame(width: 700, height: 440) + .onAppear { + appDelegate.appState = appState + } + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 700, height: 440) + .commands { + AppCommands(appState: appState) + } + + WindowGroup(id: "main", for: String.self) { _ in + ContentView(appState: appState) + .frame(minWidth: 800, minHeight: 600) + } + .defaultSize(width: 1200, height: 900) + .windowToolbarStyle(.unified(showsTitle: false)) + } +} + /// Manages MCP server lifecycle independently of SwiftUI view callbacks. @MainActor final class MCPBootstrap { @@ -11,10 +94,22 @@ final class MCPBootstrap { guard !started else { return } started = true - installBridgeScript() + installMCPHelper() + installASCEnvironment(settings: appState.settingsStore) installClaudeSkills() updateIphoneMCP() - ProjectStorage().ensureGlobalMCPConfigs() + ProjectStorage().ensureGlobalMCPConfigs( + whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, + allowASCCLICalls: appState.settingsStore.allowASCCLICalls + ) + let whitelistBlitzMCP = appState.settingsStore.whitelistBlitzMCPTools + let allowASCCLICalls = appState.settingsStore.allowASCCLICalls + Task.detached(priority: .utility) { + ProjectStorage().ensureAllProjectMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } let server = MCPServerService(appState: appState) self.server = server @@ -45,7 +140,7 @@ final class MCPBootstrap { // Look for embedded skills in the app bundle guard let bundleSkills = Bundle.main.resourceURL? - .appendingPathComponent("claude-skills") else { return } + .appendingPathComponent("claude-skills") else { return } guard fm.fileExists(atPath: bundleSkills.path) else { return } do { @@ -74,7 +169,7 @@ final class MCPBootstrap { do { let process = Process() process.executableURL = URL(fileURLWithPath: npm) - process.arguments = ["install", "-g", "@blitzdev/iphone-mcp@latest"] + process.arguments = ["install", "-g", "--prefix", BlitzPaths.root.appendingPathComponent("node-runtime").path, "@blitzdev/iphone-mcp@latest"] process.environment = [ "PATH": "\(BlitzPaths.nodeDir.path):/usr/bin:/bin", "HOME": FileManager.default.homeDirectoryForCurrentUser.path @@ -94,125 +189,58 @@ final class MCPBootstrap { } } - private func installBridgeScript() { + private func installMCPHelper() { + let fm = FileManager.default let destDir = BlitzPaths.root - let destFile = BlitzPaths.mcpBridge - if let bundlePath = Bundle.main.path(forResource: "blitz-mcp-bridge", ofType: "sh") { - try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - try? FileManager.default.removeItem(at: destFile) - try? FileManager.default.copyItem(atPath: bundlePath, toPath: destFile.path) - try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destFile.path) + try? fm.createDirectory(at: destDir, withIntermediateDirectories: true) + + if let sourceURL = bundledMCPHelperURL() { + try? fm.removeItem(at: BlitzPaths.mcpHelper) + try? fm.copyItem(at: sourceURL, to: BlitzPaths.mcpHelper) + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: BlitzPaths.mcpHelper.path) } else { - let script = """ - #!/bin/bash - PORT_FILE="$HOME/.blitz/mcp-port" - WAITED=0 - while [ ! -f "$PORT_FILE" ] && [ "$WAITED" -lt 10 ]; do - sleep 1 - WAITED=$((WAITED + 1)) - done - if [ ! -f "$PORT_FILE" ]; then - echo '{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"Blitz is not running."}}' >&2 - exit 1 - fi - PORT=$(cat "$PORT_FILE") - WAITED=0 - while ! curl -s -o /dev/null -w '' "http://127.0.0.1:${PORT}/mcp" 2>/dev/null && [ "$WAITED" -lt 5 ]; do - sleep 1 - WAITED=$((WAITED + 1)) - done - while IFS= read -r line; do - [ -z "$line" ] && continue - response=$(curl -s -X POST "http://127.0.0.1:${PORT}/mcp" \\ - -H "Content-Type: application/json" -d "$line" 2>/dev/null) - if [ $? -ne 0 ]; then - echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Cannot connect to Blitz."}}' >&2 - exit 1 - fi - echo "$response" - done - """ - try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - try? script.write(to: destFile, atomically: true, encoding: .utf8) - try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destFile.path) + print("[MCP] Failed to locate bundled blitz-macos-mcp helper") } - } -} -final class BlitzAppDelegate: NSObject, NSApplicationDelegate { - var appState: AppState? - - func applicationDidFinishLaunching(_ notification: Notification) { - if let fileMenu = NSApp.mainMenu?.item(withTitle: "File") { - fileMenu.title = "Project" - } - // Set dock icon from bundled resource (needed for swift run / non-.app launches) - if let icon = Bundle.appResources.image(forResource: "blitz-icon") { - NSApp.applicationIconImage = icon - } + // Keep the old script path working for manually created configs while + // new project configs point directly at the helper executable. + let bridgeScript = """ + #!/bin/bash + HELPER="$HOME/.blitz/blitz-macos-mcp" + if [ ! -x "$HELPER" ]; then + echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Blitz MCP helper is not installed. Start Blitz first."}}' >&2 + exit 1 + fi + exec "$HELPER" "$@" + """ + try? bridgeScript.write(to: BlitzPaths.mcpBridge, atomically: true, encoding: .utf8) + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: BlitzPaths.mcpBridge.path) } - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - false + private func installASCEnvironment(settings: SettingsService) { + try? ASCAuthBridge().installCLIShims() + try? ShellIntegrationService().sync(enabled: settings.enableASCShellIntegration) } - func applicationWillTerminate(_ notification: Notification) { - MCPBootstrap.shared.shutdown() - // Don't block termination with synchronous simctl shutdown — - // this prevents macOS TCC "Quit & Reopen" from relaunching the app. - // Fire-and-forget: let simctl handle cleanup in the background. - if let udid = appState?.simulatorManager.bootedDeviceId { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = ["simctl", "shutdown", udid] - try? process.run() - // Do NOT call waitUntilExit() — let the app terminate immediately - } - } + private func bundledMCPHelperURL() -> URL? { + let fm = FileManager.default - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if !flag { - if appState?.activeProjectId != nil { - for window in NSApp.windows where window.canBecomeMain { - window.makeKeyAndOrderFront(nil) - return false - } - } - for window in NSApp.windows { - window.makeKeyAndOrderFront(nil) - return false - } + let bundledHelper = Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers/blitz-macos-mcp") + if fm.isExecutableFile(atPath: bundledHelper.path) { + return bundledHelper } - return true - } -} -@main -struct BlitzApp: App { - @NSApplicationDelegateAdaptor private var appDelegate: BlitzAppDelegate - @State private var appState = AppState() - - var body: some Scene { - Window("Welcome to Blitz", id: "welcome") { - WelcomeWindow(appState: appState) - .frame(width: 700, height: 440) - .onAppear { - appDelegate.appState = appState - } - } - .windowResizability(.contentSize) - .windowStyle(.hiddenTitleBar) - .defaultSize(width: 700, height: 440) - .commands { - AppCommands(appState: appState) + if let executableURL = Bundle.main.executableURL { + let siblingHelper = executableURL + .deletingLastPathComponent() + .appendingPathComponent("blitz-macos-mcp") + if fm.isExecutableFile(atPath: siblingHelper.path) { + return siblingHelper + } } - WindowGroup(id: "main", for: String.self) { _ in - ContentView(appState: appState) - .frame(minWidth: 800, minHeight: 600) - } - .defaultSize(width: 1200, height: 900) - .windowToolbarStyle(.unified(showsTitle: false)) + return nil } } diff --git a/src/BlitzPaths.swift b/src/BlitzPaths.swift index c66c972..964e611 100644 --- a/src/BlitzPaths.swift +++ b/src/BlitzPaths.swift @@ -1,3 +1,4 @@ +import BlitzMCPCommon import Foundation /// Central source of truth for all ~/.blitz/ paths used across the app. @@ -18,11 +19,23 @@ enum BlitzPaths { /// Settings file: ~/.blitz/settings.json static var settings: URL { root.appendingPathComponent("settings.json") } - /// MCP port file: ~/.blitz/mcp-port - static var mcpPort: URL { root.appendingPathComponent("mcp-port") } + /// User-facing Blitz bin directory: ~/.blitz/bin/ + static var bin: URL { root.appendingPathComponent("bin") } - /// MCP bridge script: ~/.blitz/blitz-mcp-bridge.sh - static var mcpBridge: URL { root.appendingPathComponent("blitz-mcp-bridge.sh") } + /// Shell integration directory: ~/.blitz/shell/ + static var shell: URL { root.appendingPathComponent("shell") } + + /// Shell integration entrypoint: ~/.blitz/shell/init.sh + static var shellInit: URL { shell.appendingPathComponent("init.sh") } + + /// MCP helper executable: ~/.blitz/blitz-macos-mcp + static var mcpHelper: URL { BlitzMCPTransportPaths.helper } + + /// Compatibility bridge script: ~/.blitz/blitz-mcp-bridge.sh + static var mcpBridge: URL { BlitzMCPTransportPaths.bridgeScript } + + /// Local Unix socket used by the app-owned MCP executor. + static var mcpSocket: URL { BlitzMCPTransportPaths.socket } /// Signing base directory: ~/.blitz/signing/ static var signing: URL { root.appendingPathComponent("signing") } diff --git a/src/managers/app/DatabaseManager.swift b/src/managers/app/DatabaseManager.swift new file mode 100644 index 0000000..4349986 --- /dev/null +++ b/src/managers/app/DatabaseManager.swift @@ -0,0 +1,150 @@ +import Foundation + +@MainActor +@Observable +final class DatabaseManager { + // Connection & data state + var connectionStatus: ConnectionStatus = .disconnected + var schema: TeenybaseSettingsResponse? + var selectedTable: TeenybaseTable? + var rows: [TableRow] = [] + var totalRows: Int = 0 + var currentPage: Int = 0 + var pageSize: Int = 50 + var sortField: String? + var sortAscending: Bool = true + var searchText: String = "" + var errorMessage: String? + + // Tracks which project we're connected to + private(set) var connectedProjectId: String? + + // Backend process + let backendProcess = TeenybaseProcessService() + let client = TeenybaseClient() + + /// Start the backend server for a project and connect to it. + func startAndConnect(projectId: String, projectPath: String) async { + // Already connected to this project + if connectedProjectId == projectId && connectionStatus == .connected { return } + // Already in progress for this project + if connectedProjectId == projectId && connectionStatus == .connecting { return } + + // Switching projects — tear down old connection + if connectedProjectId != nil && connectedProjectId != projectId { + disconnect() + } + + connectedProjectId = projectId + connectionStatus = .connecting + errorMessage = nil + + let token = TeenybaseProjectEnvironment.adminToken(projectPath: projectPath) + guard let token, !token.isEmpty else { + connectionStatus = .error + errorMessage = "No ADMIN_SERVICE_TOKEN in .dev.vars" + return + } + + // Start the backend process + await backendProcess.start(projectPath: projectPath) + + // Wait for it to be running + guard backendProcess.status == .running else { + connectionStatus = .error + errorMessage = backendProcess.errorMessage ?? "Backend failed to start" + return + } + + // Connect the API client + let baseURL = backendProcess.baseURL + await client.configure(baseURL: baseURL, token: token) + + do { + let settings = try await client.fetchSchema() + self.schema = settings + self.connectionStatus = .connected + self.errorMessage = nil + if self.selectedTable == nil, let first = settings.tables.first { + self.selectedTable = first + } + } catch { + self.connectionStatus = .error + self.errorMessage = "Connected but schema fetch failed: \(error.localizedDescription)" + } + } + + func loadRows() async { + guard let table = selectedTable else { return } + do { + var whereClause: String? = nil + if !searchText.isEmpty { + let textFields = table.fields.filter { ($0.type ?? "text") == "text" || ($0.sqlType ?? "") == "text" } + if !textFields.isEmpty { + let escaped = searchText + .replacingOccurrences(of: "'", with: "''") + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + let clauses = textFields.map { "\($0.name) LIKE '%\(escaped)%'" } + whereClause = clauses.joined(separator: " OR ") + } + } + + let result = try await client.listRecords( + table: table.name, + limit: pageSize, + offset: currentPage * pageSize, + orderBy: sortField, + ascending: sortAscending, + where: whereClause + ) + self.rows = result.items + self.totalRows = result.total + } catch { + self.errorMessage = error.localizedDescription + } + } + + func insertRecord(values: [String: Any]) async { + guard let table = selectedTable else { return } + do { + _ = try await client.insertRecord(table: table.name, values: values) + await loadRows() + } catch { + self.errorMessage = error.localizedDescription + } + } + + func updateRecord(id: String, values: [String: Any]) async { + guard let table = selectedTable else { return } + do { + _ = try await client.updateRecord(table: table.name, id: id, values: values) + await loadRows() + } catch { + self.errorMessage = error.localizedDescription + } + } + + func deleteRecord(id: String) async { + guard let table = selectedTable else { return } + do { + _ = try await client.deleteRecord(table: table.name, id: id) + await loadRows() + } catch { + self.errorMessage = error.localizedDescription + } + } + + func disconnect() { + backendProcess.stop() + connectedProjectId = nil + connectionStatus = .disconnected + schema = nil + selectedTable = nil + rows = [] + totalRows = 0 + currentPage = 0 + errorMessage = nil + } +} diff --git a/src/managers/app/ProjectManager.swift b/src/managers/app/ProjectManager.swift new file mode 100644 index 0000000..56ad2e3 --- /dev/null +++ b/src/managers/app/ProjectManager.swift @@ -0,0 +1,87 @@ +import Foundation + +@MainActor +@Observable +final class ProjectManager { + var projects: [Project] = [] + var isLoading = false + + func loadProjects() async { + isLoading = true + defer { isLoading = false } + + let storage = ProjectStorage() + projects = await storage.listProjects() + } +} + +@MainActor +@Observable +final class ProjectSetupManager { + var isSettingUp = false + var setupProjectId: String? + var currentStep: ProjectSetupService.SetupStep? + var errorMessage: String? + + /// Set by NewProjectSheet; consumed by ContentView to trigger setup. + var pendingSetupProjectId: String? + + /// Scaffold a project using the appropriate template for its type. + func setup(projectId: String, projectName: String, projectPath: String, projectType: ProjectType = .reactNative, platform: ProjectPlatform = .iOS) async { + isSettingUp = true + setupProjectId = projectId + currentStep = nil + errorMessage = nil + + do { + switch (projectType, platform) { + case (.swift, .macOS): + try await MacSwiftProjectSetupService.setup( + projectId: projectId, + projectName: projectName, + projectPath: projectPath, + onStep: { step in self.currentStep = step } + ) + case (.swift, .iOS): + try await SwiftProjectSetupService.setup( + projectId: projectId, + projectName: projectName, + projectPath: projectPath, + onStep: { step in self.currentStep = step } + ) + case (.reactNative, _): + try await ProjectSetupService.setup( + projectId: projectId, + projectName: projectName, + projectPath: projectPath, + onStep: { step in self.currentStep = step } + ) + case (.flutter, _): + throw ProjectSetupService.SetupError(message: "Flutter projects are not yet supported") + } + // Ensure .mcp.json, CLAUDE.md, .claude/settings.local.json exist + // (setup recreates the project dir, so these must be written after) + let storage = ProjectStorage() + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, + allowASCCLICalls: SettingsService.shared.allowASCCLICalls + ) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, + allowASCCLICalls: SettingsService.shared.allowASCCLICalls + ) + isSettingUp = false + } catch { + errorMessage = error.localizedDescription + isSettingUp = false + } + } + + var stepMessage: String { + if let error = errorMessage { return "Error: \(error)" } + return currentStep?.rawValue ?? "Preparing..." + } +} diff --git a/src/managers/app/SimulatorManager.swift b/src/managers/app/SimulatorManager.swift new file mode 100644 index 0000000..e9c6771 --- /dev/null +++ b/src/managers/app/SimulatorManager.swift @@ -0,0 +1,73 @@ +import Foundation + +@MainActor +@Observable +final class SimulatorManager { + var simulators: [SimulatorInfo] = [] + var bootedDeviceId: String? + var isStreaming = false + var isBooting = false + var bootingDeviceName: String? + + func loadSimulators() async { + let client = SimctlClient() + do { + let devices = try await client.listDevices() + simulators = devices.map { device in + SimulatorInfo( + udid: device.udid, + name: device.name, + state: device.state, + deviceTypeIdentifier: device.deviceTypeIdentifier, + lastBootedAt: device.lastBootedAt + ) + } + // Only auto-select a booted device if it's supported + bootedDeviceId = simulators.first(where: { + $0.isBooted && SimulatorConfigDatabase.isSupported($0.name) + })?.udid + } catch { + print("Failed to load simulators: \(error)") + } + } + + /// Boot a simulator if none is currently running. Called when a project opens. + /// Prefers supported devices (iPhone 16/17); falls back to any iPhone. + func bootIfNeeded() async { + await loadSimulators() + + // If a supported device is already booted, keep it + if let bootedId = bootedDeviceId, + let booted = simulators.first(where: { $0.udid == bootedId }), + SimulatorConfigDatabase.isSupported(booted.name) { return } + + // Otherwise pick a supported device to boot (prefer shutdown ones to avoid conflicts) + guard let target = simulators.first(where: { + SimulatorConfigDatabase.isSupported($0.name) && !$0.isBooted + }) ?? simulators.first(where: { + SimulatorConfigDatabase.isSupported($0.name) + }) else { return } + + isBooting = true + defer { isBooting = false } + + let service = SimulatorService() + do { + try await service.boot(udid: target.udid) + bootedDeviceId = target.udid + await loadSimulators() + } catch { + print("Failed to auto-boot simulator: \(error)") + } + } + + /// Shutdown the booted simulator. Called on app quit. + func shutdownBooted() { + guard let udid = bootedDeviceId else { return } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "shutdown", udid] + try? process.run() + process.waitUntilExit() + } +} diff --git a/src/managers/app/SimulatorStreamManager.swift b/src/managers/app/SimulatorStreamManager.swift new file mode 100644 index 0000000..044726c --- /dev/null +++ b/src/managers/app/SimulatorStreamManager.swift @@ -0,0 +1,87 @@ +import Foundation + +@MainActor +@Observable +final class SimulatorStreamManager { + let captureService = SimulatorCaptureService() + var renderer: MetalRenderer? + var isCapturing = false + var errorMessage: String? + var statusMessage: String? + /// True when the stream was paused by a tab switch (not manually stopped) + var isPaused = false + + private var rendererInitialized = false + + func ensureRenderer() { + guard !rendererInitialized else { return } + rendererInitialized = true + do { + renderer = try MetalRenderer() + } catch { + errorMessage = "Metal init failed: \(error.localizedDescription)" + } + } + + /// Full start: ensure renderer, open Simulator.app, connect SCStream. + func startStreaming(bootedDeviceId: String?) async { + guard !isCapturing else { return } + guard bootedDeviceId != nil else { + statusMessage = "No simulator booted" + return + } + + errorMessage = nil + isPaused = false + ensureRenderer() + + statusMessage = "Opening Simulator.app..." + let service = SimulatorService() + try? await service.openSimulatorApp() + + statusMessage = "Connecting to simulator..." + do { + try await captureService.startCapture(retryForWindow: true) + } catch { + errorMessage = error.localizedDescription + statusMessage = nil + return + } + + if captureService.isCapturing { + isCapturing = true + statusMessage = nil + } + } + + /// Full stop: stop SCStream, clear state. + func stopStreaming() async { + await captureService.stopCapture() + isCapturing = false + isPaused = false + } + + /// Pause: stop SCStream but keep simulator booted. Lightweight for tab switches. + func pauseStream() async { + guard isCapturing else { return } + isPaused = true + await captureService.stopCapture() + isCapturing = false + } + + /// Resume: restart SCStream after a pause. No window retry needed since sim is already running. + func resumeStream() async { + guard isPaused else { return } + isPaused = false + ensureRenderer() + + do { + try await captureService.startCapture(retryForWindow: false) + if captureService.isCapturing { + isCapturing = true + } + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/src/managers/app/TerminalManager.swift b/src/managers/app/TerminalManager.swift new file mode 100644 index 0000000..adf627b --- /dev/null +++ b/src/managers/app/TerminalManager.swift @@ -0,0 +1,139 @@ +import Foundation +import AppKit +import SwiftTerm + +/// A single terminal session backed by a pseudo-terminal process. +/// The `terminalView` is created once and reused across show/hide cycles. +@MainActor +final class TerminalSession: Identifiable { + let id = UUID() + var title: String + let terminalView: LocalProcessTerminalView + private(set) var isTerminated = false + + private var delegateProxy: TerminalSessionDelegateProxy? + + init(title: String, projectPath: String?, onTerminated: @escaping (UUID) -> Void, onTitleChanged: @escaping (UUID, String) -> Void) { + self.title = title + + let termView = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) + termView.nativeBackgroundColor = NSColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1) + termView.nativeForegroundColor = NSColor.white + termView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + self.terminalView = termView + + let proxy = TerminalSessionDelegateProxy() + let sessionId = id + proxy.onTerminated = { onTerminated(sessionId) } + proxy.onTitleChanged = { newTitle in onTitleChanged(sessionId, newTitle) } + self.delegateProxy = proxy + termView.processDelegate = proxy + + let cwd: String + if let path = projectPath, FileManager.default.fileExists(atPath: path) { + cwd = path + } else { + cwd = FileManager.default.homeDirectoryForCurrentUser.path + } + + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + let authEnvironment = ASCAuthBridge().environmentOverrides(forLaunchPath: projectPath) + for (key, value) in authEnvironment { + env[key] = value + } + let envPairs = env.map { "\($0.key)=\($0.value)" } + + termView.startProcess( + executable: shell, + args: ["-l"], + environment: envPairs, + execName: "-\((shell as NSString).lastPathComponent)", + currentDirectory: cwd + ) + } + + func terminate() { + guard !isTerminated else { return } + isTerminated = true + terminalView.terminate() + } + + func markTerminated() { + isTerminated = true + } + + /// Send a command string to the shell (types it and presses Enter). + func sendCommand(_ command: String) { + guard !isTerminated else { return } + let data = Array((command + "\n").utf8) + terminalView.send(source: terminalView, data: data[...]) + } +} + +/// Bridges SwiftTerm delegate callbacks to closures for TerminalSession. +private class TerminalSessionDelegateProxy: NSObject, LocalProcessTerminalViewDelegate { + var onTerminated: (() -> Void)? + var onTitleChanged: ((String) -> Void)? + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) { + DispatchQueue.main.async { self.onTitleChanged?(title) } + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + + func processTerminated(source: TerminalView, exitCode: Int32?) { + DispatchQueue.main.async { self.onTerminated?() } + } +} + +/// Manages terminal session lifecycle. Lives on AppState to persist across all views. +@MainActor +@Observable +final class TerminalManager { + var sessions: [TerminalSession] = [] + var activeSessionId: UUID? + + private var sessionCounter = 0 + + var activeSession: TerminalSession? { + guard let id = activeSessionId else { return nil } + return sessions.first { $0.id == id } + } + + @discardableResult + func createSession(projectPath: String?) -> TerminalSession { + sessionCounter += 1 + let session = TerminalSession( + title: "Terminal \(sessionCounter)", + projectPath: projectPath, + onTerminated: { [weak self] id in + self?.sessions.first { $0.id == id }?.markTerminated() + }, + onTitleChanged: { [weak self] id, newTitle in + self?.sessions.first { $0.id == id }?.title = newTitle + } + ) + sessions.append(session) + activeSessionId = session.id + return session + } + + func closeSession(_ id: UUID) { + sessions.first { $0.id == id }?.terminate() + sessions.removeAll { $0.id == id } + if activeSessionId == id { + activeSessionId = sessions.last?.id + } + } + + func closeAllSessions() { + sessions.forEach { $0.terminate() } + sessions.removeAll() + activeSessionId = nil + sessionCounter = 0 + } +} diff --git a/src/managers/asc/ASCBuildsManager.swift b/src/managers/asc/ASCBuildsManager.swift new file mode 100644 index 0000000..7cd952c --- /dev/null +++ b/src/managers/asc/ASCBuildsManager.swift @@ -0,0 +1,24 @@ +import Foundation + +// MARK: - Builds Manager +// Extension containing builds-related functionality for ASCManager + +extension ASCManager { + // MARK: - Beta Feedback + + func refreshBetaFeedback(buildId: String) async { + guard let service else { return } + guard !buildId.isEmpty else { return } + + loadingFeedbackBuildIds.insert(buildId) + defer { loadingFeedbackBuildIds.remove(buildId) } + + do { + betaFeedback[buildId] = try await service.fetchBetaFeedback(buildId: buildId) + } catch { + // Feedback may not be available for all apps; non-fatal. + betaFeedback[buildId] = [] + } + } +} + diff --git a/src/managers/asc/ASCDetailsManager.swift b/src/managers/asc/ASCDetailsManager.swift new file mode 100644 index 0000000..d619bc1 --- /dev/null +++ b/src/managers/asc/ASCDetailsManager.swift @@ -0,0 +1,81 @@ +import Foundation + +// MARK: - App Details Manager +// Extension containing app details-related functionality for ASCManager + +extension ASCManager { + // MARK: - App Info Updates + + func updateAppInfoField(_ field: String, value: String) async { + guard let service else { return } + writeError = nil + + // Fields that live on different ASC resources: + // - copyright → appStoreVersions (PATCH /v1/appStoreVersions/{id}) + // - contentRightsDeclaration → apps (PATCH /v1/apps/{id}) + // - primaryCategory, subcategories → appInfos relationships (PATCH /v1/appInfos/{id}) + if field == "copyright" { + guard let versionId = appStoreVersions.first?.id else { return } + do { + try await service.patchVersion(id: versionId, fields: [field: value]) + // Re-fetch versions so submissionReadiness picks up the new copyright + if let appId = app?.id { + appStoreVersions = try await service.fetchAppStoreVersions(appId: appId) + } + } catch { + writeError = error.localizedDescription + } + } else if field == "contentRightsDeclaration" { + guard let appId = app?.id else { return } + do { + try await service.patchApp(id: appId, fields: [field: value]) + // Refetch app to reflect the change + app = try await service.fetchApp(bundleId: app?.bundleId ?? "") + } catch { + writeError = error.localizedDescription + } + } else if let infoId = appInfo?.id { + do { + try await service.patchAppInfo(id: infoId, fields: [field: value]) + appInfo = try? await service.fetchAppInfo(appId: app?.id ?? "") + } catch { + writeError = error.localizedDescription + } + } + } + + func updatePrivacyPolicyUrl(_ url: String) async { + await updateAppInfoLocalizationField("privacyPolicyUrl", value: url) + } + + /// Update a field on appInfoLocalizations (name, subtitle, privacyPolicyUrl) + func updateAppInfoLocalizationField(_ field: String, value: String, locale: String? = nil) async { + let targetLocale = locale ?? activeStoreListingLocale() + guard let targetLocale else { + writeError = "No app info localization selected." + return + } + + await updateStoreListingFields( + versionFields: [:], + appInfoFields: [field: value], + locale: targetLocale + ) + } + + // MARK: - Age Rating + + func updateAgeRating(_ attributes: [String: Any]) async { + guard let service else { return } + guard let id = ageRatingDeclaration?.id else { return } + writeError = nil + do { + try await service.patchAgeRating(id: id, attributes: attributes) + if let infoId = appInfo?.id { + ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) + } + } catch { + writeError = error.localizedDescription + } + } +} diff --git a/src/managers/asc/ASCIrisManager.swift b/src/managers/asc/ASCIrisManager.swift new file mode 100644 index 0000000..5be07c2 --- /dev/null +++ b/src/managers/asc/ASCIrisManager.swift @@ -0,0 +1,379 @@ +import Foundation +import Security + +// MARK: - Iris Session (Apple ID cookie-based auth for internal APIs) + +extension ASCManager { + // MARK: - Iris Session (Apple ID auth for rejection feedback) + // TODO - move iris session logic to ASCIrisManager.swift if possible + // TODO - don't do logging in production + private func irisLog(_ msg: String) { + let logPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz/iris-debug.log") + let ts = ISO8601DateFormatter().string(from: Date()) + let line = "[\(ts)] \(msg)\n" + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: logPath.path) { + if let handle = try? FileHandle(forWritingTo: logPath) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + } else { + let dir = logPath.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? data.write(to: logPath) + } + } + } + + func refreshSubmissionFeedbackIfNeeded() { + guard let appId = app?.id else { return } + + let rejectedVersion = appStoreVersions.first(where: { + $0.attributes.appStoreState == "REJECTED" + }) + let pendingVersion = appStoreVersions.first(where: { + let state = $0.attributes.appStoreState ?? "" + return state != "READY_FOR_SALE" && state != "REMOVED_FROM_SALE" + && state != "DEVELOPER_REMOVED_FROM_SALE" && !state.isEmpty + }) + + guard let version = rejectedVersion ?? pendingVersion else { + cachedFeedback = nil + rebuildSubmissionHistory(appId: appId) + return + } + + loadCachedFeedback(appId: appId, versionString: version.attributes.versionString) + loadIrisSession() + if irisSessionState == .valid { + Task { await fetchRejectionFeedback() } + } + } + + /// Loads cached feedback from disk for the given rejected version. No auth needed. + func loadCachedFeedback(appId: String, versionString: String) { + irisLog("ASCManager.loadCachedFeedback: appId=\(appId) version=\(versionString)") + if let cached = IrisFeedbackCache.load(appId: appId, versionString: versionString) { + cachedFeedback = cached + irisLog("ASCManager.loadCachedFeedback: loaded \(cached.reasons.count) reasons, \(cached.messages.count) messages, fetched \(cached.fetchedAt)") + } else { + irisLog("ASCManager.loadCachedFeedback: no cache found") + cachedFeedback = nil + } + rebuildSubmissionHistory(appId: appId) + } + + func fetchRejectionFeedback() async { + irisLog("ASCManager.fetchRejectionFeedback: irisService=\(irisService != nil), appId=\(app?.id ?? "nil")") + guard let irisService, let appId = app?.id else { + irisLog("ASCManager.fetchRejectionFeedback: guard failed, returning") + return + } + + // Determine version string for cache + let rejectedVersion = appStoreVersions.first(where: { + $0.attributes.appStoreState == "REJECTED" + })?.attributes.versionString + + isLoadingIrisFeedback = true + irisFeedbackError = nil + + do { + let threads = try await irisService.fetchResolutionCenterThreads(appId: appId) + irisLog("ASCManager.fetchRejectionFeedback: got \(threads.count) threads") + resolutionCenterThreads = threads + + if let latestThread = threads.first { + irisLog("ASCManager.fetchRejectionFeedback: fetching messages+rejections for thread \(latestThread.id)") + let result = try await irisService.fetchMessagesAndRejections(threadId: latestThread.id) + rejectionMessages = result.messages + rejectionReasons = result.rejections + irisLog("ASCManager.fetchRejectionFeedback: got \(rejectionMessages.count) messages, \(rejectionReasons.count) rejections") + + // Write cache + if let version = rejectedVersion { + let cache = buildFeedbackCache(appId: appId, versionString: version) + do { + try cache.save() + cachedFeedback = cache + irisLog("ASCManager.fetchRejectionFeedback: cache saved for \(version)") + } catch { + irisLog("ASCManager.fetchRejectionFeedback: cache save failed: \(error)") + } + } + } else { + irisLog("ASCManager.fetchRejectionFeedback: no threads found") + rejectionMessages = [] + rejectionReasons = [] + } + } catch let error as IrisError { + irisLog("ASCManager.fetchRejectionFeedback: IrisError: \(error)") + if case .sessionExpired = error { + irisSessionState = .expired + irisSession = nil + self.irisService = nil + } else { + irisFeedbackError = error.localizedDescription + } + } catch { + irisLog("ASCManager.fetchRejectionFeedback: error: \(error)") + irisFeedbackError = error.localizedDescription + } + + isLoadingIrisFeedback = false + rebuildSubmissionHistory(appId: appId) + irisLog("ASCManager.fetchRejectionFeedback: done") + } + + func loadIrisSession() { + irisLog("ASCManager.loadIrisSession: starting") + guard let loaded = IrisSession.load() else { + irisLog("ASCManager.loadIrisSession: no session file found") + irisSessionState = .noSession + irisSession = nil + irisService = nil + return + } + // No time-based expiry — we trust the session until a 401 proves otherwise + irisLog("ASCManager.loadIrisSession: loaded session with \(loaded.cookies.count) cookies, capturedAt=\(loaded.capturedAt)") + do { + try Self.storeWebSessionToKeychain(loaded) + } catch { + irisLog("ASCManager.loadIrisSession: asc-web-session backfill FAILED: \(error)") + } + irisSession = loaded + irisService = IrisService(session: loaded) + irisSessionState = .valid + irisLog("ASCManager.loadIrisSession: session valid, irisService created") + } + + func requestWebAuthForMCP() async -> IrisSession? { + pendingWebAuthContinuation?.resume(returning: nil) + irisFeedbackError = nil + showAppleIDLogin = true + return await withCheckedContinuation { continuation in + pendingWebAuthContinuation = continuation + } + } + + func cancelPendingWebAuth() { + showAppleIDLogin = false + pendingWebAuthContinuation?.resume(returning: nil) + pendingWebAuthContinuation = nil + } + + func setIrisSession(_ session: IrisSession) { + irisLog("ASCManager.setIrisSession: \(session.cookies.count) cookies") + do { + try session.save() + irisLog("ASCManager.setIrisSession: saved to native keychain") + } catch { + irisLog("ASCManager.setIrisSession: save FAILED: \(error)") + irisFeedbackError = "Failed to save session: \(error.localizedDescription)" + showAppleIDLogin = false + pendingWebAuthContinuation?.resume(returning: nil) + pendingWebAuthContinuation = nil + return + } + + // Also write the shared web session store (keychain + synced session file). + // If that write fails during an MCP-triggered login, keep the native session + // but fail the MCP request instead of reporting a false success. + do { + try Self.storeWebSessionToKeychain(session) + } catch { + irisLog("ASCManager.setIrisSession: asc-web-session save FAILED: \(error)") + irisFeedbackError = "Failed to save ASC web session: \(error.localizedDescription)" + if let continuation = pendingWebAuthContinuation { + pendingWebAuthContinuation = nil + continuation.resume(returning: nil) + } + } + + irisSession = session + irisService = IrisService(session: session) + irisSessionState = .valid + irisLog("ASCManager.setIrisSession: state set to .valid") + showAppleIDLogin = false + + // Notify MCP tool if it triggered this login + if let continuation = pendingWebAuthContinuation { + pendingWebAuthContinuation = nil + continuation.resume(returning: session) + } + } + + func clearIrisSession() { + irisLog("ASCManager.clearIrisSession") + let currentSession = irisSession + IrisSession.delete() + Self.deleteWebSessionFromKeychain(email: currentSession?.email) + irisSession = nil + irisService = nil + irisSessionState = .noSession + resolutionCenterThreads = [] + rejectionMessages = [] + rejectionReasons = [] + if let appId = app?.id { + rebuildSubmissionHistory(appId: appId) + } + } +} + +struct IrisSession: Codable, Sendable { + var cookies: [IrisCookie] + var email: String? + var capturedAt: Date + + struct IrisCookie: Codable, Sendable { + let name: String + let value: String + let domain: String + let path: String + } + + private static let keychainService = "dev.blitz.iris-session" + private static let keychainAccount = "iris-cookies" + + static func load() -> IrisSession? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return try? JSONDecoder().decode(IrisSession.self, from: data) + } + + func save() throws { + let data = try JSONEncoder().encode(self) + // Delete any existing item first + Self.delete() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: "IrisSession", code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "Failed to save session to Keychain (status: \(status))"]) + } + } + + static func delete() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - Iris API Response Models + +struct IrisResolutionCenterThread: Decodable, Identifiable { + let id: String + let attributes: Attributes + + struct Attributes: Decodable { + let state: String? + let createdDate: String? + } +} + +struct IrisResolutionCenterMessage: Decodable, Identifiable { + let id: String + let attributes: Attributes + + struct Attributes: Decodable { + let messageBody: String? + let createdDate: String? + } +} + +struct IrisReviewRejection: Decodable, Identifiable { + let id: String + let attributes: Attributes + + struct Attributes: Decodable { + let reasons: [Reason]? + } + + struct Reason: Decodable { + let reasonSection: String? + let reasonDescription: String? + let reasonCode: String? + } +} + +// MARK: - Iris Feedback Cache + +struct IrisFeedbackCache: Codable { + let appId: String + let versionString: String + let fetchedAt: Date + let messages: [CachedMessage] + let reasons: [CachedReason] + + struct CachedMessage: Codable { + let body: String + let date: String? + } + + struct CachedReason: Codable { + let section: String + let description: String + let code: String + } + + // MARK: - Persistence + + func save() throws { + let url = Self.cacheURL(appId: appId, versionString: versionString) + let dir = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + try data.write(to: url, options: .atomic) + } + + static func load(appId: String, versionString: String) -> IrisFeedbackCache? { + let url = cacheURL(appId: appId, versionString: versionString) + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) + } + + static func loadAll(appId: String) -> [IrisFeedbackCache] { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz/iris-cache/\(appId)") + guard let urls = try? FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + return urls + .filter { $0.pathExtension == "json" } + .compactMap { url in + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) + } + .sorted { $0.fetchedAt > $1.fetchedAt } + } + + private static func cacheURL(appId: String, versionString: String) -> URL { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".blitz/iris-cache/\(appId)/\(versionString).json") + } +} diff --git a/src/managers/asc/ASCManager.swift b/src/managers/asc/ASCManager.swift new file mode 100644 index 0000000..5272781 --- /dev/null +++ b/src/managers/asc/ASCManager.swift @@ -0,0 +1,156 @@ +import Foundation + +@MainActor +@Observable +final class ASCManager { + nonisolated init() {} + + // Credentials & service + var credentials: ASCCredentials? + var service: AppStoreConnectService? + + // App + var app: ASCApp? + + // Loading / error state + var isLoadingCredentials = false + var credentialsError: String? + var isLoadingApp = false + // Bumped after saving credentials so gated ASC tabs rerun their initial load task + // once the credential form disappears and the app lookup has completed. + var credentialActivationRevision = 0 + + // Per-tab data + var appStoreVersions: [ASCAppStoreVersion] = [] + var localizations: [ASCVersionLocalization] = [] + var selectedStoreListingLocale: String? + var appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] = [:] + var screenshotSetsByLocale: [String: [ASCScreenshotSet]] = [:] + var screenshotsByLocale: [String: [String: [ASCScreenshot]]] = [:] + var selectedScreenshotsLocale: String? + var customerReviews: [ASCCustomerReview] = [] + var builds: [ASCBuild] = [] + var betaGroups: [ASCBetaGroup] = [] + var betaLocalizations: [ASCBetaLocalization] = [] + var betaFeedback: [String: [ASCBetaFeedback]] = [:] // keyed by build.id + var selectedBuildId: String? + + // Monetization data + var inAppPurchases: [ASCInAppPurchase] = [] + var subscriptionGroups: [ASCSubscriptionGroup] = [] + var subscriptionsPerGroup: [String: [ASCSubscription]] = [:] // groupId -> subs + var appPricePoints: [ASCPricePoint] = [] // USA price tiers for the app + var currentAppPricePointId: String? + var scheduledAppPricePointId: String? + var scheduledAppPriceEffectiveDate: String? + + // Creation progress (survives tab switches) + var createProgress: Double = 0 + var createProgressMessage: String = "" + var isCreating = false + internal var createTask: Task? + + // New data for submission flow + var appInfo: ASCAppInfo? + var appInfoLocalization: ASCAppInfoLocalization? + var ageRatingDeclaration: ASCAgeRatingDeclaration? + var reviewDetail: ASCReviewDetail? + var pendingCredentialValues: [String: String]? // Pre-fill values for ASC credential form (from MCP) + var pendingFormValues: [String: [String: String]] = [:] // tab -> field -> value (for MCP pre-fill) + var pendingFormVersion: Int = 0 // Incremented when pendingFormValues changes; views watch this + var pendingCreateValues: [String: String]? // Pre-fill values for IAP/subscription create forms (from MCP) + var showSubmitPreview = false + var isSubmitting = false + var submissionError: String? + var writeError: String? // Inline error for write operations (does not replace tab content) + + // Review submission history (for rejection tracking) + var reviewSubmissions: [ASCReviewSubmission] = [] + var reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] + var latestSubmissionItems: [ASCReviewSubmissionItem] = [] + var submissionHistoryEvents: [ASCSubmissionHistoryEvent] = [] + + // Iris (Apple ID session) - rejection feedback from internal API + enum IrisSessionState { case unknown, noSession, valid, expired } + var irisSession: IrisSession? + var irisService: IrisService? + var irisSessionState: IrisSessionState = .unknown + var isLoadingIrisFeedback = false + var irisFeedbackError: String? + var showAppleIDLogin = false + var pendingWebAuthContinuation: CheckedContinuation? + var attachedSubmissionItemIDs: Set = [] // IAP/subscription IDs attached via iris API + var resolutionCenterThreads: [IrisResolutionCenterThread] = [] + var rejectionMessages: [IrisResolutionCenterMessage] = [] + var rejectionReasons: [IrisReviewRejection] = [] + var cachedFeedback: IrisFeedbackCache? // loaded from disk, survives session expiry + + // App icon status (set externally; nil = not checked / missing) + var appIconStatus: String? + + // Monetization status (set after monetization check or setPriceFree success) + var monetizationStatus: String? + + // Build pipeline progress (driven by MCPExecutor) + enum BuildPipelinePhase: String { + case idle + case signingSetup = "Setting up signing…" + case archiving = "Archiving…" + case exporting = "Exporting IPA…" + case uploading = "Uploading to App Store Connect…" + case processing = "Processing build…" + } + var buildPipelinePhase: BuildPipelinePhase = .idle + var buildPipelineMessage: String = "" + + // Screenshot track state per device type + var trackSlots: [String: [TrackSlot?]] = [:] // keyed by locale + ascDisplayType, 10-element arrays + var savedTrackState: [String: [TrackSlot?]] = [:] // snapshot after last load/save + var localScreenshotAssets: [LocalScreenshotAsset] = [] + var isSyncing = false + + // Per-tab loading / error + var isLoadingTab: [AppTab: Bool] = [:] + var tabError: [AppTab: String] = [:] + + // Shared internal state used by feature extensions. + var loadedTabs: Set = [] + var tabLoadedAt: [AppTab: Date] = [:] + var projectSnapshots: [String: ProjectSnapshot] = [:] + var tabHydrationTasks: [AppTab: Task] = [:] + var overviewReadinessLoadingFields: Set = [] + var loadingFeedbackBuildIds: Set = [] + var loadedProjectId: String? + + // Submission readiness labels used by both the view model and background hydration. + static let overviewLocalizationFieldLabels: Set = [ + "App Name", + "Description", + "Keywords", + "Support URL" + ] + static let overviewVersionFieldLabels: Set = ["Copyright"] + static let overviewAppInfoFieldLabels: Set = ["Primary Category"] + static let overviewMetadataFieldLabels: Set = [ + "Privacy Policy URL", + "Age Rating" + ] + static let overviewReviewFieldLabels: Set = [ + "Review Contact First Name", + "Review Contact Last Name", + "Review Contact Email", + "Review Contact Phone", + "Demo Account Name", + "Demo Account Password" + ] + static let overviewBuildFieldLabels: Set = ["Build"] + static let overviewPricingFieldLabels: Set = [ + "Pricing", + "In-App Purchases & Subscriptions" + ] + static let overviewScreenshotFieldLabels: Set = [ + "Mac Screenshots", + "iPhone Screenshots", + "iPad Screenshots" + ] +} diff --git a/src/managers/asc/ASCMonetizationManager.swift b/src/managers/asc/ASCMonetizationManager.swift new file mode 100644 index 0000000..239469e --- /dev/null +++ b/src/managers/asc/ASCMonetizationManager.swift @@ -0,0 +1,533 @@ +import Foundation + +// MARK: - Monetization Manager +// Extension containing monetization-related functionality for ASCManager + +extension ASCManager { + // MARK: - IAP Creation + + func createIAP(name: String, productId: String, type: String, displayName: String, description: String?, price: String, screenshotPath: String? = nil) { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + isCreating = true + createProgress = 0 + createProgressMessage = "Creating in-app purchase…" + + createTask = Task { [weak self] in + guard let self else { return } + do { + createProgress = 0.05 + let iap = try await service.createInAppPurchase( + appId: appId, name: name, productId: productId, inAppPurchaseType: type + ) + + createProgressMessage = "Setting localization…" + createProgress = 0.15 + try await service.localizeInAppPurchase( + iapId: iap.id, locale: "en-US", name: displayName, description: description + ) + + createProgressMessage = "Setting availability…" + createProgress = 0.3 + let territories = try await service.fetchAllTerritories() + try await service.createIAPAvailability(iapId: iap.id, territoryIds: territories) + + createProgress = 0.5 + if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { + createProgressMessage = "Setting price…" + let points = try await service.fetchInAppPurchasePricePoints(iapId: iap.id) + if let match = points.first(where: { + guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } + return abs(cpVal - priceVal) < 0.001 + }) { + try await service.setInAppPurchasePrice(iapId: iap.id, pricePointId: match.id) + } + } + + createProgress = 0.7 + if let path = screenshotPath { + createProgressMessage = "Uploading screenshot…" + try await service.uploadIAPReviewScreenshot(iapId: iap.id, path: path) + } + + createProgressMessage = "Waiting for status update…" + createProgress = 0.9 + try await pollRefreshIAPs(service: service, appId: appId) + createProgress = 1.0 + } catch { + writeError = error.localizedDescription + } + isCreating = false + createProgress = 0 + createProgressMessage = "" + } + } + + // MARK: - IAP Updates + + func updateIAP(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + // Patch IAP attributes (name, reviewNote) + var attrs: [String: Any] = [:] + if let name { attrs["name"] = name } + if let reviewNote { attrs["reviewNote"] = reviewNote } + if !attrs.isEmpty { + try await service.patchInAppPurchase(iapId: id, attrs: attrs) + } + // Patch localization (displayName, description) + if displayName != nil || description != nil { + let locs = try await service.fetchIAPLocalizations(iapId: id) + if let loc = locs.first { + var fields: [String: String] = [:] + if let displayName { fields["name"] = displayName } + if let description { fields["description"] = description } + try await service.patchIAPLocalization(locId: loc.id, fields: fields) + } + } + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - IAP Deletion + + func deleteIAP(id: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.deleteInAppPurchase(iapId: id) + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - IAP Screenshots + + func uploadIAPScreenshot(iapId: String, path: String) async { + guard let service else { return } + writeError = nil + do { + try await service.uploadIAPReviewScreenshot(iapId: iapId, path: path) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - IAP Submission + + func submitIAPForReview(id: String) async -> Bool { + guard let service else { return false } + guard let appId = app?.id else { return false } + writeError = nil + do { + try await service.submitIAPForReview(iapId: id) + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + return true + } catch { + let msg = error.localizedDescription + if msg.contains("FIRST_IAP") || msg.contains("first In-App Purchase") || msg.contains("first in-app purchase") { + writeError = "FIRST_SUBMISSION:" + msg + } else { + writeError = msg + } + return false + } + } + + // MARK: - Subscription Creation + + func createSubscription(groupName: String, name: String, productId: String, displayName: String, description: String?, duration: String, price: String, screenshotPath: String? = nil) { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + isCreating = true + createProgress = 0 + createProgressMessage = "Setting up group…" + + createTask = Task { [weak self] in + guard let self else { return } + do { + createProgress = 0.03 + let group: ASCSubscriptionGroup + if let existing = subscriptionGroups.first(where: { $0.attributes.referenceName == groupName }) { + let groupLocs = try await service.fetchSubscriptionGroupLocalizations(groupId: existing.id) + if groupLocs.isEmpty { + try await service.localizeSubscriptionGroup(groupId: existing.id, locale: "en-US", name: groupName) + } + group = existing + } else { + group = try await service.createSubscriptionGroup(appId: appId, referenceName: groupName) + try await service.localizeSubscriptionGroup(groupId: group.id, locale: "en-US", name: groupName) + } + + createProgressMessage = "Creating subscription…" + createProgress = 0.08 + let sub = try await service.createSubscription( + groupId: group.id, name: name, productId: productId, subscriptionPeriod: duration + ) + + createProgressMessage = "Setting localization…" + createProgress = 0.12 + try await service.localizeSubscription( + subscriptionId: sub.id, locale: "en-US", name: displayName, description: description + ) + + createProgressMessage = "Setting availability…" + createProgress = 0.16 + let territories = try await service.fetchAllTerritories() + try await service.createSubscriptionAvailability(subscriptionId: sub.id, territoryIds: territories) + + createProgress = 0.2 + if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { + let points = try await service.fetchSubscriptionPricePoints(subscriptionId: sub.id) + if let match = points.first(where: { + guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } + return abs(cpVal - priceVal) < 0.001 + }) { + // Pricing loop: 0.2 → 0.8 (bulk of the time) + createProgressMessage = "Setting prices (0/175)…" + try await service.setSubscriptionPrice(subscriptionId: sub.id, pricePointId: match.id) { done, total in + Task { @MainActor [weak self] in + self?.createProgressMessage = "Setting prices (\(done)/\(total))…" + self?.createProgress = 0.2 + 0.6 * (Double(done) / Double(total)) + } + } + } + } + + createProgress = 0.85 + if let path = screenshotPath { + createProgressMessage = "Uploading screenshot…" + try await service.uploadSubscriptionReviewScreenshot(subscriptionId: sub.id, path: path) + } + + createProgressMessage = "Waiting for status update…" + createProgress = 0.9 + try await pollRefreshSubscriptions(service: service, appId: appId) + createProgress = 1.0 + } catch { + writeError = error.localizedDescription + } + isCreating = false + createProgress = 0 + createProgressMessage = "" + } + } + + // MARK: - Subscription Updates + + func updateSubscription(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + var attrs: [String: Any] = [:] + if let name { attrs["name"] = name } + if let reviewNote { attrs["reviewNote"] = reviewNote } + if !attrs.isEmpty { + try await service.patchSubscription(subscriptionId: id, attrs: attrs) + } + if displayName != nil || description != nil { + let locs = try await service.fetchSubscriptionLocalizations(subscriptionId: id) + if let loc = locs.first { + var fields: [String: String] = [:] + if let displayName { fields["name"] = displayName } + if let description { fields["description"] = description } + try await service.patchSubscriptionLocalization(locId: loc.id, fields: fields) + } + } + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Deletion + + func deleteSubscription(id: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.deleteSubscription(subscriptionId: id) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + } catch { + writeError = error.localizedDescription + } + } + + func deleteSubscriptionGroup(id: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.deleteSubscriptionGroup(groupId: id) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + subscriptionsPerGroup.removeValue(forKey: id) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Screenshots + + func uploadSubscriptionScreenshot(subscriptionId: String, path: String) async { + guard let service else { return } + writeError = nil + do { + try await service.uploadSubscriptionReviewScreenshot(subscriptionId: subscriptionId, path: path) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Localization + + func updateSubscriptionGroupLocalization(groupId: String, name: String) async { + guard let service else { return } + writeError = nil + do { + let locs = try await service.fetchSubscriptionGroupLocalizations(groupId: groupId) + if let loc = locs.first { + try await service.patchSubscriptionGroupLocalization(locId: loc.id, name: name) + } else { + try await service.localizeSubscriptionGroup(groupId: groupId, locale: "en-US", name: name) + } + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Submission + + func submitSubscriptionForReview(id: String) async -> Bool { + guard let service else { return false } + guard let appId = app?.id else { return false } + writeError = nil + do { + try await service.submitSubscriptionForReview(subscriptionId: id) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + return true + } catch { + let msg = error.localizedDescription + if msg.contains("FIRST_SUBSCRIPTION") || msg.contains("first subscription") { + writeError = "FIRST_SUBMISSION:" + msg + } else { + writeError = msg + } + return false + } + } + + // MARK: - Pricing + + func setAppPrice(pricePointId: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.setAppPrice(appId: appId, pricePointId: pricePointId) + try await service.ensureAppAvailability(appId: appId) + currentAppPricePointId = pricePointId + scheduledAppPricePointId = nil + scheduledAppPriceEffectiveDate = nil + monetizationStatus = isFreePricePoint(pricePointId) ? "Free" : "Configured" + } catch { + writeError = error.localizedDescription + } + } + + func setScheduledAppPrice(currentPricePointId: String, futurePricePointId: String, effectiveDate: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.setScheduledAppPrice( + appId: appId, + currentPricePointId: currentPricePointId, + futurePricePointId: futurePricePointId, + effectiveDate: effectiveDate + ) + self.currentAppPricePointId = currentPricePointId + scheduledAppPricePointId = futurePricePointId + scheduledAppPriceEffectiveDate = effectiveDate + monetizationStatus = "Configured" + } catch { + writeError = error.localizedDescription + } + } + + func setPriceFree() async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.setPriceFree(appId: appId) + try await service.ensureAppAvailability(appId: appId) + currentAppPricePointId = freeAppPricePointId + scheduledAppPricePointId = nil + scheduledAppPriceEffectiveDate = nil + monetizationStatus = "Free" + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Refresh + + func refreshMonetization() async { + guard let service else { return } + guard let appId = app?.id else { return } + do { + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for group in subscriptionGroups { + subscriptionsPerGroup[group.id] = try await service.fetchSubscriptionsInGroup(groupId: group.id) + } + } catch { + writeError = error.localizedDescription + } + } + + func refreshAttachedSubmissionItemIDs() async { + guard let appId = app?.id else { + attachedSubmissionItemIDs = [] + return + } + guard let cookieHeader = ascWebSessionCookieHeader() else { + attachedSubmissionItemIDs = [] + return + } + + let subscriptionURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/subscriptionGroups?include=subscriptions&limit=300&fields%5Bsubscriptions%5D=productId,name,state,submitWithNextAppStoreVersion" + let iapURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/inAppPurchasesV2?limit=300&fields%5BinAppPurchases%5D=productId,name,state,submitWithNextAppStoreVersion" + + let attachedSubscriptions = await fetchAttachedSubmissionItemIDs(urlString: subscriptionURL, cookieHeader: cookieHeader) + let attachedIAPs = await fetchAttachedSubmissionItemIDs(urlString: iapURL, cookieHeader: cookieHeader) + attachedSubmissionItemIDs = attachedSubscriptions.union(attachedIAPs) + } + + // MARK: - Polling + + private func pollRefreshIAPs(service: AppStoreConnectService, appId: String) async throws { + for _ in 0..<5 { + try await Task.sleep(for: .seconds(1)) + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + let allResolved = inAppPurchases.allSatisfy { $0.attributes.state != "MISSING_METADATA" } + if allResolved { return } + } + } + + private func pollRefreshSubscriptions(service: AppStoreConnectService, appId: String) async throws { + for _ in 0..<5 { + try await Task.sleep(for: .seconds(1)) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + let allResolved = subscriptionsPerGroup.values.joined().allSatisfy { $0.attributes.state != "MISSING_METADATA" } + if allResolved { return } + } + } + + // MARK: - Pricing State + + var freeAppPricePointId: String? { + appPricePoints.first(where: { + let price = $0.attributes.customerPrice ?? "0" + return price == "0" || price == "0.0" || price == "0.00" + })?.id + } + + func applyAppPricingState(_ state: ASCAppPricingState) { + currentAppPricePointId = state.currentPricePointId + scheduledAppPricePointId = state.scheduledPricePointId + scheduledAppPriceEffectiveDate = state.scheduledEffectiveDate + + if let currentPricePointId = currentAppPricePointId { + let isCurrentlyFree = isFreePricePoint(currentPricePointId) + monetizationStatus = (isCurrentlyFree && state.scheduledPricePointId == nil) ? "Free" : "Configured" + } else if state.scheduledPricePointId != nil { + monetizationStatus = "Configured" + } else { + monetizationStatus = nil + } + } + + func isFreePricePoint(_ pricePointId: String) -> Bool { + appPricePoints.contains(where: { + guard $0.id == pricePointId else { return false } + let price = $0.attributes.customerPrice ?? "0" + return price == "0" || price == "0.0" || price == "0.00" + }) + } + + // MARK: - Web Session Helpers (for IAP attachment queries) + + func ascWebSessionCookieHeader() -> String? { + guard let storeData = Self.readKeychainItem(service: "asc-web-session", account: "asc:web-session:store"), + let store = try? JSONSerialization.jsonObject(with: storeData) as? [String: Any], + let lastKey = store["last_key"] as? String, + let sessions = store["sessions"] as? [String: Any], + let sessionDict = sessions[lastKey] as? [String: Any], + let cookies = sessionDict["cookies"] as? [String: [[String: Any]]] else { + return nil + } + + let cookieHeader = cookies.values.flatMap { $0 }.compactMap { cookie -> String? in + guard let name = cookie["name"] as? String, + let value = cookie["value"] as? String, + !name.isEmpty else { return nil } + return name.hasPrefix("DES") ? "\(name)=\"\(value)\"" : "\(name)=\(value)" + }.joined(separator: "; ") + + return cookieHeader.isEmpty ? nil : cookieHeader + } + + func fetchAttachedSubmissionItemIDs(urlString: String, cookieHeader: String) async -> Set { + guard let url = URL(string: urlString) else { return [] } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue("https://appstoreconnect.apple.com", forHTTPHeaderField: "Origin") + request.setValue("https://appstoreconnect.apple.com/", forHTTPHeaderField: "Referer") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.timeoutInterval = 10 + + guard let (data, response) = try? await URLSession.shared.data(for: request), + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [] + } + + let resources = (json["data"] as? [[String: Any]] ?? []) + + (json["included"] as? [[String: Any]] ?? []) + + return Set(resources.compactMap { item in + guard let attrs = item["attributes"] as? [String: Any], + let id = item["id"] as? String, + let submitWithNext = attrs["submitWithNextAppStoreVersion"] as? Bool, + submitWithNext else { return nil } + return id + }) + } +} + diff --git a/src/managers/asc/ASCProjectLifecycleManager.swift b/src/managers/asc/ASCProjectLifecycleManager.swift new file mode 100644 index 0000000..f7cecc4 --- /dev/null +++ b/src/managers/asc/ASCProjectLifecycleManager.swift @@ -0,0 +1,378 @@ +import Foundation + +extension ASCManager { + struct ProjectSnapshot { + let projectId: String + let app: ASCApp? + let appStoreVersions: [ASCAppStoreVersion] + let localizations: [ASCVersionLocalization] + let appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] + let screenshotSetsByLocale: [String: [ASCScreenshotSet]] + let screenshotsByLocale: [String: [String: [ASCScreenshot]]] + let customerReviews: [ASCCustomerReview] + let builds: [ASCBuild] + let betaGroups: [ASCBetaGroup] + let betaLocalizations: [ASCBetaLocalization] + let betaFeedback: [String: [ASCBetaFeedback]] + let selectedBuildId: String? + let inAppPurchases: [ASCInAppPurchase] + let subscriptionGroups: [ASCSubscriptionGroup] + let subscriptionsPerGroup: [String: [ASCSubscription]] + let appPricePoints: [ASCPricePoint] + let currentAppPricePointId: String? + let scheduledAppPricePointId: String? + let scheduledAppPriceEffectiveDate: String? + let appInfo: ASCAppInfo? + let appInfoLocalization: ASCAppInfoLocalization? + let ageRatingDeclaration: ASCAgeRatingDeclaration? + let reviewDetail: ASCReviewDetail? + let reviewSubmissions: [ASCReviewSubmission] + let reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] + let latestSubmissionItems: [ASCReviewSubmissionItem] + let submissionHistoryEvents: [ASCSubmissionHistoryEvent] + let attachedSubmissionItemIDs: Set + let resolutionCenterThreads: [IrisResolutionCenterThread] + let rejectionMessages: [IrisResolutionCenterMessage] + let rejectionReasons: [IrisReviewRejection] + let cachedFeedback: IrisFeedbackCache? + let trackSlots: [String: [TrackSlot?]] + let savedTrackState: [String: [TrackSlot?]] + let localScreenshotAssets: [LocalScreenshotAsset] + let appIconStatus: String? + let monetizationStatus: String? + let loadedTabs: Set + let tabLoadedAt: [AppTab: Date] + + @MainActor + init(manager: ASCManager, projectId: String) { + self.projectId = projectId + app = manager.app + appStoreVersions = manager.appStoreVersions + localizations = manager.localizations + appInfoLocalizationsByLocale = manager.appInfoLocalizationsByLocale + screenshotSetsByLocale = manager.screenshotSetsByLocale + screenshotsByLocale = manager.screenshotsByLocale + customerReviews = manager.customerReviews + builds = manager.builds + betaGroups = manager.betaGroups + betaLocalizations = manager.betaLocalizations + betaFeedback = manager.betaFeedback + selectedBuildId = manager.selectedBuildId + inAppPurchases = manager.inAppPurchases + subscriptionGroups = manager.subscriptionGroups + subscriptionsPerGroup = manager.subscriptionsPerGroup + appPricePoints = manager.appPricePoints + currentAppPricePointId = manager.currentAppPricePointId + scheduledAppPricePointId = manager.scheduledAppPricePointId + scheduledAppPriceEffectiveDate = manager.scheduledAppPriceEffectiveDate + appInfo = manager.appInfo + appInfoLocalization = manager.appInfoLocalization + ageRatingDeclaration = manager.ageRatingDeclaration + reviewDetail = manager.reviewDetail + reviewSubmissions = manager.reviewSubmissions + reviewSubmissionItemsBySubmissionId = manager.reviewSubmissionItemsBySubmissionId + latestSubmissionItems = manager.latestSubmissionItems + submissionHistoryEvents = manager.submissionHistoryEvents + attachedSubmissionItemIDs = manager.attachedSubmissionItemIDs + resolutionCenterThreads = manager.resolutionCenterThreads + rejectionMessages = manager.rejectionMessages + rejectionReasons = manager.rejectionReasons + cachedFeedback = manager.cachedFeedback + trackSlots = manager.trackSlots + savedTrackState = manager.savedTrackState + localScreenshotAssets = manager.localScreenshotAssets + appIconStatus = manager.appIconStatus + monetizationStatus = manager.monetizationStatus + let cachedLoadedTabs = manager.loadedTabs.intersection(Self.cachedProjectTabs) + loadedTabs = cachedLoadedTabs + tabLoadedAt = manager.tabLoadedAt.filter { cachedLoadedTabs.contains($0.key) } + } + + @MainActor + func apply(to manager: ASCManager) { + manager.app = app + manager.appStoreVersions = appStoreVersions + manager.localizations = localizations + manager.appInfoLocalizationsByLocale = appInfoLocalizationsByLocale + manager.screenshotSetsByLocale = screenshotSetsByLocale + manager.screenshotsByLocale = screenshotsByLocale + manager.customerReviews = customerReviews + manager.builds = builds + manager.betaGroups = betaGroups + manager.betaLocalizations = betaLocalizations + manager.betaFeedback = betaFeedback + manager.selectedBuildId = selectedBuildId + manager.inAppPurchases = inAppPurchases + manager.subscriptionGroups = subscriptionGroups + manager.subscriptionsPerGroup = subscriptionsPerGroup + manager.appPricePoints = appPricePoints + manager.currentAppPricePointId = currentAppPricePointId + manager.scheduledAppPricePointId = scheduledAppPricePointId + manager.scheduledAppPriceEffectiveDate = scheduledAppPriceEffectiveDate + manager.appInfo = appInfo + manager.appInfoLocalization = appInfoLocalization + manager.ageRatingDeclaration = ageRatingDeclaration + manager.reviewDetail = reviewDetail + manager.reviewSubmissions = reviewSubmissions + manager.reviewSubmissionItemsBySubmissionId = reviewSubmissionItemsBySubmissionId + manager.latestSubmissionItems = latestSubmissionItems + manager.submissionHistoryEvents = submissionHistoryEvents + manager.attachedSubmissionItemIDs = attachedSubmissionItemIDs + manager.resolutionCenterThreads = resolutionCenterThreads + manager.rejectionMessages = rejectionMessages + manager.rejectionReasons = rejectionReasons + manager.cachedFeedback = cachedFeedback + manager.trackSlots = trackSlots + manager.savedTrackState = savedTrackState + manager.localScreenshotAssets = localScreenshotAssets + manager.appIconStatus = appIconStatus + manager.monetizationStatus = monetizationStatus + manager.loadedTabs = loadedTabs + manager.tabLoadedAt = tabLoadedAt + manager.loadedProjectId = projectId + manager.tabError = [:] + manager.isLoadingTab = [:] + manager.isLoadingApp = false + manager.isLoadingIrisFeedback = false + manager.loadingFeedbackBuildIds = [] + manager.irisFeedbackError = nil + manager.writeError = nil + manager.submissionError = nil + manager.overviewReadinessLoadingFields = [] + } + + private static let cachedProjectTabs: Set = [ + .app, + .storeListing, + .screenshots, + .appDetails, + .monetization, + .review, + .analytics, + .reviews, + .builds, + .groups, + .betaInfo, + .feedback, + ] + } + + private static let projectCacheFreshness: TimeInterval = 120 + + func checkAppIcon(projectId: String) { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + let iconDir = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon" + let icon1024 = "\(iconDir)/icon_1024.png" + + if fm.fileExists(atPath: icon1024) { + appIconStatus = "1024px" + return + } + + let projectDir = "\(home)/.blitz/projects/\(projectId)" + let xcassetsPattern = ["ios", "macos", "."] + for subdir in xcassetsPattern { + let searchDir = subdir == "." ? projectDir : "\(projectDir)/\(subdir)" + guard let enumerator = fm.enumerator(atPath: searchDir) else { continue } + while let file = enumerator.nextObject() as? String { + guard file.hasSuffix("AppIcon.appiconset/Contents.json") else { continue } + let contentsPath = "\(searchDir)/\(file)" + guard let data = fm.contents(atPath: contentsPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let images = json["images"] as? [[String: Any]] else { + continue + } + if images.contains(where: { $0["filename"] != nil }) { + appIconStatus = "Configured" + return + } + } + } + + appIconStatus = nil + } + + func prepareForProjectSwitch(to projectId: String) { + cacheCurrentProjectSnapshot() + resetProjectData(preserveCredentials: true) + + if let snapshot = projectSnapshots[projectId] { + snapshot.apply(to: self) + } else { + loadedProjectId = projectId + } + } + + func loadStoredCredentialsIfNeeded() { + guard credentials == nil || service == nil else { return } + let creds = ASCCredentials.load() + try? ASCAuthBridge().syncCredentials(creds) + Self.syncWebSessionFileFromKeychain() + credentials = creds + service = creds.map { AppStoreConnectService(credentials: $0) } + } + + func loadCredentials(for projectId: String, bundleId: String?) async { + let needsCredentialReload = credentials == nil || service == nil + let shouldSkip = loadedProjectId == projectId + && !needsCredentialReload + && (bundleId == nil || app != nil) + guard !shouldSkip else { return } + + credentialsError = nil + + if needsCredentialReload { + isLoadingCredentials = true + let creds = ASCCredentials.load() + try? ASCAuthBridge().syncCredentials(creds) + Self.syncWebSessionFileFromKeychain() + credentials = creds + isLoadingCredentials = false + service = creds.map { AppStoreConnectService(credentials: $0) } + } + + loadedProjectId = projectId + refreshAppIconStatusIfNeeded(for: projectId) + + if let bundleId, !bundleId.isEmpty, credentials != nil, app == nil { + await fetchApp(bundleId: bundleId) + } + } + + func clearForProjectSwitch() { + resetProjectData(preserveCredentials: false) + } + + func saveCredentials(_ creds: ASCCredentials, projectId: String, bundleId: String?) async throws { + try creds.save() + credentials = creds + service = AppStoreConnectService(credentials: creds) + credentialsError = nil + cancelBackgroundHydrationTasks() + loadedTabs = [] + tabLoadedAt = [:] + tabError = [:] + isLoadingTab = [:] + loadingFeedbackBuildIds = [] + + if let bundleId, !bundleId.isEmpty { + await fetchApp(bundleId: bundleId) + } + + credentialActivationRevision += 1 + } + + func deleteCredentials() { + ASCCredentials.delete() + let projectId = loadedProjectId + clearForProjectSwitch() + loadedProjectId = projectId + } + + func refreshAppIconStatusIfNeeded(for projectId: String?) { + guard let projectId, !projectId.isEmpty else { return } + checkAppIcon(projectId: projectId) + } + + func cancelBackgroundHydration(for tab: AppTab) { + tabHydrationTasks[tab]?.cancel() + tabHydrationTasks.removeValue(forKey: tab) + } + + func cancelBackgroundHydrationTasks() { + for task in tabHydrationTasks.values { + task.cancel() + } + tabHydrationTasks.removeAll() + } + + func startBackgroundHydration(for tab: AppTab, operation: @escaping @MainActor () async -> Void) { + cancelBackgroundHydration(for: tab) + tabHydrationTasks[tab] = Task { + await operation() + } + } + + func resetProjectData(preserveCredentials: Bool) { + cancelBackgroundHydrationTasks() + overviewReadinessLoadingFields = [] + loadingFeedbackBuildIds = [] + + if !preserveCredentials { + credentials = nil + service = nil + } + + app = nil + isLoadingCredentials = false + credentialsError = nil + isLoadingApp = false + appStoreVersions = [] + localizations = [] + selectedStoreListingLocale = nil + appInfoLocalizationsByLocale = [:] + screenshotSetsByLocale = [:] + screenshotsByLocale = [:] + selectedScreenshotsLocale = nil + customerReviews = [] + builds = [] + betaGroups = [] + betaLocalizations = [] + betaFeedback = [:] + selectedBuildId = nil + inAppPurchases = [] + subscriptionGroups = [] + subscriptionsPerGroup = [:] + appPricePoints = [] + currentAppPricePointId = nil + scheduledAppPricePointId = nil + scheduledAppPriceEffectiveDate = nil + appInfo = nil + appInfoLocalization = nil + ageRatingDeclaration = nil + reviewDetail = nil + pendingFormValues = [:] + showSubmitPreview = false + isSubmitting = false + submissionError = nil + writeError = nil + reviewSubmissions = [] + reviewSubmissionItemsBySubmissionId = [:] + latestSubmissionItems = [] + submissionHistoryEvents = [] + appIconStatus = nil + monetizationStatus = nil + attachedSubmissionItemIDs = [] + trackSlots = [:] + savedTrackState = [:] + localScreenshotAssets = [] + isLoadingTab = [:] + tabError = [:] + loadedTabs = [] + tabLoadedAt = [:] + if !preserveCredentials { + loadedProjectId = nil + } + + resolutionCenterThreads = [] + rejectionMessages = [] + rejectionReasons = [] + cachedFeedback = nil + isLoadingIrisFeedback = false + irisFeedbackError = nil + cancelPendingWebAuth() + } + + func cacheCurrentProjectSnapshot() { + guard let projectId = loadedProjectId else { return } + guard app != nil || !loadedTabs.isEmpty else { return } + projectSnapshots[projectId] = ProjectSnapshot(manager: self, projectId: projectId) + } + + func shouldRefreshTabCache(_ tab: AppTab) -> Bool { + guard loadedTabs.contains(tab) else { return true } + guard let loadedAt = tabLoadedAt[tab] else { return true } + return Date().timeIntervalSince(loadedAt) > Self.projectCacheFreshness + } +} diff --git a/src/managers/asc/ASCReleaseManager.swift b/src/managers/asc/ASCReleaseManager.swift new file mode 100644 index 0000000..941354f --- /dev/null +++ b/src/managers/asc/ASCReleaseManager.swift @@ -0,0 +1,83 @@ +import Foundation + +// MARK: - Release Manager +// Extension containing release-related functionality for ASCManager + +extension ASCManager { + // MARK: - Build Attachment + + func attachBuild(buildId: String) async { + guard let service else { return } + guard let versionId = pendingVersionId else { + writeError = "No app store version found to attach build to." + return + } + writeError = nil + do { + try await service.attachBuild(versionId: versionId, buildId: buildId) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Submission + + func submitForReview(attachBuildId: String? = nil) async { + guard let service else { return } + guard let appId = app?.id, let versionId = pendingVersionId else { return } + isSubmitting = true + submissionError = nil + do { + // Attach build if specified + if let buildId = attachBuildId { + try await service.attachBuild(versionId: versionId, buildId: buildId) + } + try await service.submitForReview(appId: appId, versionId: versionId) + isSubmitting = false + await refreshTabData(.app) + } catch { + isSubmitting = false + submissionError = error.localizedDescription + } + } + + // MARK: - Localization Flushing + + func flushPendingLocalizations() async { + guard let service else { return } + let appInfoLocFieldNames: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] + for (tab, fields) in pendingFormValues { + if tab == "storeListing" { + let locale = activeStoreListingLocale() + var versionLocFields: [String: String] = [:] + var infoLocFields: [String: String] = [:] + for (field, value) in fields { + if appInfoLocFieldNames.contains(field) { + let apiField = (field == "title") ? "name" : field + infoLocFields[apiField] = value + } else { + versionLocFields[field] = value + } + } + if !versionLocFields.isEmpty, let locId = storeListingLocalization(locale: locale)?.id { + try? await service.patchLocalization(id: locId, fields: versionLocFields) + } + if !infoLocFields.isEmpty, let infoLocId = appInfoLocalizationForLocale(locale)?.id { + try? await service.patchAppInfoLocalization(id: infoLocId, fields: infoLocFields) + } + } + } + pendingFormValues = [:] + } + + // MARK: - Computed Properties + + /// The pending version ID (not live / not removed). + var pendingVersionId: String? { + appStoreVersions.first { + let s = $0.attributes.appStoreState ?? "" + return s != "READY_FOR_SALE" && s != "REMOVED_FROM_SALE" + && s != "DEVELOPER_REMOVED_FROM_SALE" && !s.isEmpty + }?.id ?? appStoreVersions.first?.id + } +} diff --git a/src/managers/asc/ASCReviewManager.swift b/src/managers/asc/ASCReviewManager.swift new file mode 100644 index 0000000..fc6af99 --- /dev/null +++ b/src/managers/asc/ASCReviewManager.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Review Manager +// Extension containing review-related functionality for ASCManager + +extension ASCManager { + // MARK: - Review Contact Updates + + func updateReviewContact(_ attributes: [String: Any]) async { + guard let service else { return } + guard let versionId = appStoreVersions.first?.id else { return } + writeError = nil + do { + try await service.createOrPatchReviewDetail(versionId: versionId, attributes: attributes) + reviewDetail = try? await service.fetchReviewDetail(versionId: versionId) + } catch { + writeError = error.localizedDescription + } + } +} + diff --git a/src/managers/asc/ASCScreenshotsManager.swift b/src/managers/asc/ASCScreenshotsManager.swift new file mode 100644 index 0000000..6c9fe87 --- /dev/null +++ b/src/managers/asc/ASCScreenshotsManager.swift @@ -0,0 +1,424 @@ +import Foundation +import AppKit +import ImageIO + +// MARK: - Screenshots Manager +// Extension containing screenshot-related functionality for ASCManager + +extension ASCManager { + // MARK: - Screenshot Data + + func screenshotTrackKey(displayType: String, locale: String) -> String { + "\(locale)::\(displayType)" + } + + func hasTrackState(displayType: String, locale: String = "en-US") -> Bool { + trackSlots[screenshotTrackKey(displayType: displayType, locale: locale)] != nil + } + + func trackSlotsForDisplayType(_ displayType: String, locale: String = "en-US") -> [TrackSlot?] { + trackSlots[screenshotTrackKey(displayType: displayType, locale: locale)] + ?? Array(repeating: nil, count: 10) + } + + func savedTrackStateForDisplayType(_ displayType: String, locale: String = "en-US") -> [TrackSlot?] { + savedTrackState[screenshotTrackKey(displayType: displayType, locale: locale)] + ?? Array(repeating: nil, count: 10) + } + + func loadScreenshots(locale: String, force: Bool = false) async { + guard let service else { return } + + if !force, + screenshotSetsByLocale[locale] != nil, + screenshotsByLocale[locale] != nil { + return + } + + await ensureScreenshotLocalizationsLoaded(service: service) + guard let loc = localizations.first(where: { $0.attributes.locale == locale }) + ?? localizations.first else { + return + } + + do { + let (fetchedSets, fetchedScreenshots) = try await fetchScreenshotData( + localizationId: loc.id, + service: service + ) + updateScreenshotCache(locale: loc.attributes.locale, sets: fetchedSets, screenshots: fetchedScreenshots) + } catch { + print("Failed to load screenshots for locale \(loc.attributes.locale): \(error)") + } + } + + func screenshotSetsForLocale(_ locale: String) -> [ASCScreenshotSet] { + screenshotSetsByLocale[locale] ?? [] + } + + func screenshotsForLocale(_ locale: String) -> [String: [ASCScreenshot]] { + screenshotsByLocale[locale] ?? [:] + } + + func updateScreenshotCache( + locale: String, + sets: [ASCScreenshotSet], + screenshots: [String: [ASCScreenshot]] + ) { + screenshotSetsByLocale[locale] = sets + screenshotsByLocale[locale] = screenshots + for displayType in trackDisplayTypes(for: locale) { + loadTrackFromASC(displayType: displayType, locale: locale) + } + } + + private func trackDisplayTypes(for locale: String) -> Set { + var displayTypes = Set(screenshotSetsForLocale(locale).map(\.attributes.screenshotDisplayType)) + for key in Set(trackSlots.keys).union(savedTrackState.keys) { + if let displayType = displayType(fromTrackKey: key, locale: locale) { + displayTypes.insert(displayType) + } + } + return displayTypes + } + + private func displayType(fromTrackKey key: String, locale: String) -> String? { + let prefix = "\(locale)::" + guard key.hasPrefix(prefix) else { return nil } + return String(key.dropFirst(prefix.count)) + } + + func fetchScreenshotData( + localizationId: String, + service: AppStoreConnectService + ) async throws -> ([ASCScreenshotSet], [String: [ASCScreenshot]]) { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: localizationId) + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + return (fetchedSets, Dictionary(uniqueKeysWithValues: fetchedScreenshots)) + } + + func buildTrackSlotsFromASC( + displayType: String, + locale: String, + previousSlots: [TrackSlot?] = [] + ) -> [TrackSlot?] { + let set = screenshotSetsForLocale(locale).first { $0.attributes.screenshotDisplayType == displayType } + var slots: [TrackSlot?] = Array(repeating: nil, count: 10) + if let set, let shots = screenshotsForLocale(locale)[set.id] { + for (i, shot) in shots.prefix(10).enumerated() { + var localImage: NSImage? = nil + if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { + localImage = prev.localImage + } + slots[i] = TrackSlot( + id: shot.id, + localPath: nil, + localImage: localImage, + ascScreenshot: shot, + isFromASC: true + ) + } + } + return slots + } + + func invalidateStaleTrackSnapshots(displayType: String, locale: String) { + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + let latestRemoteSlots = buildTrackSlotsFromASC( + displayType: displayType, + locale: locale, + previousSlots: trackSlots[trackKey] ?? [] + ) + let validRemoteIDs = Set(latestRemoteSlots.compactMap { slot -> String? in + guard let slot, slot.isFromASC else { return nil } + return slot.id + }) + + let current = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let sanitizedCurrent = sanitizeTrackSlots(current, validRemoteScreenshotIDs: validRemoteIDs) + + trackSlots[trackKey] = sanitizedCurrent + savedTrackState[trackKey] = latestRemoteSlots + } + + private func sanitizeTrackSlots( + _ slots: [TrackSlot?], + validRemoteScreenshotIDs: Set + ) -> [TrackSlot?] { + let sanitized = slots.compactMap { slot -> TrackSlot? in + guard let slot else { return nil } + if slot.isFromASC && !validRemoteScreenshotIDs.contains(slot.id) { + return nil + } + return slot + } + + var padded = sanitized.map(Optional.some) + if padded.count > 10 { + padded = Array(padded.prefix(10)) + } + while padded.count < 10 { + padded.append(nil) + } + return padded + } + + private func ensureScreenshotLocalizationsLoaded(service: AppStoreConnectService) async { + if localizations.isEmpty, let versionId = appStoreVersions.first?.id { + localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] + } + if localizations.isEmpty, let appId = app?.id { + let versions = (try? await service.fetchAppStoreVersions(appId: appId)) ?? [] + appStoreVersions = versions + if let versionId = versions.first?.id { + localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] + } + } + } + + // MARK: - Track Synchronization + + func syncTrackToASC(displayType: String, locale: String) async { + guard let service else { + writeError = "ASC service not configured" + return + } + + isSyncing = true + defer { isSyncing = false } + writeError = nil + + await ensureScreenshotLocalizationsLoaded(service: service) + guard let loc = localizations.first(where: { $0.attributes.locale == locale }) + ?? localizations.first else { + writeError = "No localizations found for locale '\(locale)'." + return + } + + let trackKey = screenshotTrackKey(displayType: displayType, locale: loc.attributes.locale) + + do { + // Refresh the remote baseline before diffing so stale cached ASC IDs + // don't survive server-side edits made outside Blitz. + await loadScreenshots(locale: loc.attributes.locale, force: true) + invalidateStaleTrackSnapshots(displayType: displayType, locale: loc.attributes.locale) + + let current = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let saved = savedTrackState[trackKey] ?? Array(repeating: nil, count: 10) + let savedIds = Set(saved.compactMap { $0?.id }) + let currentIds = Set(current.compactMap { $0?.id }) + let toDelete = savedIds.subtracting(currentIds) + for id in toDelete { + try await service.deleteScreenshot(screenshotId: id) + } + + let currentASCIds = current.compactMap { slot -> String? in + guard let slot, slot.isFromASC else { return nil } + return slot.id + } + let savedASCIds = saved.compactMap { slot -> String? in + guard let slot, slot.isFromASC else { return nil } + return slot.id + } + let remainingASCIds = Set(currentASCIds) + let reorderNeeded = currentASCIds != savedASCIds.filter { remainingASCIds.contains($0) } + + if reorderNeeded { + for id in currentASCIds where !toDelete.contains(id) { + try await service.deleteScreenshot(screenshotId: id) + } + } + + for slot in current { + guard let slot else { continue } + if let path = slot.localPath { + try await service.uploadScreenshot(localizationId: loc.id, path: path, displayType: displayType) + } else if reorderNeeded, slot.isFromASC, let ascShot = slot.ascScreenshot { + if let url = ascShot.imageURL, + let (data, _) = try? await URLSession.shared.data(from: url), + let fileName = ascShot.attributes.fileName { + let tmpPath = FileManager.default.temporaryDirectory.appendingPathComponent(fileName).path + try data.write(to: URL(fileURLWithPath: tmpPath)) + try await service.uploadScreenshot(localizationId: loc.id, path: tmpPath, displayType: displayType) + try? FileManager.default.removeItem(atPath: tmpPath) + } + } + } + + await loadScreenshots(locale: loc.attributes.locale, force: true) + loadTrackFromASC(displayType: displayType, locale: loc.attributes.locale, overwriteUnsaved: true) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Screenshot Deletion + + func deleteScreenshot(screenshotId: String) async throws { + guard let service else { throw ASCError.notFound("ASC service not configured") } + try await service.deleteScreenshot(screenshotId: screenshotId) + } + + // MARK: - Local Assets + + func scanLocalAssets(projectId: String) { + let dir = BlitzPaths.screenshots(projectId: projectId) + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { + localScreenshotAssets = [] + return + } + let imageExtensions: Set = ["png", "jpg", "jpeg", "webp"] + localScreenshotAssets = files + .filter { imageExtensions.contains($0.pathExtension.lowercased()) } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .compactMap { url in + var image = NSImage(contentsOf: url) + if image == nil || image!.representations.isEmpty { + if let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { + image = NSImage( + cgImage: cgImage, + size: NSSize(width: cgImage.width, height: cgImage.height) + ) + } + } + guard let image else { return nil } + return LocalScreenshotAsset(id: UUID(), url: url, image: image, fileName: url.lastPathComponent) + } + } + + // MARK: - Track Management + + @discardableResult + func addAssetToTrack( + displayType: String, + slotIndex: Int, + localPath: String, + locale: String = "en-US" + ) -> String? { + guard slotIndex >= 0 && slotIndex < 10 else { return "Invalid slot index" } + guard let image = NSImage(contentsOfFile: localPath) else { + return "Could not load image" + } + + var pixelWidth = 0 + var pixelHeight = 0 + if let rep = image.representations.first, rep.pixelsWide > 0, rep.pixelsHigh > 0 { + pixelWidth = rep.pixelsWide + pixelHeight = rep.pixelsHigh + } else if let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) { + pixelWidth = bitmap.pixelsWide + pixelHeight = bitmap.pixelsHigh + } + + if let error = Self.validateDimensions(width: pixelWidth, height: pixelHeight, displayType: displayType) { + return error + } + + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + var slots = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let slot = TrackSlot( + id: UUID().uuidString, + localPath: localPath, + localImage: image, + ascScreenshot: nil, + isFromASC: false + ) + + if slots[slotIndex] != nil { + slots.insert(slot, at: slotIndex) + slots = Array(slots.prefix(10)) + } else { + slots[slotIndex] = slot + } + + while slots.count < 10 { slots.append(nil) } + trackSlots[trackKey] = slots + return nil + } + + func removeFromTrack(displayType: String, slotIndex: Int, locale: String = "en-US") { + guard slotIndex >= 0 && slotIndex < 10 else { return } + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + var slots = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + slots.remove(at: slotIndex) + slots.append(nil) + trackSlots[trackKey] = slots + } + + func reorderTrack( + displayType: String, + fromIndex: Int, + toIndex: Int, + locale: String = "en-US" + ) { + guard fromIndex >= 0 && fromIndex < 10 && toIndex >= 0 && toIndex < 10 else { return } + guard fromIndex != toIndex else { return } + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + var slots = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let item = slots.remove(at: fromIndex) + slots.insert(item, at: toIndex) + trackSlots[trackKey] = slots + } + + // MARK: - Track Loading + + func loadTrackFromASC( + displayType: String, + locale: String = "en-US", + overwriteUnsaved: Bool = false + ) { + if !overwriteUnsaved, hasUnsavedChanges(displayType: displayType, locale: locale) { + return + } + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + let previousSlots = trackSlots[trackKey] ?? [] + let slots = buildTrackSlotsFromASC(displayType: displayType, locale: locale, previousSlots: previousSlots) + trackSlots[trackKey] = slots + savedTrackState[trackKey] = slots + } + + // MARK: - Validation + + func hasUnsavedChanges(displayType: String, locale: String = "en-US") -> Bool { + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + let current = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let saved = savedTrackState[trackKey] ?? Array(repeating: nil, count: 10) + return zip(current, saved).contains { c, s in c?.id != s?.id } + } + + /// Validate pixel dimensions for a display type. Returns nil if valid, or an error string. + static func validateDimensions(width: Int, height: Int, displayType: String) -> String? { + switch displayType { + case "APP_IPHONE_67": + let validSizes: Set = ["1290x2796", "1284x2778", "1242x2688", "1260x2736"] + if validSizes.contains("\(width)x\(height)") { return nil } + return "\(width)×\(height) — need 1290×2796, 1284×2778, 1242×2688, or 1260×2736 for iPhone" + case "APP_IPAD_PRO_3GEN_129": + if width == 2048 && height == 2732 { return nil } + return "\(width)×\(height) — need 2048×2732 for iPad" + case "APP_DESKTOP": + let valid: Set = ["1280x800", "1440x900", "2560x1600", "2880x1800"] + if valid.contains("\(width)x\(height)") { return nil } + return "\(width)×\(height) — need 1280×800, 1440×900, 2560×1600, or 2880×1800 for Mac" + default: + return nil + } + } +} diff --git a/src/managers/asc/ASCSessionStoreManager.swift b/src/managers/asc/ASCSessionStoreManager.swift new file mode 100644 index 0000000..871991f --- /dev/null +++ b/src/managers/asc/ASCSessionStoreManager.swift @@ -0,0 +1,101 @@ +import Foundation +import Security + +extension ASCManager { + private static let webSessionService = ASCWebSessionStore.keychainService + private static let webSessionAccount = ASCWebSessionStore.keychainAccount + + /// Stored in Keychain for Blitz and synced to ~/.blitz/asc-agent/web-session.json + /// so CLI skill scripts can reuse the same session. + static func storeWebSessionToKeychain(_ session: IrisSession) throws { + let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) + let data = try ASCWebSessionStore.mergedData(storing: session, into: existingData) + removeWebSessionKeychainItem() + try writeWebSessionToKeychain(data) + try ASCAuthBridge().syncWebSession(data) + } + + static func deleteWebSessionFromKeychain(email: String?) { + let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) + let updatedData: Data? + do { + updatedData = try ASCWebSessionStore.removingSession(email: email, from: existingData) + } catch { + return + } + + guard let updatedData else { + removeWebSessionKeychainItem() + return + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + ] + let status = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: updatedData] as CFDictionary + ) + if status == errSecItemNotFound { + try? writeWebSessionToKeychain(updatedData) + } + + do { + try ASCAuthBridge().syncWebSession(updatedData) + } catch { + ASCAuthBridge().removeWebSession() + } + } + + static func readKeychainItem(service: String, account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + return result as? Data + } + + static func syncWebSessionFileFromKeychain() { + guard let data = readKeychainItem(service: webSessionService, account: webSessionAccount) else { + return + } + try? ASCAuthBridge().syncWebSession(data) + } + + private static func writeWebSessionToKeychain(_ data: Data) throws { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + kSecAttrLabel as String: "ASC Web Session Store", + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError( + domain: "ASCWebSessionStore", + code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "Keychain write failed (status: \(status))"] + ) + } + } + + private static func removeWebSessionKeychainItem() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + ] + SecItemDelete(query as CFDictionary) + ASCAuthBridge().removeWebSession() + } +} diff --git a/src/managers/asc/ASCStoreListingManager.swift b/src/managers/asc/ASCStoreListingManager.swift new file mode 100644 index 0000000..e46f149 --- /dev/null +++ b/src/managers/asc/ASCStoreListingManager.swift @@ -0,0 +1,166 @@ +import Foundation + +// MARK: - Store Listing Manager +// Extension containing store listing-related functionality for ASCManager + +extension ASCManager { + // MARK: - Locale Selection + + /// Primary store-listing locale from ASC app settings, falling back to the first loaded localization. + func primaryLocalizationLocale() -> String? { + if let primaryLocale = app?.primaryLocale, + localizations.contains(where: { $0.attributes.locale == primaryLocale }) { + return primaryLocale + } + return localizations.first?.attributes.locale + } + + /// Primary version-localization record used for overview/readiness, independent of the active editor locale. + func primaryVersionLocalization(in candidates: [ASCVersionLocalization]? = nil) -> ASCVersionLocalization? { + let candidates = candidates ?? localizations + guard let primaryLocale = app?.primaryLocale else { return candidates.first } + return candidates.first(where: { $0.attributes.locale == primaryLocale }) ?? candidates.first + } + + /// Primary app-info-localization record used for overview/readiness, independent of the active editor locale. + func primaryAppInfoLocalization(in candidates: [ASCAppInfoLocalization]? = nil) -> ASCAppInfoLocalization? { + let primaryLocale = app?.primaryLocale + + if let primaryLocale, + let match = candidates?.first(where: { $0.attributes.locale == primaryLocale }) ?? appInfoLocalizationsByLocale[primaryLocale] { + return match + } + + return candidates?.first ?? appInfoLocalization + } + + /// Active store-listing locale for the UI/editor, preferring the user's selected locale when it is still valid. + func activeStoreListingLocale() -> String? { + selectedStoreListingLocale.flatMap { locale in + localizations.contains(where: { $0.attributes.locale == locale }) ? locale : nil + } ?? primaryLocalizationLocale() + } + + func storeListingLocalization(locale: String? = nil) -> ASCVersionLocalization? { + if let locale { + return localizations.first(where: { $0.attributes.locale == locale }) + } + return primaryVersionLocalization() + } + + func appInfoLocalizationForLocale(_ locale: String? = nil) -> ASCAppInfoLocalization? { + if let resolvedLocale = locale ?? activeStoreListingLocale() { + return appInfoLocalizationsByLocale[resolvedLocale] + } + return primaryAppInfoLocalization() + } + + func refreshStoreListingMetadata( + service: AppStoreConnectService, + appId: String, + preferredLocale: String? = nil + ) async throws { + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + let versions = try await versionsTask + let fetchedAppInfo = await appInfoTask ?? appInfo + + appStoreVersions = versions + appInfo = fetchedAppInfo + + let versionLocalizations: [ASCVersionLocalization] + if let latestId = versions.first?.id { + versionLocalizations = try await service.fetchLocalizations(versionId: latestId) + } else { + versionLocalizations = [] + } + + let fetchedAppInfoLocalizations: [ASCAppInfoLocalization] + if let infoId = fetchedAppInfo?.id { + fetchedAppInfoLocalizations = try await service.fetchAppInfoLocalizations(appInfoId: infoId) + } else { + fetchedAppInfoLocalizations = [] + } + + if let preferredLocale { + selectedStoreListingLocale = preferredLocale + } + + localizations = versionLocalizations + appInfoLocalizationsByLocale = Dictionary(uniqueKeysWithValues: fetchedAppInfoLocalizations.map { + ($0.attributes.locale, $0) + }) + + appInfoLocalization = primaryAppInfoLocalization(in: fetchedAppInfoLocalizations) + selectedStoreListingLocale = activeStoreListingLocale() + } + + // MARK: - Localization Updates + + private func mappedAppInfoLocalizationFields(_ fields: [String: String]) -> [String: String] { + var mapped: [String: String] = [:] + for (field, value) in fields { + mapped[field == "title" ? "name" : field] = value + } + return mapped + } + + func updateLocalizationField(_ field: String, value: String, locale: String) async { + await updateStoreListingFields( + versionFields: [field: value], + appInfoFields: [:], + locale: locale + ) + } + + func updateStoreListingFields( + versionFields: [String: String], + appInfoFields rawAppInfoFields: [String: String], + locale: String + ) async { + guard let service else { return } + let trimmedLocale = locale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLocale.isEmpty else { + writeError = "No store listing locale selected." + return + } + + writeError = nil + + do { + if !versionFields.isEmpty { + guard let locId = storeListingLocalization(locale: trimmedLocale)?.id else { + throw ASCError.notFound("Version localization for locale '\(trimmedLocale)'") + } + try await service.patchLocalization(id: locId, fields: versionFields) + } + + let appInfoFields = mappedAppInfoLocalizationFields(rawAppInfoFields) + if !appInfoFields.isEmpty { + guard let infoId = appInfo?.id else { + throw ASCError.notFound("AppInfo") + } + + if let locId = appInfoLocalizationForLocale(trimmedLocale)?.id { + try await service.patchAppInfoLocalization(id: locId, fields: appInfoFields) + } else { + _ = try await service.createAppInfoLocalization( + appInfoId: infoId, + locale: trimmedLocale, + fields: appInfoFields + ) + } + } + + guard let appId = app?.id else { return } + try await refreshStoreListingMetadata( + service: service, + appId: appId, + preferredLocale: trimmedLocale + ) + } catch { + writeError = error.localizedDescription + } + } +} diff --git a/src/managers/asc/ASCSubmissionHistoryManager.swift b/src/managers/asc/ASCSubmissionHistoryManager.swift new file mode 100644 index 0000000..53f12d4 --- /dev/null +++ b/src/managers/asc/ASCSubmissionHistoryManager.swift @@ -0,0 +1,283 @@ +import Foundation + +extension ASCManager { + func buildFeedbackCache(appId: String, versionString: String) -> IrisFeedbackCache { + let messages = rejectionMessages.map { message in + IrisFeedbackCache.CachedMessage( + body: message.attributes.messageBody.map { htmlToPlainText($0) } ?? "", + date: message.attributes.createdDate + ) + } + let reasons = rejectionReasons.flatMap { rejection in + (rejection.attributes.reasons ?? []).map { reason in + IrisFeedbackCache.CachedReason( + section: reason.reasonSection ?? "", + description: reason.reasonDescription ?? "", + code: reason.reasonCode ?? "" + ) + } + } + + return IrisFeedbackCache( + appId: appId, + versionString: versionString, + fetchedAt: Date(), + messages: messages, + reasons: reasons + ) + } + + func rebuildSubmissionHistory(appId: String) { + let cache = refreshSubmissionHistoryCache(appId: appId) + let versionSnapshots = cache.versionSnapshots + + let submissionEvents = reviewSubmissions.compactMap { submission -> ASCSubmissionHistoryEvent? in + guard let submittedAt = submission.attributes.submittedDate else { return nil } + let versionId = reviewSubmissionItemsBySubmissionId[submission.id]? + .compactMap(\.appStoreVersionId) + .first + ?? closestVersion(before: submittedAt)?.id + let resolvedVersionString = versionString(for: versionId, versionSnapshots: versionSnapshots) ?? "Unknown" + let resolvedVersionState = versionState(for: versionId, versionSnapshots: versionSnapshots) + let eventType = ASCReleaseStatus.reviewSubmissionEventType(forVersionState: resolvedVersionState) + return ASCSubmissionHistoryEvent( + id: "submission:\(submission.id)", + versionId: versionId, + versionString: resolvedVersionString, + eventType: eventType, + appleState: resolvedVersionState ?? "WAITING_FOR_REVIEW", + occurredAt: submittedAt, + source: .reviewSubmission, + accuracy: .exact, + submissionId: submission.id, + note: nil + ) + } + + var rejectionEventsByVersion: [String: ASCSubmissionHistoryEvent] = [:] + for cacheEntry in IrisFeedbackCache.loadAll(appId: appId) { + let rejectionAt = cacheEntry.messages + .compactMap(\.date) + .sorted(by: { historyDate($0) < historyDate($1) }) + .first + ?? ISO8601DateFormatter().string(from: cacheEntry.fetchedAt) + + rejectionEventsByVersion[cacheEntry.versionString] = ASCSubmissionHistoryEvent( + id: "iris:\(cacheEntry.versionString):\(rejectionAt)", + versionId: versionId(for: cacheEntry.versionString, versionSnapshots: versionSnapshots), + versionString: cacheEntry.versionString, + eventType: .rejected, + appleState: "REJECTED", + occurredAt: rejectionAt, + source: .irisFeedback, + accuracy: .derived, + submissionId: nil, + note: cacheEntry.reasons.first?.section + ) + } + + if let rejectedVersion = appStoreVersions.first(where: { $0.attributes.appStoreState == "REJECTED" }) { + let rejectionAt = resolutionCenterThreads.first?.attributes.createdDate + ?? rejectionMessages.compactMap(\.attributes.createdDate) + .sorted(by: { historyDate($0) < historyDate($1) }) + .first + if let rejectionAt { + rejectionEventsByVersion[rejectedVersion.attributes.versionString] = ASCSubmissionHistoryEvent( + id: "iris-live:\(rejectedVersion.id):\(rejectionAt)", + versionId: rejectedVersion.id, + versionString: rejectedVersion.attributes.versionString, + eventType: .rejected, + appleState: "REJECTED", + occurredAt: rejectionAt, + source: .irisFeedback, + accuracy: .derived, + submissionId: nil, + note: rejectionReasons.first?.attributes.reasons?.first?.reasonSection + ) + } + } + + let durableEvents = submissionEvents + + Array(rejectionEventsByVersion.values) + + cache.transitionEvents + + let coveredEventKeys = Set( + durableEvents.map { + historyCoverageKey(versionId: $0.versionId, versionString: $0.versionString, eventType: $0.eventType) + } + ) + + let fallbackEvents = appStoreVersions.compactMap { version -> ASCSubmissionHistoryEvent? in + let state = version.attributes.appStoreState ?? "" + guard let eventType = historyEventType(forVersionState: state) else { return nil } + + let coverageKey = historyCoverageKey( + versionId: version.id, + versionString: version.attributes.versionString, + eventType: eventType + ) + guard !coveredEventKeys.contains(coverageKey) else { return nil } + + let occurredAt = version.attributes.createdDate + ?? cache.versionSnapshots[version.id]?.lastSeenAt + ?? historyNowString() + + return ASCSubmissionHistoryEvent( + id: "version:\(version.id):\(state)", + versionId: version.id, + versionString: version.attributes.versionString, + eventType: eventType, + appleState: state, + occurredAt: occurredAt, + source: .currentVersion, + accuracy: .derived, + submissionId: nil, + note: nil + ) + } + + submissionHistoryEvents = (durableEvents + fallbackEvents) + .sorted { lhs, rhs in + historyDate(lhs.occurredAt) > historyDate(rhs.occurredAt) + } + } + + func refreshReviewSubmissionData(appId: String, service: AppStoreConnectService) async { + let submissions = (try? await service.fetchReviewSubmissions(appId: appId)) ?? [] + reviewSubmissions = submissions + + guard !submissions.isEmpty else { + reviewSubmissionItemsBySubmissionId = [:] + latestSubmissionItems = [] + return + } + + var itemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] + await withTaskGroup(of: (String, [ASCReviewSubmissionItem]).self) { group in + for submission in submissions { + group.addTask { + let items = (try? await service.fetchReviewSubmissionItems(submissionId: submission.id)) ?? [] + return (submission.id, items) + } + } + + for await (submissionId, items) in group { + itemsBySubmissionId[submissionId] = items + } + } + + reviewSubmissionItemsBySubmissionId = itemsBySubmissionId + latestSubmissionItems = itemsBySubmissionId[submissions.first?.id ?? ""] ?? [] + } + + private func historyNowString() -> String { + ISO8601DateFormatter().string(from: Date()) + } + + private func historyDate(_ iso: String?) -> Date { + guard let iso else { return .distantPast } + let formatterWithFractionalSeconds = ISO8601DateFormatter() + formatterWithFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let formatter = ISO8601DateFormatter() + return formatterWithFractionalSeconds.date(from: iso) ?? formatter.date(from: iso) ?? .distantPast + } + + private func closestVersion(before dateString: String) -> ASCAppStoreVersion? { + let submittedDate = historyDate(dateString) + return appStoreVersions + .filter { historyDate($0.attributes.createdDate) <= submittedDate } + .max { historyDate($0.attributes.createdDate) < historyDate($1.attributes.createdDate) } + ?? ASCReleaseStatus.sortedVersionsByRecency(appStoreVersions).first + } + + private func historyEventType(forVersionState state: String) -> ASCSubmissionHistoryEventType? { + ASCReleaseStatus.submissionHistoryEventType(forVersionState: state) + } + + private func historyCoverageKey( + versionId: String?, + versionString: String, + eventType: ASCSubmissionHistoryEventType + ) -> String { + "\(versionId ?? "version:\(versionString)")::\(eventType.rawValue)" + } + + private func versionString( + for versionId: String?, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + guard let versionId else { return nil } + if let version = appStoreVersions.first(where: { $0.id == versionId }) { + return version.attributes.versionString + } + return versionSnapshots[versionId]?.versionString + } + + private func versionId( + for versionString: String, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + if let version = appStoreVersions.first(where: { $0.attributes.versionString == versionString }) { + return version.id + } + return versionSnapshots.values.first(where: { $0.versionString == versionString })?.versionId + } + + private func versionState( + for versionId: String?, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + guard let versionId else { return nil } + if let version = appStoreVersions.first(where: { $0.id == versionId }) { + return version.attributes.appStoreState + } + return versionSnapshots[versionId]?.lastKnownState + } + + private func refreshSubmissionHistoryCache(appId: String) -> ASCSubmissionHistoryCache { + var cache = ASCSubmissionHistoryCache.load(appId: appId) + let now = historyNowString() + + for version in appStoreVersions { + let state = version.attributes.appStoreState ?? "" + guard !state.isEmpty else { continue } + + if var snapshot = cache.versionSnapshots[version.id] { + snapshot.versionString = version.attributes.versionString + if snapshot.lastKnownState != state, + let eventType = historyEventType(forVersionState: state) { + cache.transitionEvents.append( + ASCSubmissionHistoryEvent( + id: "ledger:\(version.id):\(state):\(now)", + versionId: version.id, + versionString: version.attributes.versionString, + eventType: eventType, + appleState: state, + occurredAt: now, + source: .transitionLedger, + accuracy: .firstSeen, + submissionId: nil, + note: nil + ) + ) + snapshot.lastKnownState = state + snapshot.lastSeenAt = now + } else { + snapshot.lastSeenAt = now + } + cache.versionSnapshots[version.id] = snapshot + } else { + cache.versionSnapshots[version.id] = .init( + versionId: version.id, + versionString: version.attributes.versionString, + lastKnownState: state, + lastSeenAt: now + ) + } + } + + cache.transitionEvents.sort { historyDate($0.occurredAt) > historyDate($1.occurredAt) } + try? cache.save() + return cache + } +} diff --git a/src/managers/asc/ASCSubmissionReadinessManager.swift b/src/managers/asc/ASCSubmissionReadinessManager.swift new file mode 100644 index 0000000..0bb061a --- /dev/null +++ b/src/managers/asc/ASCSubmissionReadinessManager.swift @@ -0,0 +1,143 @@ +import Foundation + +extension ASCManager { + /// ASC returns an age rating declaration object with nil fields by default. + /// Submitting with nil fields later causes a 409. + private var ageRatingIsConfigured: Bool { + guard let attributes = ageRatingDeclaration?.attributes else { return false } + return attributes.alcoholTobaccoOrDrugUseOrReferences != nil + && attributes.violenceCartoonOrFantasy != nil + && attributes.violenceRealistic != nil + && attributes.sexualContentOrNudity != nil + && attributes.sexualContentGraphicAndNudity != nil + && attributes.profanityOrCrudeHumor != nil + && attributes.gamblingSimulated != nil + } + + var submissionReadiness: SubmissionReadiness { + let localization = primaryVersionLocalization() + let appInfoLocalization = primaryAppInfoLocalization() + let review = reviewDetail + let demoRequired = review?.attributes.demoAccountRequired == true + let version = appStoreVersions.first + let readinessLocale = localization?.attributes.locale + let readinessScreenshotSets = readinessLocale.map(screenshotSetsForLocale) ?? [] + let readinessScreenshots = readinessLocale.map(screenshotsForLocale) ?? [:] + + let macScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } + let isMacApp = macScreenshots != nil + let iphoneScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPHONE_67" } + let ipadScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPAD_PRO_3GEN_129" } + + let privacyUrl: String? = app.map { + "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/privacy" + } + + func readinessField( + label: String, + value: String?, + required: Bool = true, + actionUrl: String? = nil, + hint: String? = nil + ) -> SubmissionReadiness.FieldStatus { + SubmissionReadiness.FieldStatus( + label: label, + value: value, + isLoading: overviewReadinessLoadingFields.contains(label) && (value == nil || value?.isEmpty == true), + required: required, + actionUrl: actionUrl, + hint: hint + ) + } + + var fields: [SubmissionReadiness.FieldStatus] = [ + readinessField(label: "App Name", value: appInfoLocalization?.attributes.name ?? localization?.attributes.title), + readinessField(label: "Description", value: localization?.attributes.description), + readinessField(label: "Keywords", value: localization?.attributes.keywords), + readinessField(label: "Support URL", value: localization?.attributes.supportUrl), + readinessField(label: "Privacy Policy URL", value: appInfoLocalization?.attributes.privacyPolicyUrl), + readinessField(label: "Copyright", value: version?.attributes.copyright), + readinessField(label: "Content Rights", value: app?.contentRightsDeclaration), + readinessField(label: "Primary Category", value: appInfo?.primaryCategoryId), + readinessField(label: "Age Rating", value: ageRatingIsConfigured ? "Configured" : nil), + readinessField(label: "Pricing", value: monetizationStatus), + readinessField(label: "Review Contact First Name", value: review?.attributes.contactFirstName), + readinessField(label: "Review Contact Last Name", value: review?.attributes.contactLastName), + readinessField(label: "Review Contact Email", value: review?.attributes.contactEmail), + readinessField(label: "Review Contact Phone", value: review?.attributes.contactPhone), + ] + + if demoRequired { + fields.append(readinessField(label: "Demo Account Name", value: review?.attributes.demoAccountName)) + fields.append(readinessField(label: "Demo Account Password", value: review?.attributes.demoAccountPassword)) + } + + fields.append(readinessField(label: "App Icon", value: appIconStatus)) + + func validCount(for set: ASCScreenshotSet?) -> Int { + guard let set else { return 0 } + if let screenshots = readinessScreenshots[set.id] { + return screenshots.filter { !$0.hasError }.count + } + return set.attributes.screenshotCount ?? 0 + } + + if isMacApp { + let macCount = validCount(for: macScreenshots) + fields.append(readinessField(label: "Mac Screenshots", value: macCount > 0 ? "\(macCount) screenshot(s)" : nil)) + } else { + let iphoneCount = validCount(for: iphoneScreenshots) + let ipadCount = validCount(for: ipadScreenshots) + fields.append(readinessField(label: "iPhone Screenshots", value: iphoneCount > 0 ? "\(iphoneCount) screenshot(s)" : nil)) + fields.append(readinessField(label: "iPad Screenshots", value: ipadCount > 0 ? "\(ipadCount) screenshot(s)" : nil)) + } + + fields.append(contentsOf: [ + readinessField(label: "Privacy Nutrition Labels", value: nil, required: false, actionUrl: privacyUrl), + readinessField(label: "Build", value: builds.first?.attributes.version), + ]) + + let approvedStates: Set = [ + "READY_FOR_SALE", + "REMOVED_FROM_SALE", + "DEVELOPER_REMOVED_FROM_SALE", + "REPLACED_WITH_NEW_VERSION", + "PROCESSING_FOR_APP_STORE" + ] + let hasApprovedVersion = appStoreVersions.contains { + approvedStates.contains($0.attributes.appStoreState ?? "") + } + let isFirstVersion = !hasApprovedVersion + if isFirstVersion { + let readyIAPs = inAppPurchases.filter { + $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) + } + let readySubscriptions = subscriptionsPerGroup.values.flatMap { $0 } + .filter { + $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) + } + let readyCount = readyIAPs.count + readySubscriptions.count + if readyCount > 0 { + let names = (readyIAPs.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id } + + readySubscriptions.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id }) + .joined(separator: ", ") + let iapUrl: String? = app.map { + "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/ios/version/inflight" + } + fields.append(readinessField( + label: "In-App Purchases & Subscriptions", + value: nil, + required: true, + actionUrl: iapUrl, + hint: "\(readyCount) item(s) in Ready to Submit state (\(names)) must be attached to this version before submission. " + + "Use the asc-iap-attach skill to attach them via the iris API (asc web session). " + + "The public API does not support first-time IAP/subscription attachment - " + + "run: asc web auth login, then POST to /iris/v1/subscriptionSubmissions or /iris/v1/inAppPurchaseSubmissions " + + "with submitWithNextAppStoreVersion:true for each item." + )) + } + } + + return SubmissionReadiness(fields: fields) + } +} diff --git a/src/managers/asc/ASCTabDataManager.swift b/src/managers/asc/ASCTabDataManager.swift new file mode 100644 index 0000000..3f7e3f3 --- /dev/null +++ b/src/managers/asc/ASCTabDataManager.swift @@ -0,0 +1,483 @@ +import Foundation + +extension ASCManager { + func ensureTabData(_ tab: AppTab) async { + guard credentials != nil else { return } + + if loadedTabs.contains(tab) { + if shouldRefreshTabCache(tab) { + await refreshTabData(tab) + } + return + } + + await fetchTabData(tab) + } + + func hasLoadedTabData(_ tab: AppTab) -> Bool { + loadedTabs.contains(tab) + } + + func isTabLoading(_ tab: AppTab) -> Bool { + isLoadingTab[tab] == true || isLoadingApp + } + + func isFeedbackLoading(for buildId: String?) -> Bool { + guard let buildId, !buildId.isEmpty else { + return isLoadingTab[.feedback] == true + } + return loadingFeedbackBuildIds.contains(buildId) + } + + @discardableResult + func fetchApp(bundleId: String, exactName: String? = nil) async -> Bool { + guard let service else { return false } + isLoadingApp = true + do { + let fetched = try await service.fetchApp(bundleId: bundleId, exactName: exactName) + app = fetched + credentialsError = nil + isLoadingApp = false + return true + } catch { + app = nil + credentialsError = error.localizedDescription + isLoadingApp = false + return false + } + } + + func fetchTabData(_ tab: AppTab) async { + guard let service else { return } + guard credentials != nil else { return } + guard !loadedTabs.contains(tab) else { return } + guard isLoadingTab[tab] != true else { return } + + cancelBackgroundHydration(for: tab) + isLoadingTab[tab] = true + tabError.removeValue(forKey: tab) + + do { + try await loadData(for: tab, service: service) + isLoadingTab[tab] = false + loadedTabs.insert(tab) + tabLoadedAt[tab] = Date() + } catch { + isLoadingTab[tab] = false + tabError[tab] = error.localizedDescription + } + } + + /// Called after bundle ID setup completes and the app is confirmed in ASC. + /// Clears all tab errors and forces data to be re-fetched. + func resetTabState() { + cancelBackgroundHydrationTasks() + tabError.removeAll() + loadedTabs.removeAll() + tabLoadedAt.removeAll() + loadingFeedbackBuildIds = [] + } + + func refreshTabData(_ tab: AppTab) async { + guard let service else { return } + guard credentials != nil else { return } + + let hadLoadedData = loadedTabs.contains(tab) + cancelBackgroundHydration(for: tab) + isLoadingTab[tab] = true + tabError.removeValue(forKey: tab) + + do { + try await loadData(for: tab, service: service) + isLoadingTab[tab] = false + loadedTabs.insert(tab) + tabLoadedAt[tab] = Date() + } catch { + isLoadingTab[tab] = false + if !hadLoadedData { + loadedTabs.remove(tab) + tabLoadedAt.removeValue(forKey: tab) + } + tabError[tab] = error.localizedDescription + } + } + + func refreshSubmissionReadinessData() async { + await refreshMonetization() + await refreshAttachedSubmissionItemIDs() + } + + func startOverviewReadinessLoading(_ fields: Set) { + overviewReadinessLoadingFields = fields + } + + func finishOverviewReadinessLoading(_ fields: Set) { + overviewReadinessLoadingFields.subtract(fields) + } + + func isCurrentProject(_ projectId: String?) -> Bool { + guard let projectId else { return false } + return loadedProjectId == projectId + } + + private struct OverviewPrimaryLocalization { + /// ASC localization record ID used in follow-up API calls like `fetchScreenshotSets(localizationId:)`. + let localizationId: String + /// Locale code used when storing overview data in Blitz's locale-keyed caches. + let locale: String + + init?(_ localization: ASCVersionLocalization?) { + guard let localization else { return nil } + localizationId = localization.id + locale = localization.attributes.locale + } + } + + private func hydrateOverviewSecondaryData( + projectId: String?, + appId: String, + primaryLocalization: OverviewPrimaryLocalization?, + appInfoId: String?, + service: AppStoreConnectService + ) async { + if let primaryLocalization { + do { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: primaryLocalization.localizationId) + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + updateScreenshotCache( + locale: primaryLocalization.locale, + sets: fetchedSets, + screenshots: Dictionary(uniqueKeysWithValues: fetchedScreenshots) + ) + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } catch { + print("Failed to hydrate overview screenshots: \(error)") + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } + } else { + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } + + if let appInfoId { + async let ageRatingTask: ASCAgeRatingDeclaration? = try? service.fetchAgeRating(appInfoId: appInfoId) + async let appInfoLocalizationsTask: [ASCAppInfoLocalization]? = try? service.fetchAppInfoLocalizations(appInfoId: appInfoId) + + let fetchedAgeRating = await ageRatingTask + let fetchedAppInfoLocalizations = await appInfoLocalizationsTask ?? [] + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + ageRatingDeclaration = fetchedAgeRating + appInfoLocalizationsByLocale = Dictionary(uniqueKeysWithValues: fetchedAppInfoLocalizations.map { + ($0.attributes.locale, $0) + }) + appInfoLocalization = primaryAppInfoLocalization(in: fetchedAppInfoLocalizations) + finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) + } else { + ageRatingDeclaration = nil + appInfoLocalizationsByLocale = [:] + appInfoLocalization = nil + finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshReviewSubmissionData(appId: appId, service: service) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() + + if monetizationStatus == nil { + let hasPricing = await service.fetchPricingConfigured(appId: appId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + monetizationStatus = hasPricing ? "Configured" : nil + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshSubmissionReadinessData() + finishOverviewReadinessLoading(Self.overviewPricingFieldLabels) + } + + private func hydrateReviewSecondaryData( + projectId: String?, + appId: String, + appInfoId: String?, + service: AppStoreConnectService + ) async { + if let appInfoId { + let fetchedAgeRating = try? await service.fetchAgeRating(appInfoId: appInfoId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + ageRatingDeclaration = fetchedAgeRating + } else { + ageRatingDeclaration = nil + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshReviewSubmissionData(appId: appId, service: service) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() + } + + private func hydrateMonetizationSecondaryData( + projectId: String?, + appId: String, + groups: [ASCSubscriptionGroup], + service: AppStoreConnectService + ) async { + do { + let fetchedSubscriptions = try await withThrowingTaskGroup(of: (String, [ASCSubscription]).self) { taskGroup in + for subscriptionGroup in groups { + taskGroup.addTask { + let subscriptions = try await service.fetchSubscriptionsInGroup(groupId: subscriptionGroup.id) + return (subscriptionGroup.id, subscriptions) + } + } + + var pairs: [(String, [ASCSubscription])] = [] + for try await pair in taskGroup { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + subscriptionsPerGroup = Dictionary(uniqueKeysWithValues: fetchedSubscriptions) + } catch { + print("Failed to hydrate monetization subscriptions: \(error)") + } + + if currentAppPricePointId == nil && scheduledAppPricePointId == nil && monetizationStatus == nil { + let hasPricing = await service.fetchPricingConfigured(appId: appId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + monetizationStatus = hasPricing ? "Configured" : nil + } + } + + private func hydrateFeedbackSecondaryData( + projectId: String?, + buildId: String, + service: AppStoreConnectService + ) async { + guard isCurrentProject(projectId) else { return } + guard !Task.isCancelled else { return } + loadingFeedbackBuildIds.insert(buildId) + defer { loadingFeedbackBuildIds.remove(buildId) } + + do { + let items = try await service.fetchBetaFeedback(buildId: buildId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + betaFeedback[buildId] = items + } catch { + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + betaFeedback[buildId] = [] + } + } + + private func loadData(for tab: AppTab, service: AppStoreConnectService) async throws { + guard let appId = app?.id else { + throw ASCError.notFound("App - check your bundle ID in project settings") + } + + switch tab { + case .app: + refreshAppIconStatusIfNeeded(for: loadedProjectId) + startOverviewReadinessLoading( + Self.overviewLocalizationFieldLabels + .union(Self.overviewVersionFieldLabels) + .union(Self.overviewAppInfoFieldLabels) + .union(Self.overviewMetadataFieldLabels) + .union(Self.overviewReviewFieldLabels) + .union(Self.overviewBuildFieldLabels) + .union(Self.overviewPricingFieldLabels) + .union(Self.overviewScreenshotFieldLabels) + ) + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? service.fetchAppInfo(appId: appId) + async let buildsTask = service.fetchBuilds(appId: appId) + + let versions = try await versionsTask + appStoreVersions = versions + finishOverviewReadinessLoading(Self.overviewVersionFieldLabels) + appInfo = await appInfoTask + finishOverviewReadinessLoading(Self.overviewAppInfoFieldLabels) + builds = try await buildsTask + finishOverviewReadinessLoading(Self.overviewBuildFieldLabels) + + var primaryLocalization: OverviewPrimaryLocalization? + if let latestId = versions.first?.id { + async let localizationsTask = service.fetchLocalizations(versionId: latestId) + async let reviewDetailTask: ASCReviewDetail? = try? service.fetchReviewDetail(versionId: latestId) + + let fetchedLocalizations = try await localizationsTask + localizations = fetchedLocalizations + primaryLocalization = OverviewPrimaryLocalization( + primaryVersionLocalization(in: fetchedLocalizations) + ) + finishOverviewReadinessLoading(Self.overviewLocalizationFieldLabels) + reviewDetail = await reviewDetailTask + finishOverviewReadinessLoading(Self.overviewReviewFieldLabels) + } else { + finishOverviewReadinessLoading( + Self.overviewLocalizationFieldLabels + .union(Self.overviewReviewFieldLabels) + ) + } + + refreshSubmissionFeedbackIfNeeded() + + let projectId = loadedProjectId + let currentAppInfoId = appInfo?.id + startBackgroundHydration(for: .app) { + await self.hydrateOverviewSecondaryData( + projectId: projectId, + appId: appId, + primaryLocalization: primaryLocalization, + appInfoId: currentAppInfoId, + service: service + ) + } + + case .storeListing: + try await refreshStoreListingMetadata( + service: service, + appId: appId, + preferredLocale: selectedStoreListingLocale + ) + + case .screenshots: + let versions = try await service.fetchAppStoreVersions(appId: appId) + appStoreVersions = versions + if let latestId = versions.first?.id { + let localizations = try await service.fetchLocalizations(versionId: latestId) + self.localizations = localizations + let preferredLocale = selectedScreenshotsLocale + let targetLocalization = localizations.first(where: { $0.attributes.locale == preferredLocale }) + ?? localizations.first + if let targetLocalization { + selectedScreenshotsLocale = targetLocalization.attributes.locale + await loadScreenshots(locale: targetLocalization.attributes.locale, force: true) + } else { + screenshotSetsByLocale = [:] + screenshotsByLocale = [:] + selectedScreenshotsLocale = nil + } + } else { + localizations = [] + screenshotSetsByLocale = [:] + screenshotsByLocale = [:] + selectedScreenshotsLocale = nil + } + + case .appDetails: + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + appStoreVersions = try await versionsTask + appInfo = await appInfoTask + + case .review: + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + async let buildsTask = service.fetchBuilds(appId: appId) + + let versions = try await versionsTask + appStoreVersions = versions + if let latestId = versions.first?.id { + reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) + } else { + reviewDetail = nil + } + appInfo = await appInfoTask + builds = try await buildsTask + let projectId = loadedProjectId + let currentAppInfoId = appInfo?.id + startBackgroundHydration(for: .review) { + await self.hydrateReviewSecondaryData( + projectId: projectId, + appId: appId, + appInfoId: currentAppInfoId, + service: service + ) + } + + case .monetization: + async let pricePointsTask = service.fetchAppPricePoints(appId: appId) + async let pricingStateTask = (try? await service.fetchAppPricingState(appId: appId)) + ?? ASCAppPricingState(currentPricePointId: nil, scheduledPricePointId: nil, scheduledEffectiveDate: nil) + async let iapTask = service.fetchInAppPurchases(appId: appId) + async let groupsTask = service.fetchSubscriptionGroups(appId: appId) + + appPricePoints = try await pricePointsTask + applyAppPricingState(await pricingStateTask) + inAppPurchases = try await iapTask + let groups = try await groupsTask + subscriptionGroups = groups + + let projectId = loadedProjectId + startBackgroundHydration(for: .monetization) { + await self.hydrateMonetizationSecondaryData( + projectId: projectId, + appId: appId, + groups: groups, + service: service + ) + } + + case .analytics: + break + + case .reviews: + customerReviews = try await service.fetchCustomerReviews(appId: appId) + + case .builds: + builds = try await service.fetchBuilds(appId: appId) + + case .groups: + betaGroups = try await service.fetchBetaGroups(appId: appId) + + case .betaInfo: + betaLocalizations = try await service.fetchBetaLocalizations(appId: appId) + + case .feedback: + let fetchedBuilds = try await service.fetchBuilds(appId: appId) + builds = fetchedBuilds + let resolvedBuildId: String? + if let currentSelectedBuildId = selectedBuildId, + fetchedBuilds.contains(where: { $0.id == currentSelectedBuildId }) { + resolvedBuildId = currentSelectedBuildId + } else { + resolvedBuildId = fetchedBuilds.first?.id + } + selectedBuildId = resolvedBuildId + if let resolvedBuildId { + let projectId = loadedProjectId + startBackgroundHydration(for: .feedback) { + await self.hydrateFeedbackSecondaryData( + projectId: projectId, + buildId: resolvedBuildId, + service: service + ) + } + } else { + betaFeedback = [:] + } + + default: + break + } + } +} diff --git a/src/models/ASCModels.swift b/src/models/ASCModels.swift index ab5f317..246d95d 100644 --- a/src/models/ASCModels.swift +++ b/src/models/ASCModels.swift @@ -1,5 +1,6 @@ import Foundation import Security +import AppKit // MARK: - Credentials @@ -20,16 +21,34 @@ struct ASCCredentials: Codable { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let data = try JSONEncoder().encode(self) try data.write(to: url, options: .atomic) + try ASCAuthBridge().syncCredentials(self) } static func delete() { try? FileManager.default.removeItem(at: credentialsURL()) + cleanupLegacyPrivateKeys() + ASCAuthBridge().cleanup() } static func credentialsURL() -> URL { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".blitz/asc-credentials.json") } + + private static func cleanupLegacyPrivateKeys() { + let fm = FileManager.default + let home = FileManager.default.homeDirectoryForCurrentUser + let blitzRoot = home.appendingPathComponent(".blitz") + guard let entries = try? fm.contentsOfDirectory( + at: blitzRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return } + + for entry in entries where entry.lastPathComponent.hasPrefix("AuthKey_") && entry.pathExtension == "p8" { + try? fm.removeItem(at: entry) + } + } } // MARK: - JSON:API Response Wrappers @@ -89,6 +108,7 @@ struct ASCAppStoreVersion: Decodable, Identifiable { enum ASCSubmissionHistoryEventType: String, Codable { case submitted + case submissionError case inReview case processing case accepted @@ -246,6 +266,25 @@ struct ASCScreenshot: Decodable, Identifiable { } } +struct TrackSlot: Identifiable, Equatable { + let id: String // UUID for local, ASC id for uploaded + var localPath: String? // file path for local assets + var localImage: NSImage? // loaded thumbnail + var ascScreenshot: ASCScreenshot? // present if from ASC + var isFromASC: Bool // true if this slot was loaded from ASC + + static func == (lhs: TrackSlot, rhs: TrackSlot) -> Bool { + lhs.id == rhs.id + } +} + +struct LocalScreenshotAsset: Identifiable { + let id: UUID + let url: URL + let image: NSImage + let fileName: String +} + // MARK: - CustomerReview struct ASCCustomerReview: Decodable, Identifiable { @@ -421,14 +460,23 @@ struct SubmissionReadiness { let id: String let label: String let value: String? + let isLoading: Bool let required: Bool let actionUrl: String? // If set, shows an "Open in ASC" button let hint: String? // Agent-visible guidance for resolving this field - init(label: String, value: String?, required: Bool = true, actionUrl: String? = nil, hint: String? = nil) { + init( + label: String, + value: String?, + isLoading: Bool = false, + required: Bool = true, + actionUrl: String? = nil, + hint: String? = nil + ) { self.id = label self.label = label self.value = value + self.isLoading = isLoading self.required = required self.actionUrl = actionUrl self.hint = hint @@ -438,11 +486,11 @@ struct SubmissionReadiness { var fields: [FieldStatus] var isComplete: Bool { - fields.filter(\.required).allSatisfy { $0.value != nil && !($0.value!.isEmpty) } + fields.filter(\.required).allSatisfy { !$0.isLoading && $0.value != nil && !($0.value!.isEmpty) } } var missingRequired: [FieldStatus] { - fields.filter { $0.required && ($0.value == nil || $0.value!.isEmpty) } + fields.filter { $0.required && !$0.isLoading && ($0.value == nil || $0.value!.isEmpty) } } } @@ -609,161 +657,3 @@ struct ASCProfile: Decodable, Identifiable { } let attributes: Attributes } - -// MARK: - Iris Session (Apple ID cookie-based auth for internal APIs) - -struct IrisSession: Codable, Sendable { - var cookies: [IrisCookie] - var email: String? - var capturedAt: Date - - struct IrisCookie: Codable, Sendable { - let name: String - let value: String - let domain: String - let path: String - } - - private static let keychainService = "dev.blitz.iris-session" - private static let keychainAccount = "iris-cookies" - - static func load() -> IrisSession? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return try? JSONDecoder().decode(IrisSession.self, from: data) - } - - func save() throws { - let data = try JSONEncoder().encode(self) - // Delete any existing item first - Self.delete() - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: Self.keychainService, - kSecAttrAccount as String: Self.keychainAccount, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - ] - let status = SecItemAdd(query as CFDictionary, nil) - guard status == errSecSuccess else { - throw NSError(domain: "IrisSession", code: Int(status), - userInfo: [NSLocalizedDescriptionKey: "Failed to save session to Keychain (status: \(status))"]) - } - } - - static func delete() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - ] - SecItemDelete(query as CFDictionary) - } -} - -// MARK: - Iris API Response Models - -struct IrisResolutionCenterThread: Decodable, Identifiable { - let id: String - let attributes: Attributes - - struct Attributes: Decodable { - let state: String? - let createdDate: String? - } -} - -struct IrisResolutionCenterMessage: Decodable, Identifiable { - let id: String - let attributes: Attributes - - struct Attributes: Decodable { - let messageBody: String? - let createdDate: String? - } -} - -struct IrisReviewRejection: Decodable, Identifiable { - let id: String - let attributes: Attributes - - struct Attributes: Decodable { - let reasons: [Reason]? - } - - struct Reason: Decodable { - let reasonSection: String? - let reasonDescription: String? - let reasonCode: String? - } -} - -// MARK: - Iris Feedback Cache - -struct IrisFeedbackCache: Codable { - let appId: String - let versionString: String - let fetchedAt: Date - let messages: [CachedMessage] - let reasons: [CachedReason] - - struct CachedMessage: Codable { - let body: String - let date: String? - } - - struct CachedReason: Codable { - let section: String - let description: String - let code: String - } - - // MARK: - Persistence - - func save() throws { - let url = Self.cacheURL(appId: appId, versionString: versionString) - let dir = url.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(self) - try data.write(to: url, options: .atomic) - } - - static func load(appId: String, versionString: String) -> IrisFeedbackCache? { - let url = cacheURL(appId: appId, versionString: versionString) - guard let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) - } - - static func loadAll(appId: String) -> [IrisFeedbackCache] { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".blitz/iris-cache/\(appId)") - guard let urls = try? FileManager.default.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { return [] } - - return urls - .filter { $0.pathExtension == "json" } - .compactMap { url in - guard let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) - } - .sorted { $0.fetchedAt > $1.fetchedAt } - } - - private static func cacheURL(appId: String, versionString: String) -> URL { - let home = FileManager.default.homeDirectoryForCurrentUser - return home.appendingPathComponent(".blitz/iris-cache/\(appId)/\(versionString).json") - } -} diff --git a/src/models/ASCReleaseStatus.swift b/src/models/ASCReleaseStatus.swift new file mode 100644 index 0000000..526310d --- /dev/null +++ b/src/models/ASCReleaseStatus.swift @@ -0,0 +1,180 @@ +import Foundation + +struct ASCDashboardProjectStatus: Sendable, Equatable { + let isLiveOnStore: Bool + let isPendingReview: Bool + let isRejected: Bool + + init(isLiveOnStore: Bool, isPendingReview: Bool, isRejected: Bool) { + self.isLiveOnStore = isLiveOnStore + self.isPendingReview = isPendingReview + self.isRejected = isRejected + } + + static let empty = ASCDashboardProjectStatus( + isLiveOnStore: false, + isPendingReview: false, + isRejected: false + ) + + init(versions: [ASCAppStoreVersion]) { + let sortedVersions = ASCReleaseStatus.sortedVersionsByRecency(versions) + let liveIndex = sortedVersions.firstIndex { + ASCReleaseStatus.liveStates.contains( + ASCReleaseStatus.normalize($0.attributes.appStoreState) + ) + } + let actionableIndex = sortedVersions.firstIndex { version in + let state = ASCReleaseStatus.normalize(version.attributes.appStoreState) + return ASCReleaseStatus.pendingReviewStates.contains(state) + || ASCReleaseStatus.rejectedStates.contains(state) + } + + isLiveOnStore = liveIndex != nil + + guard let actionableIndex else { + isPendingReview = false + isRejected = false + return + } + + let actionableState = ASCReleaseStatus.normalize( + sortedVersions[actionableIndex].attributes.appStoreState + ) + let actionableStateIsCurrent = liveIndex == nil || actionableIndex < liveIndex! + + isPendingReview = actionableStateIsCurrent + && ASCReleaseStatus.pendingReviewStates.contains(actionableState) + isRejected = actionableStateIsCurrent + && ASCReleaseStatus.rejectedStates.contains(actionableState) + } +} + +struct ASCDashboardSummary: Sendable, Equatable { + var liveCount: Int + var pendingCount: Int + var rejectedCount: Int + + static let empty = ASCDashboardSummary(liveCount: 0, pendingCount: 0, rejectedCount: 0) + + mutating func include(_ projectStatus: ASCDashboardProjectStatus) { + if projectStatus.isLiveOnStore { + liveCount += 1 + } + if projectStatus.isPendingReview { + pendingCount += 1 + } + if projectStatus.isRejected { + rejectedCount += 1 + } + } +} + +enum ASCReleaseStatus { + static let liveStates: Set = [ + "READY_FOR_SALE", + ] + + static let pendingReviewStates: Set = [ + "ACCEPTED", + "IN_REVIEW", + "PENDING_APPLE_RELEASE", + "PENDING_DEVELOPER_RELEASE", + "PROCESSING", + "PROCESSING_FOR_APP_STORE", + "PROCESSING_FOR_DISTRIBUTION", + "WAITING_FOR_REVIEW", + ] + + static let rejectedStates: Set = [ + "INVALID_BINARY", + "METADATA_REJECTED", + "REJECTED", + ] + + static func submissionHistoryEventType(forVersionState state: String?) -> ASCSubmissionHistoryEventType? { + switch normalize(state) { + case "WAITING_FOR_REVIEW": + return .submitted + case "IN_REVIEW": + return .inReview + case "PROCESSING", "PROCESSING_FOR_APP_STORE", "PROCESSING_FOR_DISTRIBUTION": + return .processing + case "ACCEPTED", "PENDING_DEVELOPER_RELEASE": + return .accepted + case "READY_FOR_SALE": + return .live + case "INVALID_BINARY": + return .submissionError + case "METADATA_REJECTED", "REJECTED": + return .rejected + case "DEVELOPER_REJECTED": + return .withdrawn + case "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE": + return .removed + default: + return nil + } + } + + static func reviewSubmissionEventType(forVersionState state: String?) -> ASCSubmissionHistoryEventType { + if normalize(state) == "INVALID_BINARY" { + return .submissionError + } + return .submitted + } + + static func normalize(_ state: String?) -> String { + state? + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() ?? "" + } + + static func sortedVersionsByRecency(_ versions: [ASCAppStoreVersion]) -> [ASCAppStoreVersion] { + versions.sorted { lhs, rhs in + let dateComparison = compareDates(lhs.attributes.createdDate, rhs.attributes.createdDate) + if dateComparison != 0 { + return dateComparison > 0 + } + return lhs.id > rhs.id + } + } + + private static func compareDates(_ lhs: String?, _ rhs: String?) -> Int { + let lhsDate = parseDate(lhs) + let rhsDate = parseDate(rhs) + + switch (lhsDate, rhsDate) { + case let (.some(lhsDate), .some(rhsDate)): + if lhsDate > rhsDate { return 1 } + if lhsDate < rhsDate { return -1 } + return 0 + case (.some, .none): + return 1 + case (.none, .some): + return -1 + case (.none, .none): + let lhsValue = lhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let rhsValue = rhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if lhsValue > rhsValue { return 1 } + if lhsValue < rhsValue { return -1 } + return 0 + } + } + + private static func parseDate(_ value: String?) -> Date? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = fractionalFormatter.date(from: trimmed) { + return parsed + } + + let formatter = ISO8601DateFormatter() + return formatter.date(from: trimmed) + } +} diff --git a/src/models/ASCSupplementalModels.swift b/src/models/ASCSupplementalModels.swift new file mode 100644 index 0000000..1d9529f --- /dev/null +++ b/src/models/ASCSupplementalModels.swift @@ -0,0 +1,77 @@ +import Foundation + +struct ASCPricePoint: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let customerPrice: String? + } + + let attributes: Attributes +} + +struct ASCScreenshotReservation: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let sourceFileChecksum: String? + let uploadOperations: [UploadOperation]? + } + + let attributes: Attributes + + struct UploadOperation: Decodable { + let method: String + let url: String + let offset: Int + let length: Int + let requestHeaders: [Header] + + struct Header: Decodable { + let name: String + let value: String + } + } +} + +struct ASCReviewSubmission: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let state: String? + let submittedDate: String? + let platform: String? + } + + let attributes: Attributes +} + +struct ASCReviewSubmissionItem: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let state: String? + let resolved: Bool? + let createdDate: String? + } + + let attributes: Attributes + let relationships: Relationships? + + struct Relationships: Decodable { + let appStoreVersion: ToOneRelationship? + + struct ToOneRelationship: Decodable { + let data: ResourceIdentifier? + } + + struct ResourceIdentifier: Decodable { + let type: String + let id: String + } + } + + var appStoreVersionId: String? { + relationships?.appStoreVersion?.data?.id + } +} diff --git a/src/models/SimulatorInfo.swift b/src/models/SimulatorInfo.swift deleted file mode 100644 index 0dbbd7e..0000000 --- a/src/models/SimulatorInfo.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -struct SimulatorInfo: Identifiable, Hashable { - let udid: String - let name: String - let state: String - let deviceTypeIdentifier: String? - let lastBootedAt: String? - - var id: String { udid } - var isBooted: Bool { state == "Booted" } - - var displayName: String { - // Extract device type from identifier like "com.apple.CoreSimulator.SimDeviceType.iPhone-16" - if let typeId = deviceTypeIdentifier { - let components = typeId.split(separator: ".") - if let last = components.last { - return String(last).replacingOccurrences(of: "-", with: " ") - } - } - return name - } -} diff --git a/src/models/DatabaseSchema.swift b/src/models/database/DatabaseSchema.swift similarity index 100% rename from src/models/DatabaseSchema.swift rename to src/models/database/DatabaseSchema.swift diff --git a/src/models/SimulatorConfig.swift b/src/models/simulator/SimulatorConfig.swift similarity index 90% rename from src/models/SimulatorConfig.swift rename to src/models/simulator/SimulatorConfig.swift index 4b7e9bc..a0c8b5f 100644 --- a/src/models/SimulatorConfig.swift +++ b/src/models/simulator/SimulatorConfig.swift @@ -156,3 +156,25 @@ struct SimulatorConfigDatabase { return (x: vx * viewWidth, y: vy * viewHeight) } } + +struct SimulatorInfo: Identifiable, Hashable { + let udid: String + let name: String + let state: String + let deviceTypeIdentifier: String? + let lastBootedAt: String? + + var id: String { udid } + var isBooted: Bool { state == "Booted" } + + var displayName: String { + // Extract device type from identifier like "com.apple.CoreSimulator.SimDeviceType.iPhone-16" + if let typeId = deviceTypeIdentifier { + let components = typeId.split(separator: ".") + if let last = components.last { + return String(last).replacingOccurrences(of: "-", with: " ") + } + } + return name + } +} diff --git a/src/resources/CLAUDE.md.template b/src/resources/CLAUDE.md.template index 83d1954..61e0e00 100644 --- a/src/resources/CLAUDE.md.template +++ b/src/resources/CLAUDE.md.template @@ -2,7 +2,7 @@ ## blitz-macos -This project is opened in **Blitz**, a native macOS iOS development IDE with integrated simulator streaming. The user sees Build, Release, Insights, Testflight tab groups, and can see simulator view in Build>Simulator tab. +This project is opened in **Blitz**, a native macOS iOS/macOS development IDE with integrated simulator streaming. The user sees Build, Release, Insights, Testflight tab groups, and can see simulator view in Build>Simulator tab. ### MCP Servers @@ -10,6 +10,30 @@ Two MCP servers are configured in `.mcp.json`: - **`blitz-macos`** — Controls the Blitz app: project state, tab navigation, App Store Connect forms, build pipeline, settings. - **`blitz-iphone`** — Controls the iOS device/simulator: tap, swipe, type, screenshots, UI hierarchy. See [iPhone MCP docs](https://github.com/blitzdotdev/iPhone-mcp). +- **`asc` CLI** — A bundled Go binary at `~/.blitz/bin/asc` for direct App Store Connect API access. Shares credentials with Blitz MCP tools automatically (no separate login needed). + +### MCP Tools vs `asc` CLI vs Direct API Calls + +**Priority order: MCP tools → `asc` CLI → direct API calls (last resort).** + +**Default to MCP tools** for common workflows — they are opinionated, safe, and integrate with Blitz's UI (approval prompts, progress tracking, form validation). **When MCP tools don't cover an operation, use `asc` CLI** — it has 60+ subcommands covering nearly every ASC API endpoint and handles JWT auth, pagination, and retries. **Direct API calls (python scripts, curl to api.appstoreconnect.apple.com, urllib) should be an absolute last resort** — only when both MCP tools AND `asc` CLI genuinely cannot accomplish the task. Before writing any script, run `asc --help` and `asc --help` to confirm the CLI doesn't already support it. + +**Use MCP tools for:** filling ASC forms (`asc_fill_form`), reading tab/app state (`get_tab_state`), the build pipeline (`app_store_setup_signing` → `app_store_build` → `app_store_upload`), creating IAPs/subscriptions, setting prices, managing screenshots, and submitting for review. + +**Use `asc` CLI for:** listing builds (`asc builds list`), TestFlight beta management (`asc testflight`), certificate/profile inspection (`asc certificates list`, `asc profiles list`), analytics & finance reports (`asc analytics`, `asc finance`), release management (`asc releases`), submission management (`asc submit create`, `asc submit cancel`), custom product pages (`asc product-pages`), Xcode Cloud (`asc xcode-cloud`), Game Center, offer codes, bulk localization updates, and any ASC operation without a dedicated MCP tool. + +| Task | MCP (preferred) | `asc` CLI (fallback) | +|---|---|---| +| Set store listing metadata | `asc_fill_form` tab="storeListing" | `asc metadata update --locale en-US ...` | +| Check submission readiness | `get_tab_state` tab="ascOverview" | `asc versions list` + manual checks | +| Create subscription | `asc_create_subscription` (one call) | `asc subscriptions create` + localization + pricing (3 steps) | +| Cancel stuck submission | N/A | `asc submit cancel --id "..."` | +| List all builds | N/A | `asc builds list` | +| Add beta testers | N/A | `asc testflight add-tester --email ...` | +| Upload IPA | `app_store_upload` (with polling) | `asc builds upload --path ./app.ipa` | +| Financial reports | N/A | `asc finance download --period 2026-01` | + +Both tools share the same API key via Blitz's auth bridge (`~/.blitz/asc-agent/config.json`). If `asc` is not on PATH: `export PATH="$HOME/.blitz/bin:$PATH"` ### Testing Workflow diff --git a/src/resources/blitz-rules.md b/src/resources/blitz-rules.md deleted file mode 100644 index 9d57679..0000000 --- a/src/resources/blitz-rules.md +++ /dev/null @@ -1,17 +0,0 @@ -# Blitz MCP Integration - -This project is open in **Blitz**, a native macOS iOS development environment. -Two MCP servers are active in `.mcp.json`: - -- **`blitz-macos`** — Controls Blitz: navigate tabs, read project/simulator state, - manage builds, fill App Store Connect forms, manage settings. -- **`blitz-iphone`** — Controls the iOS simulator/device: tap, swipe, type text, - take screenshots, inspect UI hierarchy. See blitz-iphone tool docs for full command list. - -## Testing workflow - -After making code changes: -1. Wait briefly for hot reload / rebuild -2. `blitz-iphone` `describe_screen` — verify the UI updated as expected -3. `blitz-iphone` `device_action` — interact (tap buttons, type text, swipe) -4. `blitz-iphone` `describe_screen` — confirm the result diff --git a/src/resources/rules/blitz-rules.md b/src/resources/rules/blitz-rules.md new file mode 100644 index 0000000..48ac3d4 --- /dev/null +++ b/src/resources/rules/blitz-rules.md @@ -0,0 +1,74 @@ +# Blitz MCP Integration + +This project is open in **Blitz**, a native macOS iOS development environment. +Two MCP servers are active in `.mcp.json`: + +- **`blitz-macos`** — Controls Blitz: navigate tabs, read project/simulator state, + manage builds, fill App Store Connect forms, manage settings. +- **`blitz-iphone`** — Controls the iOS simulator/device: tap, swipe, type text, + take screenshots, inspect UI hierarchy. See blitz-iphone tool docs for full command list. + +Additionally, the **`asc`** CLI (a bundled Go binary for App Store Connect) is available at `~/.blitz/bin/asc`. It shares credentials with Blitz MCP tools automatically via an auth bridge — no separate login required. + +## When to use Blitz MCP tools vs `asc` CLI vs direct API calls + +**Default to MCP tools.** They are opinionated, safe, and designed for common workflows with built-in approval prompts for mutating operations. **When MCP tools don't cover an operation, use `asc` CLI** — it has 60+ subcommands covering nearly every App Store Connect API endpoint. **Direct API calls (python scripts, curl to api.appstoreconnect.apple.com, urllib, etc.) should be an absolute last resort** — only when both MCP tools AND `asc` CLI genuinely cannot accomplish the task. The `asc` CLI already handles JWT auth, pagination, error handling, and retries; writing raw API scripts bypasses all of that and is fragile. + +### Use MCP tools when: +- **Filling ASC forms** — `asc_fill_form` handles store listing, app details, monetization, age rating, review contact with validation and auto-navigation +- **Reading app/tab state** — `get_tab_state` returns structured data (form values, submission readiness, builds, versions) without parsing CLI output +- **Build pipeline** — `app_store_setup_signing` → `app_store_build` → `app_store_upload` is the standard flow with progress tracking in Blitz UI +- **Creating IAPs/subscriptions** — `asc_create_iap` and `asc_create_subscription` handle the full creation flow (product + localization + pricing) in one call +- **Setting prices** — `asc_set_app_price` for app pricing, including scheduled price changes +- **Managing screenshots** — `screenshots_add_asset` → `screenshots_set_track` → `screenshots_save` for screenshot upload workflow +- **Submission** — `asc_open_submit_preview` checks readiness and opens the submit modal +- **Anything with a dedicated MCP tool** — the tool exists because it handles the common case safely + +### Use `asc` CLI when: +- **Listing/querying resources** — `asc builds list`, `asc versions list`, `asc apps list` for quick lookups not exposed by MCP +- **Bulk operations** — updating localizations for multiple locales, managing many IAPs at once +- **Certificate/profile management** — `asc certificates list`, `asc profiles list`, `asc devices list` for inspecting signing state beyond what `app_store_setup_signing` manages +- **TestFlight management** — `asc testflight` for beta group management, tester invitations, build distribution beyond what MCP exposes +- **Analytics/finance** — `asc analytics`, `asc finance` for pulling reports +- **Release management** — `asc releases`, `asc versions` for version-level operations (phased release, platform-specific versioning) +- **Submission management** — `asc submit create`, `asc submit cancel` for creating/cancelling review submissions +- **Custom product pages** — `asc product-pages` for A/B testing store listings +- **Xcode Cloud** — `asc xcode-cloud` for CI/CD workflow management +- **Game Center** — `asc game-center` for leaderboards and achievements +- **Offer codes** — `asc offer-codes` for subscription promotional codes +- **Any operation not covered by an MCP tool** — check `asc --help` and `asc --help` before resorting to other approaches + +### Direct API calls (last resort only): +Writing raw Python/curl/urllib scripts against `api.appstoreconnect.apple.com` should only happen when you have confirmed that **both** MCP tools and `asc` CLI cannot do what's needed. Before writing a script, always: +1. Check if there's an MCP tool for it +2. Run `asc --help` and `asc --help` to see if `asc` covers it +3. Only then consider a direct API call + +The `asc` CLI covers 60+ command groups — it almost certainly has what you need. Even for uncommon operations like cancelling a stuck review submission or creating API keys, try `asc` first. + +### Examples: MCP vs CLI side-by-side + +| Task | MCP tool (preferred) | `asc` CLI (fallback/advanced) | +|---|---|---| +| Set app title & description | `asc_fill_form` tab="storeListing" | `asc metadata update --locale en-US --name "..." --description "..."` | +| Check if ready to submit | `get_tab_state` tab="ascOverview" | `asc versions list` + manual field checks | +| Create a subscription | `asc_create_subscription` (one call) | `asc subscriptions create` + `asc subscriptions add-localization` + `asc pricing set` (three steps) | +| List all builds | Not available via MCP | `asc builds list` | +| Add beta testers | Not available via MCP | `asc testflight add-tester --email user@example.com` | +| Upload IPA | `app_store_upload` (with polling) | `asc builds upload --path ./app.ipa` | +| Get financial reports | Not available via MCP | `asc finance download --period 2026-01` | +| Manage provisioning profiles | `app_store_setup_signing` (automated) | `asc profiles list`, `asc profiles create` (manual control) | + +### Auth bridge + +Both MCP tools and `asc` CLI share the same App Store Connect API key credentials. When you authenticate in Blitz (via `asc_set_credentials` or the Settings UI), the auth bridge automatically syncs credentials to `~/.blitz/asc-agent/config.json`. The `asc` wrapper at `~/.blitz/bin/asc` reads from this config — no separate `asc auth init` needed. + +If `asc` is not on PATH, add it: `export PATH="$HOME/.blitz/bin:$PATH"` + +## Testing workflow + +After making code changes: +1. Wait briefly for hot reload / rebuild +2. `blitz-iphone` `describe_screen` — verify the UI updated as expected +3. `blitz-iphone` `device_action` — interact (tap buttons, type text, swipe) +4. `blitz-iphone` `describe_screen` — confirm the result diff --git a/src/resources/teenybase-rules-backend.md b/src/resources/rules/teenybase-rules-backend.md similarity index 100% rename from src/resources/teenybase-rules-backend.md rename to src/resources/rules/teenybase-rules-backend.md diff --git a/src/resources/teenybase-rules-no-backend.md b/src/resources/rules/teenybase-rules-no-backend.md similarity index 100% rename from src/resources/teenybase-rules-no-backend.md rename to src/resources/rules/teenybase-rules-no-backend.md diff --git a/src/resources/skills/asc-app-create-ui/SKILL.md b/src/resources/skills/asc-app-create-ui/SKILL.md new file mode 100644 index 0000000..392abf5 --- /dev/null +++ b/src/resources/skills/asc-app-create-ui/SKILL.md @@ -0,0 +1,166 @@ +--- +name: asc-app-create-ui +description: Create an App Store Connect app via iris API using web session from Blitz +--- + +Create an App Store Connect app using Apple's iris API. Authentication is handled via a web session file at `~/.blitz/asc-agent/web-session.json` managed by Blitz. + +Extract from the conversation context: +- `bundleId` — the bundle identifier (e.g. `com.blitz.myapp`) +- `sku` — the SKU string (may be provided; if missing, generate one from the app name) + +## Workflow + +### 1. Check for an existing web session + +```bash +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" +``` + +- If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. +- If `SESSION_EXISTS`: proceed. + +### 2. Ask the user for the primary language + +Ask what primary language/locale the app should use. Common choices: `en-US` (English US), `en-GB` (English UK), `ja` (Japanese), `zh-Hans` (Simplified Chinese), `ko` (Korean), `fr-FR` (French), `de-DE` (German). + +### 3. Derive the app name + +Take the last component of the bundle ID after the final `.`, capitalize the first letter. Confirm with the user. + +### 4. Create the app via iris API + +Use the following self-contained script. Replace `BUNDLE_ID`, `SKU`, `APP_NAME`, and `LOCALE` with the resolved values. **Do not print or log cookies.** + +Key differences from the public REST API: +- Uses `appstoreconnect.apple.com/iris/v1/` (not `api.appstoreconnect.apple.com`) +- Authenticated via web session cookies (not JWT) +- Uses `appInfos` relationship (not `bundleId` relationship) +- App name goes on `appInfoLocalizations` (not `appStoreVersionLocalizations`) +- Uses `${new-...}` placeholder IDs for inline-created resources + +```bash +python3 -c " +import json, os, urllib.request, sys + +BUNDLE_ID = 'BUNDLE_ID_HERE' +SKU = 'SKU_HERE' +APP_NAME = 'APP_NAME_HERE' +LOCALE = 'LOCALE_HERE' + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + (f'{c[\"name\"]}=\"{c[\"value\"]}\"' if c['name'].startswith('DES') else f'{c[\"name\"]}={c[\"value\"]}') + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str +} + +create_body = json.dumps({ + 'data': { + 'type': 'apps', + 'attributes': { + 'bundleId': BUNDLE_ID, + 'sku': SKU, + 'primaryLocale': LOCALE, + }, + 'relationships': { + 'appStoreVersions': { + 'data': [{'type': 'appStoreVersions', 'id': '\${new-appStoreVersion-1}'}] + }, + 'appInfos': { + 'data': [{'type': 'appInfos', 'id': '\${new-appInfo-1}'}] + } + } + }, + 'included': [ + { + 'type': 'appStoreVersions', + 'id': '\${new-appStoreVersion-1}', + 'attributes': {'platform': 'IOS', 'versionString': '1.0'}, + 'relationships': { + 'appStoreVersionLocalizations': { + 'data': [{'type': 'appStoreVersionLocalizations', 'id': '\${new-appStoreVersionLocalization-1}'}] + } + } + }, + { + 'type': 'appStoreVersionLocalizations', + 'id': '\${new-appStoreVersionLocalization-1}', + 'attributes': {'locale': LOCALE} + }, + { + 'type': 'appInfos', + 'id': '\${new-appInfo-1}', + 'relationships': { + 'appInfoLocalizations': { + 'data': [{'type': 'appInfoLocalizations', 'id': '\${new-appInfoLocalization-1}'}] + } + } + }, + { + 'type': 'appInfoLocalizations', + 'id': '\${new-appInfoLocalization-1}', + 'attributes': {'locale': LOCALE, 'name': APP_NAME} + } + ] +}).encode() + +req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/apps', + data=create_body, method='POST', headers=headers) +try: + resp = urllib.request.urlopen(req) + result = json.loads(resp.read().decode()) + app_id = result['data']['id'] + print(f'App created successfully!') + print(f'App ID: {app_id}') + print(f'Bundle ID: {BUNDLE_ID}') + print(f'Name: {APP_NAME}') + print(f'SKU: {SKU}') +except urllib.error.HTTPError as e: + body = e.read().decode() + if e.code == 401: + print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + elif e.code == 409: + print(f'ERROR: App may already exist or conflict. Details: {body[:500]}') + else: + print(f'ERROR creating app: HTTP {e.code} — {body[:500]}') + sys.exit(1) +" +``` + +### 5. Report results + +After success, report the App ID, bundle ID, name, and SKU to the user. + +## Common Errors + +### 401 Not Authorized +Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz. Then retry. + +### 409 Conflict +An app with the same bundle ID or SKU may already exist. Try a different SKU. + +## Agent Behavior + +- **Do NOT ask for Apple ID email** — authentication is handled via cached web session file, not email. +- **NEVER print, log, or echo session cookies.** +- Use the self-contained python script — do NOT extract cookies separately. +- If iris API returns 401, call `asc_web_auth` MCP tool and retry. \ No newline at end of file diff --git a/src/resources/skills/asc-iap-attach/SKILL.md b/src/resources/skills/asc-iap-attach/SKILL.md new file mode 100644 index 0000000..a5b7723 --- /dev/null +++ b/src/resources/skills/asc-iap-attach/SKILL.md @@ -0,0 +1,251 @@ +--- +name: asc-iap-attach +description: Attach in-app purchases and subscriptions to an app version for App Store review. Use when the user has IAPs or subscriptions in "Ready to Submit" state that need to be included with a first-time version submission. Works for both first-time and subsequent submissions. +--- + +# asc iap attach + +Use this skill to attach in-app purchases and/or subscriptions to an app version for App Store review. This is the equivalent of checking the boxes in the "Add In-App Purchases or Subscriptions" modal on the version page in App Store Connect. + +## When to use + +- User is preparing an app version for submission and has IAPs or subscriptions to include +- User says "attach IAPs", "add subscriptions to version", "include in-app purchases for review", "select in-app purchases" +- The app version page in ASC shows an "In-App Purchases and Subscriptions" section with items to select +- IAPs/subscriptions have been created and are in "Ready to Submit" state + +## Background + +Apple's official App Store Connect API (`POST /v1/subscriptionSubmissions`, `POST /v1/inAppPurchaseSubmissions`) returns `FIRST_SUBSCRIPTION_MUST_BE_SUBMITTED_ON_VERSION` for first-time IAP/subscription submissions. The `reviewSubmissionItems` API also does not support `subscription` or `inAppPurchase` relationship types. + +This skill uses Apple's internal iris API (`/iris/v1/subscriptionSubmissions`) via cached web session cookies, which supports the `submitWithNextAppStoreVersion` attribute that the public API lacks. This is the same mechanism the ASC web UI uses when you check the checkbox in the modal. + +## Preconditions + +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- Know your app ID. +- IAPs and/or subscriptions already exist and are in **Ready to Submit** state. +- A build is uploaded and attached to the current app version. + +## Workflow + +### 1. Check for an existing web session + +```bash +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" +``` + +- If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. +- If `SESSION_EXISTS`: proceed to the next step. + +### 2. List subscriptions and IAPs to identify items to attach + +Use the iris API to list subscription groups (with subscriptions) and in-app purchases. Replace `APP_ID` with the actual app ID. + +```bash +python3 -c " +import json, os, urllib.request, sys + +APP_ID = 'APP_ID_HERE' + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +headers = { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str +} + +def iris_get(path): + url = f'https://appstoreconnect.apple.com/iris/v1/{path}' + req = urllib.request.Request(url, method='GET', headers=headers) + try: + resp = urllib.request.urlopen(req) + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 401: + print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + sys.exit(1) + print(f'ERROR: HTTP {e.code} — {e.read().decode()[:200]}') + sys.exit(1) + +# List subscription groups with subscriptions included +print('=== Subscription Groups ===') +sg = iris_get(f'apps/{APP_ID}/subscriptionGroups?include=subscriptions&limit=300&fields%5Bsubscriptions%5D=productId,name,state,submitWithNextAppStoreVersion') +for group in sg.get('data', []): + print(f'Group: {group[\"attributes\"][\"referenceName\"]} (id={group[\"id\"]})') +for sub in sg.get('included', []): + if sub['type'] == 'subscriptions': + a = sub['attributes'] + attached = a.get('submitWithNextAppStoreVersion', False) + print(f' Subscription: {a.get(\"name\",\"?\")} | productId={a.get(\"productId\",\"?\")} | state={a.get(\"state\",\"?\")} | attached={attached} | id={sub[\"id\"]}') + +# List in-app purchases +print() +print('=== In-App Purchases ===') +iaps = iris_get(f'apps/{APP_ID}/inAppPurchasesV2?limit=300&fields%5BinAppPurchases%5D=productId,name,state,submitWithNextAppStoreVersion') +for iap in iaps.get('data', []): + a = iap['attributes'] + attached = a.get('submitWithNextAppStoreVersion', False) + print(f'IAP: {a.get(\"name\",\"?\")} | productId={a.get(\"productId\",\"?\")} | state={a.get(\"state\",\"?\")} | attached={attached} | id={iap[\"id\"]}') +" +``` + +Look for items with `state=READY_TO_SUBMIT` and `attached=False`. Note their IDs. + +### 3. Attach subscriptions via iris API + +Use the following script to attach subscriptions. **Do not print or log the cookies** — they contain sensitive session tokens. + +```bash +python3 -c " +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +def iris_attach_subscription(sub_id): + body = json.dumps({'data': { + 'type': 'subscriptionSubmissions', + 'attributes': {'submitWithNextAppStoreVersion': True}, + 'relationships': {'subscription': {'data': {'type': 'subscriptions', 'id': sub_id}}} + }}).encode() + req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/subscriptionSubmissions', + data=body, method='POST', + headers={ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str + }) + try: + resp = urllib.request.urlopen(req) + print(f'Attached subscription {sub_id}: HTTP {resp.status}') + except urllib.error.HTTPError as e: + body = e.read().decode() + if 'already set to submit' in body: + print(f'Subscription {sub_id} already attached (OK)') + elif e.code == 401: + print(f'ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + else: + print(f'ERROR attaching {sub_id}: HTTP {e.code} — {body[:200]}') + +# Replace with actual subscription IDs: +iris_attach_subscription('SUB_ID_1') +iris_attach_subscription('SUB_ID_2') +" +``` + +For in-app purchases (non-subscription), change the type and relationship: + +```bash +python3 -c " +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +def iris_attach_iap(iap_id): + body = json.dumps({'data': { + 'type': 'inAppPurchaseSubmissions', + 'attributes': {'submitWithNextAppStoreVersion': True}, + 'relationships': {'inAppPurchaseV2': {'data': {'type': 'inAppPurchases', 'id': iap_id}}} + }}).encode() + req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/inAppPurchaseSubmissions', + data=body, method='POST', + headers={ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str + }) + try: + resp = urllib.request.urlopen(req) + print(f'Attached IAP {iap_id}: HTTP {resp.status}') + except urllib.error.HTTPError as e: + body = e.read().decode() + if 'already set to submit' in body: + print(f'IAP {iap_id} already attached (OK)') + elif e.code == 401: + print(f'ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + else: + print(f'ERROR attaching {iap_id}: HTTP {e.code} — {body[:200]}') + +# Replace with actual IAP IDs: +iris_attach_iap('IAP_ID') +" +``` + +### 4. Verify attachments + +After attachment, call `get_tab_state` for `ascOverview` to refresh the submission readiness checklist. The MCP tool auto-refreshes monetization data and will reflect the updated attachment state. + +## Common Errors + +### "Subscription is already set to submit with next AppStoreVersion" +The subscription is already attached — this is safe to ignore. HTTP 409 with this message means the item was previously attached. + +### 401 Not Authorized (iris API) +The web session has expired. Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz — this captures a fresh session and refreshes `~/.blitz/asc-agent/web-session.json` automatically. The user will need to complete Apple ID login + 2FA in the popup. After the tool returns success, retry the iris API calls. + +## Agent Behavior + +- Always list IAPs and subscriptions first (using Step 2) to identify which are in `READY_TO_SUBMIT` state. +- If the user specifies particular items, match by reference name or product ID. +- If the user says "all", attach every item in `READY_TO_SUBMIT` state. +- **NEVER print, log, or echo session cookies.** The python scripts handle cookies internally without exposing them. +- Use the self-contained python scripts above — do NOT extract cookies separately or pass them as shell variables. +- If iris API returns 409 "already set to submit", treat as success. +- If iris API returns 401, call the `asc_web_auth` MCP tool to open the login window in Blitz, then retry. +- After attachment, call `get_tab_state` for `ascOverview` to refresh the submission readiness checklist. + +## Notes + +- This skill handles the "attach to version" step only. +- The iris API (`/iris/v1`) mirrors the official ASC API resource types (same JSON:API format) but supports additional attributes like `submitWithNextAppStoreVersion` that the public API lacks. +- The iris API is rate-limited; keep a minimum 350ms interval between requests. diff --git a/src/resources/skills/asc-privacy-nutrition-labels/SKILL.md b/src/resources/skills/asc-privacy-nutrition-labels/SKILL.md new file mode 100644 index 0000000..6b9a4c3 --- /dev/null +++ b/src/resources/skills/asc-privacy-nutrition-labels/SKILL.md @@ -0,0 +1,281 @@ +--- +name: asc-privacy-nutrition-labels +description: Set up App Store privacy nutrition labels (data collection declarations) for an app. Use when the user needs to declare what data their app collects, how it's used, and whether it's linked to the user. Handles both "no data collected" and full data collection declarations. +--- + +# asc privacy nutrition labels + +Use this skill to configure App Store privacy nutrition labels for an app. This is the "App Privacy" section in App Store Connect where you declare what data your app collects, what purposes it's used for, and how it's protected. + +## When to use + +- User says "set up privacy labels", "configure nutrition labels", "app privacy", "data collection declaration" +- The submission readiness checklist shows "Privacy Nutrition Labels" as incomplete +- User is preparing an app for first submission and needs to declare data practices +- User needs to update privacy declarations after adding new data collection + +## Preconditions + +- Web session authenticated (cached in keychain from prior `asc web auth login`, or call `asc_web_auth` MCP tool) +- Know your app ID (`ASC_APP_ID` or `--app`) + +## Data Model + +Each privacy declaration is a tuple of three dimensions: + +### Categories (what data is collected) + +Grouped by type: + +| Grouping | Categories | +|----------|-----------| +| CONTACT_INFO | `NAME`, `EMAIL_ADDRESS`, `PHONE_NUMBER`, `PHYSICAL_ADDRESS`, `OTHER_CONTACT_INFO` | +| HEALTH_AND_FITNESS | `HEALTH`, `FITNESS` | +| FINANCIAL_INFO | `PAYMENT_INFORMATION`, `CREDIT_AND_FRAUD`, `OTHER_FINANCIAL_INFO` | +| LOCATION | `PRECISE_LOCATION`, `COARSE_LOCATION` | +| SENSITIVE_INFO | `SENSITIVE_INFO` | +| CONTACTS | `CONTACTS` | +| USER_CONTENT | `EMAILS_OR_TEXT_MESSAGES`, `PHOTOS_OR_VIDEOS`, `AUDIO`, `GAMEPLAY_CONTENT`, `CUSTOMER_SUPPORT`, `OTHER_USER_CONTENT` | +| BROWSING_HISTORY | `BROWSING_HISTORY` | +| SEARCH_HISTORY | `SEARCH_HISTORY` | +| IDENTIFIERS | `USER_ID`, `DEVICE_ID` | +| PURCHASES | `PURCHASE_HISTORY` | +| USAGE_DATA | `PRODUCT_INTERACTION`, `ADVERTISING_DATA`, `OTHER_USAGE_DATA` | +| DIAGNOSTICS | `CRASH_DATA`, `PERFORMANCE_DATA`, `OTHER_DIAGNOSTIC_DATA` | +| OTHER_DATA | `OTHER_DATA_TYPES` | + +### Purposes (why it's collected) + +| Purpose ID | Meaning | +|-----------|---------| +| `APP_FUNCTIONALITY` | Required for the app to work | +| `ANALYTICS` | Used for analytics | +| `PRODUCT_PERSONALIZATION` | Used to personalize the product | +| `DEVELOPERS_ADVERTISING` | Used for developer's advertising | +| `THIRD_PARTY_ADVERTISING` | Used for third-party advertising | +| `OTHER_PURPOSES` | Other purposes | + +### Data Protections (how it's handled) + +| Protection ID | Meaning | +|--------------|---------| +| `DATA_NOT_COLLECTED` | App does not collect this data (mutually exclusive with others) | +| `DATA_LINKED_TO_YOU` | Collected and linked to user identity | +| `DATA_NOT_LINKED_TO_YOU` | Collected but not linked to identity | +| `DATA_USED_TO_TRACK_YOU` | Used for tracking (no purpose needed) | + +## Workflow + +### 1. Ask the user what data the app collects + +Before proceeding, understand the app's data practices. Ask: + +- Does the app collect any user data? If no → use the "No data collected" flow +- What types of data does it collect? (e.g., name, email, location, analytics) +- What purposes? (e.g., app functionality, analytics, advertising) +- Is the data linked to the user's identity? +- Is any data used for tracking? + +If the user is unsure, analyze the app's source code to determine data collection practices (look for analytics SDKs, location APIs, user accounts, etc.). + +### 2a. "No data collected" flow + +If the app collects no data: + +```bash +# Create the declaration file +cat > /tmp/privacy.json << 'EOF' +{ + "schemaVersion": 1, + "dataUsages": [] +} +EOF + +# Apply (this sets DATA_NOT_COLLECTED) +asc web privacy apply --app "APP_ID" --file /tmp/privacy.json --allow-deletes --confirm + +# Publish +asc web privacy publish --app "APP_ID" --confirm +``` + +### 2b. Full data collection flow + +Create a declaration file listing all collected data types with their purposes and protections: + +```bash +cat > /tmp/privacy.json << 'EOF' +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "EMAIL_ADDRESS", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "NAME", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "CRASH_DATA", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_NOT_LINKED_TO_YOU"] + } + ] +} +EOF +``` + +Each entry in `dataUsages` specifies: +- `category` — one category ID from the table above +- `purposes` — array of purpose IDs (what the data is used for) +- `dataProtections` — array of protection IDs (how it's handled) + +Each combination of (category, purpose, protection) becomes a separate tuple in the API. For tracking data, use `DATA_USED_TO_TRACK_YOU` as the protection (no purpose needed for tracking entries). + +### 3. Preview changes + +```bash +asc web privacy plan --app "APP_ID" --file /tmp/privacy.json --pretty +``` + +This shows a diff of what will be created, updated, or deleted. Review with the user before applying. + +### 4. Apply changes + +```bash +asc web privacy apply --app "APP_ID" --file /tmp/privacy.json --allow-deletes --confirm +``` + +- `--allow-deletes` removes remote entries not in the local file +- `--confirm` confirms destructive operations +- This command **never auto-publishes** + +### 5. Publish + +```bash +asc web privacy publish --app "APP_ID" --confirm +``` + +This makes the declarations live. Must be done after apply. + +### 6. Verify + +```bash +asc web privacy pull --app "APP_ID" --pretty +``` + +## Common App Patterns + +### Simple app with no tracking, no user accounts + +```json +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "CRASH_DATA", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_NOT_LINKED_TO_YOU"] + } + ] +} +``` + +### App with user accounts + analytics + +```json +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "NAME", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "EMAIL_ADDRESS", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "USER_ID", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "PRODUCT_INTERACTION", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "CRASH_DATA", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_NOT_LINKED_TO_YOU"] + } + ] +} +``` + +### Health/fitness app + +```json +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "HEALTH", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "FITNESS", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "PRECISE_LOCATION", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + } + ] +} +``` + +## Session Authentication + +If `asc web privacy` commands fail with 401 or session errors, authenticate via the Blitz MCP tool: + +``` +Call the asc_web_auth MCP tool to open the Apple ID login window +``` + +Or ask the user to run in their terminal: +``` +asc web auth login --apple-id "EMAIL" +``` + +## Validation Rules + +- `DATA_NOT_COLLECTED` (empty `dataUsages` array) is mutually exclusive — cannot coexist with collected data entries +- Each collected-data entry requires at least one `purpose` and one `dataProtection` +- `DATA_USED_TO_TRACK_YOU` entries are stored without a purpose (tracking is category-wide) +- The `publish` step is required after `apply` — changes are not live until published + +## Agent Behavior + +- Always ask the user what data their app collects before creating the declaration +- If the user is unsure, analyze the source code for data collection patterns (SDKs, APIs, user auth) +- Use `plan` to preview changes before `apply` — show the diff to the user +- Always `publish` after successful `apply` +- Use `pull` to verify the final state +- **NEVER print session cookies.** All `asc web` commands handle auth internally +- If auth fails, call `asc_web_auth` MCP tool or ask user to run `asc web auth login` + +## Notes + +- Privacy nutrition labels are required for App Store submission +- Changes are not visible until published +- The declaration file format (`schemaVersion: 1`) is the canonical format used by `asc web privacy` +- Use `asc web privacy catalog` to get the full list of available tokens if needed diff --git a/src/resources/skills/asc-team-key-create/SKILL.md b/src/resources/skills/asc-team-key-create/SKILL.md new file mode 100644 index 0000000..1d7daa9 --- /dev/null +++ b/src/resources/skills/asc-team-key-create/SKILL.md @@ -0,0 +1,212 @@ +--- +name: asc-team-key-create +description: Create a new App Store Connect Team API Key with Admin permissions, download the one-time .p8 private key, and store it in ~/.blitz. Use when the user needs a new ASC API key for CLI auth, CI/CD, or external tooling. +--- + +# asc team key create + +Use this skill to create a new App Store Connect API Key with Admin permissions via Apple's iris API, download the one-time .p8 private key, and save it to `~/.blitz`. + +## When to use + +- User asks to "create an API key", "generate a team key", "new ASC key" +- User needs a fresh key for `asc auth login`, CI/CD pipelines, or external tooling +- User wants to rotate or replace an existing API key + +## Preconditions + +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- The authenticated Apple ID must have Account Holder or Admin role. + +## Workflow + +### 1. Check for an existing web session + +Before anything else, check if a web session file already exists: + +```bash +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" +``` + +- If `NO_SESSION`: call the `asc_web_auth` MCP tool first to open the Apple ID login window in Blitz. Wait for it to complete before proceeding. +- If `SESSION_EXISTS`: proceed to the next step. + +### 2. Ask the user for a key name + +Ask the user what they want to name the key (the `nickname` field in ASC). This is a required input — do not guess or use a default. + +### 3. Create the key, download the .p8, and save it + +Use the following self-contained script. Replace `KEY_NAME` with the user's chosen name. **Do not print or log cookies** — they contain sensitive session tokens. + +```bash +python3 -c " +import json, urllib.request, base64, os, sys, time + +KEY_NAME = 'KEY_NAME_HERE' + +# Read web session file (silent — never print these) +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str +} + +# Step 1: Create the API key +create_body = json.dumps({ + 'data': { + 'type': 'apiKeys', + 'attributes': { + 'nickname': KEY_NAME, + 'roles': ['ADMIN'], + 'allAppsVisible': True, + 'keyType': 'PUBLIC_API' + } + } +}).encode() + +req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/apiKeys', + data=create_body, method='POST', headers=headers) +try: + resp = urllib.request.urlopen(req) + create_data = json.loads(resp.read().decode()) +except urllib.error.HTTPError as e: + body = e.read().decode() + if e.code == 401: + print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + elif e.code == 409: + print(f'ERROR: A key with this name may already exist. Details: {body[:300]}') + else: + print(f'ERROR creating key: HTTP {e.code} — {body[:300]}') + sys.exit(1) + +key_id = create_data['data']['id'] +can_download = create_data['data']['attributes'].get('canDownload', False) +print(f'Created API key \"{KEY_NAME}\" — Key ID: {key_id}') + +if not can_download: + print('ERROR: Key created but canDownload is false. Cannot retrieve private key.') + sys.exit(1) + +# Step 2: Download the one-time private key +time.sleep(0.5) +dl_headers = dict(headers) +dl_headers.pop('Content-Type', None) +req = urllib.request.Request( + f'https://appstoreconnect.apple.com/iris/v1/apiKeys/{key_id}?fields%5BapiKeys%5D=privateKey', + method='GET', headers=dl_headers) +try: + resp = urllib.request.urlopen(req) + dl_data = json.loads(resp.read().decode()) +except urllib.error.HTTPError as e: + print(f'ERROR downloading key: HTTP {e.code} — {e.read().decode()[:300]}') + sys.exit(1) + +pk_b64 = dl_data['data']['attributes'].get('privateKey') +if not pk_b64: + print('ERROR: No privateKey in response. The key may have already been downloaded.') + sys.exit(1) + +private_key_pem = base64.b64decode(pk_b64).decode() + +# Step 3: Get the issuer ID from the provider relationship +time.sleep(0.35) +req = urllib.request.Request( + f'https://appstoreconnect.apple.com/iris/v1/apiKeys/{key_id}?include=provider', + method='GET', headers=dl_headers) +try: + resp = urllib.request.urlopen(req) + provider_data = json.loads(resp.read().decode()) + issuer_id = None + for inc in provider_data.get('included', []): + if inc['type'] == 'contentProviders': + issuer_id = inc['id'] + break + if not issuer_id: + issuer_id = provider_data['data']['relationships']['provider']['data']['id'] +except Exception: + issuer_id = 'UNKNOWN' + +# Step 4: Save .p8 file to ~/.blitz +blitz_dir = os.path.expanduser('~/.blitz') +os.makedirs(blitz_dir, exist_ok=True) +p8_path = os.path.join(blitz_dir, f'AuthKey_{key_id}.p8') +with open(p8_path, 'w') as f: + f.write(private_key_pem) +os.chmod(p8_path, 0o600) + +print(f'Private key saved to: {p8_path}') +print(f'Issuer ID: {issuer_id}') +print(f'Key ID: {key_id}') +print() +print('To use with asc CLI:') +print(f' asc auth login --key-id {key_id} --issuer-id {issuer_id} --private-key-path {p8_path}') +print() +print('WARNING: This .p8 file can only be downloaded ONCE. Keep it safe.') +" +``` + +### 4. Fill the credential form via MCP + +After the script succeeds, call the `asc_set_credentials` MCP tool to pre-fill the Blitz credential form: + +``` +asc_set_credentials(issuerId: "", keyId: "", privateKeyPath: "~/.blitz/AuthKey_.p8") +``` + +This lets the user visually verify the values and click "Save Credentials" in Blitz. + +### 5. Report results to the user + +After the script runs, report: +- Key name and Key ID +- Issuer ID +- File path of the saved .p8 +- That the credential form has been pre-filled — they should verify and click Save + +## Common Errors + +### 401 Not Authorized +The web session has expired or doesn't exist. Call the `asc_web_auth` MCP tool — this opens the Apple ID login window in Blitz and refreshes `~/.blitz/asc-agent/web-session.json` automatically. Then retry the key creation script. + +### 409 Conflict +A key with the same name may already exist, or another conflict occurred. Try a different name. + +### "No privateKey in response" +The key's one-time download window has passed (`canDownload` flipped to `false`). This happens if the key was already downloaded. The key must be revoked and a new one created. + +## Agent Behavior + +- **Always ask the user for the key name** before creating. Do not use defaults. +- **NEVER print, log, or echo session cookies.** The python script handles cookies internally. +- Use the self-contained python script above — do NOT extract cookies separately or pass them as shell variables. +- After creation, confirm the .p8 file exists and report the path. +- If iris API returns 401, tell the user to re-authenticate via Blitz or `asc web auth login`. +- The iris API is rate-limited; the script includes 350ms+ delays between requests. +- The .p8 private key file is saved with `0600` permissions (owner read/write only). + +## Notes + +- The private key can only be downloaded **once** from Apple. After the first download, `canDownload` flips to `false` permanently. The saved .p8 file is the only copy. +- The issuer ID is the same for all keys in the team — it's the content provider UUID. +- Keys are created with `allAppsVisible: true` (access to all apps in the team). +- To revoke a key later, use `PATCH /iris/v1/apiKeys/{keyId}` with `{"data": {"type": "apiKeys", "id": "KEY_ID", "attributes": {"isActive": false}}}`. diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift deleted file mode 100644 index ec83792..0000000 --- a/src/services/ASCManager.swift +++ /dev/null @@ -1,2079 +0,0 @@ -import Foundation -import AppKit -import ImageIO -import Security -import CryptoKit - -// MARK: - Screenshot Track Models - -struct TrackSlot: Identifiable, Equatable { - let id: String // UUID for local, ASC id for uploaded - var localPath: String? // file path for local assets - var localImage: NSImage? // loaded thumbnail - var ascScreenshot: ASCScreenshot? // present if from ASC - var isFromASC: Bool // true if this slot was loaded from ASC - - static func == (lhs: TrackSlot, rhs: TrackSlot) -> Bool { - lhs.id == rhs.id - } -} - -struct LocalScreenshotAsset: Identifiable { - let id: UUID - let url: URL - let image: NSImage - let fileName: String -} - -@MainActor -@Observable -final class ASCManager { - nonisolated init() {} - - // Credentials & service - var credentials: ASCCredentials? - private(set) var service: AppStoreConnectService? - - // App - var app: ASCApp? - - // Loading / error state - var isLoadingCredentials = false - var credentialsError: String? - var isLoadingApp = false - - // Per-tab data - var appStoreVersions: [ASCAppStoreVersion] = [] - var localizations: [ASCVersionLocalization] = [] - var screenshotSets: [ASCScreenshotSet] = [] - var screenshots: [String: [ASCScreenshot]] = [:] // keyed by screenshotSet.id - var customerReviews: [ASCCustomerReview] = [] - var builds: [ASCBuild] = [] - var betaGroups: [ASCBetaGroup] = [] - var betaLocalizations: [ASCBetaLocalization] = [] - var betaFeedback: [String: [ASCBetaFeedback]] = [:] // keyed by build.id - var selectedBuildId: String? - - // Monetization data - var inAppPurchases: [ASCInAppPurchase] = [] - var subscriptionGroups: [ASCSubscriptionGroup] = [] - var subscriptionsPerGroup: [String: [ASCSubscription]] = [:] // groupId → subs - var appPricePoints: [ASCPricePoint] = [] // USA price tiers for the app - var currentAppPricePointId: String? - var scheduledAppPricePointId: String? - var scheduledAppPriceEffectiveDate: String? - - // Creation progress (survives tab switches) - var createProgress: Double = 0 - var createProgressMessage: String = "" - var isCreating = false - private var createTask: Task? - - // New data for submission flow - var appInfo: ASCAppInfo? - var appInfoLocalization: ASCAppInfoLocalization? - var ageRatingDeclaration: ASCAgeRatingDeclaration? - var reviewDetail: ASCReviewDetail? - var pendingCredentialValues: [String: String]? // Pre-fill values for ASC credential form (from MCP) - var pendingFormValues: [String: [String: String]] = [:] // tab → field → value (for MCP pre-fill) - var pendingFormVersion: Int = 0 // Incremented when pendingFormValues changes; views watch this - var pendingCreateValues: [String: String]? // Pre-fill values for IAP/subscription create forms (from MCP) - var showSubmitPreview = false - var isSubmitting = false - var submissionError: String? - var writeError: String? // Inline error for write operations (does not replace tab content) - - // Review submission history (for rejection tracking) - var reviewSubmissions: [ASCReviewSubmission] = [] - var reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] - var latestSubmissionItems: [ASCReviewSubmissionItem] = [] - var submissionHistoryEvents: [ASCSubmissionHistoryEvent] = [] - - // Iris (Apple ID session) — rejection feedback from internal API - enum IrisSessionState { case unknown, noSession, valid, expired } - var irisSession: IrisSession? - private(set) var irisService: IrisService? - var irisSessionState: IrisSessionState = .unknown - var isLoadingIrisFeedback = false - var irisFeedbackError: String? - var showAppleIDLogin = false - private var pendingWebAuthContinuation: CheckedContinuation? - var attachedSubmissionItemIDs: Set = [] // IAP/subscription IDs attached via iris API - var resolutionCenterThreads: [IrisResolutionCenterThread] = [] - var rejectionMessages: [IrisResolutionCenterMessage] = [] - var rejectionReasons: [IrisReviewRejection] = [] - var cachedFeedback: IrisFeedbackCache? // loaded from disk, survives session expiry - - // App icon status (set externally; nil = not checked / missing) - var appIconStatus: String? - - // monetization status (set after monetization check or setPriceFree success) - var monetizationStatus: String? - - // Build pipeline progress (driven by MCPToolExecutor) - enum BuildPipelinePhase: String { - case idle - case signingSetup = "Setting up signing…" - case archiving = "Archiving…" - case exporting = "Exporting IPA…" - case uploading = "Uploading to App Store Connect…" - case processing = "Processing build…" - } - var buildPipelinePhase: BuildPipelinePhase = .idle - var buildPipelineMessage: String = "" // Latest progress line from the build - - // Screenshot track state per device type - var trackSlots: [String: [TrackSlot?]] = [:] // keyed by ascDisplayType, 10-element arrays - var savedTrackState: [String: [TrackSlot?]] = [:] // snapshot after last load/save - var localScreenshotAssets: [LocalScreenshotAsset] = [] - var isSyncing = false - - /// Age rating is "configured" only if the enum fields have actual values (not nil). - /// ASC returns the declaration object with nil fields by default — submitting with - /// nil fields causes a 409. - private var ageRatingIsConfigured: Bool { - guard let ar = ageRatingDeclaration?.attributes else { return false } - // Check that at least the required enum fields are non-nil - return ar.alcoholTobaccoOrDrugUseOrReferences != nil - && ar.violenceCartoonOrFantasy != nil - && ar.violenceRealistic != nil - && ar.sexualContentOrNudity != nil - && ar.sexualContentGraphicAndNudity != nil - && ar.profanityOrCrudeHumor != nil - && ar.gamblingSimulated != nil - } - - var submissionReadiness: SubmissionReadiness { - let loc = localizations.first - let info = appInfoLocalization - let review = reviewDetail - let demoRequired = review?.attributes.demoAccountRequired == true - let version = appStoreVersions.first - - // Screenshot checks per display type — detect platform from available sets - let macScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } - let isMacApp = macScreenshots != nil - let iphoneScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPHONE_67" } - let ipadScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPAD_PRO_3GEN_129" } - - // Privacy nutrition labels URL (manual action required) - let privacyUrl: String? = app.map { - "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/privacy" - } - - var fields: [SubmissionReadiness.FieldStatus] = [ - .init(label: "App Name", value: info?.attributes.name ?? loc?.attributes.title), - .init(label: "Description", value: loc?.attributes.description), - .init(label: "Keywords", value: loc?.attributes.keywords), - .init(label: "Support URL", value: loc?.attributes.supportUrl), - .init(label: "Privacy Policy URL", value: info?.attributes.privacyPolicyUrl), - .init(label: "Copyright", value: version?.attributes.copyright), - .init(label: "Content Rights", value: app?.contentRightsDeclaration), - .init(label: "Primary Category", value: appInfo?.primaryCategoryId), - .init(label: "Age Rating", value: ageRatingIsConfigured ? "Configured" : nil), - .init(label: "Pricing", value: monetizationStatus), - .init(label: "Review Contact First Name", value: review?.attributes.contactFirstName), - .init(label: "Review Contact Last Name", value: review?.attributes.contactLastName), - .init(label: "Review Contact Email", value: review?.attributes.contactEmail), - .init(label: "Review Contact Phone", value: review?.attributes.contactPhone), - ] - - // Conditional: demo credentials required when demoAccountRequired is set - if demoRequired { - fields.append(.init(label: "Demo Account Name", value: review?.attributes.demoAccountName)) - fields.append(.init(label: "Demo Account Password", value: review?.attributes.demoAccountPassword)) - } - - fields.append(.init(label: "App Icon", value: appIconStatus)) - - // Count only non-failed screenshots for readiness - func validCount(for set: ASCScreenshotSet?) -> Int { - guard let set else { return 0 } - if let shots = screenshots[set.id] { - return shots.filter { !$0.hasError }.count - } - return set.attributes.screenshotCount ?? 0 - } - - if isMacApp { - let macCount = validCount(for: macScreenshots) - fields.append(.init(label: "Mac Screenshots", value: macCount > 0 ? "\(macCount) screenshot(s)" : nil)) - } else { - let iphoneCount = validCount(for: iphoneScreenshots) - let ipadCount = validCount(for: ipadScreenshots) - fields.append(.init(label: "iPhone Screenshots", value: iphoneCount > 0 ? "\(iphoneCount) screenshot(s)" : nil)) - fields.append(.init(label: "iPad Screenshots", value: ipadCount > 0 ? "\(ipadCount) screenshot(s)" : nil)) - } - - fields.append(contentsOf: [ - .init(label: "Privacy Nutrition Labels", value: nil, required: false, actionUrl: privacyUrl), - .init(label: "Build", value: builds.first?.attributes.version), - ]) - - // Conditional: first-time IAP/subscription attachment - // Only shown when (a) IAPs or subscriptions exist in READY_TO_SUBMIT state - // AND (b) no version has ever been approved (first-time submission) - let approvedStates: Set = ["READY_FOR_SALE", "REMOVED_FROM_SALE", - "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION", "PROCESSING_FOR_APP_STORE"] - let hasApprovedVersion = appStoreVersions.contains { - approvedStates.contains($0.attributes.appStoreState ?? "") - } - let isFirstVersion = !hasApprovedVersion - if isFirstVersion { - let readyIAPs = inAppPurchases.filter { $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) } - let readySubs = subscriptionsPerGroup.values.flatMap { $0 } - .filter { $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) } - let readyCount = readyIAPs.count + readySubs.count - if readyCount > 0 { - let names = (readyIAPs.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id } - + readySubs.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id }) - .joined(separator: ", ") - let iapUrl: String? = app.map { - "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/ios/version/inflight" - } - fields.append(.init( - label: "In-App Purchases & Subscriptions", - value: nil, - required: true, - actionUrl: iapUrl, - hint: "\(readyCount) item(s) in Ready to Submit state (\(names)) must be attached to this version before submission. " - + "Use the asc-iap-attach skill to attach them via the iris API (asc web session). " - + "The public API does not support first-time IAP/subscription attachment — " - + "run: asc web auth login, then POST to /iris/v1/subscriptionSubmissions or /iris/v1/inAppPurchaseSubmissions " - + "with submitWithNextAppStoreVersion:true for each item." - )) - } - } - - return SubmissionReadiness(fields: fields) - } - - // Per-tab loading / error - var isLoadingTab: [AppTab: Bool] = [:] - var tabError: [AppTab: String] = [:] - private var loadedTabs: Set = [] - - var loadedProjectId: String? - - // MARK: - App Icon Check - - /// Check whether the project has app icon assets at ~/.blitz/projects/{projectId}/assets/AppIcon/ - func checkAppIcon(projectId: String) { - let fm = FileManager.default - let home = fm.homeDirectoryForCurrentUser.path - let iconDir = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon" - let icon1024 = "\(iconDir)/icon_1024.png" - - if fm.fileExists(atPath: icon1024) { - appIconStatus = "1024px" - } else { - // Also check the Xcode project's xcassets as fallback - let projectDir = "\(home)/.blitz/projects/\(projectId)" - let xcassetsPattern = ["ios", "macos", "."] - for subdir in xcassetsPattern { - let searchDir = subdir == "." ? projectDir : "\(projectDir)/\(subdir)" - if let enumerator = fm.enumerator(atPath: searchDir) { - while let file = enumerator.nextObject() as? String { - if file.hasSuffix("AppIcon.appiconset/Contents.json") { - let contentsPath = "\(searchDir)/\(file)" - if let data = fm.contents(atPath: contentsPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let images = json["images"] as? [[String: Any]] { - let hasFilename = images.contains { $0["filename"] != nil } - if hasFilename { - appIconStatus = "Configured" - return - } - } - } - } - } - } - appIconStatus = nil - } - } - - private func refreshAppIconStatusIfNeeded(for projectId: String?) { - guard let projectId, !projectId.isEmpty else { return } - checkAppIcon(projectId: projectId) - } - - // MARK: - Project Lifecycle - - func loadCredentials(for projectId: String, bundleId: String?) async { - guard loadedProjectId != projectId else { return } - - isLoadingCredentials = true - credentialsError = nil - - let creds = ASCCredentials.load() - - credentials = creds - isLoadingCredentials = false - loadedProjectId = projectId - refreshAppIconStatusIfNeeded(for: projectId) - - if let creds { - service = AppStoreConnectService(credentials: creds) - } - - if let bundleId, !bundleId.isEmpty, creds != nil { - await fetchApp(bundleId: bundleId) - } - } - - func clearForProjectSwitch() { - credentials = nil - service = nil - app = nil - isLoadingCredentials = false - credentialsError = nil - isLoadingApp = false - appStoreVersions = [] - localizations = [] - screenshotSets = [] - screenshots = [:] - customerReviews = [] - builds = [] - betaGroups = [] - betaLocalizations = [] - betaFeedback = [:] - selectedBuildId = nil - inAppPurchases = [] - subscriptionGroups = [] - subscriptionsPerGroup = [:] - appPricePoints = [] - currentAppPricePointId = nil - scheduledAppPricePointId = nil - scheduledAppPriceEffectiveDate = nil - appInfo = nil - appInfoLocalization = nil - ageRatingDeclaration = nil - reviewDetail = nil - pendingFormValues = [:] - showSubmitPreview = false - isSubmitting = false - submissionError = nil - writeError = nil - reviewSubmissions = [] - reviewSubmissionItemsBySubmissionId = [:] - latestSubmissionItems = [] - submissionHistoryEvents = [] - appIconStatus = nil - monetizationStatus = nil - attachedSubmissionItemIDs = [] - isLoadingTab = [:] - tabError = [:] - loadedTabs = [] - loadedProjectId = nil - // Clear iris data but keep session (it's account-wide, not project-specific) - resolutionCenterThreads = [] - rejectionMessages = [] - rejectionReasons = [] - cachedFeedback = nil - isLoadingIrisFeedback = false - irisFeedbackError = nil - cancelPendingWebAuth() - } - - // MARK: - Iris Session (Apple ID auth for rejection feedback) - - private func irisLog(_ msg: String) { - let logPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".blitz/iris-debug.log") - let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] \(msg)\n" - if let data = line.data(using: .utf8) { - if FileManager.default.fileExists(atPath: logPath.path) { - if let handle = try? FileHandle(forWritingTo: logPath) { - handle.seekToEndOfFile() - handle.write(data) - handle.closeFile() - } - } else { - let dir = logPath.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? data.write(to: logPath) - } - } - } - - func loadIrisSession() { - irisLog("ASCManager.loadIrisSession: starting") - guard let loaded = IrisSession.load() else { - irisLog("ASCManager.loadIrisSession: no session file found") - irisSessionState = .noSession - irisSession = nil - irisService = nil - return - } - // No time-based expiry — we trust the session until a 401 proves otherwise - irisLog("ASCManager.loadIrisSession: loaded session with \(loaded.cookies.count) cookies, capturedAt=\(loaded.capturedAt)") - irisSession = loaded - irisService = IrisService(session: loaded) - irisSessionState = .valid - irisLog("ASCManager.loadIrisSession: session valid, irisService created") - } - - func requestWebAuthForMCP() async -> IrisSession? { - pendingWebAuthContinuation?.resume(returning: nil) - irisFeedbackError = nil - showAppleIDLogin = true - return await withCheckedContinuation { continuation in - pendingWebAuthContinuation = continuation - } - } - - func cancelPendingWebAuth() { - showAppleIDLogin = false - pendingWebAuthContinuation?.resume(returning: nil) - pendingWebAuthContinuation = nil - } - - func setIrisSession(_ session: IrisSession) { - irisLog("ASCManager.setIrisSession: \(session.cookies.count) cookies") - do { - try session.save() - irisLog("ASCManager.setIrisSession: saved to native keychain") - } catch { - irisLog("ASCManager.setIrisSession: save FAILED: \(error)") - irisFeedbackError = "Failed to save session: \(error.localizedDescription)" - showAppleIDLogin = false - pendingWebAuthContinuation?.resume(returning: nil) - pendingWebAuthContinuation = nil - return - } - - // Also write the asc-web-session keychain item used by CLI skill scripts. - // If that write fails during an MCP-triggered login, keep the native session - // but fail the MCP request instead of reporting a false success. - do { - try Self.storeWebSessionToKeychain(session) - } catch { - irisLog("ASCManager.setIrisSession: asc-web-session save FAILED: \(error)") - irisFeedbackError = "Failed to save ASC web session: \(error.localizedDescription)" - if let continuation = pendingWebAuthContinuation { - pendingWebAuthContinuation = nil - continuation.resume(returning: nil) - } - } - - irisSession = session - irisService = IrisService(session: session) - irisSessionState = .valid - irisLog("ASCManager.setIrisSession: state set to .valid") - showAppleIDLogin = false - - // Notify MCP tool if it triggered this login - if let continuation = pendingWebAuthContinuation { - pendingWebAuthContinuation = nil - continuation.resume(returning: session) - } - } - - func clearIrisSession() { - irisLog("ASCManager.clearIrisSession") - IrisSession.delete() - Self.deleteWebSessionFromKeychain() - irisSession = nil - irisService = nil - irisSessionState = .noSession - resolutionCenterThreads = [] - rejectionMessages = [] - rejectionReasons = [] - if let appId = app?.id { - rebuildSubmissionHistory(appId: appId) - } - } - - // MARK: - Unified Web Session Keychain (for CLI skill scripts) - - private static let webSessionService = "asc-web-session" - private static let webSessionAccount = "asc:web-session:store" - - /// Write session cookies in the format expected by CLI skill scripts - /// (readable via `security find-generic-password -s "asc-web-session" -w`). - private static func storeWebSessionToKeychain(_ session: IrisSession) throws { - var cookiesByDomain: [String: [[String: Any]]] = [:] - for cookie in session.cookies { - let domainKey = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain - cookiesByDomain[domainKey, default: []].append([ - "name": cookie.name, - "value": cookie.value, - "domain": cookie.domain, - "path": cookie.path, - "secure": true, - "http_only": true, - ]) - } - - let normalizedEmail = (session.email ?? "unknown") - .lowercased() - .trimmingCharacters(in: .whitespaces) - let hashBytes = SHA256.hash(data: Data(normalizedEmail.utf8)) - let hashString = hashBytes.map { String(format: "%02x", $0) }.joined() - - let sessionEntry: [String: Any] = [ - "version": 1, - "updated_at": ISO8601DateFormatter().string(from: Date()), - "cookies": cookiesByDomain, - ] - - let store: [String: Any] = [ - "version": 1, - "last_key": hashString, - "sessions": [hashString: sessionEntry], - ] - - let data = try JSONSerialization.data(withJSONObject: store) - - deleteWebSessionFromKeychain() - - let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: webSessionService, - kSecAttrAccount as String: webSessionAccount, - kSecAttrLabel as String: "ASC Web Session Store", - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, - ] - let status = SecItemAdd(addQuery as CFDictionary, nil) - guard status == errSecSuccess else { - throw NSError( - domain: "ASCWebSessionStore", - code: Int(status), - userInfo: [NSLocalizedDescriptionKey: "Keychain write failed (status: \(status))"] - ) - } - } - - private static func deleteWebSessionFromKeychain() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: webSessionService, - kSecAttrAccount as String: webSessionAccount, - ] - SecItemDelete(query as CFDictionary) - } - - /// Loads cached feedback from disk for the given rejected version. No auth needed. - func loadCachedFeedback(appId: String, versionString: String) { - irisLog("ASCManager.loadCachedFeedback: appId=\(appId) version=\(versionString)") - if let cached = IrisFeedbackCache.load(appId: appId, versionString: versionString) { - cachedFeedback = cached - irisLog("ASCManager.loadCachedFeedback: loaded \(cached.reasons.count) reasons, \(cached.messages.count) messages, fetched \(cached.fetchedAt)") - } else { - irisLog("ASCManager.loadCachedFeedback: no cache found") - cachedFeedback = nil - } - rebuildSubmissionHistory(appId: appId) - } - - func fetchRejectionFeedback() async { - irisLog("ASCManager.fetchRejectionFeedback: irisService=\(irisService != nil), appId=\(app?.id ?? "nil")") - guard let irisService, let appId = app?.id else { - irisLog("ASCManager.fetchRejectionFeedback: guard failed, returning") - return - } - - // Determine version string for cache - let rejectedVersion = appStoreVersions.first(where: { - $0.attributes.appStoreState == "REJECTED" - })?.attributes.versionString - - isLoadingIrisFeedback = true - irisFeedbackError = nil - - do { - let threads = try await irisService.fetchResolutionCenterThreads(appId: appId) - irisLog("ASCManager.fetchRejectionFeedback: got \(threads.count) threads") - resolutionCenterThreads = threads - - if let latestThread = threads.first { - irisLog("ASCManager.fetchRejectionFeedback: fetching messages+rejections for thread \(latestThread.id)") - let result = try await irisService.fetchMessagesAndRejections(threadId: latestThread.id) - rejectionMessages = result.messages - rejectionReasons = result.rejections - irisLog("ASCManager.fetchRejectionFeedback: got \(rejectionMessages.count) messages, \(rejectionReasons.count) rejections") - - // Write cache - if let version = rejectedVersion { - let cache = buildFeedbackCache(appId: appId, versionString: version) - do { - try cache.save() - cachedFeedback = cache - irisLog("ASCManager.fetchRejectionFeedback: cache saved for \(version)") - } catch { - irisLog("ASCManager.fetchRejectionFeedback: cache save failed: \(error)") - } - } - } else { - irisLog("ASCManager.fetchRejectionFeedback: no threads found") - rejectionMessages = [] - rejectionReasons = [] - } - } catch let error as IrisError { - irisLog("ASCManager.fetchRejectionFeedback: IrisError: \(error)") - if case .sessionExpired = error { - irisSessionState = .expired - irisSession = nil - self.irisService = nil - } else { - irisFeedbackError = error.localizedDescription - } - } catch { - irisLog("ASCManager.fetchRejectionFeedback: error: \(error)") - irisFeedbackError = error.localizedDescription - } - - isLoadingIrisFeedback = false - rebuildSubmissionHistory(appId: appId) - irisLog("ASCManager.fetchRejectionFeedback: done") - } - - /// Builds a cache object from current in-memory rejection data. - private func buildFeedbackCache(appId: String, versionString: String) -> IrisFeedbackCache { - let msgs = rejectionMessages.map { msg in - IrisFeedbackCache.CachedMessage( - body: msg.attributes.messageBody.map { htmlToPlainText($0) } ?? "", - date: msg.attributes.createdDate - ) - } - let reasons = rejectionReasons.flatMap { rejection in - (rejection.attributes.reasons ?? []).map { r in - IrisFeedbackCache.CachedReason( - section: r.reasonSection ?? "", - description: r.reasonDescription ?? "", - code: r.reasonCode ?? "" - ) - } - } - return IrisFeedbackCache( - appId: appId, - versionString: versionString, - fetchedAt: Date(), - messages: msgs, - reasons: reasons - ) - } - - private func refreshReviewSubmissionData(appId: String, service: AppStoreConnectService) async { - let submissions = (try? await service.fetchReviewSubmissions(appId: appId)) ?? [] - reviewSubmissions = submissions - - guard !submissions.isEmpty else { - reviewSubmissionItemsBySubmissionId = [:] - latestSubmissionItems = [] - return - } - - var itemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] - await withTaskGroup(of: (String, [ASCReviewSubmissionItem]).self) { group in - for submission in submissions { - group.addTask { - let items = (try? await service.fetchReviewSubmissionItems(submissionId: submission.id)) ?? [] - return (submission.id, items) - } - } - - for await (submissionId, items) in group { - itemsBySubmissionId[submissionId] = items - } - } - - reviewSubmissionItemsBySubmissionId = itemsBySubmissionId - latestSubmissionItems = itemsBySubmissionId[submissions.first?.id ?? ""] ?? [] - } - - private func historyNowString() -> String { - ISO8601DateFormatter().string(from: Date()) - } - - private func historyDate(_ iso: String?) -> Date { - guard let iso else { return .distantPast } - let f1 = ISO8601DateFormatter() - f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let f2 = ISO8601DateFormatter() - return f1.date(from: iso) ?? f2.date(from: iso) ?? .distantPast - } - - private func historyEventType(forVersionState state: String) -> ASCSubmissionHistoryEventType? { - switch state { - case "WAITING_FOR_REVIEW": - return .submitted - case "IN_REVIEW": - return .inReview - case "PROCESSING": - return .processing - case "PENDING_DEVELOPER_RELEASE": - return .accepted - case "READY_FOR_SALE": - return .live - case "REJECTED": - return .rejected - case "DEVELOPER_REJECTED": - return .withdrawn - case "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE": - return .removed - default: - return nil - } - } - - private func historyCoverageKey( - versionId: String?, - versionString: String, - eventType: ASCSubmissionHistoryEventType - ) -> String { - "\(versionId ?? "version:\(versionString)")::\(eventType.rawValue)" - } - - private func versionString( - for versionId: String?, - versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] - ) -> String? { - guard let versionId else { return nil } - if let version = appStoreVersions.first(where: { $0.id == versionId }) { - return version.attributes.versionString - } - return versionSnapshots[versionId]?.versionString - } - - private func versionId( - for versionString: String, - versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] - ) -> String? { - if let version = appStoreVersions.first(where: { $0.attributes.versionString == versionString }) { - return version.id - } - return versionSnapshots.values.first(where: { $0.versionString == versionString })?.versionId - } - - private func refreshSubmissionHistoryCache(appId: String) -> ASCSubmissionHistoryCache { - var cache = ASCSubmissionHistoryCache.load(appId: appId) - let now = historyNowString() - - for version in appStoreVersions { - let state = version.attributes.appStoreState ?? "" - guard !state.isEmpty else { continue } - - if var snapshot = cache.versionSnapshots[version.id] { - snapshot.versionString = version.attributes.versionString - if snapshot.lastKnownState != state, - let eventType = historyEventType(forVersionState: state) { - cache.transitionEvents.append( - ASCSubmissionHistoryEvent( - id: "ledger:\(version.id):\(state):\(now)", - versionId: version.id, - versionString: version.attributes.versionString, - eventType: eventType, - appleState: state, - occurredAt: now, - source: .transitionLedger, - accuracy: .firstSeen, - submissionId: nil, - note: nil - ) - ) - snapshot.lastKnownState = state - snapshot.lastSeenAt = now - } else { - snapshot.lastSeenAt = now - } - cache.versionSnapshots[version.id] = snapshot - } else { - cache.versionSnapshots[version.id] = .init( - versionId: version.id, - versionString: version.attributes.versionString, - lastKnownState: state, - lastSeenAt: now - ) - } - } - - cache.transitionEvents.sort { historyDate($0.occurredAt) > historyDate($1.occurredAt) } - try? cache.save() - return cache - } - - private func rebuildSubmissionHistory(appId: String) { - let cache = refreshSubmissionHistoryCache(appId: appId) - let versionSnapshots = cache.versionSnapshots - - let submissionEvents = reviewSubmissions.compactMap { submission -> ASCSubmissionHistoryEvent? in - guard let submittedAt = submission.attributes.submittedDate else { return nil } - let versionId = reviewSubmissionItemsBySubmissionId[submission.id]? - .compactMap(\.appStoreVersionId) - .first - let versionString = versionString(for: versionId, versionSnapshots: versionSnapshots) ?? "Unknown" - return ASCSubmissionHistoryEvent( - id: "submission:\(submission.id)", - versionId: versionId, - versionString: versionString, - eventType: .submitted, - appleState: "WAITING_FOR_REVIEW", - occurredAt: submittedAt, - source: .reviewSubmission, - accuracy: .exact, - submissionId: submission.id, - note: nil - ) - } - - var rejectionEventsByVersion: [String: ASCSubmissionHistoryEvent] = [:] - for cacheEntry in IrisFeedbackCache.loadAll(appId: appId) { - let rejectionAt = cacheEntry.messages - .compactMap(\.date) - .sorted(by: { historyDate($0) < historyDate($1) }) - .first - ?? ISO8601DateFormatter().string(from: cacheEntry.fetchedAt) - - rejectionEventsByVersion[cacheEntry.versionString] = ASCSubmissionHistoryEvent( - id: "iris:\(cacheEntry.versionString):\(rejectionAt)", - versionId: versionId(for: cacheEntry.versionString, versionSnapshots: versionSnapshots), - versionString: cacheEntry.versionString, - eventType: .rejected, - appleState: "REJECTED", - occurredAt: rejectionAt, - source: .irisFeedback, - accuracy: .derived, - submissionId: nil, - note: cacheEntry.reasons.first?.section - ) - } - - if let rejectedVersion = appStoreVersions.first(where: { $0.attributes.appStoreState == "REJECTED" }) { - let rejectionAt = resolutionCenterThreads.first?.attributes.createdDate - ?? rejectionMessages.compactMap(\.attributes.createdDate) - .sorted(by: { historyDate($0) < historyDate($1) }) - .first - if let rejectionAt { - rejectionEventsByVersion[rejectedVersion.attributes.versionString] = ASCSubmissionHistoryEvent( - id: "iris-live:\(rejectedVersion.id):\(rejectionAt)", - versionId: rejectedVersion.id, - versionString: rejectedVersion.attributes.versionString, - eventType: .rejected, - appleState: "REJECTED", - occurredAt: rejectionAt, - source: .irisFeedback, - accuracy: .derived, - submissionId: nil, - note: rejectionReasons.first?.attributes.reasons?.first?.reasonSection - ) - } - } - - let durableEvents = submissionEvents - + Array(rejectionEventsByVersion.values) - + cache.transitionEvents - - let coveredEventKeys = Set( - durableEvents.map { - historyCoverageKey(versionId: $0.versionId, versionString: $0.versionString, eventType: $0.eventType) - } - ) - - let fallbackEvents = appStoreVersions.compactMap { version -> ASCSubmissionHistoryEvent? in - let state = version.attributes.appStoreState ?? "" - guard let eventType = historyEventType(forVersionState: state) else { return nil } - - let coverageKey = historyCoverageKey( - versionId: version.id, - versionString: version.attributes.versionString, - eventType: eventType - ) - guard !coveredEventKeys.contains(coverageKey) else { return nil } - - let occurredAt = version.attributes.createdDate - ?? cache.versionSnapshots[version.id]?.lastSeenAt - ?? historyNowString() - - return ASCSubmissionHistoryEvent( - id: "version:\(version.id):\(state)", - versionId: version.id, - versionString: version.attributes.versionString, - eventType: eventType, - appleState: state, - occurredAt: occurredAt, - source: .currentVersion, - accuracy: .derived, - submissionId: nil, - note: nil - ) - } - - submissionHistoryEvents = (durableEvents + fallbackEvents) - .sorted { lhs, rhs in - historyDate(lhs.occurredAt) > historyDate(rhs.occurredAt) - } - } - - func refreshSubmissionFeedbackIfNeeded() { - guard let appId = app?.id else { return } - - let rejectedVersion = appStoreVersions.first(where: { - $0.attributes.appStoreState == "REJECTED" - }) - let pendingVersion = appStoreVersions.first(where: { - let state = $0.attributes.appStoreState ?? "" - return state != "READY_FOR_SALE" && state != "REMOVED_FROM_SALE" - && state != "DEVELOPER_REMOVED_FROM_SALE" && !state.isEmpty - }) - - guard let version = rejectedVersion ?? pendingVersion else { - cachedFeedback = nil - rebuildSubmissionHistory(appId: appId) - return - } - - loadCachedFeedback(appId: appId, versionString: version.attributes.versionString) - loadIrisSession() - if irisSessionState == .valid { - Task { await fetchRejectionFeedback() } - } - } - - func saveCredentials(_ creds: ASCCredentials, projectId: String, bundleId: String?) async throws { - try creds.save() - credentials = creds - service = AppStoreConnectService(credentials: creds) - credentialsError = nil - loadedTabs = [] // force re-fetch after new credentials - - if let bundleId, !bundleId.isEmpty { - await fetchApp(bundleId: bundleId) - } - } - - func deleteCredentials(projectId: String) { - ASCCredentials.delete() - let pid = loadedProjectId - clearForProjectSwitch() - loadedProjectId = pid // keep project id so gate re-checks correctly - } - - // MARK: - App Fetch - - @discardableResult - func fetchApp(bundleId: String, exactName: String? = nil) async -> Bool { - guard let service else { return false } - isLoadingApp = true - do { - let fetched = try await service.fetchApp(bundleId: bundleId, exactName: exactName) - app = fetched - credentialsError = nil - isLoadingApp = false - return true - } catch { - app = nil - credentialsError = error.localizedDescription - isLoadingApp = false - return false - } - } - - // MARK: - Tab Data - - func fetchTabData(_ tab: AppTab) async { - guard let service else { return } - guard credentials != nil else { return } - guard !loadedTabs.contains(tab) else { return } - guard isLoadingTab[tab] != true else { return } - - isLoadingTab[tab] = true - tabError.removeValue(forKey: tab) - - do { - try await loadData(for: tab, service: service) - isLoadingTab[tab] = false - loadedTabs.insert(tab) - } catch { - isLoadingTab[tab] = false - tabError[tab] = error.localizedDescription - } - } - - /// Called after bundle ID setup completes and the app is confirmed in ASC. - /// Clears all tab errors and forces data to be re-fetched. - func resetTabState() { - tabError.removeAll() - loadedTabs.removeAll() - } - - func refreshTabData(_ tab: AppTab) async { - guard let service else { return } - guard credentials != nil else { return } - - loadedTabs.remove(tab) - isLoadingTab[tab] = true - tabError.removeValue(forKey: tab) - - do { - try await loadData(for: tab, service: service) - isLoadingTab[tab] = false - loadedTabs.insert(tab) - } catch { - isLoadingTab[tab] = false - tabError[tab] = error.localizedDescription - } - } - - func refreshSubmissionReadinessData() async { - await refreshMonetization() - await refreshAttachedSubmissionItemIDs() - } - - private func loadData(for tab: AppTab, service: AppStoreConnectService) async throws { - guard let appId = app?.id else { - throw ASCError.notFound("App — check your bundle ID in project settings") - } - - switch tab { - case .ascOverview: - refreshAppIconStatusIfNeeded(for: loadedProjectId) - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - appInfo = try? await service.fetchAppInfo(appId: appId) - // Fetch all data needed for submission readiness - if let latestId = versions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) - let locs = localizations - if let firstLocId = locs.first?.id { - let sets = try await service.fetchScreenshotSets(localizationId: firstLocId) - screenshotSets = sets - for set in sets { - screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) - } - } - } - if let infoId = appInfo?.id { - ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } - builds = try await service.fetchBuilds(appId: appId) - await refreshReviewSubmissionData(appId: appId, service: service) - rebuildSubmissionHistory(appId: appId) - refreshSubmissionFeedbackIfNeeded() - - // Check monetization status — skip if already set (avoids race with in-flight fetches overwriting optimistic updates from setPriceFree/setAppPrice) - if monetizationStatus == nil { - let hasPricing = await service.fetchPricingConfigured(appId: appId) - monetizationStatus = hasPricing ? "Configured" : nil - } - - await refreshSubmissionReadinessData() - - case .storeListing: - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - if let latestId = versions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - } - // Also fetch appInfoLocalization for privacy policy URL - if appInfo == nil { - appInfo = try? await service.fetchAppInfo(appId: appId) - } - if let infoId = appInfo?.id, appInfoLocalization == nil { - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } - - case .screenshots: - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - if let latestId = versions.first?.id { - let locs = try await service.fetchLocalizations(versionId: latestId) - localizations = locs - if let firstLocId = locs.first?.id { - let sets = try await service.fetchScreenshotSets(localizationId: firstLocId) - screenshotSets = sets - for set in sets { - let shots = try await service.fetchScreenshots(setId: set.id) - screenshots[set.id] = shots - } - } - } - - case .appDetails: - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - appInfo = try? await service.fetchAppInfo(appId: appId) - - case .review: - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - if let latestId = versions.first?.id { - reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) - } - appInfo = try? await service.fetchAppInfo(appId: appId) - if let infoId = appInfo?.id { - ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) - } - builds = try await service.fetchBuilds(appId: appId) - await refreshReviewSubmissionData(appId: appId, service: service) - rebuildSubmissionHistory(appId: appId) - - case .monetization: - appPricePoints = try await service.fetchAppPricePoints(appId: appId) - let pricingState = (try? await service.fetchAppPricingState(appId: appId)) - ?? ASCAppPricingState(currentPricePointId: nil, scheduledPricePointId: nil, scheduledEffectiveDate: nil) - applyAppPricingState(pricingState) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for group in subscriptionGroups { - subscriptionsPerGroup[group.id] = try await service.fetchSubscriptionsInGroup(groupId: group.id) - } - if currentAppPricePointId == nil && scheduledAppPricePointId == nil && monetizationStatus == nil { - let hasPricing = await service.fetchPricingConfigured(appId: appId) - monetizationStatus = hasPricing ? "Configured" : nil - } - - case .analytics: - break // Sales reports use a separate reports API; handled in view - - case .reviews: - customerReviews = try await service.fetchCustomerReviews(appId: appId) - - case .builds: - builds = try await service.fetchBuilds(appId: appId) - - case .groups: - betaGroups = try await service.fetchBetaGroups(appId: appId) - - case .betaInfo: - betaLocalizations = try await service.fetchBetaLocalizations(appId: appId) - - case .feedback: - let fetched = try await service.fetchBuilds(appId: appId) - builds = fetched - if let first = fetched.first { - selectedBuildId = first.id - do { - betaFeedback[first.id] = try await service.fetchBetaFeedback(buildId: first.id) - } catch { - // Feedback may not be available for all apps; non-fatal - betaFeedback[first.id] = [] - } - } - - default: - break - } - } - - // MARK: - Write Methods - - func updateLocalizationField(_ field: String, value: String, locId: String) async { - guard let service else { return } - writeError = nil - do { - try await service.patchLocalization(id: locId, fields: [field: value]) - if let latestId = appStoreVersions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - } - } catch { - writeError = error.localizedDescription - } - } - - func updatePrivacyPolicyUrl(_ url: String) async { - await updateAppInfoLocalizationField("privacyPolicyUrl", value: url) - } - - /// Update a field on appInfoLocalizations (name, subtitle, privacyPolicyUrl) - func updateAppInfoLocalizationField(_ field: String, value: String) async { - guard let service else { return } - guard let locId = appInfoLocalization?.id else { return } - writeError = nil - // Map UI field names to API field names - let apiField = (field == "title") ? "name" : field - do { - try await service.patchAppInfoLocalization(id: locId, fields: [apiField: value]) - if let infoId = appInfo?.id { - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } - } catch { - writeError = error.localizedDescription - } - } - - func updateAppInfoField(_ field: String, value: String) async { - guard let service else { return } - writeError = nil - - // Fields that live on different ASC resources: - // - copyright → appStoreVersions (PATCH /v1/appStoreVersions/{id}) - // - contentRightsDeclaration → apps (PATCH /v1/apps/{id}) - // - primaryCategory, subcategories → appInfos relationships (PATCH /v1/appInfos/{id}) - if field == "copyright" { - guard let versionId = appStoreVersions.first?.id else { return } - do { - try await service.patchVersion(id: versionId, fields: [field: value]) - // Re-fetch versions so submissionReadiness picks up the new copyright - if let appId = app?.id { - appStoreVersions = try await service.fetchAppStoreVersions(appId: appId) - } - } catch { - writeError = error.localizedDescription - } - } else if field == "contentRightsDeclaration" { - guard let appId = app?.id else { return } - do { - try await service.patchApp(id: appId, fields: [field: value]) - // Refetch app to reflect the change - app = try await service.fetchApp(bundleId: app?.bundleId ?? "") - } catch { - writeError = error.localizedDescription - } - } else if let infoId = appInfo?.id { - do { - try await service.patchAppInfo(id: infoId, fields: [field: value]) - appInfo = try? await service.fetchAppInfo(appId: app?.id ?? "") - } catch { - writeError = error.localizedDescription - } - } - } - - func updateAgeRating(_ attributes: [String: Any]) async { - guard let service else { return } - guard let id = ageRatingDeclaration?.id else { return } - writeError = nil - do { - try await service.patchAgeRating(id: id, attributes: attributes) - if let infoId = appInfo?.id { - ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) - } - } catch { - writeError = error.localizedDescription - } - } - - func updateReviewContact(_ attributes: [String: Any]) async { - guard let service else { return } - guard let versionId = appStoreVersions.first?.id else { return } - writeError = nil - do { - try await service.createOrPatchReviewDetail(versionId: versionId, attributes: attributes) - reviewDetail = try? await service.fetchReviewDetail(versionId: versionId) - } catch { - writeError = error.localizedDescription - } - } - - func setAppPrice(pricePointId: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.setAppPrice(appId: appId, pricePointId: pricePointId) - try await service.ensureAppAvailability(appId: appId) - currentAppPricePointId = pricePointId - scheduledAppPricePointId = nil - scheduledAppPriceEffectiveDate = nil - monetizationStatus = isFreePricePoint(pricePointId) ? "Free" : "Configured" - } catch { - writeError = error.localizedDescription - } - } - - func setScheduledAppPrice(currentPricePointId: String, futurePricePointId: String, effectiveDate: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.setScheduledAppPrice( - appId: appId, - currentPricePointId: currentPricePointId, - futurePricePointId: futurePricePointId, - effectiveDate: effectiveDate - ) - self.currentAppPricePointId = currentPricePointId - scheduledAppPricePointId = futurePricePointId - scheduledAppPriceEffectiveDate = effectiveDate - monetizationStatus = "Configured" - } catch { - writeError = error.localizedDescription - } - } - - func createIAP(name: String, productId: String, type: String, displayName: String, description: String?, price: String, screenshotPath: String? = nil) { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - isCreating = true - createProgress = 0 - createProgressMessage = "Creating in-app purchase…" - - createTask = Task { [weak self] in - guard let self else { return } - do { - createProgress = 0.05 - let iap = try await service.createInAppPurchase( - appId: appId, name: name, productId: productId, inAppPurchaseType: type - ) - - createProgressMessage = "Setting localization…" - createProgress = 0.15 - try await service.localizeInAppPurchase( - iapId: iap.id, locale: "en-US", name: displayName, description: description - ) - - createProgressMessage = "Setting availability…" - createProgress = 0.3 - let territories = try await service.fetchAllTerritories() - try await service.createIAPAvailability(iapId: iap.id, territoryIds: territories) - - createProgress = 0.5 - if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { - createProgressMessage = "Setting price…" - let points = try await service.fetchInAppPurchasePricePoints(iapId: iap.id) - if let match = points.first(where: { - guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } - return abs(cpVal - priceVal) < 0.001 - }) { - try await service.setInAppPurchasePrice(iapId: iap.id, pricePointId: match.id) - } - } - - createProgress = 0.7 - if let path = screenshotPath { - createProgressMessage = "Uploading screenshot…" - try await service.uploadIAPReviewScreenshot(iapId: iap.id, path: path) - } - - createProgressMessage = "Waiting for status update…" - createProgress = 0.9 - try await pollRefreshIAPs(service: service, appId: appId) - createProgress = 1.0 - } catch { - writeError = error.localizedDescription - } - isCreating = false - createProgress = 0 - createProgressMessage = "" - } - } - - func updateIAP(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - // Patch IAP attributes (name, reviewNote) - var attrs: [String: Any] = [:] - if let name { attrs["name"] = name } - if let reviewNote { attrs["reviewNote"] = reviewNote } - if !attrs.isEmpty { - try await service.patchInAppPurchase(iapId: id, attrs: attrs) - } - // Patch localization (displayName, description) - if displayName != nil || description != nil { - let locs = try await service.fetchIAPLocalizations(iapId: id) - if let loc = locs.first { - var fields: [String: String] = [:] - if let displayName { fields["name"] = displayName } - if let description { fields["description"] = description } - try await service.patchIAPLocalization(locId: loc.id, fields: fields) - } - } - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - } catch { - writeError = error.localizedDescription - } - } - - func uploadIAPScreenshot(iapId: String, path: String) async { - guard let service else { return } - writeError = nil - do { - try await service.uploadIAPReviewScreenshot(iapId: iapId, path: path) - } catch { - writeError = error.localizedDescription - } - } - - func deleteIAP(id: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.deleteInAppPurchase(iapId: id) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - } catch { - writeError = error.localizedDescription - } - } - - func createSubscription(groupName: String, name: String, productId: String, displayName: String, description: String?, duration: String, price: String, screenshotPath: String? = nil) { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - isCreating = true - createProgress = 0 - createProgressMessage = "Setting up group…" - - createTask = Task { [weak self] in - guard let self else { return } - do { - createProgress = 0.03 - let group: ASCSubscriptionGroup - if let existing = subscriptionGroups.first(where: { $0.attributes.referenceName == groupName }) { - let groupLocs = try await service.fetchSubscriptionGroupLocalizations(groupId: existing.id) - if groupLocs.isEmpty { - try await service.localizeSubscriptionGroup(groupId: existing.id, locale: "en-US", name: groupName) - } - group = existing - } else { - group = try await service.createSubscriptionGroup(appId: appId, referenceName: groupName) - try await service.localizeSubscriptionGroup(groupId: group.id, locale: "en-US", name: groupName) - } - - createProgressMessage = "Creating subscription…" - createProgress = 0.08 - let sub = try await service.createSubscription( - groupId: group.id, name: name, productId: productId, subscriptionPeriod: duration - ) - - createProgressMessage = "Setting localization…" - createProgress = 0.12 - try await service.localizeSubscription( - subscriptionId: sub.id, locale: "en-US", name: displayName, description: description - ) - - createProgressMessage = "Setting availability…" - createProgress = 0.16 - let territories = try await service.fetchAllTerritories() - try await service.createSubscriptionAvailability(subscriptionId: sub.id, territoryIds: territories) - - createProgress = 0.2 - if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { - let points = try await service.fetchSubscriptionPricePoints(subscriptionId: sub.id) - if let match = points.first(where: { - guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } - return abs(cpVal - priceVal) < 0.001 - }) { - // Pricing loop: 0.2 → 0.8 (bulk of the time) - createProgressMessage = "Setting prices (0/175)…" - try await service.setSubscriptionPrice(subscriptionId: sub.id, pricePointId: match.id) { done, total in - Task { @MainActor [weak self] in - self?.createProgressMessage = "Setting prices (\(done)/\(total))…" - self?.createProgress = 0.2 + 0.6 * (Double(done) / Double(total)) - } - } - } - } - - createProgress = 0.85 - if let path = screenshotPath { - createProgressMessage = "Uploading screenshot…" - try await service.uploadSubscriptionReviewScreenshot(subscriptionId: sub.id, path: path) - } - - createProgressMessage = "Waiting for status update…" - createProgress = 0.9 - try await pollRefreshSubscriptions(service: service, appId: appId) - createProgress = 1.0 - } catch { - writeError = error.localizedDescription - } - isCreating = false - createProgress = 0 - createProgressMessage = "" - } - } - - func updateSubscription(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - var attrs: [String: Any] = [:] - if let name { attrs["name"] = name } - if let reviewNote { attrs["reviewNote"] = reviewNote } - if !attrs.isEmpty { - try await service.patchSubscription(subscriptionId: id, attrs: attrs) - } - if displayName != nil || description != nil { - let locs = try await service.fetchSubscriptionLocalizations(subscriptionId: id) - if let loc = locs.first { - var fields: [String: String] = [:] - if let displayName { fields["name"] = displayName } - if let description { fields["description"] = description } - try await service.patchSubscriptionLocalization(locId: loc.id, fields: fields) - } - } - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - } catch { - writeError = error.localizedDescription - } - } - - func uploadSubscriptionScreenshot(subscriptionId: String, path: String) async { - guard let service else { return } - writeError = nil - do { - try await service.uploadSubscriptionReviewScreenshot(subscriptionId: subscriptionId, path: path) - } catch { - writeError = error.localizedDescription - } - } - - func updateSubscriptionGroupLocalization(groupId: String, name: String) async { - guard let service else { return } - writeError = nil - do { - let locs = try await service.fetchSubscriptionGroupLocalizations(groupId: groupId) - if let loc = locs.first { - try await service.patchSubscriptionGroupLocalization(locId: loc.id, name: name) - } else { - try await service.localizeSubscriptionGroup(groupId: groupId, locale: "en-US", name: name) - } - } catch { - writeError = error.localizedDescription - } - } - - func deleteSubscription(id: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.deleteSubscription(subscriptionId: id) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - } catch { - writeError = error.localizedDescription - } - } - - func deleteSubscriptionGroup(id: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.deleteSubscriptionGroup(groupId: id) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - subscriptionsPerGroup.removeValue(forKey: id) - } catch { - writeError = error.localizedDescription - } - } - - // MARK: - Post-Create Polling - - private func pollRefreshIAPs(service: AppStoreConnectService, appId: String) async throws { - for _ in 0..<5 { - try await Task.sleep(for: .seconds(1)) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - let allResolved = inAppPurchases.allSatisfy { $0.attributes.state != "MISSING_METADATA" } - if allResolved { return } - } - } - - private func pollRefreshSubscriptions(service: AppStoreConnectService, appId: String) async throws { - for _ in 0..<5 { - try await Task.sleep(for: .seconds(1)) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - let allResolved = subscriptionsPerGroup.values.joined().allSatisfy { $0.attributes.state != "MISSING_METADATA" } - if allResolved { return } - } - } - - // MARK: - Review Submissions - - /// Returns true on success, false on failure (writeError set). - /// Sets writeError to a message starting with "FIRST_SUBMISSION:" if the first-time restriction applies. - func submitIAPForReview(id: String) async -> Bool { - guard let service else { return false } - guard let appId = app?.id else { return false } - writeError = nil - do { - try await service.submitIAPForReview(iapId: id) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - return true - } catch { - let msg = error.localizedDescription - if msg.contains("FIRST_IAP") || msg.contains("first In-App Purchase") || msg.contains("first in-app purchase") { - writeError = "FIRST_SUBMISSION:" + msg - } else { - writeError = msg - } - return false - } - } - - /// Returns true on success, false on failure (writeError set). - /// Sets writeError to a message starting with "FIRST_SUBMISSION:" if the first-time restriction applies. - func submitSubscriptionForReview(id: String) async -> Bool { - guard let service else { return false } - guard let appId = app?.id else { return false } - writeError = nil - do { - try await service.submitSubscriptionForReview(subscriptionId: id) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - return true - } catch { - let msg = error.localizedDescription - if msg.contains("FIRST_SUBSCRIPTION") || msg.contains("first subscription") { - writeError = "FIRST_SUBMISSION:" + msg - } else { - writeError = msg - } - return false - } - } - - func refreshMonetization() async { - guard let service else { return } - guard let appId = app?.id else { return } - do { - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for group in subscriptionGroups { - subscriptionsPerGroup[group.id] = try await service.fetchSubscriptionsInGroup(groupId: group.id) - } - } catch { - writeError = error.localizedDescription - } - } - - func refreshAttachedSubmissionItemIDs() async { - guard let appId = app?.id else { - attachedSubmissionItemIDs = [] - return - } - guard let cookieHeader = ascWebSessionCookieHeader() else { - attachedSubmissionItemIDs = [] - return - } - - let subscriptionURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/subscriptionGroups?include=subscriptions&limit=300&fields%5Bsubscriptions%5D=productId,name,state,submitWithNextAppStoreVersion" - let iapURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/inAppPurchasesV2?limit=300&fields%5BinAppPurchases%5D=productId,name,state,submitWithNextAppStoreVersion" - - let attachedSubscriptions = await fetchAttachedSubmissionItemIDs(urlString: subscriptionURL, cookieHeader: cookieHeader) - let attachedIAPs = await fetchAttachedSubmissionItemIDs(urlString: iapURL, cookieHeader: cookieHeader) - attachedSubmissionItemIDs = attachedSubscriptions.union(attachedIAPs) - } - - private func fetchAttachedSubmissionItemIDs(urlString: String, cookieHeader: String) async -> Set { - guard let url = URL(string: urlString) else { return [] } - - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") - request.setValue("https://appstoreconnect.apple.com", forHTTPHeaderField: "Origin") - request.setValue("https://appstoreconnect.apple.com/", forHTTPHeaderField: "Referer") - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - request.timeoutInterval = 10 - - guard let (data, response) = try? await URLSession.shared.data(for: request), - let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [] - } - - let resources = (json["data"] as? [[String: Any]] ?? []) - + (json["included"] as? [[String: Any]] ?? []) - - return Set(resources.compactMap { item in - guard let attrs = item["attributes"] as? [String: Any], - let id = item["id"] as? String, - let submitWithNext = attrs["submitWithNextAppStoreVersion"] as? Bool, - submitWithNext else { return nil } - return id - }) - } - - private func ascWebSessionCookieHeader() -> String? { - guard let storeData = readKeychainItem(service: "asc-web-session", account: "asc:web-session:store"), - let store = try? JSONSerialization.jsonObject(with: storeData) as? [String: Any], - let lastKey = store["last_key"] as? String, - let sessions = store["sessions"] as? [String: Any], - let sessionDict = sessions[lastKey] as? [String: Any], - let cookies = sessionDict["cookies"] as? [String: [[String: Any]]] else { - return nil - } - - let cookieHeader = cookies.values.flatMap { $0 }.compactMap { cookie -> String? in - guard let name = cookie["name"] as? String, - let value = cookie["value"] as? String, - !name.isEmpty else { return nil } - return name.hasPrefix("DES") ? "\(name)=\"\(value)\"" : "\(name)=\(value)" - }.joined(separator: "; ") - - return cookieHeader.isEmpty ? nil : cookieHeader - } - - private func readKeychainItem(service: String, account: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { return nil } - return result as? Data - } - - func setPriceFree() async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.setPriceFree(appId: appId) - try await service.ensureAppAvailability(appId: appId) - currentAppPricePointId = freeAppPricePointId - scheduledAppPricePointId = nil - scheduledAppPriceEffectiveDate = nil - monetizationStatus = "Free" - } catch { - writeError = error.localizedDescription - } - } - - var freeAppPricePointId: String? { - appPricePoints.first(where: { - let price = $0.attributes.customerPrice ?? "0" - return price == "0" || price == "0.0" || price == "0.00" - })?.id - } - - func applyAppPricingState(_ state: ASCAppPricingState) { - currentAppPricePointId = state.currentPricePointId - scheduledAppPricePointId = state.scheduledPricePointId - scheduledAppPriceEffectiveDate = state.scheduledEffectiveDate - - if let currentPricePointId = currentAppPricePointId { - let isCurrentlyFree = isFreePricePoint(currentPricePointId) - monetizationStatus = (isCurrentlyFree && state.scheduledPricePointId == nil) ? "Free" : "Configured" - } else if state.scheduledPricePointId != nil { - monetizationStatus = "Configured" - } else { - monetizationStatus = nil - } - } - - func isFreePricePoint(_ pricePointId: String) -> Bool { - appPricePoints.contains(where: { - guard $0.id == pricePointId else { return false } - let price = $0.attributes.customerPrice ?? "0" - return price == "0" || price == "0.0" || price == "0.00" - }) - } - - // MARK: - Screenshot Track - - func hasUnsavedChanges(displayType: String) -> Bool { - let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) - return zip(current, saved).contains { c, s in c?.id != s?.id } - } - - func loadTrackFromASC(displayType: String) { - let previousSlots = trackSlots[displayType] ?? [] - let set = screenshotSets.first { $0.attributes.screenshotDisplayType == displayType } - var slots: [TrackSlot?] = Array(repeating: nil, count: 10) - if let set, let shots = screenshots[set.id] { - for (i, shot) in shots.prefix(10).enumerated() { - // If ASC hasn't processed the image yet, carry forward the local preview - var localImage: NSImage? = nil - if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { - localImage = prev.localImage - } - slots[i] = TrackSlot( - id: shot.id, - localPath: nil, - localImage: localImage, - ascScreenshot: shot, - isFromASC: true - ) - } - } - trackSlots[displayType] = slots - savedTrackState[displayType] = slots - } - - func syncTrackToASC(displayType: String, locale: String) async { - guard let service else { writeError = "ASC service not configured"; return } - isSyncing = true - writeError = nil - - // Ensure localizations are loaded - if localizations.isEmpty, let versionId = appStoreVersions.first?.id { - localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] - } - if localizations.isEmpty, let appId = app?.id { - let versions = (try? await service.fetchAppStoreVersions(appId: appId)) ?? [] - appStoreVersions = versions - if let versionId = versions.first?.id { - localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] - } - } - guard let loc = localizations.first(where: { $0.attributes.locale == locale }) - ?? localizations.first else { - writeError = "No localizations found for locale '\(locale)'." - isSyncing = false - return - } - - let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) - - do { - // 1. Delete screenshots that were in saved state but not in current track - let savedIds = Set(saved.compactMap { $0?.id }) - let currentIds = Set(current.compactMap { $0?.id }) - let toDelete = savedIds.subtracting(currentIds) - for id in toDelete { - try await service.deleteScreenshot(screenshotId: id) - } - - // 2. Check if existing ASC screenshots need reorder - let currentASCIds = current.compactMap { slot -> String? in - guard let slot, slot.isFromASC else { return nil } - return slot.id - } - let savedASCIds = saved.compactMap { slot -> String? in - guard let slot, slot.isFromASC else { return nil } - return slot.id - } - let remainingASCIds = Set(currentASCIds) - let reorderNeeded = currentASCIds != savedASCIds.filter { remainingASCIds.contains($0) } - - if reorderNeeded { - // Delete remaining ASC screenshots and re-upload in new order - for id in currentASCIds { - if !toDelete.contains(id) { - try await service.deleteScreenshot(screenshotId: id) - } - } - } - - // 3. Upload local assets + re-upload reordered ASC screenshots - for slot in current { - guard let slot else { continue } - if let path = slot.localPath { - try await service.uploadScreenshot(localizationId: loc.id, path: path, displayType: displayType) - } else if reorderNeeded, slot.isFromASC, let ascShot = slot.ascScreenshot { - // For reordered ASC screenshots, we need the original file - // Download from ASC URL and re-upload - if let url = ascShot.imageURL, - let (data, _) = try? await URLSession.shared.data(from: url), - let fileName = ascShot.attributes.fileName { - let tmpPath = FileManager.default.temporaryDirectory.appendingPathComponent(fileName).path - try data.write(to: URL(fileURLWithPath: tmpPath)) - try await service.uploadScreenshot(localizationId: loc.id, path: tmpPath, displayType: displayType) - try? FileManager.default.removeItem(atPath: tmpPath) - } - } - } - - // 4. Reload from ASC - let sets = try await service.fetchScreenshotSets(localizationId: loc.id) - screenshotSets = sets - for set in sets { - screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) - } - loadTrackFromASC(displayType: displayType) - } catch { - writeError = error.localizedDescription - } - - isSyncing = false - } - - func deleteScreenshot(screenshotId: String) async throws { - guard let service else { throw ASCError.notFound("ASC service not configured") } - try await service.deleteScreenshot(screenshotId: screenshotId) - } - - func scanLocalAssets(projectId: String) { - let dir = BlitzPaths.screenshots(projectId: projectId) - let fm = FileManager.default - guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { - localScreenshotAssets = [] - return - } - let imageExtensions: Set = ["png", "jpg", "jpeg", "webp"] - localScreenshotAssets = files - .filter { imageExtensions.contains($0.pathExtension.lowercased()) } - .sorted { $0.lastPathComponent < $1.lastPathComponent } - .compactMap { url in - // Try NSImage first, fall back to CGImageSource for WebP - var image = NSImage(contentsOf: url) - if image == nil || image!.representations.isEmpty { - if let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { - image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) - } - } - guard let image else { return nil } - return LocalScreenshotAsset(id: UUID(), url: url, image: image, fileName: url.lastPathComponent) - } - } - - /// Validate pixel dimensions for a display type. Returns nil if valid, or an error string. - static func validateDimensions(width: Int, height: Int, displayType: String) -> String? { - switch displayType { - case "APP_IPHONE_67": - let validSizes: Set = ["1290x2796", "1284x2778", "1242x2688", "1260x2736"] - if validSizes.contains("\(width)x\(height)") { return nil } - return "\(width)\u{00d7}\(height) — need 1290\u{00d7}2796, 1284\u{00d7}2778, 1242\u{00d7}2688, or 1260\u{00d7}2736 for iPhone" - case "APP_IPAD_PRO_3GEN_129": - if width == 2048 && height == 2732 { return nil } - return "\(width)\u{00d7}\(height) — need 2048\u{00d7}2732 for iPad" - case "APP_DESKTOP": - let valid: Set = ["1280x800", "1440x900", "2560x1600", "2880x1800"] - if valid.contains("\(width)x\(height)") { return nil } - return "\(width)\u{00d7}\(height) — need 1280\u{00d7}800, 1440\u{00d7}900, 2560\u{00d7}1600, or 2880\u{00d7}1800 for Mac" - default: - return nil - } - } - - /// Add asset to track slot. Returns nil on success, or an error string on dimension mismatch. - @discardableResult - func addAssetToTrack(displayType: String, slotIndex: Int, localPath: String) -> String? { - guard slotIndex >= 0 && slotIndex < 10 else { return "Invalid slot index" } - - guard let image = NSImage(contentsOfFile: localPath) else { - return "Could not load image" - } - - // Validate dimensions - var pixelWidth = 0, pixelHeight = 0 - if let rep = image.representations.first, rep.pixelsWide > 0, rep.pixelsHigh > 0 { - pixelWidth = rep.pixelsWide - pixelHeight = rep.pixelsHigh - } else if let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff) { - pixelWidth = bitmap.pixelsWide - pixelHeight = bitmap.pixelsHigh - } - - if let error = Self.validateDimensions(width: pixelWidth, height: pixelHeight, displayType: displayType) { - return error - } - - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let slot = TrackSlot( - id: UUID().uuidString, - localPath: localPath, - localImage: image, - ascScreenshot: nil, - isFromASC: false - ) - // If target slot occupied, shift right - if slots[slotIndex] != nil { - slots.insert(slot, at: slotIndex) - slots = Array(slots.prefix(10)) - } else { - slots[slotIndex] = slot - } - // Pad back to 10 - while slots.count < 10 { slots.append(nil) } - trackSlots[displayType] = slots - return nil - } - - func removeFromTrack(displayType: String, slotIndex: Int) { - guard slotIndex >= 0 && slotIndex < 10 else { return } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - slots.remove(at: slotIndex) - slots.append(nil) // maintain 10 elements - trackSlots[displayType] = slots - } - - func reorderTrack(displayType: String, fromIndex: Int, toIndex: Int) { - guard fromIndex >= 0 && fromIndex < 10 && toIndex >= 0 && toIndex < 10 else { return } - guard fromIndex != toIndex else { return } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let item = slots.remove(at: fromIndex) - slots.insert(item, at: toIndex) - trackSlots[displayType] = slots - } - - /// The pending version ID (not live / not removed). - var pendingVersionId: String? { - appStoreVersions.first { - let s = $0.attributes.appStoreState ?? "" - return s != "READY_FOR_SALE" && s != "REMOVED_FROM_SALE" - && s != "DEVELOPER_REMOVED_FROM_SALE" && !s.isEmpty - }?.id ?? appStoreVersions.first?.id - } - - func attachBuild(buildId: String) async { - guard let service else { return } - guard let versionId = pendingVersionId else { - writeError = "No app store version found to attach build to." - return - } - writeError = nil - do { - try await service.attachBuild(versionId: versionId, buildId: buildId) - } catch { - writeError = error.localizedDescription - } - } - - func submitForReview(attachBuildId: String? = nil) async { - guard let service else { return } - guard let appId = app?.id, let versionId = pendingVersionId else { return } - isSubmitting = true - submissionError = nil - do { - // Attach build if specified - if let buildId = attachBuildId { - try await service.attachBuild(versionId: versionId, buildId: buildId) - } - try await service.submitForReview(appId: appId, versionId: versionId) - isSubmitting = false - await refreshTabData(.ascOverview) - } catch { - isSubmitting = false - submissionError = error.localizedDescription - } - } - - func flushPendingLocalizations() async { - guard let service else { return } - let appInfoLocFieldNames: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] - for (tab, fields) in pendingFormValues { - if tab == "storeListing" { - var versionLocFields: [String: String] = [:] - var infoLocFields: [String: String] = [:] - for (field, value) in fields { - if appInfoLocFieldNames.contains(field) { - let apiField = (field == "title") ? "name" : field - infoLocFields[apiField] = value - } else { - versionLocFields[field] = value - } - } - if !versionLocFields.isEmpty, let locId = localizations.first?.id { - try? await service.patchLocalization(id: locId, fields: versionLocFields) - } - if !infoLocFields.isEmpty, let infoLocId = appInfoLocalization?.id { - try? await service.patchAppInfoLocalization(id: infoLocId, fields: infoLocFields) - } - } - } - pendingFormValues = [:] - } -} diff --git a/src/services/AppRelaunchService.swift b/src/services/AppRelaunchService.swift new file mode 100644 index 0000000..7836d3f --- /dev/null +++ b/src/services/AppRelaunchService.swift @@ -0,0 +1,127 @@ +import AppKit +import Foundation + +/// Tracks permission-driven restart flows and can reopen the current app bundle +/// after macOS terminates Blitz. +final class AppRelaunchService { + static let shared = AppRelaunchService() + + private enum Keys { + static let pendingReason = "blitz.pendingRelaunch.reason" + static let pendingCreatedAt = "blitz.pendingRelaunch.createdAt" + static let pendingAppPath = "blitz.pendingRelaunch.appPath" + } + + private enum PendingReason: String { + case screenRecordingPermission + } + + private let defaults: UserDefaults + private let now: () -> Date + private let appURLProvider: () -> URL? + private let screenRecordingAccessProvider: () -> Bool + private let launcher: (String, Int32) -> Bool + + static let pendingWindow: TimeInterval = 180 + + init( + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init, + appURLProvider: @escaping () -> URL? = AppRelaunchService.defaultAppURL, + screenRecordingAccessProvider: @escaping () -> Bool = { CGPreflightScreenCaptureAccess() }, + launcher: @escaping (String, Int32) -> Bool = AppRelaunchService.launchDetachedRelaunchProcess + ) { + self.defaults = defaults + self.now = now + self.appURLProvider = appURLProvider + self.screenRecordingAccessProvider = screenRecordingAccessProvider + self.launcher = launcher + } + + func prepareForScreenRecordingPermissionRestart() { + guard let appURL = appURLProvider() else { return } + defaults.set(PendingReason.screenRecordingPermission.rawValue, forKey: Keys.pendingReason) + defaults.set(now().timeIntervalSince1970, forKey: Keys.pendingCreatedAt) + defaults.set(appURL.path, forKey: Keys.pendingAppPath) + } + + func clearPendingRestart() { + defaults.removeObject(forKey: Keys.pendingReason) + defaults.removeObject(forKey: Keys.pendingCreatedAt) + defaults.removeObject(forKey: Keys.pendingAppPath) + } + + /// If Blitz becomes active again, the OS restart did not happen and the + /// pending relaunch should not survive future manual quits. + func clearPendingRestartAfterReturningToApp() { + guard pendingReason() != nil else { return } + clearPendingRestart() + } + + @discardableResult + func schedulePendingScreenRecordingRelaunchIfNeeded(pid: Int32 = ProcessInfo.processInfo.processIdentifier) -> Bool { + defer { clearPendingRestart() } + + guard pendingReason() == .screenRecordingPermission else { return false } + guard let createdAt = pendingCreatedAt(), now().timeIntervalSince(createdAt) <= Self.pendingWindow else { + return false + } + guard screenRecordingAccessProvider() else { return false } + guard let appPath = pendingAppPath() else { return false } + + return launcher(appPath, pid) + } + + private func pendingReason() -> PendingReason? { + guard let rawValue = defaults.string(forKey: Keys.pendingReason) else { return nil } + return PendingReason(rawValue: rawValue) + } + + private func pendingCreatedAt() -> Date? { + guard defaults.object(forKey: Keys.pendingCreatedAt) != nil else { return nil } + return Date(timeIntervalSince1970: defaults.double(forKey: Keys.pendingCreatedAt)) + } + + private func pendingAppPath() -> String? { + guard let path = defaults.string(forKey: Keys.pendingAppPath), !path.isEmpty else { return nil } + return path + } + + static func launchDetachedRelaunchProcess(appPath: String, pid: Int32) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", relaunchShellCommand(appPath: appPath, pid: pid)] + process.standardOutput = nil + process.standardError = nil + + do { + try process.run() + return true + } catch { + print("[Relaunch] Failed to schedule app relaunch: \(error)") + return false + } + } + + static func relaunchShellCommand(appPath: String, pid: Int32) -> String { + "while kill -0 \(pid) 2>/dev/null; do sleep 0.2; done; open \(shellQuote(appPath))" + } + + static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func defaultAppURL() -> URL? { + let bundleURL = Bundle.main.bundleURL.standardizedFileURL + if bundleURL.pathExtension == "app" { + return bundleURL + } + + if let bundleID = Bundle.main.bundleIdentifier, + let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return appURL.standardizedFileURL + } + + return nil + } +} diff --git a/src/services/AutoUpdateService.swift b/src/services/AutoUpdateService.swift index bb12296..f667aea 100644 --- a/src/services/AutoUpdateService.swift +++ b/src/services/AutoUpdateService.swift @@ -48,6 +48,15 @@ final class AutoUpdateManager { func checkForUpdate() async { state = .checking + // TODO - remove before release: DEBUG use local zip instead of fetching from GitHub + // let debugLocalZip = "SET_TO_YOUR_LOCAL_PATH/Blitz.app.zip" + // latestVersion = "1.0.30" + // downloadURL = "file://\(debugLocalZip)" + // downloadFilename = "Blitz.app.zip" + // state = .available(version: "1.0.30", releaseNotes: "Debug test update") + // return + // END DEBUG + do { var request = URLRequest(url: URL(string: Self.releasesURL)!) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") @@ -169,27 +178,11 @@ final class AutoUpdateManager { private func installApp(zipPath: URL) async throws { let pid = ProcessInfo.processInfo.processIdentifier - let zip = zipPath.path.replacingOccurrences(of: "'", with: "'\\''") // AppleScript statement 1: unzip, run preinstall, replace app, run postinstall // The PKG postinstall chowns Blitz.app to the current user, so no admin needed. // Pre/postinstall scripts are embedded in the .app at Contents/Resources/pkg-scripts/. - let installScript = """ - do shell script "\ - TMPZIP='\(zip)'; \ - UNZIP_DIR=$(mktemp -d); \ - unzip -qo \\"$TMPZIP\\" -d \\"$UNZIP_DIR\\"; \ - APP_SRC=$(find \\"$UNZIP_DIR\\" -maxdepth 1 -name '*.app' -type d | head -1); \ - if [ -z \\"$APP_SRC\\" ]; then rm -rf \\"$UNZIP_DIR\\"; exit 1; fi; \ - PREINSTALL=\\"$APP_SRC/Contents/Resources/pkg-scripts/preinstall\\"; \ - if [ -x \\"$PREINSTALL\\" ]; then \\"$PREINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ - rm -rf /Applications/Blitz.app; \ - mv \\"$APP_SRC\\" /Applications/Blitz.app; \ - POSTINSTALL='/Applications/Blitz.app/Contents/Resources/pkg-scripts/postinstall'; \ - if [ -x \\"$POSTINSTALL\\" ]; then \\"$POSTINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ - rm -rf \\"$UNZIP_DIR\\" \\"$TMPZIP\\"\ - " - """ + let installScript = Self.appUpdateInstallScript(zipPath: zipPath) // AppleScript statement 2: background wait-for-exit + relaunch let relaunchScript = """ @@ -284,4 +277,32 @@ final class AutoUpdateManager { let message: String var errorDescription: String? { message } } + + static func appUpdateInstallScript(zipPath: URL) -> String { + let zip = shellLiteral(zipPath.path) + return """ + do shell script "set -eu; \ + TMPZIP=\(zip); \ + UPDATE_LOG='/tmp/blitz_install.log'; \ + UNZIP_DIR=$(mktemp -d); \ + cleanup() { rm -rf \\"$UNZIP_DIR\\" \\"$TMPZIP\\"; }; \ + trap cleanup EXIT; \ + unzip -qo \\"$TMPZIP\\" -d \\"$UNZIP_DIR\\"; \ + APP_SRC=$(find \\"$UNZIP_DIR\\" -maxdepth 1 -name '*.app' -type d | head -1); \ + if [ -z \\"$APP_SRC\\" ]; then echo 'Update failed: extracted app not found' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ + BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \\"$APP_SRC/Contents/Info.plist\\" 2>> \\"$UPDATE_LOG\\" || true); \ + if [ \\"$BUNDLE_ID\\" != 'com.blitz.macos' ]; then echo 'Update failed: unexpected bundle identifier' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ + if [ ! -x \\"$APP_SRC/Contents/Helpers/ascd\\" ]; then echo 'Update failed: bundled ascd helper missing' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ + PREINSTALL=\\"$APP_SRC/Contents/Resources/pkg-scripts/preinstall\\"; \ + if [ -x \\"$PREINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$PREINSTALL\\" '' '' '/' >> \\"$UPDATE_LOG\\" 2>&1; fi; \ + rm -rf /Applications/Blitz.app; \ + mv \\"$APP_SRC\\" /Applications/Blitz.app; \ + POSTINSTALL='/Applications/Blitz.app/Contents/Resources/pkg-scripts/postinstall'; \ + if [ -x \\"$POSTINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$POSTINSTALL\\" '' '' '/' >> \\"$UPDATE_LOG\\" 2>&1; fi" + """ + } + + private static func shellLiteral(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } } diff --git a/src/services/BuildPipelineService.swift b/src/services/BuildPipelineService.swift index b3f7d08..82364ea 100644 --- a/src/services/BuildPipelineService.swift +++ b/src/services/BuildPipelineService.swift @@ -360,6 +360,7 @@ actor BuildPipelineService { "-keypbe", "PBE-SHA1-3DES", "-macalg", "SHA1" ], timeout: 30) + try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: p12Path) // Import to keychain @@ -398,7 +399,7 @@ actor BuildPipelineService { } /// Update only PRODUCT_BUNDLE_IDENTIFIER in the project's pbxproj. - /// Public so it can be called from MCPToolExecutor when the user changes bundle ID. + /// Public so it can be called from MCPExecutor when the user changes bundle ID. func updateBundleIdInPbxproj(projectPath: String, bundleId: String) { let projectURL = URL(fileURLWithPath: projectPath).resolvingSymlinksInPath() var searchDirs = [projectURL] diff --git a/src/services/DashboardSummaryStore.swift b/src/services/DashboardSummaryStore.swift new file mode 100644 index 0000000..b669454 --- /dev/null +++ b/src/services/DashboardSummaryStore.swift @@ -0,0 +1,70 @@ +import Foundation + +@MainActor +@Observable +final class DashboardSummaryStore { + static let shared = DashboardSummaryStore() + + private static let freshness: TimeInterval = 120 + + var summary = ASCDashboardSummary.empty + var projectStatuses: [String: ASCDashboardProjectStatus] = [:] + var hasLoadedSummary = false + var isLoadingSummary = false + + private(set) var cacheKey: String? + private var refreshedAt: Date? + + private init() {} + + func shouldRefresh(for key: String) -> Bool { + guard cacheKey == key, let refreshedAt else { return true } + return Date().timeIntervalSince(refreshedAt) > Self.freshness + } + + func isLoading(for key: String) -> Bool { + isLoadingSummary && cacheKey == key + } + + func beginLoading(for key: String) { + if cacheKey != key { + summary = .empty + projectStatuses = [:] + hasLoadedSummary = false + } + cacheKey = key + isLoadingSummary = true + } + + func store(summary: ASCDashboardSummary, projectStatuses: [String: ASCDashboardProjectStatus], for key: String) { + self.summary = summary + self.projectStatuses = projectStatuses + hasLoadedSummary = true + cacheKey = key + refreshedAt = Date() + isLoadingSummary = false + } + + func markEmpty(for key: String) { + summary = .empty + projectStatuses = [:] + hasLoadedSummary = true + cacheKey = key + refreshedAt = Date() + isLoadingSummary = false + } + + func markUnavailable(for key: String) { + summary = .empty + projectStatuses = [:] + hasLoadedSummary = false + cacheKey = key + refreshedAt = Date() + isLoadingSummary = false + } + + func cancelLoading(for key: String) { + guard cacheKey == key else { return } + isLoadingSummary = false + } +} diff --git a/src/services/MCPServerService.swift b/src/services/MCPServerService.swift deleted file mode 100644 index 44e4cb4..0000000 --- a/src/services/MCPServerService.swift +++ /dev/null @@ -1,328 +0,0 @@ -import Foundation - -/// MCP (Model Context Protocol) HTTP server for Claude Code integration -/// Port of server/mcp/mcp-server.ts -actor MCPServerService { - private var acceptSource: DispatchSourceRead? - private var serverSocket: Int32 = -1 - private(set) var port: Int = 0 - private(set) var isRunning = false - - private let toolExecutor: MCPToolExecutor - - private static var portFileURL: URL { - BlitzPaths.mcpPort - } - - init(appState: AppState) { - self.toolExecutor = MCPToolExecutor(appState: appState) - - // Store executor reference in AppState for approval resolution - Task { @MainActor in - appState.toolExecutor = self.toolExecutor - } - } - - /// Start the MCP server on a free port - func start() async throws { - let assignedPort = PortAllocator.findFreePort() - guard assignedPort > 0 else { - throw MCPError.noPortAvailable - } - self.port = Int(assignedPort) - - // Create TCP socket - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { throw MCPError.socketCreationFailed } - - var reuse: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = UInt16(assignedPort).bigEndian - addr.sin_addr.s_addr = UInt32(0x7F000001).bigEndian - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - bind(fd, sockPtr, socklen_t(MemoryLayout.size)) - } - } - - guard bindResult == 0 else { - close(fd) - throw MCPError.bindFailed - } - - guard listen(fd, 5) == 0 else { - close(fd) - throw MCPError.listenFailed - } - - serverSocket = fd - isRunning = true - - // Write port file for bridge script - writePortFile(port: Int(assignedPort)) - - print("[MCP] Server listening on port \(assignedPort)") - - // Accept connections in background using DispatchSource - let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .global(qos: .userInitiated)) - source.setEventHandler { [weak self] in - var clientAddr = sockaddr_in() - var addrLen = socklen_t(MemoryLayout.size) - let clientFd = withUnsafeMutablePointer(to: &clientAddr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - accept(fd, sockPtr, &addrLen) - } - } - if clientFd < 0 { return } - Task { [weak self] in - await self?.handleConnection(clientFd) - } - } - source.resume() - self.acceptSource = source as? DispatchSource - } - - /// Stop the MCP server - func stop() { - acceptSource?.cancel() - acceptSource = nil - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 - } - isRunning = false - removePortFile() - } - - /// Handle a single client connection - private func handleConnection(_ fd: Int32) async { - defer { close(fd) } - - // Read HTTP request with larger buffer for tool arguments - var requestData = Data() - let bufSize = 65536 - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - - // Read until we have the full body - var totalRead = 0 - var contentLength = -1 - - while true { - let bytesRead = recv(fd, buf, bufSize, 0) - guard bytesRead > 0 else { break } - requestData.append(buf, count: bytesRead) - totalRead += bytesRead - - // Parse content-length from headers if not yet found - if contentLength < 0, let str = String(data: requestData, encoding: .utf8) { - if let range = str.range(of: "\r\n\r\n") { - let headers = String(str[..= contentLength { break } - } else { - continue // Haven't received full headers yet - } - } else if contentLength >= 0 { - // Check if we have enough body data - if let str = String(data: requestData, encoding: .utf8), - let range = str.range(of: "\r\n\r\n") { - let headerSize = str.distance(from: str.startIndex, to: range.upperBound) - let bodySize = requestData.count - headerSize - if bodySize >= contentLength { break } - } - } else { - break - } - } - - guard let requestStr = String(data: requestData, encoding: .utf8) else { return } - - // Parse HTTP request line - let lines = requestStr.components(separatedBy: "\r\n") - guard let requestLine = lines.first else { return } - let parts = requestLine.components(separatedBy: " ") - guard parts.count >= 2 else { return } - - let method = parts[0] - let path = parts[1] - - // Extract body (after \r\n\r\n) - var body: Data? - if let range = requestStr.range(of: "\r\n\r\n") { - let bodyStr = String(requestStr[range.upperBound...]) - if !bodyStr.isEmpty { - body = Data(bodyStr.utf8) - } - } - - // Route request - let responseBody: String - do { - responseBody = try await routeRequest(method: method, path: path, body: body) - } catch { - let escapedError = error.localizedDescription - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let errorJson = "{\"error\": \"\(escapedError)\"}" - sendHTTPResponse(fd: fd, statusCode: 500, body: errorJson) - return - } - - sendHTTPResponse(fd: fd, statusCode: 200, body: responseBody) - } - - /// Route MCP requests to handlers - private func routeRequest(method: String, path: String, body: Data?) async throws -> String { - // MCP Streamable HTTP transport — handle JSON-RPC messages - if path == "/mcp" && method == "POST" { - guard let body else { throw MCPError.missingBody } - return try await handleMCPRequest(body) - } - - return "{\"error\": \"Not found\"}" - } - - /// Handle MCP JSON-RPC request - private func handleMCPRequest(_ body: Data) async throws -> String { - guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], - let methodName = json["method"] as? String else { - throw MCPError.invalidRequest - } - - // Notifications (method starts with "notifications/") have no id and - // expect no response per JSON-RPC 2.0. Accept them gracefully. - let id: Any = json["id"] ?? NSNull() - let isNotification = methodName.hasPrefix("notifications/") - - let params = json["params"] as? [String: Any] ?? [:] - - let result: Any - switch methodName { - case "initialize": - result = [ - "protocolVersion": "2024-11-05", - "capabilities": [ - "tools": ["listChanged": false] - ], - "serverInfo": [ - "name": "blitz-mcp", - "version": "1.0.0" - ] - ] as [String: Any] - - case "notifications/initialized": - // Client acknowledgment — return empty result - result = [:] as [String: Any] - - case "tools/list": - result = [ - "tools": MCPToolRegistry.allTools() - ] - - case "tools/call": - let toolName = params["name"] as? String ?? "" - let toolArgs = params["arguments"] as? [String: Any] ?? [:] - do { - result = try await toolExecutor.execute(name: toolName, arguments: toolArgs) - } catch { - // Return a proper JSON-RPC error response instead of letting the error - // propagate to the HTTP layer (which sends a non-JSON-RPC 500 body) - let errorResponse: [String: Any] = [ - "jsonrpc": "2.0", - "id": id, - "error": [ - "code": -32603, - "message": error.localizedDescription - ] as [String: Any] - ] - let data = try JSONSerialization.data(withJSONObject: errorResponse) - return String(data: data, encoding: .utf8) ?? "{}" - } - - default: - if isNotification { - // Unknown notification — accept silently - result = [:] as [String: Any] - } else { - throw MCPError.unknownMethod(methodName) - } - } - - let response: [String: Any] = [ - "jsonrpc": "2.0", - "id": id, - "result": result - ] - - let data = try JSONSerialization.data(withJSONObject: response) - return String(data: data, encoding: .utf8) ?? "{}" - } - - /// Send HTTP response - private func sendHTTPResponse(fd: Int32, statusCode: Int, body: String) { - let statusText = statusCode == 200 ? "OK" : "Error" - let response = """ - HTTP/1.1 \(statusCode) \(statusText)\r - Content-Type: application/json\r - Content-Length: \(body.utf8.count)\r - Connection: close\r - \r - \(body) - """ - let data = Data(response.utf8) - data.withUnsafeBytes { buf in - _ = send(fd, buf.baseAddress!, buf.count, 0) - } - } - - // MARK: - Port File - - private func writePortFile(port: Int) { - let url = Self.portFileURL - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? "\(port)".write(to: url, atomically: true, encoding: .utf8) - } - - private func removePortFile() { - try? FileManager.default.removeItem(at: Self.portFileURL) - } - - enum MCPError: Error, LocalizedError { - case noPortAvailable - case socketCreationFailed - case bindFailed - case listenFailed - case missingBody - case invalidRequest - case unknownMethod(String) - case unknownTool(String) - case invalidToolArgs - - var errorDescription: String? { - switch self { - case .noPortAvailable: return "No port available for MCP server" - case .socketCreationFailed: return "Failed to create MCP server socket" - case .bindFailed: return "Failed to bind MCP server port" - case .listenFailed: return "Failed to listen on MCP server port" - case .missingBody: return "Missing request body" - case .invalidRequest: return "Invalid MCP request" - case .unknownMethod(let m): return "Unknown MCP method: \(m)" - case .unknownTool(let t): return "Unknown MCP tool: \(t)" - case .invalidToolArgs: return "Invalid tool arguments" - } - } - } -} diff --git a/src/services/MCPToolExecutor.swift b/src/services/MCPToolExecutor.swift deleted file mode 100644 index e5f1585..0000000 --- a/src/services/MCPToolExecutor.swift +++ /dev/null @@ -1,1882 +0,0 @@ -import Foundation -import AppKit -import Security - -/// Runs an async operation with a timeout. Throws CancellationError if the deadline is exceeded. -private func withThrowingTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(for: .seconds(seconds)) - throw CancellationError() - } - guard let result = try await group.next() else { - throw CancellationError() - } - group.cancelAll() - return result - } -} - -/// Executes MCP tool calls against AppState. -/// Holds pending approval continuations for destructive operations. -actor MCPToolExecutor { - private let appState: AppState - private var pendingContinuations: [String: CheckedContinuation] = [:] - - init(appState: AppState) { - self.appState = appState - } - - /// Execute a tool call, requesting approval if needed - func execute(name: String, arguments: [String: Any]) async throws -> [String: Any] { - let category = MCPToolRegistry.category(for: name) - - // Pre-navigate for ASC form tools so the user sees the target tab before approving - var previousTab: AppTab? - if name == "asc_fill_form" || name == "asc_open_submit_preview" - || name == "asc_create_iap" || name == "asc_create_subscription" || name == "asc_set_app_price" - || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { - previousTab = await preNavigateASCTool(name: name, arguments: arguments) - } - - let request = ApprovalRequest( - id: UUID().uuidString, - toolName: name, - description: "Execute '\(name)'", - parameters: arguments.mapValues { "\($0)" }, - category: category - ) - - if request.requiresApproval(permissionToggles: await SettingsService.shared.permissionToggles) { - let approved = await requestApproval(request) - guard approved else { - // Navigate back if denied - if let prev = previousTab { - await MainActor.run { appState.activeTab = prev } - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeAll() } - } - return mcpText("Tool '\(name)' was denied by the user.") - } - } - - return try await executeTool(name: name, arguments: arguments) - } - - /// Navigate to the appropriate tab before approval, and set pending form values. - /// Returns the previous tab so we can navigate back if denied. - private func preNavigateASCTool(name: String, arguments: [String: Any]) async -> AppTab? { - let previousTab = await MainActor.run { appState.activeTab } - - let targetTab: AppTab? - if name == "asc_fill_form" { - let tab = arguments["tab"] as? String ?? "" - switch tab { - case "storeListing": targetTab = .storeListing - case "appDetails": targetTab = .appDetails - case "monetization": targetTab = .monetization - case "review.ageRating", "review.contact": targetTab = .review - case "settings.bundleId": targetTab = .settings - default: targetTab = nil - } - } else if name == "asc_open_submit_preview" { - targetTab = .ascOverview - } else if name == "screenshots_add_asset" - || name == "screenshots_set_track" || name == "screenshots_save" { - targetTab = .screenshots - } else if name == "asc_set_app_price" { - targetTab = .monetization - } else if name == "asc_create_iap" || name == "asc_create_subscription" { - targetTab = .monetization - } else { - targetTab = nil - } - - if let targetTab { - await MainActor.run { appState.activeTab = targetTab } - // Ensure tab data is loaded - if targetTab.isASCTab { - await appState.ascManager.fetchTabData(targetTab) - } - } - - // For asc_fill_form, pre-populate pending values so the form shows intended changes - if name == "asc_fill_form", - let tab = arguments["tab"] as? String { - var fieldMap: [String: String] = [:] - if let fieldsArray = arguments["fields"] as? [[String: Any]] { - for item in fieldsArray { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[field] = value - } - } - } else if let fieldsDict = arguments["fields"] as? [String: Any] { - for (key, value) in fieldsDict { - fieldMap[key] = "\(value)" - } - } else if let fieldsString = arguments["fields"] as? String, - let data = fieldsString.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - if let dict = parsed as? [String: Any] { - for (key, value) in dict { - fieldMap[key] = "\(value)" - } - } else if let array = parsed as? [[String: Any]] { - for item in array { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[field] = value - } - } - } - } - if !fieldMap.isEmpty { - let fieldMapCopy = fieldMap - await MainActor.run { - appState.ascManager.pendingFormValues[tab] = fieldMapCopy - appState.ascManager.pendingFormVersion += 1 - } - } - } - - return previousTab - } - - /// Resume a pending approval - nonisolated func resolveApproval(id: String, approved: Bool) { - Task { await _resolveApproval(id: id, approved: approved) } - } - - private func _resolveApproval(id: String, approved: Bool) { - guard let continuation = pendingContinuations.removeValue(forKey: id) else { return } - continuation.resume(returning: approved) - } - - // MARK: - Approval Flow - - private func requestApproval(_ request: ApprovalRequest) async -> Bool { - // Show alert on main thread and bring Blitz to front so user sees it - await MainActor.run { - appState.pendingApproval = request - appState.showApprovalAlert = true - NSApp.activate(ignoringOtherApps: true) - } - - // Suspend until user approves/denies or timeout - let approved = await withCheckedContinuation { (continuation: CheckedContinuation) in - pendingContinuations[request.id] = continuation - - // 5-minute auto-deny timeout - Task { - try? await Task.sleep(for: .seconds(300)) - if pendingContinuations[request.id] != nil { - _resolveApproval(id: request.id, approved: false) - } - } - } - - // Clear alert - await MainActor.run { - appState.pendingApproval = nil - appState.showApprovalAlert = false - } - - return approved - } - - // MARK: - Tool Execution - - private func executeTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { - switch name { - // -- App State -- - case "app_get_state": - return try await executeAppGetState() - - // -- Navigation -- - case "nav_switch_tab": - return try await executeNavSwitchTab(arguments) - case "nav_list_tabs": - return await executeNavListTabs() - - // -- Projects -- - case "project_list": - return await executeProjectList() - case "project_get_active": - return await executeProjectGetActive() - case "project_open": - return try await executeProjectOpen(arguments) - case "project_create": - return try await executeProjectCreate(arguments) - case "project_import": - return try await executeProjectImport(arguments) - case "project_close": - return await executeProjectClose() - - // -- Simulator -- - case "simulator_list_devices": - return await executeSimulatorListDevices() - case "simulator_select_device": - return try await executeSimulatorSelectDevice(arguments) - - // -- Settings -- - case "settings_get": - return await executeSettingsGet() - case "settings_update": - return await executeSettingsUpdate(arguments) - case "settings_save": - return await executeSettingsSave() - - - - // -- Rejection Feedback -- - case "get_rejection_feedback": - return try await executeGetRejectionFeedback(arguments) - - // -- Tab State -- - case "get_tab_state": - return try await executeGetTabState(arguments) - - // -- ASC Credentials -- - case "asc_set_credentials": - return await executeASCSetCredentials(arguments) - - // -- ASC Form Tools -- - case "asc_fill_form": - return try await executeASCFillForm(arguments) - case "screenshots_add_asset": - return try await executeScreenshotsAddAsset(arguments) - case "screenshots_set_track": - return try await executeScreenshotsSetTrack(arguments) - case "screenshots_save": - return try await executeScreenshotsSave(arguments) - case "asc_open_submit_preview": - return await executeASCOpenSubmitPreview() - case "asc_create_iap": - return try await executeASCCreateIAP(arguments) - case "asc_create_subscription": - return try await executeASCCreateSubscription(arguments) - case "asc_set_app_price": - return try await executeASCSetAppPrice(arguments) - case "asc_web_auth": - return await executeASCWebAuth() - - // -- Build Pipeline -- - case "app_store_setup_signing": - return try await executeSetupSigning(arguments) - case "app_store_build": - return try await executeBuildIPA(arguments) - case "app_store_upload": - return try await executeUploadToTestFlight(arguments) - - case "get_blitz_screenshot": - let path = "/tmp/blitz-app-screenshot-\(Int(Date().timeIntervalSince1970)).png" - let saved = await MainActor.run { () -> Bool in - guard let window = NSApp.windows.first(where: { $0.title != "Welcome to Blitz" && $0.canBecomeMain && $0.isVisible }) ?? NSApp.mainWindow else { - return false - } - let windowId = CGWindowID(window.windowNumber) - guard let cgImage = CGWindowListCreateImage( - .null, - .optionIncludingWindow, - windowId, - [.boundsIgnoreFraming, .bestResolution] - ) else { - return false - } - let bitmap = NSBitmapImageRep(cgImage: cgImage) - guard let png = bitmap.representation(using: .png, properties: [:]) else { - return false - } - return ((try? png.write(to: URL(fileURLWithPath: path))) != nil) - } - if saved { - return mcpText(path) - } else { - return mcpText("Error: could not capture Blitz window screenshot") - } - - default: - throw MCPServerService.MCPError.unknownTool(name) - } - } - - // MARK: - App State Tools - - private func executeAppGetState() async throws -> [String: Any] { - let state = await MainActor.run { () -> [String: Any] in - var result: [String: Any] = [ - "activeTab": appState.activeTab.rawValue, - "isStreaming": appState.simulatorStream.isCapturing - ] - if let project = appState.activeProject { - result["activeProject"] = [ - "id": project.id, - "name": project.name, - "path": project.path, - "type": project.type.rawValue - ] - } - if let udid = appState.simulatorManager.bootedDeviceId { - result["bootedSimulator"] = udid - } - // Expose Teenybase DB URL so AI agents can curl it directly - let db = appState.databaseManager - if db.connectionStatus == .connected || db.backendProcess.isRunning { - result["database"] = [ - "url": db.backendProcess.baseURL, - "status": db.connectionStatus == .connected ? "connected" : "running" - ] - } - return result - } - return mcpJSON(state) - } - - // MARK: - Navigation Tools - - private func executeNavSwitchTab(_ args: [String: Any]) async throws -> [String: Any] { - guard let tabStr = args["tab"] as? String, - let tab = AppTab(rawValue: tabStr) else { - throw MCPServerService.MCPError.invalidToolArgs - } - await MainActor.run { appState.activeTab = tab } - - // Auto-connect database when switching to database tab - if tab == .database { - let status = await MainActor.run { appState.databaseManager.connectionStatus } - if status != .connected, let project = await MainActor.run(body: { appState.activeProject }) { - await appState.databaseManager.startAndConnect(projectId: project.id, projectPath: project.path) - } - } - - return mcpText("Switched to tab: \(tab.label)") - } - - private func executeNavListTabs() async -> [String: Any] { - var groups: [[String: Any]] = [] - for group in AppTab.Group.allCases { - let tabs = group.tabs.map { ["name": $0.rawValue, "label": $0.label, "icon": $0.icon] as [String: Any] } - groups.append(["group": group.rawValue, "tabs": tabs]) - } - // Include settings separately - groups.append(["group": "Other", "tabs": [["name": "settings", "label": "Settings", "icon": "gear"]]]) - return mcpJSON(["groups": groups]) - } - - // MARK: - Project Tools - - private func executeProjectList() async -> [String: Any] { - await appState.projectManager.loadProjects() - let projects = await MainActor.run { - appState.projectManager.projects.map { p -> [String: Any] in - ["id": p.id, "name": p.name, "path": p.path, "type": p.type.rawValue] - } - } - return mcpJSON(["projects": projects]) - } - - private func executeProjectGetActive() async -> [String: Any] { - let result = await MainActor.run { () -> [String: Any]? in - guard let project = appState.activeProject else { return nil } - return ["id": project.id, "name": project.name, "path": project.path, "type": project.type.rawValue] - } - if let result { - return mcpJSON(result) - } - return mcpText("No active project") - } - - private func executeProjectOpen(_ args: [String: Any]) async throws -> [String: Any] { - guard let projectId = args["projectId"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let storage = ProjectStorage() - storage.updateLastOpened(projectId: projectId) - await MainActor.run { appState.activeProjectId = projectId } - await appState.projectManager.loadProjects() - return mcpText("Opened project: \(projectId)") - } - - private func executeProjectCreate(_ args: [String: Any]) async throws -> [String: Any] { - guard let name = args["name"] as? String, - let typeStr = args["type"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - let storage = ProjectStorage() - let projectId = name.lowercased() - .replacingOccurrences(of: " ", with: "-") - .filter { $0.isLetter || $0.isNumber || $0 == "-" } - let projectDir = storage.baseDirectory.appendingPathComponent(projectId) - - try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true) - - let projectType = ProjectType(rawValue: typeStr) ?? .reactNative - let platformStr = args["platform"] as? String - let platform = ProjectPlatform(rawValue: platformStr ?? "iOS") ?? .iOS - let metadata = BlitzProjectMetadata( - name: name, - type: projectType, - platform: platform, - createdAt: Date(), - lastOpenedAt: Date() - ) - try storage.writeMetadata(projectId: projectId, metadata: metadata) - storage.ensureMCPConfig(projectId: projectId) - await appState.projectManager.loadProjects() - - // Set pending setup so ContentView triggers template scaffolding - await MainActor.run { - appState.projectSetup.pendingSetupProjectId = projectId - appState.activeProjectId = projectId - } - - // Wait for setup to complete (ContentView picks up pendingSetupProjectId). - // If the main window isn't open (WelcomeWindow's onChange should open it), - // fall back to running setup directly. - try? await Task.sleep(for: .seconds(2)) - let setupStarted = await MainActor.run { appState.projectSetup.isSettingUp } - if !setupStarted { - // ContentView didn't pick it up — run setup directly - guard let project = await MainActor.run(body: { appState.activeProject }) else { - return mcpText("Created project '\(name)' but could not start setup (project not found)") - } - await appState.projectSetup.setup( - projectId: project.id, - projectName: project.name, - projectPath: project.path, - projectType: project.type, - platform: project.platform - ) - } else { - // Wait for setup to finish (up to 3 min) - for _ in 0..<180 { - let done = await MainActor.run { !appState.projectSetup.isSettingUp } - if done { break } - try? await Task.sleep(for: .seconds(1)) - } - } - - let errorMsg = await MainActor.run { appState.projectSetup.errorMessage } - if let errorMsg { - return mcpText("Created project '\(name)' but setup failed: \(errorMsg)") - } - return mcpText("Created project '\(name)' (type: \(typeStr), id: \(projectId)) — setup complete") - } - - private func executeProjectImport(_ args: [String: Any]) async throws -> [String: Any] { - guard let path = args["path"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - let url = URL(fileURLWithPath: path) - let storage = ProjectStorage() - let projectId = try storage.openProject(at: url) - storage.ensureMCPConfig(projectId: projectId) - await appState.projectManager.loadProjects() - await MainActor.run { appState.activeProjectId = projectId } - - return mcpText("Imported project from '\(path)' (id: \(projectId))") - } - - private func executeProjectClose() async -> [String: Any] { - await MainActor.run { appState.activeProjectId = nil } - return mcpText("Project closed") - } - - // MARK: - Simulator Tools - - private func executeSimulatorListDevices() async -> [String: Any] { - await appState.simulatorManager.loadSimulators() - let devices = await MainActor.run { - appState.simulatorManager.simulators.map { sim -> [String: Any] in - [ - "udid": sim.udid, - "name": sim.name, - "state": sim.state, - "isBooted": sim.isBooted - ] - } - } - return mcpJSON(["devices": devices]) - } - - private func executeSimulatorSelectDevice(_ args: [String: Any]) async throws -> [String: Any] { - guard let udid = args["udid"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - let service = SimulatorService() - try await service.boot(udid: udid) - await MainActor.run { appState.simulatorManager.bootedDeviceId = udid } - await appState.simulatorManager.loadSimulators() - - return mcpText("Booted simulator: \(udid)") - } - - - // MARK: - Settings Tools - - private func executeSettingsGet() async -> [String: Any] { - let settings = await MainActor.run { () -> [String: Any] in - [ - "showCursor": appState.settingsStore.showCursor, - "cursorSize": appState.settingsStore.cursorSize, - "defaultSimulatorUDID": appState.settingsStore.defaultSimulatorUDID ?? "" - ] - } - return mcpJSON(settings) - } - - private func executeSettingsUpdate(_ args: [String: Any]) async -> [String: Any] { - await MainActor.run { - if let cursor = args["showCursor"] as? Bool { appState.settingsStore.showCursor = cursor } - if let size = args["cursorSize"] as? Double { appState.settingsStore.cursorSize = size } - } - return mcpText("Settings updated") - } - - private func executeSettingsSave() async -> [String: Any] { - await MainActor.run { appState.settingsStore.save() } - return mcpText("Settings saved to disk") - } - - // MARK: - ASC Form Tools - - // Valid field names per tab — rejects unknown fields before API roundtrip - private static let validFieldsByTab: [String: Set] = [ - "storeListing": ["title", "name", "subtitle", "description", "keywords", "promotionalText", - "marketingUrl", "supportUrl", "whatsNew", "privacyPolicyUrl"], - "appDetails": ["copyright", "primaryCategory", "contentRightsDeclaration"], - "monetization": ["isFree"], - "review.ageRating": ["gambling", "messagingAndChat", "unrestrictedWebAccess", - "userGeneratedContent", "advertising", "lootBox", - "healthOrWellnessTopics", "parentalControls", "ageAssurance", - "alcoholTobaccoOrDrugUseOrReferences", "contests", "gamblingSimulated", - "gunsOrOtherWeapons", "horrorOrFearThemes", "matureOrSuggestiveThemes", - "medicalOrTreatmentInformation", "profanityOrCrudeHumor", - "sexualContentGraphicAndNudity", "sexualContentOrNudity", - "violenceCartoonOrFantasy", "violenceRealistic", - "violenceRealisticProlongedGraphicOrSadistic"], - "review.contact": ["contactFirstName", "contactLastName", "contactEmail", "contactPhone", - "notes", "demoAccountRequired", "demoAccountName", "demoAccountPassword"], - "settings.bundleId": ["bundleId"], - ] - - // Common aliases: user-friendly field names → API field names (per tab) - private static let fieldAliases: [String: String] = [ - "firstName": "contactFirstName", - "lastName": "contactLastName", - "email": "contactEmail", - "phone": "contactPhone", - ] - - private func executeASCSetCredentials(_ args: [String: Any]) async -> [String: Any] { - guard let issuerId = args["issuerId"] as? String, - let keyId = args["keyId"] as? String, - let rawPath = args["privateKeyPath"] as? String else { - return mcpText("Error: issuerId, keyId, and privateKeyPath are required.") - } - - let path = NSString(string: rawPath).expandingTildeInPath - guard FileManager.default.fileExists(atPath: path), - let privateKey = try? String(contentsOfFile: path, encoding: .utf8), - !privateKey.isEmpty else { - return mcpText("Error: could not read private key file at \(rawPath)") - } - - await MainActor.run { - appState.ascManager.pendingCredentialValues = [ - "issuerId": issuerId, - "keyId": keyId, - "privateKey": privateKey, - "privateKeyFileName": URL(fileURLWithPath: path).lastPathComponent - ] - } - return mcpText("Credentials pre-filled. The user can verify and click 'Save Credentials'.") - } - - private func executeASCFillForm(_ args: [String: Any]) async throws -> [String: Any] { - guard let tab = args["tab"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - // Build field map with alias resolution — accept multiple formats: - // 1. Array of {field, value} objects: [{"field":"k","value":"v"}, ...] - // 2. Flat dict: {"key": "value", ...} - // 3. JSON string containing either format above - var fieldMap: [String: String] = [:] - if let fieldsArray = args["fields"] as? [[String: Any]] { - for item in fieldsArray { - if let field = item["field"] as? String, let value = item["value"] as? String { - let resolved = Self.fieldAliases[field] ?? field - fieldMap[resolved] = value - } - } - } else if let fieldsDict = args["fields"] as? [String: Any] { - for (key, value) in fieldsDict { - let resolved = Self.fieldAliases[key] ?? key - fieldMap[resolved] = "\(value)" - } - } else if let fieldsString = args["fields"] as? String, - let data = fieldsString.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - if let dict = parsed as? [String: Any] { - for (key, value) in dict { - let resolved = Self.fieldAliases[key] ?? key - fieldMap[resolved] = "\(value)" - } - } else if let array = parsed as? [[String: Any]] { - for item in array { - if let field = item["field"] as? String, let value = item["value"] as? String { - let resolved = Self.fieldAliases[field] ?? field - fieldMap[resolved] = value - } - } - } - } - - guard !fieldMap.isEmpty else { - throw MCPServerService.MCPError.invalidToolArgs - } - - // Validate field names against allowed set for this tab - if let validFields = Self.validFieldsByTab[tab] { - let invalid = fieldMap.keys.filter { !validFields.contains($0) } - if !invalid.isEmpty { - // Check if the field belongs to a different tab - var hints: [String] = [] - for field in invalid { - for (otherTab, otherFields) in Self.validFieldsByTab where otherTab != tab { - if otherFields.contains(field) { - hints.append("'\(field)' belongs on tab '\(otherTab)'") - } - } - } - let hintStr = hints.isEmpty ? "" : " Hint: \(hints.joined(separator: "; "))." - return mcpText("Error: invalid field(s) for tab '\(tab)': \(invalid.sorted().joined(separator: ", ")). Valid fields: \(validFields.sorted().joined(separator: ", ")).\(hintStr)") - } - } - - // Navigation + pending values already set by preNavigateASCTool in execute() - - // Execute the write based on tab - switch tab { - case "storeListing": - // Fields are split across two ASC resources: - // - appInfoLocalizations: name (title), subtitle, privacyPolicyUrl - // - appStoreVersionLocalizations: description, keywords, whatsNew, marketingUrl, supportUrl, promotionalText - let appInfoLocFields: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] - var versionLocFields: [String: String] = [:] - var infoLocFields: [String: String] = [:] - - for (field, value) in fieldMap { - if appInfoLocFields.contains(field) { - // Map "title" to "name" for the API - let apiField = (field == "title") ? "name" : field - infoLocFields[apiField] = value - } else { - versionLocFields[field] = value - } - } - - // Save appInfoLocalization fields (name, subtitle, privacyPolicyUrl) - if !infoLocFields.isEmpty { - for (field, value) in infoLocFields { - await appState.ascManager.updateAppInfoLocalizationField(field, value: value) - } - if let err = await checkASCWriteError(tab: tab) { return err } - } - - // Save version localization fields (description, keywords, etc.) - if !versionLocFields.isEmpty { - guard let locId = await MainActor.run(body: { appState.ascManager.localizations.first?.id }) else { - return mcpText("Error: no version localizations found.") - } - do { - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return mcpText("Error: ASC service not configured") - } - try await service.patchLocalization(id: locId, fields: versionLocFields) - if let versionId = await MainActor.run(body: { appState.ascManager.appStoreVersions.first?.id }) { - let locs = try await service.fetchLocalizations(versionId: versionId) - await MainActor.run { appState.ascManager.localizations = locs } - } - } catch { - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - return mcpText("Error: \(error.localizedDescription)") - } - } - - case "appDetails": - for (field, value) in fieldMap { - await appState.ascManager.updateAppInfoField(field, value: value) - } - if let err = await checkASCWriteError(tab: tab) { return err } - - case "monetization": - guard let isFree = fieldMap["isFree"] else { - return mcpText("Error: monetization tab requires the 'isFree' field (value: \"true\" or \"false\").") - } - if isFree == "true" { - await appState.ascManager.setPriceFree() - } else { - // Paid pricing — use asc_set_app_price tool instead - return mcpText("To set a paid price, use the asc_set_app_price tool with a price parameter (e.g. price=\"0.99\").") - } - if let err = await checkASCWriteError(tab: tab) { return err } - - case "review.ageRating": - var attrs: [String: Any] = [:] - let boolFields = Set(["gambling", "messagingAndChat", "unrestrictedWebAccess", - "userGeneratedContent", "advertising", "lootBox", - "healthOrWellnessTopics", "parentalControls", "ageAssurance"]) - for (field, value) in fieldMap { - if boolFields.contains(field) { - attrs[field] = value == "true" - } else { - attrs[field] = value - } - } - await appState.ascManager.updateAgeRating(attrs) - if let err = await checkASCWriteError(tab: tab) { return err } - - case "review.contact": - var attrs: [String: Any] = [:] - for (field, value) in fieldMap { - if field == "demoAccountRequired" { - attrs[field] = value == "true" - } else if field == "contactPhone" { - // ASC requires phone as + — strip dashes, spaces, parens - let stripped = value.hasPrefix("+") - ? "+" + value.dropFirst().filter(\.isNumber) - : value.filter(\.isNumber) - attrs[field] = stripped - } else { - attrs[field] = value - } - } - await appState.ascManager.updateReviewContact(attrs) - if let err = await checkASCWriteError(tab: tab) { return err } - - case "settings.bundleId": - if let bundleId = fieldMap["bundleId"] { - let projectPath = await MainActor.run { appState.activeProject?.path } - await MainActor.run { - guard let projectId = appState.activeProjectId else { return } - let storage = ProjectStorage() - guard var metadata = storage.readMetadata(projectId: projectId) else { return } - metadata.bundleIdentifier = bundleId - try? storage.writeMetadata(projectId: projectId, metadata: metadata) - } - // Also update PRODUCT_BUNDLE_IDENTIFIER in pbxproj - if let projectPath { - let pipeline = BuildPipelineService() - await pipeline.updateBundleIdInPbxproj(projectPath: projectPath, bundleId: bundleId) - } - await appState.projectManager.loadProjects() - let hasCreds = await MainActor.run { appState.ascManager.credentials != nil } - if hasCreds { - await appState.ascManager.fetchApp(bundleId: bundleId) - } - } - - default: - return mcpText("Unknown tab: \(tab)") - } - - // Clear pending values - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - - return mcpJSON(["success": true, "tab": tab, "fieldsUpdated": fieldMap.count]) - } - - private func executeScreenshotsAddAsset(_ args: [String: Any]) async throws -> [String: Any] { - guard let sourcePath = args["sourcePath"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let expanded = (sourcePath as NSString).expandingTildeInPath - guard FileManager.default.fileExists(atPath: expanded) else { - return mcpText("Error: file not found at \(expanded)") - } - - guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { - return mcpText("Error: no active project") - } - - let destDir = BlitzPaths.screenshots(projectId: projectId) - let fm = FileManager.default - try? fm.createDirectory(at: destDir, withIntermediateDirectories: true) - - let fileName = args["fileName"] as? String ?? (expanded as NSString).lastPathComponent - let dest = destDir.appendingPathComponent(fileName) - - do { - if fm.fileExists(atPath: dest.path) { - try fm.removeItem(at: dest) - } - try fm.copyItem(atPath: expanded, toPath: dest.path) - } catch { - return mcpText("Error copying file: \(error.localizedDescription)") - } - - await MainActor.run { appState.ascManager.scanLocalAssets(projectId: projectId) } - return mcpJSON(["success": true, "fileName": fileName]) - } - - private func executeScreenshotsSetTrack(_ args: [String: Any]) async throws -> [String: Any] { - guard let assetFileName = args["assetFileName"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - guard let slotRaw = args["slotIndex"] as? Int ?? (args["slotIndex"] as? Double).map({ Int($0) }), - slotRaw >= 1 && slotRaw <= 10 else { - return mcpText("Error: slotIndex must be between 1 and 10") - } - let slotIndex = slotRaw - 1 // Convert to 0-based - let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" - - guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { - return mcpText("Error: no active project") - } - - let dir = BlitzPaths.screenshots(projectId: projectId) - let filePath = dir.appendingPathComponent(assetFileName).path - - guard FileManager.default.fileExists(atPath: filePath) else { - return mcpText("Error: asset '\(assetFileName)' not found in local screenshots library") - } - - let error = await MainActor.run { - appState.ascManager.addAssetToTrack(displayType: displayType, slotIndex: slotIndex, localPath: filePath) - } - if let error { - return mcpText("Error: \(error)") - } - return mcpJSON(["success": true, "slot": slotRaw]) - } - - private func executeScreenshotsSave(_ args: [String: Any]) async throws -> [String: Any] { - let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" - let locale = args["locale"] as? String ?? "en-US" - - let hasChanges = await MainActor.run { appState.ascManager.hasUnsavedChanges(displayType: displayType) } - guard hasChanges else { - return mcpJSON(["success": true, "message": "No changes to save"]) - } - - await appState.ascManager.syncTrackToASC(displayType: displayType, locale: locale) - - if let err = await checkASCWriteError(tab: "screenshots") { return err } - - let slotCount = await MainActor.run { - (appState.ascManager.trackSlots[displayType] ?? []).compactMap { $0 }.count - } - return mcpJSON(["success": true, "synced": slotCount]) - } - - private func executeASCOpenSubmitPreview() async -> [String: Any] { - // Navigation already done by preNavigateASCTool - - // Refresh IAP/subscription data so readiness reflects latest App Store Connect and iris API state - await appState.ascManager.refreshSubmissionReadinessData() - - var readiness = await MainActor.run { appState.ascManager.submissionReadiness } - - // If Build is the only (or one of the) missing fields, try to auto-attach - let buildMissing = readiness.missingRequired.contains { $0.label == "Build" } - if buildMissing { - // Refresh builds list from ASC - let service = await MainActor.run { appState.ascManager.service } - let appId = await MainActor.run { appState.ascManager.app?.id } - if let service, let appId { - // Fetch latest builds - if let latestBuild = try? await service.fetchLatestBuild(appId: appId), - latestBuild.attributes.processingState == "VALID" { - // Find the pending version to attach to - let versionId = await MainActor.run { () -> String? in - appState.ascManager.appStoreVersions.first { - let s = $0.attributes.appStoreState ?? "" - return s != "READY_FOR_SALE" && s != "REMOVED_FROM_SALE" - && s != "DEVELOPER_REMOVED_FROM_SALE" && !s.isEmpty - }?.id ?? appState.ascManager.appStoreVersions.first?.id - } - if let versionId { - do { - try await service.attachBuild(versionId: versionId, buildId: latestBuild.id) - // Refresh data so readiness reflects the attached build - await appState.ascManager.refreshTabData(.ascOverview) - readiness = await MainActor.run { appState.ascManager.submissionReadiness } - } catch { - // Non-fatal: report in missing fields - } - } - } - } - } - - if !readiness.isComplete { - let missing = readiness.missingRequired.map { $0.label } - return mcpJSON(["ready": false, "missing": missing]) - } - - await MainActor.run { - appState.ascManager.showSubmitPreview = true - } - - return mcpJSON(["ready": true, "opened": true]) - } - - // MARK: - ASC IAP / Subscriptions / Pricing Tools - - /// Fuzzy price match: "0.99" matches "0.990", "0.99", etc. - private static func priceMatches(_ customerPrice: String?, target: String) -> Bool { - guard let customerPrice else { return false } - guard let a = Double(customerPrice), let b = Double(target) else { - return customerPrice == target - } - return abs(a - b) < 0.001 - } - - private func executeASCWebAuth() async -> [String: Any] { - await MainActor.run { - NSApp.activate(ignoringOtherApps: true) - } - - guard let session = await appState.ascManager.requestWebAuthForMCP() else { - let authError = await MainActor.run { appState.ascManager.irisFeedbackError } - if let authError, !authError.isEmpty { - return mcpJSON([ - "success": false, - "cancelled": false, - "message": authError - ]) - } - return mcpJSON([ - "success": false, - "cancelled": true, - "message": "Web authentication was cancelled before a session was captured." - ]) - } - - // setIrisSession (called by the login sheet callback) already saves to - // both keychain stores (native + asc-web-session), so no extra work needed. - let email = session.email ?? "unknown" - return mcpJSON([ - "success": true, - "email": email, - "message": "Web session authenticated and saved to keychain. The asc-iap-attach skill can now use the iris API." - ]) - } - - private func executeASCSetAppPrice(_ args: [String: Any]) async throws -> [String: Any] { - guard let priceStr = args["price"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let effectiveDate = args["effectiveDate"] as? String // optional: ISO date like "2026-06-01" - - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return mcpText("Error: ASC service not configured") - } - guard let appId = await MainActor.run(body: { appState.ascManager.app?.id }) else { - return mcpText("Error: no ASC app loaded. Open a project with a bundle ID first.") - } - - // If price is "0" or "0.00", use the existing setPriceFree method - if let priceVal = Double(priceStr), priceVal < 0.001 { - try await service.setPriceFree(appId: appId) - try await service.ensureAppAvailability(appId: appId) - await MainActor.run { - appState.ascManager.currentAppPricePointId = appState.ascManager.freeAppPricePointId - appState.ascManager.scheduledAppPricePointId = nil - appState.ascManager.scheduledAppPriceEffectiveDate = nil - appState.ascManager.monetizationStatus = "Free" - } - await appState.ascManager.refreshTabData(.monetization) - return mcpJSON(["success": true, "price": "0.00", "message": "App set to free with territory availability configured"]) - } - - // Fetch price points and find matching one - let pricePoints = try await service.fetchAppPricePoints(appId: appId) - guard let match = pricePoints.first(where: { Self.priceMatches($0.attributes.customerPrice, target: priceStr) }) else { - let sorted = pricePoints.compactMap { $0.attributes.customerPrice } - .compactMap { Double($0) } - .filter { $0 > 0 } - .sorted() - let samples = sorted.count <= 30 ? sorted : { - // Show a spread: lowest 5, some mid-range, highest 5 - let lo = Array(sorted.prefix(5)) - let hi = Array(sorted.suffix(5)) - let step = max(1, (sorted.count - 10) / 10) - let mid = stride(from: 5, to: sorted.count - 5, by: step).map { sorted[$0] } - return lo + mid + hi - }() - let formatted = samples.map { String(format: "%.2f", $0) } - return mcpText("Error: no price point matching $\(priceStr). \(sorted.count) tiers available, samples: \(formatted.joined(separator: ", "))") - } - - if let effectiveDate { - // Scheduled price change: keep current price until effectiveDate, then switch - let freePoint = pricePoints.first(where: { - let p = $0.attributes.customerPrice ?? "0" - return p == "0" || p == "0.0" || p == "0.00" - }) - // Use free point as default current price - let currentId = freePoint?.id ?? match.id - try await service.setScheduledAppPrice( - appId: appId, - currentPricePointId: currentId, - futurePricePointId: match.id, - effectiveDate: effectiveDate - ) - try await service.ensureAppAvailability(appId: appId) - await MainActor.run { - appState.ascManager.currentAppPricePointId = currentId - appState.ascManager.scheduledAppPricePointId = match.id - appState.ascManager.scheduledAppPriceEffectiveDate = effectiveDate - appState.ascManager.monetizationStatus = "Configured" - } - await appState.ascManager.refreshTabData(.monetization) - return mcpJSON(["success": true, "price": priceStr, "effectiveDate": effectiveDate, "message": "Scheduled price change for \(effectiveDate) with territory availability configured"]) - } - - try await service.setAppPrice(appId: appId, pricePointId: match.id) - try await service.ensureAppAvailability(appId: appId) - await MainActor.run { - appState.ascManager.currentAppPricePointId = match.id - appState.ascManager.scheduledAppPricePointId = nil - appState.ascManager.scheduledAppPriceEffectiveDate = nil - appState.ascManager.monetizationStatus = "Configured" - } - await appState.ascManager.refreshTabData(.monetization) - return mcpJSON(["success": true, "price": priceStr, "pricePointId": match.id]) - } - - private func executeASCCreateIAP(_ args: [String: Any]) async throws -> [String: Any] { - guard let productId = args["productId"] as? String, - let name = args["name"] as? String, - let type = args["type"] as? String, - let displayName = args["displayName"] as? String, - let priceStr = args["price"] as? String, - let screenshotPath = args["screenshotPath"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let description = args["description"] as? String - - // Validate type - let validTypes = ["CONSUMABLE", "NON_CONSUMABLE", "NON_RENEWING_SUBSCRIPTION"] - guard validTypes.contains(type) else { - return mcpText("Error: invalid type '\(type)'. Must be one of: \(validTypes.joined(separator: ", "))") - } - - // Pre-fill form values so the UI shows them - await MainActor.run { - var values: [String: String] = [ - "kind": "iap", - "name": name, "productId": productId, "type": type, - "displayName": displayName, "price": priceStr - ] - if let description { values["description"] = description } - appState.ascManager.pendingCreateValues = values - } - - // Delegate to ASCManager (same flow as the SwiftUI form) - await MainActor.run { - appState.ascManager.createIAP( - name: name, productId: productId, type: type, - displayName: displayName, description: description, - price: priceStr, screenshotPath: screenshotPath - ) - } - - // Poll until creation completes - let result = await pollASCCreation() - - if let error = result { - return mcpText("Error creating IAP: \(error)") - } - - return mcpJSON([ - "success": true, - "productId": productId, - "type": type, - "displayName": displayName, - "price": priceStr - ] as [String: Any]) - } - - private func executeASCCreateSubscription(_ args: [String: Any]) async throws -> [String: Any] { - guard let groupName = args["groupName"] as? String, - let productId = args["productId"] as? String, - let name = args["name"] as? String, - let displayName = args["displayName"] as? String, - let duration = args["duration"] as? String, - let priceStr = args["price"] as? String, - let screenshotPath = args["screenshotPath"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let description = args["description"] as? String - - // Validate duration - let validDurations = ["ONE_WEEK", "ONE_MONTH", "TWO_MONTHS", "THREE_MONTHS", "SIX_MONTHS", "ONE_YEAR"] - guard validDurations.contains(duration) else { - return mcpText("Error: invalid duration '\(duration)'. Must be one of: \(validDurations.joined(separator: ", "))") - } - - // Pre-fill form values so the UI shows them - await MainActor.run { - var values: [String: String] = [ - "kind": "subscription", - "groupName": groupName, "name": name, "productId": productId, - "displayName": displayName, "duration": duration, "price": priceStr - ] - if let description { values["description"] = description } - appState.ascManager.pendingCreateValues = values - } - - // Delegate to ASCManager (same flow as the SwiftUI form) - await MainActor.run { - appState.ascManager.createSubscription( - groupName: groupName, name: name, productId: productId, - displayName: displayName, description: description, - duration: duration, price: priceStr, screenshotPath: screenshotPath - ) - } - - // Poll until creation completes - let result = await pollASCCreation() - - if let error = result { - return mcpText("Error creating subscription: \(error)") - } - - return mcpJSON([ - "success": true, - "groupName": groupName, - "productId": productId, - "displayName": displayName, - "duration": duration, - "price": priceStr - ] as [String: Any]) - } - - /// Poll ASCManager.isCreating until it finishes. Returns the error string if failed, nil on success. - private func pollASCCreation() async -> String? { - // Wait for isCreating to become true (task starts) - for _ in 0..<10 { - let creating = await MainActor.run { appState.ascManager.isCreating } - if creating { break } - try? await Task.sleep(for: .milliseconds(100)) - } - // Wait for isCreating to become false (task completes) - while await MainActor.run(body: { appState.ascManager.isCreating }) { - try? await Task.sleep(for: .milliseconds(500)) - } - return await MainActor.run { appState.ascManager.writeError } - } - - // MARK: - Tab State Tool - - private func executeGetRejectionFeedback(_ args: [String: Any]) async throws -> [String: Any] { - let raw = await MainActor.run { () -> [String: Any] in - let asc = appState.ascManager - guard let appId = asc.app?.id else { - return ["error": "No app connected. Set up ASC credentials first."] - } - - let requestedVersion = args["version"] as? String - let version: String - if let v = requestedVersion { - version = v - } else if let rejected = asc.appStoreVersions.first(where: { $0.attributes.appStoreState == "REJECTED" }) { - version = rejected.attributes.versionString - } else { - return ["error": "No rejected version found.", "appId": appId] as [String: Any] - } - - if let cached = IrisFeedbackCache.load(appId: appId, versionString: version) { - let reasons = cached.reasons.map { r in - ["section": r.section, "description": r.description, "code": r.code] - } - let messages = cached.messages.map { m -> [String: String] in - var msg = ["body": m.body] - if let d = m.date { msg["date"] = d } - return msg - } - return [ - "appId": appId, - "version": version, - "fetchedAt": ISO8601DateFormatter().string(from: cached.fetchedAt), - "reasons": reasons, - "messages": messages, - "source": "cache" - ] as [String: Any] - } - - return [ - "error": "No rejection feedback cached for version \(version). The user needs to sign in with their Apple ID in the ASC Overview tab to fetch feedback.", - "appId": appId, - "version": version - ] as [String: Any] - } - return mcpJSON(raw) - } - - private func executeGetTabState(_ args: [String: Any]) async throws -> [String: Any] { - let tabStr = args["tab"] as? String - let tab: AppTab - if let tabStr, let parsed = AppTab(rawValue: tabStr) { - tab = parsed - } else { - tab = await MainActor.run { appState.activeTab } - } - - // Build base result on main actor - var result = await MainActor.run { () -> [String: Any] in - let asc = appState.ascManager - var r: [String: Any] = [ - "tab": tab.rawValue, - "isLoading": asc.isLoadingTab[tab] ?? false, - ] - if let error = asc.tabError[tab] { r["error"] = error } - if let writeErr = asc.writeError { r["writeError"] = writeErr } - if tab.isASCTab, let app = asc.app { - r["app"] = ["id": app.id, "name": app.name, "bundleId": app.bundleId] as [String: Any] - } - return r - } - - // Refresh IAP/subscription data for overview so readiness reflects latest state - if tab == .ascOverview { - await appState.ascManager.refreshSubmissionReadinessData() - } - - // Build tab-specific data - let tabData = await MainActor.run { () -> [String: Any] in - let projectId = appState.activeProjectId - return tabStateData(for: tab, asc: appState.ascManager, projectId: projectId) - } - for (key, value) in tabData { - result[key] = value - } - - return mcpJSON(result) - } - - /// Extract tab-specific state data. Must be called on MainActor. - @MainActor - private func tabStateData(for tab: AppTab, asc: ASCManager, projectId: String?) -> [String: Any] { - switch tab { - case .ascOverview: - if let pid = projectId { - asc.checkAppIcon(projectId: pid) - } - return tabStateASCOverview(asc) - case .storeListing: - return tabStateStoreListing(asc) - case .appDetails: - return tabStateAppDetails(asc) - case .review: - return tabStateReview(asc) - case .screenshots: - return tabStateScreenshots(asc) - case .reviews: - return tabStateReviews(asc) - case .builds: - return tabStateBuilds(asc) - case .groups: - return tabStateGroups(asc) - case .betaInfo: - return tabStateBetaInfo(asc) - case .feedback: - return tabStateFeedback(asc) - default: - return ["note": "No structured state available for this tab"] - } - } - - @MainActor - private func tabStateASCOverview(_ asc: ASCManager) -> [String: Any] { - let readiness = asc.submissionReadiness - var fields: [[String: Any]] = [] - for f in readiness.fields { - let filled = f.value != nil && !f.value!.isEmpty - var entry: [String: Any] = ["label": f.label, "value": f.value as Any, "required": f.required, "filled": filled] - if let hint = f.hint { - entry["hint"] = hint - } - fields.append(entry) - } - var r: [String: Any] = [ - "submissionReadiness": [ - "isComplete": readiness.isComplete, - "fields": fields, - "missingRequired": readiness.missingRequired.map { $0.label } - ] as [String: Any], - "totalVersions": asc.appStoreVersions.count, - "isSubmitting": asc.isSubmitting - ] - if let v = asc.appStoreVersions.first { - r["latestVersion"] = ["id": v.id, "versionString": v.attributes.versionString, "state": v.attributes.appStoreState ?? "unknown"] as [String: Any] - } - if let error = asc.submissionError { - r["submissionError"] = error - } - // Include rejection feedback hint if available - if let cached = asc.cachedFeedback { - r["rejectionFeedback"] = [ - "version": cached.versionString, - "reasonCount": cached.reasons.count, - "messageCount": cached.messages.count, - "hint": "Use get_rejection_feedback tool for full details" - ] as [String: Any] - } - return r - } - - @MainActor - private func tabStateStoreListing(_ asc: ASCManager) -> [String: Any] { - let loc = asc.localizations.first - let infoLoc = asc.appInfoLocalization - return [ - "localization": [ - "locale": loc?.attributes.locale ?? "", - "name": infoLoc?.attributes.name ?? loc?.attributes.title ?? "", - "subtitle": infoLoc?.attributes.subtitle ?? loc?.attributes.subtitle ?? "", - "description": loc?.attributes.description ?? "", - "keywords": loc?.attributes.keywords ?? "", - "promotionalText": loc?.attributes.promotionalText ?? "", - "marketingUrl": loc?.attributes.marketingUrl ?? "", - "supportUrl": loc?.attributes.supportUrl ?? "", - "whatsNew": loc?.attributes.whatsNew ?? "" - ] as [String: Any], - "privacyPolicyUrl": infoLoc?.attributes.privacyPolicyUrl ?? "", - "localeCount": asc.localizations.count - ] - } - - @MainActor - private func tabStateAppDetails(_ asc: ASCManager) -> [String: Any] { - var r: [String: Any] = [ - "appInfo": [ - "primaryCategory": asc.appInfo?.primaryCategoryId ?? "", - "contentRightsDeclaration": asc.app?.contentRightsDeclaration ?? "" - ] as [String: Any], - "versionCount": asc.appStoreVersions.count - ] - if let v = asc.appStoreVersions.first { - r["latestVersion"] = ["versionString": v.attributes.versionString, "state": v.attributes.appStoreState ?? "unknown"] as [String: Any] - } - return r - } - - @MainActor - private func tabStateReview(_ asc: ASCManager) -> [String: Any] { - var r: [String: Any] = [:] - - if let ar = asc.ageRatingDeclaration { - let a = ar.attributes - var arDict: [String: Any] = ["id": ar.id] - arDict["gambling"] = a.gambling ?? false - arDict["messagingAndChat"] = a.messagingAndChat ?? false - arDict["unrestrictedWebAccess"] = a.unrestrictedWebAccess ?? false - arDict["userGeneratedContent"] = a.userGeneratedContent ?? false - arDict["advertising"] = a.advertising ?? false - arDict["lootBox"] = a.lootBox ?? false - arDict["healthOrWellnessTopics"] = a.healthOrWellnessTopics ?? false - arDict["parentalControls"] = a.parentalControls ?? false - arDict["ageAssurance"] = a.ageAssurance ?? false - arDict["alcoholTobaccoOrDrugUseOrReferences"] = a.alcoholTobaccoOrDrugUseOrReferences ?? "NONE" - arDict["contests"] = a.contests ?? "NONE" - arDict["gamblingSimulated"] = a.gamblingSimulated ?? "NONE" - arDict["gunsOrOtherWeapons"] = a.gunsOrOtherWeapons ?? "NONE" - arDict["horrorOrFearThemes"] = a.horrorOrFearThemes ?? "NONE" - arDict["matureOrSuggestiveThemes"] = a.matureOrSuggestiveThemes ?? "NONE" - arDict["medicalOrTreatmentInformation"] = a.medicalOrTreatmentInformation ?? "NONE" - arDict["profanityOrCrudeHumor"] = a.profanityOrCrudeHumor ?? "NONE" - arDict["sexualContentGraphicAndNudity"] = a.sexualContentGraphicAndNudity ?? "NONE" - arDict["sexualContentOrNudity"] = a.sexualContentOrNudity ?? "NONE" - arDict["violenceCartoonOrFantasy"] = a.violenceCartoonOrFantasy ?? "NONE" - arDict["violenceRealistic"] = a.violenceRealistic ?? "NONE" - arDict["violenceRealisticProlongedGraphicOrSadistic"] = a.violenceRealisticProlongedGraphicOrSadistic ?? "NONE" - r["ageRating"] = arDict - } - - if let rd = asc.reviewDetail { - let a = rd.attributes - r["reviewContact"] = [ - "contactFirstName": a.contactFirstName ?? "", - "contactLastName": a.contactLastName ?? "", - "contactEmail": a.contactEmail ?? "", - "contactPhone": a.contactPhone ?? "", - "notes": a.notes ?? "", - "demoAccountRequired": a.demoAccountRequired ?? false, - "demoAccountName": a.demoAccountName ?? "", - "demoAccountPassword": a.demoAccountPassword ?? "" - ] as [String: Any] - } - - r["builds"] = asc.builds.prefix(10).map { b -> [String: Any] in - ["id": b.id, "version": b.attributes.version, "processingState": b.attributes.processingState ?? "unknown", "uploadedDate": b.attributes.uploadedDate ?? ""] - } - return r - } - - @MainActor - private func tabStateScreenshots(_ asc: ASCManager) -> [String: Any] { - let sets = asc.screenshotSets.map { s -> [String: Any] in - var set: [String: Any] = ["id": s.id, "displayType": s.attributes.screenshotDisplayType] - if let shots = asc.screenshots[s.id] { - set["screenshotCount"] = shots.count - set["screenshots"] = shots.map { ["id": $0.id, "fileName": $0.attributes.fileName ?? ""] as [String: Any] } - } - return set - } - return ["screenshotSets": sets, "localeCount": asc.localizations.count] - } - - @MainActor - private func tabStateReviews(_ asc: ASCManager) -> [String: Any] { - let reviews = asc.customerReviews.prefix(20).map { r -> [String: Any] in - ["id": r.id, "title": r.attributes.title ?? "", "body": r.attributes.body ?? "", "rating": r.attributes.rating, "reviewerNickname": r.attributes.reviewerNickname ?? ""] - } - return ["reviews": reviews, "totalReviews": asc.customerReviews.count] - } - - @MainActor - private func tabStateBuilds(_ asc: ASCManager) -> [String: Any] { - let builds = asc.builds.prefix(20).map { b -> [String: Any] in - ["id": b.id, "version": b.attributes.version, "processingState": b.attributes.processingState ?? "unknown", "uploadedDate": b.attributes.uploadedDate ?? ""] - } - return ["builds": builds] - } - - @MainActor - private func tabStateGroups(_ asc: ASCManager) -> [String: Any] { - let groups = asc.betaGroups.map { g -> [String: Any] in - ["id": g.id, "name": g.attributes.name, "isInternalGroup": g.attributes.isInternalGroup ?? false] - } - return ["betaGroups": groups] - } - - @MainActor - private func tabStateBetaInfo(_ asc: ASCManager) -> [String: Any] { - let locs = asc.betaLocalizations.map { l -> [String: Any] in - ["id": l.id, "locale": l.attributes.locale, "description": l.attributes.description ?? ""] - } - return ["betaLocalizations": locs] - } - - @MainActor - private func tabStateFeedback(_ asc: ASCManager) -> [String: Any] { - var items: [[String: Any]] = [] - for (buildId, feedbackItems) in asc.betaFeedback { - for item in feedbackItems { - items.append(["buildId": buildId, "id": item.id, "comment": item.attributes.comment ?? "", "timestamp": item.attributes.timestamp ?? ""]) - } - } - return ["feedback": items, "selectedBuildId": asc.selectedBuildId ?? ""] - } - - // MARK: - Build Pipeline Tools - - private func executeSetupSigning(_ args: [String: Any]) async throws -> [String: Any] { - let (optCtx, err) = await requireBuildContext() - guard let ctx = optCtx else { return err! } - let project = ctx.project - let bundleId = ctx.bundleId - let service = ctx.service - let teamId = args["teamId"] as? String ?? (ctx.teamId.isEmpty ? nil : ctx.teamId) - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .signingSetup - appState.ascManager.buildPipelineMessage = "Setting up signing…" - } - - let pipeline = BuildPipelineService() - let appStateRef = appState - do { - // Run with 5-minute overall timeout to prevent silent hangs - let projectPlatform = await MainActor.run { project.platform } - let result = try await withThrowingTimeout(seconds: 300) { - try await pipeline.setupSigning( - projectPath: project.path, - bundleId: bundleId, - teamId: teamId, - ascService: service, - platform: projectPlatform, - onProgress: { msg in - Task { @MainActor in - appStateRef.ascManager.buildPipelineMessage = msg - } - } - ) - } - - // Persist teamId to project metadata on success - if !result.teamId.isEmpty { - await MainActor.run { - let storage = ProjectStorage() - guard var metadata = storage.readMetadata(projectId: project.id) else { return } - metadata.teamId = result.teamId - try? storage.writeMetadata(projectId: project.id, metadata: metadata) - } - } - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - - var resultDict: [String: Any] = [ - "success": true, - "bundleIdResourceId": result.bundleIdResourceId, - "certificateId": result.certificateId, - "profileUUID": result.profileUUID, - "teamId": result.teamId, - "log": result.log - ] - if let installerCertId = result.installerCertificateId { - resultDict["installerCertificateId"] = installerCertId - } - return mcpJSON(resultDict) - } catch { - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - return mcpText("Error in signing setup: \(error.localizedDescription)") - } - } - - private func executeBuildIPA(_ args: [String: Any]) async throws -> [String: Any] { - let (optCtx, err) = await requireBuildContext(needsTeamId: true) - guard let ctx = optCtx else { return err! } - let project = ctx.project - let bundleId = ctx.bundleId - let teamId = ctx.teamId - - let scheme = args["scheme"] as? String - let configuration = args["configuration"] as? String - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .archiving - appState.ascManager.buildPipelineMessage = "Starting build…" - } - - let pipeline = BuildPipelineService() - let appStateRef = appState - do { - let buildPlatform = await MainActor.run { project.platform } - let result = try await pipeline.buildIPA( - projectPath: project.path, - bundleId: bundleId, - teamId: teamId, - scheme: scheme, - configuration: configuration, - platform: buildPlatform, - onProgress: { msg in - Task { @MainActor in - // Detect phase transitions from build output - if msg.contains("ARCHIVE SUCCEEDED") || msg.contains("-exportArchive") { - appStateRef.ascManager.buildPipelinePhase = .exporting - } - appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) - } - } - ) - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - - return mcpJSON([ - "success": true, - "ipaPath": result.ipaPath, - "archivePath": result.archivePath, - "log": result.log - ] as [String: Any]) - } catch { - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - return mcpText("Error building IPA: \(error.localizedDescription)") - } - } - - private func executeUploadToTestFlight(_ args: [String: Any]) async throws -> [String: Any] { - guard let credentials = await MainActor.run(body: { appState.ascManager.credentials }) else { - return mcpText("Error: ASC credentials not configured.") - } - guard await MainActor.run(body: { appState.activeProject }) != nil else { - return mcpText("Error: no active project.") - } - - // Resolve IPA path - let ipaPath: String - if let path = args["ipaPath"] as? String { - ipaPath = (path as NSString).expandingTildeInPath - } else { - // Try to find most recent IPA in /tmp - let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) - let tmpContents = try FileManager.default.contentsOfDirectory(at: tmpURL, includingPropertiesForKeys: [.contentModificationDateKey]) - let exportDirs = tmpContents.filter { $0.lastPathComponent.hasPrefix("BlitzExport-") } - .sorted { a, b in - let aDate = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let bDate = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return aDate > bDate - } - // Search for .ipa (iOS) or .pkg (macOS) - let searchExts: Set = ["ipa", "pkg"] - var foundArtifact: String? - for dir in exportDirs { - let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) - if let match = files.first(where: { searchExts.contains($0.pathExtension) }) { - foundArtifact = match.path - break - } - } - guard let found = foundArtifact else { - return mcpText("Error: no IPA/PKG path provided and no recent build found. Run app_store_build first.") - } - ipaPath = found - } - - guard FileManager.default.fileExists(atPath: ipaPath) else { - return mcpText("Error: IPA not found at \(ipaPath)") - } - - let skipPolling = args["skipPolling"] as? Bool ?? false - - // Get app ID for polling - let appId = await MainActor.run { appState.ascManager.app?.id } - let service = await MainActor.run { appState.ascManager.service } - - // --- Pre-upload validation: build version & encryption key (IPA only, skip for PKG) --- - let isIPA = ipaPath.hasSuffix(".ipa") - var existingVersions: Set = [] - do { - guard isIPA else { throw NSError(domain: "skip", code: 0) } - // Extract IPA plist fields in one pass - let plistXML = try await ProcessRunner.run( - "/bin/bash", - arguments: ["-c", "unzip -p '\(ipaPath)' 'Payload/*.app/Info.plist' | plutil -convert xml1 -o - -"] - ) - - // Check CFBundleVersion - let ipaVersion: String? = { - guard let range = plistXML.range(of: "CFBundleVersion"), - let valueStart = plistXML.range(of: "", range: range.upperBound..", range: valueStart.upperBound..ITSAppUsesNonExemptEncryption directly to Info.plist." - ) - } - - // Validate build version against existing builds - if let ipaVersion, !ipaVersion.isEmpty, let appId, let service { - let builds = try await service.fetchBuilds(appId: appId) - existingVersions = Set(builds.map(\.attributes.version)) - if existingVersions.contains(ipaVersion) { - let maxVersion = existingVersions.compactMap { Int($0) }.max() ?? 0 - return mcpText( - "Error: build version \(ipaVersion) already exists in App Store Connect. " - + "Existing build versions: \(existingVersions.sorted().joined(separator: ", ")). " - + "The next valid build version is \(maxVersion + 1). " - + "Update CFBundleVersion in Info.plist (or CURRENT_PROJECT_VERSION in the Xcode build settings) and rebuild." - ) - } - } - } catch { - // Non-fatal — proceed with upload and let altool catch any issues - } - - // If we didn't capture existing versions above, fetch them now for polling comparison - if existingVersions.isEmpty, let appId, let service { - existingVersions = Set((try? await service.fetchBuilds(appId: appId))?.map(\.attributes.version) ?? []) - } - - // --- Upload --- - await MainActor.run { - appState.ascManager.buildPipelinePhase = .uploading - appState.ascManager.buildPipelineMessage = "Uploading IPA…" - } - - let pipeline = BuildPipelineService() - let appStateRef = appState - do { - // Always skip BuildPipelineService's built-in polling — we poll ourselves below - let uploadPlatform = await MainActor.run { appState.activeProject?.platform ?? .iOS } - let result = try await pipeline.uploadToTestFlight( - ipaPath: ipaPath, - keyId: credentials.keyId, - issuerId: credentials.issuerId, - privateKeyPEM: credentials.privateKey, - appId: appId, - ascService: service, - skipPolling: true, - platform: uploadPlatform, - onProgress: { msg in - Task { @MainActor in - appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) - } - } - ) - - var allLog = result.log - var finalState = result.processingState - var finalVersion = result.buildVersion - - // --- Poll for new build to appear (every 10s, up to 300s) --- - if !skipPolling, let appId, let service { - await MainActor.run { - appStateRef.ascManager.buildPipelinePhase = .processing - appStateRef.ascManager.buildPipelineMessage = "Waiting for new build to appear…" - } - - let pollInterval: TimeInterval = 10 - let maxAttempts = 30 // 300 seconds total - - for attempt in 1...maxAttempts { - try? await Task.sleep(for: .seconds(pollInterval)) - - guard let builds = try? await service.fetchBuilds(appId: appId) else { continue } - - if let newBuild = builds.first(where: { !existingVersions.contains($0.attributes.version) }) { - let state = newBuild.attributes.processingState ?? "UNKNOWN" - let version = newBuild.attributes.version - let msg = "Poll \(attempt): build \(version) — \(state)" - allLog.append(msg) - await MainActor.run { - appStateRef.ascManager.buildPipelineMessage = msg - appStateRef.ascManager.builds = builds - } - - finalVersion = version - finalState = state - - if state == "VALID" { - allLog.append("Build processing complete!") - // Auto-set encryption exemption via API as backup - try? await service.patchBuildEncryption( - buildId: newBuild.id, - usesNonExemptEncryption: false - ) - // Auto-attach to pending version - let versionId = await MainActor.run(body: { - appStateRef.ascManager.pendingVersionId - }) - if let versionId { - do { - try await service.attachBuild(versionId: versionId, buildId: newBuild.id) - allLog.append("Build \(version) attached to app store version.") - } catch { - allLog.append("Warning: could not auto-attach build \u{2014} \(error.localizedDescription)") - } - } - break - } else if state == "INVALID" { - allLog.append("Build processing failed with INVALID state.") - break - } - // Still processing — keep polling - } else { - let msg = "Poll \(attempt): new build not yet visible…" - allLog.append(msg) - await MainActor.run { - appStateRef.ascManager.buildPipelineMessage = msg - } - } - } - } - - // --- Finalize: reset UI and refresh tab data --- - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - await appState.ascManager.refreshTabData(.builds) - - var response: [String: Any] = [ - "success": true, - "processingState": finalState ?? "UNKNOWN", - "log": allLog - ] - if let version = finalVersion { - response["buildVersion"] = version - } - return mcpJSON(response) - } catch { - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - return mcpText("Error uploading to TestFlight: \(error.localizedDescription)") - } - } - - // MARK: - Helpers - - private func mcpText(_ text: String) -> [String: Any] { - ["content": [["type": "text", "text": text]]] - } - - private func mcpJSON(_ value: Any) -> [String: Any] { - if let data = try? JSONSerialization.data(withJSONObject: value), - let str = String(data: data, encoding: .utf8) { - return mcpText(str) - } - return mcpText("{}") - } - - /// Check for ASC write error and return it, clearing pending form values. - private func checkASCWriteError(tab: String) async -> [String: Any]? { - guard let error = await MainActor.run(body: { appState.ascManager.writeError }) else { return nil } - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - return mcpText("Error: \(error)") - } - - private struct BuildContext { - let project: Project - let bundleId: String - let teamId: String - let service: AppStoreConnectService - } - - /// Resolve and validate bundle ID + ASC service for build pipeline tools. - /// Returns (context, nil) on success or (nil, errorResponse) on failure. - private func requireBuildContext(needsTeamId: Bool = false) async -> (BuildContext?, [String: Any]?) { - guard let project = await MainActor.run(body: { appState.activeProject }) else { - return (nil, mcpText("Error: no active project.")) - } - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return (nil, mcpText("Error: ASC credentials not configured.")) - } - let bundleId = await MainActor.run { () -> String? in - ProjectStorage().readMetadata(projectId: project.id)?.bundleIdentifier - } - guard let bundleId, !bundleId.isEmpty else { - return (nil, mcpText("Error: no bundle identifier set. Use asc_fill_form tab=settings.bundleId to set it first.")) - } - let ascBundleId = await MainActor.run { appState.ascManager.app?.bundleId } - if let ascBundleId, !ascBundleId.isEmpty, ascBundleId != bundleId { - return (nil, mcpText("Error: bundle ID mismatch. Project has '\(bundleId)' but ASC app uses '\(ascBundleId)'.")) - } - let teamId = await MainActor.run { () -> String? in - ProjectStorage().readMetadata(projectId: project.id)?.teamId - } - if needsTeamId, (teamId == nil || teamId!.isEmpty) { - return (nil, mcpText("Error: no team ID set. Run app_store_setup_signing first.")) - } - return (BuildContext(project: project, bundleId: bundleId, teamId: teamId ?? "", service: service), nil) - } -} diff --git a/src/services/MacSwiftProjectSetupService.swift b/src/services/MacSwiftProjectSetupService.swift deleted file mode 100644 index 43f7cf1..0000000 --- a/src/services/MacSwiftProjectSetupService.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation - -/// Scaffolds a new macOS Swift/SwiftUI project from the bundled template. -/// Sandboxed by default for Mac App Store submission. -struct MacSwiftProjectSetupService { - - /// Set up a new macOS Swift project from the bundled template. - static func setup( - projectId: String, - projectName: String, - projectPath: String, - onStep: @MainActor (ProjectSetupService.SetupStep) -> Void - ) async throws { - - let fm = FileManager.default - let appName = SwiftProjectSetupService.toSwiftAppName(projectId) - let bundleId = SwiftProjectSetupService.toBundleId(appName) - - // --- Step 1: Copy & patch template --- - await onStep(.copying) - print("[mac-swift-setup] Scaffolding: appName=\(appName) bundleId=\(bundleId)") - - guard let templateURL = Bundle.appResources.url(forResource: "swift-mac-template", withExtension: nil, subdirectory: "templates") else { - throw ProjectSetupService.SetupError(message: "Bundled macOS Swift template not found") - } - - // Back up project metadata before overwriting dir - let metadataPath = projectPath + "/.blitz/project.json" - let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataPath)) - - // Remove existing (near-empty) project dir - if fm.fileExists(atPath: projectPath) { - try fm.removeItem(atPath: projectPath) - } - try fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true) - - // Recursively copy template, replacing placeholders in names & contents - try copyTemplateDir( - src: templateURL.path, - dest: projectPath, - appName: appName, - bundleId: bundleId - ) - - // Restore project metadata - let blitzDir = projectPath + "/.blitz" - if !fm.fileExists(atPath: blitzDir) { - try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) - } - if let data = metadataData { - try data.write(to: URL(fileURLWithPath: metadataPath)) - } - - print("[mac-swift-setup] Template copied and patched") - - // No npm install needed for Swift projects — go straight to ready - await onStep(.ready) - print("[mac-swift-setup] Project setup complete!") - } - - // MARK: - Helpers - - private static let appNamePlaceholder = "__APP_NAME__" - private static let bundleIdPlaceholder = "__BUNDLE_ID__" - - private static func copyTemplateDir( - src: String, - dest: String, - appName: String, - bundleId: String - ) throws { - let fm = FileManager.default - try fm.createDirectory(atPath: dest, withIntermediateDirectories: true) - - let entries = try fm.contentsOfDirectory(atPath: src) - for entry in entries { - let resolvedName = entry.replacingOccurrences(of: appNamePlaceholder, with: appName) - let srcPath = src + "/" + entry - let destPath = dest + "/" + resolvedName - - var isDir: ObjCBool = false - fm.fileExists(atPath: srcPath, isDirectory: &isDir) - - if isDir.boolValue { - try copyTemplateDir(src: srcPath, dest: destPath, appName: appName, bundleId: bundleId) - } else { - var content = try String(contentsOfFile: srcPath, encoding: .utf8) - content = content - .replacingOccurrences(of: appNamePlaceholder, with: appName) - .replacingOccurrences(of: bundleIdPlaceholder, with: bundleId) - try content.write(toFile: destPath, atomically: true, encoding: .utf8) - } - } - } -} diff --git a/src/services/ProjectSetupService.swift b/src/services/ProjectSetupService.swift deleted file mode 100644 index 81c630a..0000000 --- a/src/services/ProjectSetupService.swift +++ /dev/null @@ -1,138 +0,0 @@ -import Foundation - -/// Scaffolds a new React Native / Blitz project from the bundled template. -/// Handles the full lifecycle: copy template → patch placeholders → write .dev.vars -/// The AI agent handles npm install, pod install, metro, and builds. -struct ProjectSetupService { - - enum SetupStep: String { - case copying = "Copying template..." - case ready = "Ready" - } - - struct SetupError: LocalizedError { - let message: String - var errorDescription: String? { message } - } - - private static let sampleDevVars = """ - JWT_SECRET_MAIN=this_is_the_main_secret_used_for_all_tables_and_admin - JWT_SECRET_USERS=secret_used_for_users_table_appended_to_the_main_secret - ADMIN_SERVICE_TOKEN=password_for_accessing_the_backend_as_admin - ADMIN_JWT_SECRET=this_will_be_used_for_jwt_token_for_admin_operations - POCKET_UI_VIEWER_PASSWORD=admin_db_password_for_readonly_mode - POCKET_UI_EDITOR_PASSWORD=admin_db_password_for_readwrite_mode - MAILGUN_API_KEY=api-key-from-mailgun - API_ROUTE=NA - """ - - private static let projectNamePlaceholder = "__PROJECT_NAME__" - - /// Set up a new project from the bundled RN template. - /// Calls `onStep` on the main actor as each phase begins. - static func setup( - projectId: String, - projectName: String, - projectPath: String, - onStep: @MainActor (SetupStep) -> Void - ) async throws { - - let fm = FileManager.default - - // --- Step 1: Copy bundled template --- - await onStep(.copying) - print("[setup] Step 1: Copying bundled RN template") - - guard let templateURL = Bundle.appResources.url(forResource: "rn-notes-template", withExtension: nil, subdirectory: "templates") else { - throw SetupError(message: "Bundled RN template not found") - } - print("[setup] Template source: \(templateURL.path)") - print("[setup] Project path: \(projectPath)") - - // Back up project metadata before overwriting dir - let metadataBackup = projectPath + "/.blitz/project.json" - let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataBackup)) - print("[setup] Metadata backed up: \(metadataData != nil)") - - // Remove existing (near-empty) project dir - if fm.fileExists(atPath: projectPath) { - try fm.removeItem(atPath: projectPath) - print("[setup] Removed existing project dir") - } - - // Recursively copy template, replacing placeholders in names & contents - try copyTemplateDir( - src: templateURL.path, - dest: projectPath, - projectName: projectName - ) - print("[setup] Template copied and patched") - - // Remove any stale local database state from the template copy - let localPersist = projectPath + "/.local-persist" - if fm.fileExists(atPath: localPersist) { - try? fm.removeItem(atPath: localPersist) - print("[setup] Removed stale .local-persist") - } - - // Restore project metadata - let blitzDir = projectPath + "/.blitz" - if !fm.fileExists(atPath: blitzDir) { - try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) - } - if let data = metadataData { - try data.write(to: URL(fileURLWithPath: metadataBackup)) - print("[setup] Metadata restored") - } - - // Ensure .dev.vars exists - let devVarsPath = projectPath + "/.dev.vars" - if !fm.fileExists(atPath: devVarsPath) { - let sampleVarsPath = projectPath + "/sample.vars" - if fm.fileExists(atPath: sampleVarsPath) { - try fm.copyItem(atPath: sampleVarsPath, toPath: devVarsPath) - print("[setup] .dev.vars copied from sample.vars") - } else { - try sampleDevVars.write(toFile: devVarsPath, atomically: true, encoding: .utf8) - print("[setup] .dev.vars written from default") - } - } else { - print("[setup] .dev.vars already exists") - } - - // --- Done --- - await onStep(.ready) - print("[setup] Project setup complete!") - } - - // MARK: - Helpers - - /// Recursively copy a template directory, replacing placeholders in - /// filenames/directory names and file contents. - private static func copyTemplateDir( - src: String, - dest: String, - projectName: String - ) throws { - let fm = FileManager.default - try fm.createDirectory(atPath: dest, withIntermediateDirectories: true) - - let entries = try fm.contentsOfDirectory(atPath: src) - for entry in entries { - let srcPath = (src as NSString).appendingPathComponent(entry) - let patchedName = entry.replacingOccurrences(of: projectNamePlaceholder, with: projectName) - let destPath = (dest as NSString).appendingPathComponent(patchedName) - - var isDir: ObjCBool = false - fm.fileExists(atPath: srcPath, isDirectory: &isDir) - - if isDir.boolValue { - try copyTemplateDir(src: srcPath, dest: destPath, projectName: projectName) - } else { - var content = try String(contentsOfFile: srcPath, encoding: .utf8) - content = content.replacingOccurrences(of: projectNamePlaceholder, with: projectName) - try content.write(toFile: destPath, atomically: true, encoding: .utf8) - } - } - } -} diff --git a/src/services/SettingsService.swift b/src/services/SettingsService.swift index 0562b0b..866c507 100644 --- a/src/services/SettingsService.swift +++ b/src/services/SettingsService.swift @@ -28,10 +28,14 @@ final class SettingsService { // Onboarding var hasCompletedOnboarding: Bool = false - var defaultTerminal: String = "terminal" // "terminal", "ghostty", "iterm", or custom path + var defaultTerminal: String = "builtIn" // "builtIn", "terminal", "ghostty", "iterm", or custom path var defaultAgentCLI: String = AIAgent.claudeCode.rawValue var sendDefaultPrompt: Bool = true var skipAgentPermissions: Bool = false + var whitelistBlitzMCPTools: Bool = true + var allowASCCLICalls: Bool = false + var enableASCShellIntegration: Bool = false + var terminalPosition: String = "bottom" // "bottom" or "right" init() { self.settingsURL = BlitzPaths.settings @@ -52,6 +56,10 @@ final class SettingsService { if let agent = json["defaultAgentCLI"] as? String { defaultAgentCLI = agent } if let sendPrompt = json["sendDefaultPrompt"] as? Bool { sendDefaultPrompt = sendPrompt } if let skipPerms = json["skipAgentPermissions"] as? Bool { skipAgentPermissions = skipPerms } + if let whitelist = json["whitelistBlitzMCPTools"] as? Bool { whitelistBlitzMCPTools = whitelist } + if let allowASCCLI = json["allowASCCLICalls"] as? Bool { allowASCCLICalls = allowASCCLI } + if let shellIntegration = json["enableASCShellIntegration"] as? Bool { enableASCShellIntegration = shellIntegration } + if let termPos = json["terminalPosition"] as? String { terminalPosition = termPos } } func save() { @@ -64,6 +72,10 @@ final class SettingsService { "defaultAgentCLI": defaultAgentCLI, "sendDefaultPrompt": sendDefaultPrompt, "skipAgentPermissions": skipAgentPermissions, + "whitelistBlitzMCPTools": whitelistBlitzMCPTools, + "allowASCCLICalls": allowASCCLICalls, + "enableASCShellIntegration": enableASCShellIntegration, + "terminalPosition": terminalPosition, ] if let udid = defaultSimulatorUDID { json["defaultSimulatorUDID"] = udid @@ -98,4 +110,18 @@ final class SettingsService { save() return ResolvedTerminalSelection(terminal: resolved, replacedMissingTerminal: configured) } + + func setASCShellIntegrationEnabled(_ enabled: Bool) throws { + let previousValue = enableASCShellIntegration + enableASCShellIntegration = enabled + save() + + do { + try ShellIntegrationService().sync(enabled: enabled) + } catch { + enableASCShellIntegration = previousValue + save() + throw error + } + } } diff --git a/src/services/ShellIntegrationService.swift b/src/services/ShellIntegrationService.swift new file mode 100644 index 0000000..a4fc868 --- /dev/null +++ b/src/services/ShellIntegrationService.swift @@ -0,0 +1,251 @@ +import Darwin +import Foundation + +struct ShellIntegrationService { + enum ShellKind: Equatable { + case zsh + case bash + case unsupported(String?) + + var displayName: String { + switch self { + case .zsh: + return "zsh" + case .bash: + return "bash" + case .unsupported(let path): + let shellName = URL(fileURLWithPath: path ?? "").lastPathComponent + return shellName.isEmpty ? "unknown shell" : shellName + } + } + } + + private static let startMarker = "# >>> Blitz shell integration >>>" + private static let endMarker = "# <<< Blitz shell integration <<<" + + let homeDirectory: URL + let blitzRoot: URL + let fileManager: FileManager + private let authBridge: ASCAuthBridge + private let loginShellPathProvider: () -> String? + + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + blitzRoot: URL = BlitzPaths.root, + fileManager: FileManager = .default, + bundledASCDPathProvider: @escaping () -> String? = { + ASCAuthBridge.resolveBundledASCDPath( + fileManager: .default, + environment: ProcessInfo.processInfo.environment + ) + }, + loginShellPathProvider: @escaping () -> String? = { + ShellIntegrationService.defaultLoginShellPath() + } + ) { + self.homeDirectory = homeDirectory + self.blitzRoot = blitzRoot + self.fileManager = fileManager + self.authBridge = ASCAuthBridge( + blitzRoot: blitzRoot, + fileManager: fileManager, + bundledASCDPathProvider: bundledASCDPathProvider + ) + self.loginShellPathProvider = loginShellPathProvider + } + + var shellKind: ShellKind { + Self.detectShellKind(loginShellPathProvider()) + } + + var isSupported: Bool { + targetRCFile != nil + } + + var targetRCFile: URL? { + switch shellKind { + case .zsh: + return homeDirectory.appendingPathComponent(".zshrc") + case .bash: + return homeDirectory.appendingPathComponent(".bashrc") + case .unsupported: + return nil + } + } + + var targetRCFileLabel: String { + guard let targetRCFile else { return "unsupported shell" } + return "~/" + targetRCFile.lastPathComponent + } + + var initScriptURL: URL { + blitzRoot.appendingPathComponent("shell/init.sh") + } + + func sync(enabled: Bool) throws { + if enabled { + try install() + } else { + try uninstall() + } + } + + private func install() throws { + guard let targetRCFile else { + throw ShellIntegrationError.unsupportedShell(shellKind.displayName) + } + + try authBridge.installCLIShims() + try writeInitScript() + try upsertManagedBlock(in: targetRCFile) + + for extraRCFile in managedRCFiles where extraRCFile != targetRCFile { + try removeManagedBlock(from: extraRCFile) + } + } + + private func uninstall() throws { + for rcFile in managedRCFiles { + try removeManagedBlock(from: rcFile) + } + + try? fileManager.removeItem(at: initScriptURL) + + let shellDirectory = initScriptURL.deletingLastPathComponent() + if let contents = try? fileManager.contentsOfDirectory(atPath: shellDirectory.path), contents.isEmpty { + try? fileManager.removeItem(at: shellDirectory) + } + } + + private var managedRCFiles: [URL] { + [ + homeDirectory.appendingPathComponent(".zshrc"), + homeDirectory.appendingPathComponent(".bashrc"), + ] + } + + private func writeInitScript() throws { + let shellDirectory = initScriptURL.deletingLastPathComponent() + try fileManager.createDirectory( + at: shellDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shellDirectory.path) + + let binDirectory = blitzRoot.appendingPathComponent("bin").path + let script = """ + #!/bin/sh + BLITZ_BIN=\(shellQuote(binDirectory)) + + case ":${PATH}:" in + *":${BLITZ_BIN}:"*) ;; + *) export PATH="${BLITZ_BIN}:$PATH" ;; + esac + """ + + try script.write(to: initScriptURL, atomically: true, encoding: .utf8) + try? fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: initScriptURL.path) + } + + private func upsertManagedBlock(in rcFile: URL) throws { + let existing = (try? String(contentsOf: rcFile, encoding: .utf8)) ?? "" + let stripped = removingManagedBlock(from: existing).trimmingCharacters(in: .whitespacesAndNewlines) + + let block = managedBlock() + let newContents: String + if stripped.isEmpty { + newContents = block + } else { + newContents = stripped + "\n\n" + block + } + + try newContents.write(to: rcFile, atomically: true, encoding: .utf8) + } + + private func removeManagedBlock(from rcFile: URL) throws { + guard fileManager.fileExists(atPath: rcFile.path) else { return } + + let existing = try String(contentsOf: rcFile, encoding: .utf8) + let stripped = removingManagedBlock(from: existing) + guard stripped != existing else { return } + + let trimmed = stripped.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + try "".write(to: rcFile, atomically: true, encoding: .utf8) + } else { + try (trimmed + "\n").write(to: rcFile, atomically: true, encoding: .utf8) + } + } + + private func managedBlock() -> String { + """ + \(Self.startMarker) + if [ -f "$HOME/.blitz/shell/init.sh" ]; then + . "$HOME/.blitz/shell/init.sh" + fi + \(Self.endMarker) + """ + } + + private func removingManagedBlock(from text: String) -> String { + var contents = text + + while let startRange = contents.range(of: Self.startMarker), + let endRange = contents.range(of: Self.endMarker, range: startRange.lowerBound.. contents.startIndex { + let previousIndex = contents.index(before: lowerBound) + if contents[previousIndex] == "\n" { + lowerBound = previousIndex + } + } + + var upperBound = endRange.upperBound + if upperBound < contents.endIndex, contents[upperBound] == "\n" { + upperBound = contents.index(after: upperBound) + } + + contents.removeSubrange(lowerBound.. String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func detectShellKind(_ shellPath: String?) -> ShellKind { + let shellName = URL(fileURLWithPath: shellPath ?? "").lastPathComponent + switch shellName { + case "zsh": + return .zsh + case "bash": + return .bash + default: + return .unsupported(shellPath) + } + } + + private static func defaultLoginShellPath() -> String? { + guard let entry = getpwuid(getuid()) else { return nil } + let shellPath = String(cString: entry.pointee.pw_shell) + return shellPath.isEmpty ? nil : shellPath + } +} + +enum ShellIntegrationError: LocalizedError { + case unsupportedShell(String) + + var errorDescription: String? { + switch self { + case .unsupportedShell(let shellName): + return "Automatic shell integration only supports zsh and bash. Detected \(shellName)." + } + } +} diff --git a/src/services/SwiftProjectSetupService.swift b/src/services/SwiftProjectSetupService.swift deleted file mode 100644 index 9c4096e..0000000 --- a/src/services/SwiftProjectSetupService.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation - -/// Scaffolds a new Swift/SwiftUI project from the bundled template. -/// Mirrors the logic in blitz-cn's create-swift-project.ts. -struct SwiftProjectSetupService { - - /// Convert a project ID like "my-cool-app" → "MyCoolApp". - static func toSwiftAppName(_ projectId: String) -> String { - let parts = projectId.components(separatedBy: CharacterSet.alphanumerics.inverted) - let camel = parts - .filter { !$0.isEmpty } - .map { $0.prefix(1).uppercased() + $0.dropFirst() } - .joined() - - // Ensure starts with a letter - var result = camel - while let first = result.first, !first.isLetter { - result = String(result.dropFirst()) - } - return result.isEmpty ? "App" : result - } - - /// Derive a bundle ID: "MyCoolApp" → "dev.blitz.MyCoolApp". - static func toBundleId(_ appName: String) -> String { - let safe = appName.filter { $0.isLetter || $0.isNumber } - return "dev.blitz.\(safe.isEmpty ? "App" : safe)" - } - - /// Set up a new Swift project from the bundled template. - /// Calls `onStep` on the main actor as each phase begins. - static func setup( - projectId: String, - projectName: String, - projectPath: String, - onStep: @MainActor (ProjectSetupService.SetupStep) -> Void - ) async throws { - - let fm = FileManager.default - let appName = toSwiftAppName(projectId) - let bundleId = toBundleId(appName) - - // --- Step 1: Copy & patch template --- - await onStep(.copying) - print("[swift-setup] Scaffolding: appName=\(appName) bundleId=\(bundleId)") - - guard let templateURL = Bundle.appResources.url(forResource: "swift-hello-template", withExtension: nil, subdirectory: "templates") else { - throw ProjectSetupService.SetupError(message: "Bundled Swift template not found") - } - - // Back up project metadata before overwriting dir - let metadataPath = projectPath + "/.blitz/project.json" - let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataPath)) - - // Remove existing (near-empty) project dir - if fm.fileExists(atPath: projectPath) { - try fm.removeItem(atPath: projectPath) - } - try fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true) - - // Recursively copy template, replacing placeholders in names & contents - try copyTemplateDir( - src: templateURL.path, - dest: projectPath, - appName: appName, - bundleId: bundleId - ) - - // Restore project metadata - let blitzDir = projectPath + "/.blitz" - if !fm.fileExists(atPath: blitzDir) { - try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) - } - if let data = metadataData { - try data.write(to: URL(fileURLWithPath: metadataPath)) - } - - print("[swift-setup] Template copied and patched") - - // No npm install needed for Swift projects — go straight to ready - await onStep(.ready) - print("[swift-setup] Project setup complete!") - } - - // MARK: - Helpers - - private static let appNamePlaceholder = "__APP_NAME__" - private static let bundleIdPlaceholder = "__BUNDLE_ID__" - - /// Recursively copy a template directory, replacing placeholders in - /// filenames/directory names and file contents. - private static func copyTemplateDir( - src: String, - dest: String, - appName: String, - bundleId: String - ) throws { - let fm = FileManager.default - try fm.createDirectory(atPath: dest, withIntermediateDirectories: true) - - let entries = try fm.contentsOfDirectory(atPath: src) - for entry in entries { - let resolvedName = entry.replacingOccurrences(of: appNamePlaceholder, with: appName) - let srcPath = src + "/" + entry - let destPath = dest + "/" + resolvedName - - var isDir: ObjCBool = false - fm.fileExists(atPath: srcPath, isDirectory: &isDir) - - if isDir.boolValue { - try copyTemplateDir(src: srcPath, dest: destPath, appName: appName, bundleId: bundleId) - } else { - var content = try String(contentsOfFile: srcPath, encoding: .utf8) - content = content - .replacingOccurrences(of: appNamePlaceholder, with: appName) - .replacingOccurrences(of: bundleIdPlaceholder, with: bundleId) - try content.write(toFile: destPath, atomically: true, encoding: .utf8) - } - } - } -} diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index 956970e..d6d2816 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -3,32 +3,50 @@ import CoreServices /// Launches the user's configured terminal with an AI agent CLI command. enum TerminalLauncher { - /// Launch the default terminal with the default agent CLI, optionally with a prompt. - /// Returns true if the launch was attempted, false if the terminal couldn't be resolved. - @discardableResult - static func launch( + static func buildAgentCommand( projectPath: String?, agent: AIAgent, - terminal: TerminalApp, prompt: String? = nil, skipPermissions: Bool = false - ) -> Bool { - // Build the shell command: cd to project + agent cli + optional prompt - var shellCommand = "" + ) -> String { + var segments = shellExportCommands(for: projectPath) + if let path = projectPath { - let escaped = path.replacingOccurrences(of: "'", with: "'\\''") - shellCommand = "cd '\(escaped)' && " + segments.append("cd \(shellQuote(path))") } - shellCommand += agent.cliCommand + + var agentCommand = agent.cliCommand if skipPermissions, let flag = agent.skipPermissionsFlag { - shellCommand += " \(flag)" + agentCommand += " \(flag)" } if let prompt, !prompt.isEmpty { - let escapedPrompt = prompt.replacingOccurrences(of: "'", with: "'\\''") - shellCommand += " '\(escapedPrompt)'" + agentCommand += " \(shellQuote(prompt))" } + segments.append(agentCommand) + + return segments.joined(separator: " && ") + } + + /// Launch the default terminal with the default agent CLI, optionally with a prompt. + /// Returns true if the launch was attempted, false if the terminal couldn't be resolved. + @discardableResult + static func launch( + projectPath: String?, + agent: AIAgent, + terminal: TerminalApp, + prompt: String? = nil, + skipPermissions: Bool = false + ) -> Bool { + let shellCommand = buildAgentCommand( + projectPath: projectPath, + agent: agent, + prompt: prompt, + skipPermissions: skipPermissions + ) switch terminal.resolvedFallback { + case .builtIn: + return false // Handled by ContentView directly case .terminal: return launchTerminalApp(command: shellCommand) case .ghostty: @@ -155,6 +173,14 @@ enum TerminalLauncher { .replacingOccurrences(of: "\"", with: "\\\"") } + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func shellExportCommands(for projectPath: String?) -> [String] { + ASCAuthBridge().shellExportCommands(forLaunchPath: projectPath) + } + private static func runOsascript(_ script: String) -> Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") @@ -176,7 +202,7 @@ enum TerminalLauncher { /// Ghostty uses direct process execution and doesn't need it. static func needsAutomationPermission(_ terminal: TerminalApp) -> Bool { switch terminal { - case .ghostty: return false + case .builtIn, .ghostty: return false default: return true } } diff --git a/src/services/asc/ASCAuthBridge.swift b/src/services/asc/ASCAuthBridge.swift new file mode 100644 index 0000000..f78c105 --- /dev/null +++ b/src/services/asc/ASCAuthBridge.swift @@ -0,0 +1,309 @@ +import Foundation + +struct ASCAuthBridge { + static let managedProfileName = "BlitzKey" + private static let cliSubprocessModeArg = "__ascd_run_cli__" + + let blitzRoot: URL + let fileManager: FileManager + private let bundledASCDPathProvider: () -> String? + + init( + blitzRoot: URL = BlitzPaths.root, + fileManager: FileManager = .default, + bundledASCDPathProvider: @escaping () -> String? = { + ASCAuthBridge.resolveBundledASCDPath( + fileManager: .default, + environment: ProcessInfo.processInfo.environment + ) + } + ) { + self.blitzRoot = blitzRoot + self.fileManager = fileManager + self.bundledASCDPathProvider = bundledASCDPathProvider + } + + var bridgeDirectory: URL { + blitzRoot.appendingPathComponent("asc-agent", isDirectory: true) + } + + var binDirectory: URL { + blitzRoot.appendingPathComponent("bin", isDirectory: true) + } + + var configURL: URL { + bridgeDirectory.appendingPathComponent("config.json") + } + + var privateKeyURL: URL { + bridgeDirectory.appendingPathComponent("AuthKey_\(Self.managedProfileName).p8") + } + + var webSessionURL: URL { + bridgeDirectory.appendingPathComponent("web-session.json") + } + + var ascWrapperURL: URL { + binDirectory.appendingPathComponent("asc") + } + + var ascdShimURL: URL { + binDirectory.appendingPathComponent("ascd") + } + + func environmentOverrides(forLaunchPath launchPath: String?) -> [String: String] { + guard shouldInjectEnvironment(forLaunchPath: launchPath) else { + return [:] + } + + prepareEnvironment() + let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + return [ + "PATH": "\(binDirectory.path):\(currentPath)", + ] + } + + func shellExportCommands(forLaunchPath launchPath: String?) -> [String] { + guard shouldInjectEnvironment(forLaunchPath: launchPath) else { + return [] + } + + prepareEnvironment() + return [ + "export PATH=\(shellQuote(binDirectory.path)):\"$PATH\"", + ] + } + + func syncStoredCredentials() throws { + try syncCredentials(ASCCredentials.load()) + } + + func syncCredentials(_ credentials: ASCCredentials?) throws { + guard let credentials else { + cleanup() + return + } + + try ensureBridgeDirectory() + try writePrivateKey(credentials.privateKey) + try writeConfig(credentials: credentials) + } + + /// Write web session data to a file so CLI scripts can read it without Keychain popups. + func syncWebSession(_ data: Data) throws { + try ensureBridgeDirectory() + try data.write(to: webSessionURL, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: webSessionURL.path) + } + + func removeWebSession() { + try? fileManager.removeItem(at: webSessionURL) + } + + func cleanup() { + try? fileManager.removeItem(at: configURL) + try? fileManager.removeItem(at: privateKeyURL) + } + + func installCLIShims() throws { + let bundledASCDPath = bundledASCDPathProvider()? + .trimmingCharacters(in: .whitespacesAndNewlines) + try ensureBinDirectory() + try installASCDShim(from: bundledASCDPath) + try ensureCLIWrapper() + } + + private func prepareEnvironment() { + try? syncStoredCredentials() + try? installCLIShims() + } + + private func ensureBridgeDirectory() throws { + try fileManager.createDirectory( + at: bridgeDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: bridgeDirectory.path) + } + + private func writePrivateKey(_ privateKey: String) throws { + let data = Data(privateKey.trimmingCharacters(in: .whitespacesAndNewlines).utf8) + try data.write(to: privateKeyURL, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path) + } + + private func writeConfig(credentials: ASCCredentials) throws { + let config = ManagedConfig( + keyID: credentials.keyId, + issuerID: credentials.issuerId, + privateKeyPath: privateKeyURL.path, + defaultKeyName: Self.managedProfileName, + keys: [ + ManagedCredential( + name: Self.managedProfileName, + keyID: credentials.keyId, + issuerID: credentials.issuerId, + privateKeyPath: privateKeyURL.path + ) + ] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: configURL, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configURL.path) + } + + private func ensureBinDirectory() throws { + try fileManager.createDirectory( + at: binDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: binDirectory.path) + } + + private func installASCDShim(from bundledASCDPath: String?) throws { + guard let bundledASCDPath, + !bundledASCDPath.isEmpty, + fileManager.isExecutableFile(atPath: bundledASCDPath) else { + guard fileManager.isExecutableFile(atPath: ascdShimURL.path) else { + throw NSError( + domain: "ASCAuthBridge", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Bundled ascd helper is unavailable."] + ) + } + return + } + + let tempURL = binDirectory.appendingPathComponent("ascd.tmp.\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.copyItem(at: URL(fileURLWithPath: bundledASCDPath), to: tempURL) + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: ascdShimURL) + try fileManager.moveItem(at: tempURL, to: ascdShimURL) + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ascdShimURL.path) + } + + private func ensureCLIWrapper() throws { + let script = wrapperScript() + try script.write(to: ascWrapperURL, atomically: true, encoding: .utf8) + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ascWrapperURL.path) + } + + private func wrapperScript() -> String { + return """ + #!/bin/sh + set -eu + + SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + ASCD_PATH="${SELF_DIR}/ascd" + + if [ ! -x "${ASCD_PATH}" ]; then + echo "asc: Blitz helper not found at ${ASCD_PATH}. Start Blitz first." >&2 + exit 1 + fi + + if [ -z "${ASC_CONFIG_PATH:-}" ]; then + export ASC_CONFIG_PATH=\(shellQuote(configURL.path)) + fi + + if [ -z "${ASC_BYPASS_KEYCHAIN:-}" ]; then + export ASC_BYPASS_KEYCHAIN='1' + fi + + exec "${ASCD_PATH}" \(Self.cliSubprocessModeArg) "$@" + """ + } + + private func shouldInjectEnvironment(forLaunchPath launchPath: String?) -> Bool { + guard let launchPath else { return false } + + let normalizedPath = URL(fileURLWithPath: launchPath).standardizedFileURL.path + let projectsRoot = blitzRoot.appendingPathComponent("projects", isDirectory: true).standardizedFileURL.path + let mcpsRoot = blitzRoot.appendingPathComponent("mcps", isDirectory: true).standardizedFileURL.path + + return normalizedPath == projectsRoot + || normalizedPath.hasPrefix(projectsRoot + "/") + || normalizedPath == mcpsRoot + || normalizedPath.hasPrefix(mcpsRoot + "/") + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func resolveBundledASCDPath( + fileManager: FileManager, + environment: [String: String] + ) -> String? { + var candidates: [String] = [] + var seen = Set() + + func appendCandidate(_ rawValue: String?) { + guard let rawValue else { return } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let expanded = NSString(string: trimmed).expandingTildeInPath + let normalized: String + if expanded.hasPrefix("/") { + normalized = URL(fileURLWithPath: expanded).standardizedFileURL.path + } else { + normalized = expanded + } + + guard !normalized.isEmpty, seen.insert(normalized).inserted else { return } + candidates.append(normalized) + } + + appendCandidate(environment["BLITZ_ASCD_PATH"]) + appendCandidate(Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/ascd").path) + appendCandidate( + Bundle.main.executableURL? + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Helpers/ascd").path + ) + appendCandidate( + Bundle.main.privateFrameworksURL? + .deletingLastPathComponent() + .appendingPathComponent("Helpers/ascd").path + ) + + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) + } +} + +private struct ManagedConfig: Codable { + let keyID: String + let issuerID: String + let privateKeyPath: String + let defaultKeyName: String + let keys: [ManagedCredential] + + private enum CodingKeys: String, CodingKey { + case keyID = "key_id" + case issuerID = "issuer_id" + case privateKeyPath = "private_key_path" + case defaultKeyName = "default_key_name" + case keys + } +} + +private struct ManagedCredential: Codable { + let name: String + let keyID: String + let issuerID: String + let privateKeyPath: String + + private enum CodingKeys: String, CodingKey { + case name + case keyID = "key_id" + case issuerID = "issuer_id" + case privateKeyPath = "private_key_path" + } +} diff --git a/src/services/asc/ASCDaemonClient.swift b/src/services/asc/ASCDaemonClient.swift new file mode 100644 index 0000000..435d47f --- /dev/null +++ b/src/services/asc/ASCDaemonClient.swift @@ -0,0 +1,627 @@ +import Foundation + +struct SequencedPipeBuffer { + private let newline: UInt8 = 0x0A + private var buffer = Data() + private var pendingChunks: [Int: Data] = [:] + private var nextSequence = 0 + + mutating func append(_ data: Data, sequence: Int) -> [Data] { + pendingChunks[sequence] = data + + var lines: [Data] = [] + while let nextChunk = pendingChunks.removeValue(forKey: nextSequence) { + nextSequence += 1 + + if nextChunk.isEmpty { + if !buffer.isEmpty { + lines.append(buffer) + buffer.removeAll(keepingCapacity: false) + } + continue + } + + buffer.append(nextChunk) + + while let newlineIndex = buffer.firstIndex(of: newline) { + let line = buffer.prefix(upTo: newlineIndex).filter { $0 != 0x0D } + buffer.removeSubrange(...newlineIndex) + lines.append(Data(line)) + } + } + + return lines + } + + mutating func reset() { + buffer.removeAll(keepingCapacity: false) + pendingChunks.removeAll() + nextSequence = 0 + } +} + +actor ASCDaemonClient { + private final class SequenceCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + let current = value + value += 1 + return current + } + + func reset() { + lock.lock() + value = 0 + lock.unlock() + } + } + + private struct PendingRequest { + let continuation: CheckedContinuation + let summary: String + let startedAt: Date + } + + struct HTTPResponse: Sendable { + let statusCode: Int + let headers: [String: [String]] + let contentType: String + let body: Data + } + + private struct DaemonResponse: Decodable { + let id: String? + let result: Result? + let error: DaemonErrorPayload? + } + + private struct DaemonErrorPayload: Decodable { + let code: Int + let message: String + let data: String? + } + + private struct SessionOpenResult: Decodable { + let session: SessionInfo + } + + private struct SessionInfo: Decodable { + let profile: String? + let usesInMemoryKey: Bool + } + + private struct SessionRequestResult: Decodable { + let statusCode: Int + let headers: [String: [String]]? + let contentType: String? + let body: String? + } + + enum Error: LocalizedError { + case helperNotFound(String) + case helperLaunchFailed(String) + case invalidResponse + case processExited(Int32, String) + case helperError(String, String?) + case invalidRequestBody + case responseTimeout(String) + + var errorDescription: String? { + switch self { + case .helperNotFound(let message), + .helperLaunchFailed(let message): + return message + case .invalidResponse: + return "Invalid response from ascd" + case .processExited(let code, let stderr): + if stderr.isEmpty { + return "ascd exited with status \(code)" + } + return "ascd exited with status \(code): \(stderr)" + case .helperError(let message, let data): + if let data, !data.isEmpty { + return "\(message): \(data)" + } + return message + case .invalidRequestBody: + return "Request body must be valid JSON" + case .responseTimeout(let summary): + return "Timed out waiting for ascd response: \(summary)" + } + } + } + + private let credentials: ASCCredentials + private let fileManager = FileManager.default + private let decoder = JSONDecoder() + private let logger = ASCDaemonLogger.shared + private let responseTimeoutSeconds: TimeInterval = 45 + + private var process: Process? + private var stdinHandle: FileHandle? + private var waitTask: Task? + private var stdoutReadHandle: FileHandle? + private var stderrReadHandle: FileHandle? + private var stdoutBuffer = SequencedPipeBuffer() + private var stderrBuffer = SequencedPipeBuffer() + private var pendingResponses: [String: PendingRequest] = [:] + private var recentStderr: [String] = [] + private var requestCounter = 0 + private var sessionOpen = false + private let stdoutSequenceCounter = SequenceCounter() + private let stderrSequenceCounter = SequenceCounter() + + init(credentials: ASCCredentials) { + self.credentials = credentials + Task { + await logger.info("ASCDaemonClient initialized keyId=\(Self.redact(credentials.keyId)) issuerId=\(Self.redact(credentials.issuerId))") + } + } + + deinit { + stdoutReadHandle?.readabilityHandler = nil + stderrReadHandle?.readabilityHandler = nil + waitTask?.cancel() + process?.terminate() + } + + func request( + method: String, + path: String, + headers: [String: String] = [:], + body: Data? = nil, + timeoutMs: Int = 30_000, + expectedStatusCodes: Set = [] + ) async throws -> HTTPResponse { + try await ensureSessionOpen() + + var params: [String: Any] = [ + "method": method, + "path": path, + ] + if !headers.isEmpty { + params["headers"] = headers + } + if timeoutMs > 0 { + params["timeoutMs"] = timeoutMs + } + if let body { + params["body"] = try jsonObject(from: body) + } + + let result: SessionRequestResult = try await send(method: "session.request", params: params) + let response = HTTPResponse( + statusCode: result.statusCode, + headers: result.headers ?? [:], + contentType: result.contentType ?? "", + body: Data((result.body ?? "").utf8) + ) + if (200..<300).contains(response.statusCode) || expectedStatusCodes.contains(response.statusCode) { + await logger.debug("session.request \(method.uppercased()) \(Self.truncate(path)) -> \(response.statusCode) bytes=\(response.body.count)") + } else { + let bodySnippet = Self.truncate(String(decoding: response.body, as: UTF8.self), limit: 1200) + await logger.error("session.request \(method.uppercased()) \(Self.truncate(path)) -> \(response.statusCode) contentType=\(response.contentType) body=\(bodySnippet)") + } + return response + } + + func cliExec(args: [String]) async throws -> (exitCode: Int, stdout: String, stderr: String) { + struct CLIExecResult: Decodable { + let exitCode: Int + let stdout: String? + let stderr: String? + } + + try await ensureProcessRunning() + let result: CLIExecResult = try await send(method: "cli.exec", params: ["args": args]) + return (result.exitCode, result.stdout ?? "", result.stderr ?? "") + } + + private func ensureSessionOpen() async throws { + try await ensureProcessRunning() + guard !sessionOpen else { return } + _ = try await send(method: "session.open", params: nil) as SessionOpenResult + sessionOpen = true + await logger.info("ascd session opened") + } + + private func ensureProcessRunning() async throws { + if let process, process.isRunning, stdinHandle != nil { + return + } + try startProcess() + } + + private func startProcess() throws { + let executablePath = try resolveExecutablePath() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executablePath] + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.environment = helperEnvironment() + + do { + try process.run() + } catch { + Task { + await logger.error("Failed to launch ascd at \(executablePath): \(error.localizedDescription)") + } + throw Error.helperLaunchFailed("Failed to launch ascd at \(executablePath): \(error.localizedDescription)") + } + + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutReadHandle = stdoutPipe.fileHandleForReading + self.stderrReadHandle = stderrPipe.fileHandleForReading + self.sessionOpen = false + self.recentStderr = [] + self.stdoutBuffer.reset() + self.stderrBuffer.reset() + self.stdoutSequenceCounter.reset() + self.stderrSequenceCounter.reset() + + Task { + await logger.info("Started ascd pid=\(process.processIdentifier) path=\(executablePath)") + } + + let stdoutSequenceCounter = self.stdoutSequenceCounter + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self, stdoutSequenceCounter] handle in + let data = handle.availableData + let sequence = stdoutSequenceCounter.next() + Task { + await self?.handlePipeData(data, isStdout: true, sequence: sequence) + } + } + + let stderrSequenceCounter = self.stderrSequenceCounter + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self, stderrSequenceCounter] handle in + let data = handle.availableData + let sequence = stderrSequenceCounter.next() + Task { + await self?.handlePipeData(data, isStdout: false, sequence: sequence) + } + } + + waitTask = Task { + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in + continuation.resume() + } + } + await self.handleProcessExit(status: process.terminationStatus) + } + } + + private func resolveExecutablePath() throws -> String { + let candidates = helperExecutableCandidates() + + for candidate in candidates where !candidate.isEmpty { + if fileManager.isExecutableFile(atPath: candidate) { + Task { + await logger.debug("Resolved ascd executable path: \(candidate)") + } + return candidate + } + } + + let searched = candidates.isEmpty ? "(no candidates)" : candidates.joined(separator: ", ") + Task { + await logger.error("ascd executable not found. searched=\(searched)") + } + throw Error.helperNotFound( + "ascd not found. Bundle the signed helper at Contents/Helpers/ascd or set BLITZ_ASCD_PATH to the forked helper binary. Searched: \(searched)" + ) + } + + private func helperExecutableCandidates() -> [String] { + var candidates: [String] = [] + var seen = Set() + + func appendCandidate(_ rawValue: String?) { + guard let rawValue else { return } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let expanded = NSString(string: trimmed).expandingTildeInPath + let normalized: String + if expanded.hasPrefix("/") { + normalized = URL(fileURLWithPath: expanded).standardizedFileURL.path + } else { + normalized = expanded + } + + guard !normalized.isEmpty, seen.insert(normalized).inserted else { return } + candidates.append(normalized) + } + + appendCandidate(ProcessInfo.processInfo.environment["BLITZ_ASCD_PATH"]) + appendCandidate(Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/ascd").path) + appendCandidate( + Bundle.main.executableURL? + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Helpers/ascd").path + ) + appendCandidate(Bundle.main.privateFrameworksURL?.deletingLastPathComponent().appendingPathComponent("Helpers/ascd").path) + + return candidates + } + + private func helperEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + environment["ASC_KEY_ID"] = credentials.keyId + environment["ASC_ISSUER_ID"] = credentials.issuerId + environment["ASC_PRIVATE_KEY"] = credentials.privateKey + environment["ASC_PRIVATE_KEY_PATH"] = nil + environment["ASC_PRIVATE_KEY_B64"] = nil + environment["ASC_BYPASS_KEYCHAIN"] = "1" + if environment["ASC_DEBUG"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { + environment["ASC_DEBUG"] = nil + } + + let isolatedConfigPath = fileManager.temporaryDirectory + .appendingPathComponent("blitz-ascd-\(UUID().uuidString).json") + try? fileManager.removeItem(at: isolatedConfigPath) + environment["ASC_CONFIG_PATH"] = isolatedConfigPath.path + Task { + await logger.debug( + "Prepared ascd environment keyId=\(Self.redact(credentials.keyId)) " + + "issuerId=\(Self.redact(credentials.issuerId)) " + + "configPath=\(isolatedConfigPath.path) " + + "ascDebug=\(environment["ASC_DEBUG"] ?? "")" + ) + } + return environment + } + + private func handlePipeData(_ data: Data, isStdout: Bool, sequence: Int) async { + let lineData = if isStdout { + stdoutBuffer.append(data, sequence: sequence) + } else { + stderrBuffer.append(data, sequence: sequence) + } + + for entry in lineData { + if let line = String(data: entry, encoding: .utf8) { + if isStdout { + await handleStdoutLine(line) + } else { + await handleStderrLine(line) + } + } else { + await logger.error("Received non-UTF8 pipe output: \(entry.count) bytes") + } + } + } + + private func handleStdoutLine(_ line: String) async { + guard let data = line.data(using: .utf8) else { + await logger.error("Received non-UTF8 stdout from ascd") + return + } + + let metadata = extractResponseMetadata(from: data) + guard let id = metadata.id else { + await logger.error("Received stdout line without response id: \(Self.truncate(line, limit: 1200))") + return + } + + guard let pending = pendingResponses.removeValue(forKey: id) else { + await logger.error("Received unmatched response id=\(id) summary=\(metadata.summary) raw=\(Self.truncate(line, limit: 1200))") + return + } + + let elapsed = Date().timeIntervalSince(pending.startedAt) + await logger.debug("<- [\(id)] \(pending.summary) response=\(metadata.summary) elapsed=\(String(format: "%.3f", elapsed))s") + pending.continuation.resume(returning: data) + } + + private func handleStderrLine(_ line: String) async { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + recentStderr.append(trimmed) + if recentStderr.count > 30 { + recentStderr.removeFirst(recentStderr.count - 30) + } + await logger.error("ascd stderr: \(trimmed)") + } + + private func handleProcessExit(status: Int32) async { + let message = recentStderr.joined(separator: "\n") + let error = Error.processExited(status, message) + await logger.error("ascd exited status=\(status) stderr=\(Self.truncate(message, limit: 1200))") + + for (_, pending) in pendingResponses { + pending.continuation.resume(throwing: error) + } + pendingResponses.removeAll() + + process = nil + stdinHandle = nil + sessionOpen = false + stdoutReadHandle?.readabilityHandler = nil + stderrReadHandle?.readabilityHandler = nil + waitTask?.cancel() + stdoutReadHandle = nil + stderrReadHandle = nil + stdoutBuffer.reset() + stderrBuffer.reset() + stdoutSequenceCounter.reset() + stderrSequenceCounter.reset() + waitTask = nil + } + + private func send(method: String, params: [String: Any]?, as type: Result.Type = Result.self) async throws -> Result { + try await ensureProcessRunning() + + requestCounter += 1 + let id = "ascd-\(requestCounter)" + let summary = Self.requestSummary(method: method, params: params) + + var request: [String: Any] = [ + "id": id, + "method": method, + ] + if let params { + request["params"] = params + } + + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + await logger.debug("-> [\(id)] \(summary)") + + let timeoutSeconds = self.responseTimeoutSeconds + let timeoutTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(timeoutSeconds)) + await self?.failPendingRequest(id: id, error: Error.responseTimeout(summary)) + } catch {} + } + + let rawResponse = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + pendingResponses[id] = PendingRequest( + continuation: continuation, + summary: summary, + startedAt: Date() + ) + do { + try writeRequestLine(requestData) + } catch { + pendingResponses.removeValue(forKey: id) + continuation.resume(throwing: error) + } + } + timeoutTask.cancel() + + let response: DaemonResponse + do { + response = try decoder.decode(DaemonResponse.self, from: rawResponse) + } catch { + await logger.error("Failed to decode response for [\(id)] \(summary): \(error.localizedDescription) raw=\(Self.truncate(String(decoding: rawResponse, as: UTF8.self), limit: 1200))") + throw error + } + if let error = response.error { + await logger.error("Helper returned error for [\(id)] \(summary): code=\(error.code) message=\(error.message) data=\(error.data ?? "")") + throw Error.helperError(error.message, error.data) + } + guard let result = response.result else { + await logger.error("Missing result payload for [\(id)] \(summary)") + throw Error.invalidResponse + } + return result + } + + private func failPendingRequest(id: String, error: Swift.Error) async { + guard let pending = pendingResponses.removeValue(forKey: id) else { return } + await logger.error("Timed out waiting for [\(id)] \(pending.summary)") + pending.continuation.resume(throwing: error) + await restartProcessAfterTimeout(id: id, summary: pending.summary) + } + + private func restartProcessAfterTimeout(id: String, summary: String) async { + guard let process else { return } + await logger.error("Terminating ascd after timeout for [\(id)] \(summary) pid=\(process.processIdentifier)") + sessionOpen = false + stdinHandle = nil + process.terminate() + } + + private func writeRequestLine(_ requestData: Data) throws { + guard let stdinHandle else { + throw Error.helperLaunchFailed("ascd stdin is unavailable") + } + var line = requestData + line.append(0x0A) + try stdinHandle.write(contentsOf: line) + } + + private func extractResponseMetadata(from data: Data) -> (id: String?, summary: String) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, "invalid-json") + } + + let id = json["id"] as? String + + if let error = json["error"] as? [String: Any] { + let code = error["code"] as? Int ?? 0 + let message = error["message"] as? String ?? "unknown" + return (id, "error code=\(code) message=\(Self.truncate(message, limit: 200))") + } + + if let result = json["result"] as? [String: Any] { + if let statusCode = result["statusCode"] as? Int { + let contentType = result["contentType"] as? String ?? "" + return (id, "statusCode=\(statusCode) contentType=\(Self.truncate(contentType, limit: 120))") + } + let keys = result.keys.sorted().joined(separator: ",") + return (id, "result keys=\(keys)") + } + + return (id, "unknown-payload") + } + + private func jsonObject(from data: Data) throws -> Any { + guard !data.isEmpty else { return NSNull() } + return try JSONSerialization.jsonObject(with: data) + } + + private static func requestSummary(method: String, params: [String: Any]?) -> String { + switch method { + case "session.open": + let profile = (params?["profile"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return profile.isEmpty ? "session.open" : "session.open profile=\(profile)" + case "session.request": + let httpMethod = (params?["method"] as? String)?.uppercased() ?? "UNKNOWN" + let path = truncate(params?["path"] as? String ?? "") + let timeoutDescription: String + if let timeoutMs = params?["timeoutMs"] as? Int, timeoutMs > 0 { + timeoutDescription = " timeoutMs=\(timeoutMs)" + } else { + timeoutDescription = "" + } + let bodyDescription: String + if let body = params?["body"] { + if let data = try? JSONSerialization.data(withJSONObject: body) { + bodyDescription = " bodyBytes=\(data.count)" + } else { + bodyDescription = " body=unserializable" + } + } else { + bodyDescription = "" + } + return "session.request \(httpMethod) \(path)\(timeoutDescription)\(bodyDescription)" + case "cli.exec": + let args = params?["args"] as? [String] ?? [] + return "cli.exec args=\(truncate(args.joined(separator: " "), limit: 200))" + default: + return params == nil ? method : "\(method) params" + } + } + + private static func truncate(_ value: String, limit: Int = 300) -> String { + let normalized = value.replacingOccurrences(of: "\n", with: "\\n") + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + private static func redact(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 8 else { + return String(repeating: "*", count: max(trimmed.count, 1)) + } + let prefix = trimmed.prefix(4) + let suffix = trimmed.suffix(4) + return "\(prefix)...\(suffix)" + } +} diff --git a/src/services/asc/ASCDaemonLogger.swift b/src/services/asc/ASCDaemonLogger.swift new file mode 100644 index 0000000..bea7fe2 --- /dev/null +++ b/src/services/asc/ASCDaemonLogger.swift @@ -0,0 +1,52 @@ +import Foundation + +actor ASCDaemonLogger { + static let shared = ASCDaemonLogger() + + private let fileManager = FileManager.default + private let logURL: URL + private let formatter: ISO8601DateFormatter + + init() { + let home = fileManager.homeDirectoryForCurrentUser + self.logURL = home.appendingPathComponent(".blitz/logs/ascd-client.log") + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.formatter = formatter + } + + func info(_ message: String) async { + await write(level: "INFO", message: message) + } + + func error(_ message: String) async { + await write(level: "ERROR", message: message) + } + + func debug(_ message: String) async { + await write(level: "DEBUG", message: message) + } + + private func write(level: String, message: String) async { + let directoryURL = logURL.deletingLastPathComponent() + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + let timestamp = formatter.string(from: Date()) + let line = "[\(timestamp)] [\(level)] \(message)\n" + guard let data = line.data(using: .utf8) else { return } + + if fileManager.fileExists(atPath: logURL.path) { + do { + let handle = try FileHandle(forWritingTo: logURL) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } catch { + try? data.write(to: logURL, options: .atomic) + } + } else { + try? data.write(to: logURL, options: .atomic) + } + } +} diff --git a/src/services/asc/ASCError.swift b/src/services/asc/ASCError.swift new file mode 100644 index 0000000..4b39a4a --- /dev/null +++ b/src/services/asc/ASCError.swift @@ -0,0 +1,55 @@ +import Foundation + +enum ASCError: LocalizedError { + case invalidURL + case notFound(String) + case httpError(Int, String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .notFound(let what): + return "\(what) not found" + case .httpError(let code, let body): + return "HTTP \(code): \(Self.parseErrorMessages(body))" + } + } + + var isConflict: Bool { + if case .httpError(409, _) = self { return true } + return false + } + + private static func parseErrorMessages(_ body: String) -> String { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errors = json["errors"] as? [[String: Any]] else { + return String(body.prefix(300)) + } + + var messages: [String] = [] + for error in errors { + if let detail = error["detail"] as? String { + messages.append(detail) + } else if let title = error["title"] as? String { + messages.append(title) + } + + if let meta = error["meta"] as? [String: Any], + let associatedErrors = meta["associatedErrors"] as? [String: [[String: Any]]] { + for (_, subErrors) in associatedErrors { + for subError in subErrors { + if let detail = subError["detail"] as? String { + messages.append(detail) + } else if let title = subError["title"] as? String { + messages.append(title) + } + } + } + } + } + + return messages.isEmpty ? String(body.prefix(300)) : messages.joined(separator: "\n") + } +} diff --git a/src/services/AppStoreConnectService.swift b/src/services/asc/ASCService.swift similarity index 83% rename from src/services/AppStoreConnectService.swift rename to src/services/asc/ASCService.swift index f6f0610..50f528b 100644 --- a/src/services/AppStoreConnectService.swift +++ b/src/services/asc/ASCService.swift @@ -1,187 +1,94 @@ import Foundation -import CryptoKit - -// MARK: - Error - -enum ASCError: LocalizedError { - case invalidURL - case notFound(String) - case httpError(Int, String) - - var errorDescription: String? { - switch self { - case .invalidURL: return "Invalid URL" - case .notFound(let what): return "\(what) not found" - case .httpError(let code, let body): return "HTTP \(code): \(Self.parseErrorMessages(body))" - } - } - - var isConflict: Bool { - if case .httpError(409, _) = self { return true } - return false - } - - /// Extract human-readable error messages from ASC JSON error responses. - private static func parseErrorMessages(_ body: String) -> String { - guard let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let errors = json["errors"] as? [[String: Any]] else { - return String(body.prefix(300)) - } - var messages: [String] = [] - for error in errors { - if let detail = error["detail"] as? String { - messages.append(detail) - } else if let title = error["title"] as? String { - messages.append(title) - } - // Parse associatedErrors for richer context - if let meta = error["meta"] as? [String: Any], - let assoc = meta["associatedErrors"] as? [String: [[String: Any]]] { - for (_, subErrors) in assoc { - for sub in subErrors { - if let detail = sub["detail"] as? String { - messages.append(detail) - } else if let title = sub["title"] as? String { - messages.append(title) - } - } - } - } - } - return messages.isEmpty ? String(body.prefix(300)) : messages.joined(separator: "\n") - } -} - -// MARK: - Service final class AppStoreConnectService { - private let credentials: ASCCredentials - private var cachedToken: String? - private var tokenExpiry: Date? - - private let baseHost = "api.appstoreconnect.apple.com" + private let client: ASCDaemonClient private let session = URLSession.shared init(credentials: ASCCredentials) { - self.credentials = credentials + self.client = ASCDaemonClient(credentials: credentials) } - // MARK: - JWT - - private func generateJWT() throws -> String { - let now = Date() - let expiry = now.addingTimeInterval(1200) - - let header: [String: Any] = [ - "alg": "ES256", - "kid": credentials.keyId, - "typ": "JWT" - ] - let payload: [String: Any] = [ - "iss": credentials.issuerId, - "iat": Int(now.timeIntervalSince1970), - "exp": Int(expiry.timeIntervalSince1970), - "aud": "appstoreconnect-v1" - ] - - let headerData = try JSONSerialization.data(withJSONObject: header) - let payloadData = try JSONSerialization.data(withJSONObject: payload) - let headerEncoded = base64urlEncode(headerData) - let payloadEncoded = base64urlEncode(payloadData) - let message = "\(headerEncoded).\(payloadEncoded)" + // MARK: - HTTP - let privateKey = try P256.Signing.PrivateKey(pemRepresentation: credentials.privateKey) - let signature = try privateKey.signature(for: Data(message.utf8)) - let signatureEncoded = base64urlEncode(signature.rawRepresentation) + private func resolvedPath(_ rawPath: String, queryItems: [URLQueryItem] = []) throws -> String { + let trimmedPath = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { throw ASCError.invalidURL } - tokenExpiry = expiry - return "\(message).\(signatureEncoded)" - } - - private func validToken() throws -> String { - if let token = cachedToken, let expiry = tokenExpiry, - Date().addingTimeInterval(60) < expiry { - return token + if trimmedPath.hasPrefix("http://") || trimmedPath.hasPrefix("https://") { + guard var components = URLComponents(string: trimmedPath) else { + throw ASCError.invalidURL + } + if !queryItems.isEmpty { + components.queryItems = (components.queryItems ?? []) + queryItems + } + guard let path = components.string else { throw ASCError.invalidURL } + return path } - let token = try generateJWT() - cachedToken = token - return token - } - private func base64urlEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .trimmingCharacters(in: CharacterSet(charactersIn: "=")) - } - - // MARK: - HTTP - - private func makeRequest(path: String, queryItems: [URLQueryItem] = []) throws -> URLRequest { var components = URLComponents() - components.scheme = "https" - components.host = baseHost - components.path = "/v1/\(path)" + components.path = trimmedPath.hasPrefix("/") ? trimmedPath : "/v1/\(trimmedPath)" if !queryItems.isEmpty { components.queryItems = queryItems } - guard let url = components.url else { throw ASCError.invalidURL } - - var request = URLRequest(url: url) - request.timeoutInterval = 30 - request.setValue("Bearer \(try validToken())", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - return request + guard let path = components.string else { throw ASCError.invalidURL } + return path } private func get(_ path: String, queryItems: [URLQueryItem] = [], as type: T.Type) async throws -> T { - let request = try makeRequest(path: path, queryItems: queryItems) - let (data, response) = try await session.data(for: request) - - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "GET", + path: try resolvedPath(path, queryItems: queryItems), + headers: ["Accept": "application/json"] + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } - - return try JSONDecoder().decode(T.self, from: data) + return try JSONDecoder().decode(T.self, from: response.body) } private func patch(path: String, body: [String: Any]) async throws { - var request = try makeRequest(path: path) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "PATCH", + path: try resolvedPath(path), + headers: [ + "Accept": "application/json", + "Content-Type": "application/json", + ], + body: try JSONSerialization.data(withJSONObject: body) + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } } private func post(path: String, body: [String: Any]) async throws -> Data { - var request = try makeRequest(path: path) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "POST", + path: try resolvedPath(path), + headers: [ + "Accept": "application/json", + "Content-Type": "application/json", + ], + body: try JSONSerialization.data(withJSONObject: body) + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } - return data + return response.body } private func delete(path: String) async throws { - var request = try makeRequest(path: path) - request.httpMethod = "DELETE" - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "DELETE", + path: try resolvedPath(path), + headers: ["Accept": "application/json"] + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } } @@ -189,73 +96,20 @@ final class AppStoreConnectService { try await delete(path: "appScreenshots/\(screenshotId)") } - // MARK: - Versioned-Path HTTP Helpers (for /v2, /v3 endpoints) - - private func makeRequest(fullPath: String, queryItems: [URLQueryItem] = []) throws -> URLRequest { - var components = URLComponents() - components.scheme = "https" - components.host = baseHost - components.path = fullPath - if !queryItems.isEmpty { - components.queryItems = queryItems - } - guard let url = components.url else { throw ASCError.invalidURL } - - var request = URLRequest(url: url) - request.timeoutInterval = 30 - request.setValue("Bearer \(try validToken())", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - return request - } - private func get(fullPath: String, queryItems: [URLQueryItem] = [], as type: T.Type) async throws -> T { - let request = try makeRequest(fullPath: fullPath, queryItems: queryItems) - let (data, response) = try await session.data(for: request) - - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } - - return try JSONDecoder().decode(T.self, from: data) + try await get(fullPath, queryItems: queryItems, as: type) } private func post(fullPath: String, body: [String: Any]) async throws -> Data { - var request = try makeRequest(fullPath: fullPath) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } - return data + try await post(path: fullPath, body: body) } private func patch(fullPath: String, body: [String: Any]) async throws { - var request = try makeRequest(fullPath: fullPath) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } + try await patch(path: fullPath, body: body) } private func delete(fullPath: String) async throws { - var request = try makeRequest(fullPath: fullPath) - request.httpMethod = "DELETE" - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } + try await delete(path: fullPath) } private func upload(url: URL, method: String, headers: [String: String], body: Data) async throws { @@ -305,9 +159,9 @@ final class AppStoreConnectService { func fetchAppStoreVersions(appId: String) async throws -> [ASCAppStoreVersion] { let resp = try await get("apps/\(appId)/appStoreVersions", queryItems: [ - URLQueryItem(name: "limit", value: "20") + URLQueryItem(name: "limit", value: "50") ], as: ASCListResponse.self) - return resp.data + return ASCReleaseStatus.sortedVersionsByRecency(resp.data) } // MARK: - Localizations @@ -615,14 +469,30 @@ final class AppStoreConnectService { "apps/\(appId)/appPriceSchedule", as: ASCSingleResponse.self ) - let prices = try await get( - "appPriceSchedules/\(schedule.data.id)/manualPrices", - queryItems: [ - URLQueryItem(name: "include", value: "appPricePoint"), - URLQueryItem(name: "limit", value: "200") - ], - as: ASCListResponse.self + let pricesResponse = try await client.request( + method: "GET", + path: try resolvedPath( + "appPriceSchedules/\(schedule.data.id)/manualPrices", + queryItems: [ + URLQueryItem(name: "include", value: "appPricePoint"), + URLQueryItem(name: "limit", value: "200") + ] + ), + headers: ["Accept": "application/json"], + expectedStatusCodes: [404] ) + if pricesResponse.statusCode == 404 { + return ASCAppPricingState( + currentPricePointId: nil, + scheduledPricePointId: nil, + scheduledEffectiveDate: nil + ) + } + guard (200..<300).contains(pricesResponse.statusCode) else { + let body = String(data: pricesResponse.body, encoding: .utf8) ?? "" + throw ASCError.httpError(pricesResponse.statusCode, body) + } + let prices = try JSONDecoder().decode(ASCListResponse.self, from: pricesResponse.body) let today = Self.isoDateString(referenceDate) let sortedByStartDesc = prices.data.sorted { lhs, rhs in @@ -1351,7 +1221,7 @@ final class AppStoreConnectService { // MARK: - Write: Build Encryption func patchBuildEncryption(buildId: String, usesNonExemptEncryption: Bool) async throws { - let body: [String: Any] = [ + let requestBody: [String: Any] = [ "data": [ "type": "builds", "id": buildId, @@ -1360,7 +1230,29 @@ final class AppStoreConnectService { ] ] ] - try await patch(path: "builds/\(buildId)", body: body) + let response = try await client.request( + method: "PATCH", + path: try resolvedPath("builds/\(buildId)"), + headers: [ + "Accept": "application/json", + "Content-Type": "application/json", + ], + body: try JSONSerialization.data(withJSONObject: requestBody), + expectedStatusCodes: [409] + ) + if (200..<300).contains(response.statusCode) { + return + } + if response.statusCode == 409 { + let responseBody = String(data: response.body, encoding: .utf8) ?? "" + if responseBody.contains("You cannot update when the value is already set.") + || responseBody.contains("/data/attributes/usesNonExemptEncryption") { + return + } + throw ASCError.httpError(response.statusCode, responseBody) + } + let responseBody = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, responseBody) } // MARK: - Fetch: AppInfo @@ -1399,18 +1291,50 @@ final class AppStoreConnectService { // MARK: - Fetch: AppInfoLocalization - func fetchAppInfoLocalization(appInfoId: String) async throws -> ASCAppInfoLocalization { + func fetchAppInfoLocalizations(appInfoId: String) async throws -> [ASCAppInfoLocalization] { let resp = try await get( "appInfos/\(appInfoId)/appInfoLocalizations", - queryItems: [URLQueryItem(name: "limit", value: "1")], + queryItems: [URLQueryItem(name: "limit", value: "200")], as: ASCListResponse.self ) - guard let loc = resp.data.first else { + return resp.data + } + + func fetchAppInfoLocalization(appInfoId: String) async throws -> ASCAppInfoLocalization { + let localizations = try await fetchAppInfoLocalizations(appInfoId: appInfoId) + guard let loc = localizations.first else { throw ASCError.notFound("AppInfoLocalization for appInfo \(appInfoId)") } return loc } + func createAppInfoLocalization( + appInfoId: String, + locale: String, + fields: [String: String] = [:] + ) async throws -> ASCAppInfoLocalization { + var attributes = fields + attributes["locale"] = locale + + let body: [String: Any] = [ + "data": [ + "type": "appInfoLocalizations", + "attributes": attributes, + "relationships": [ + "appInfo": [ + "data": [ + "type": "appInfos", + "id": appInfoId + ] + ] + ] + ] + ] + + let data = try await post(path: "appInfoLocalizations", body: body) + return try JSONDecoder().decode(ASCSingleResponse.self, from: data).data + } + // MARK: - Pricing Check /// Check if pricing has been configured for an app. @@ -1423,11 +1347,23 @@ final class AppStoreConnectService { as: ASCSingleResponse.self ) // Then check if it has manual prices configured - let prices = try await get( - "appPriceSchedules/\(schedule.data.id)/manualPrices", - queryItems: [URLQueryItem(name: "limit", value: "1")], - as: ASCListResponse.self + let pricesResponse = try await client.request( + method: "GET", + path: try resolvedPath( + "appPriceSchedules/\(schedule.data.id)/manualPrices", + queryItems: [URLQueryItem(name: "limit", value: "1")] + ), + headers: ["Accept": "application/json"], + expectedStatusCodes: [404] ) + if pricesResponse.statusCode == 404 { + return false + } + guard (200..<300).contains(pricesResponse.statusCode) else { + let body = String(data: pricesResponse.body, encoding: .utf8) ?? "" + throw ASCError.httpError(pricesResponse.statusCode, body) + } + let prices = try JSONDecoder().decode(ASCListResponse.self, from: pricesResponse.body) return !prices.data.isEmpty } catch { return false @@ -1584,10 +1520,9 @@ final class AppStoreConnectService { func fetchReviewSubmissions(appId: String) async throws -> [ASCReviewSubmission] { let resp = try await get("reviewSubmissions", queryItems: [ URLQueryItem(name: "filter[app]", value: appId), - URLQueryItem(name: "sort", value: "-submittedDate"), URLQueryItem(name: "limit", value: "10") ], as: ASCPaginatedResponse.self) - return resp.data + return sortReviewSubmissions(resp.data) } func fetchReviewSubmissionItems(submissionId: String) async throws -> [ASCReviewSubmissionItem] { @@ -1597,6 +1532,46 @@ final class AppStoreConnectService { return resp.data } + private func sortReviewSubmissions(_ submissions: [ASCReviewSubmission]) -> [ASCReviewSubmission] { + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let standardFormatter = ISO8601DateFormatter() + standardFormatter.formatOptions = [.withInternetDateTime] + + return submissions + .enumerated() + .sorted { lhs, rhs in + let lhsDate = reviewSubmissionDate( + lhs.element.attributes.submittedDate, + fractionalFormatter: fractionalFormatter, + standardFormatter: standardFormatter + ) + let rhsDate = reviewSubmissionDate( + rhs.element.attributes.submittedDate, + fractionalFormatter: fractionalFormatter, + standardFormatter: standardFormatter + ) + + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + return lhs.offset < rhs.offset + } + .map(\.element) + } + + private func reviewSubmissionDate( + _ value: String?, + fractionalFormatter: ISO8601DateFormatter, + standardFormatter: ISO8601DateFormatter + ) -> Date { + guard let value, !value.isEmpty else { return .distantPast } + return fractionalFormatter.date(from: value) + ?? standardFormatter.date(from: value) + ?? .distantPast + } + // MARK: - Submit for Review func submitForReview(appId: String, versionId: String) async throws { @@ -1637,37 +1612,7 @@ final class AppStoreConnectService { } } -// MARK: - Supporting Types for Upload/Submission - -struct ASCPricePoint: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let customerPrice: String? - } - let attributes: Attributes -} - -struct ASCScreenshotReservation: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let sourceFileChecksum: String? - let uploadOperations: [UploadOperation]? - } - let attributes: Attributes - - struct UploadOperation: Decodable { - let method: String - let url: String - let offset: Int - let length: Int - let requestHeaders: [Header] - - struct Header: Decodable { - let name: String - let value: String - } - } -} +// MARK: - Supporting Types private actor ProgressCounter { private var count = 0 @@ -1676,41 +1621,3 @@ private actor ProgressCounter { return count } } - -struct ASCReviewSubmission: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let state: String? - let submittedDate: String? - let platform: String? - } - let attributes: Attributes -} - -struct ASCReviewSubmissionItem: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let state: String? - let resolved: Bool? - let createdDate: String? - } - let attributes: Attributes - let relationships: Relationships? - - struct Relationships: Decodable { - let appStoreVersion: ToOneRelationship? - - struct ToOneRelationship: Decodable { - let data: ResourceIdentifier? - } - - struct ResourceIdentifier: Decodable { - let type: String - let id: String - } - } - - var appStoreVersionId: String? { - relationships?.appStoreVersion?.data?.id - } -} diff --git a/src/services/asc/ASCWebSessionStore.swift b/src/services/asc/ASCWebSessionStore.swift new file mode 100644 index 0000000..0a646e7 --- /dev/null +++ b/src/services/asc/ASCWebSessionStore.swift @@ -0,0 +1,179 @@ +import CryptoKit +import Foundation + +struct ASCWebSessionStore: Codable { + static let keychainService = "asc-web-session" + static let keychainAccount = "asc:web-session:store" + static let version = 1 + + struct Session: Codable { + let version: Int + let updatedAt: Date + let userEmail: String? + let cookies: [String: [Cookie]] + + private enum CodingKeys: String, CodingKey { + case version + case updatedAt = "updated_at" + case userEmail = "user_email" + case cookies + } + } + + struct Cookie: Codable { + let name: String + let value: String + let path: String + let domain: String + let expires: Date? + let maxAge: Int? + let secure: Bool + let httpOnly: Bool + let sameSite: Int? + + private enum CodingKeys: String, CodingKey { + case name + case value + case path + case domain + case expires + case maxAge = "max_age" + case secure + case httpOnly = "http_only" + case sameSite = "same_site" + } + } + + let version: Int + var lastKey: String? + var sessions: [String: Session] + + private enum CodingKeys: String, CodingKey { + case version + case lastKey = "last_key" + case sessions + } + + private static let baseURLs = [ + "https://appstoreconnect.apple.com/", + "https://idmsa.apple.com/", + "https://gsa.apple.com/", + ] + + static func mergedData( + storing session: IrisSession, + into existingData: Data?, + now: Date = Date() + ) throws -> Data { + var store = try decode(existingData) ?? ASCWebSessionStore(version: version, lastKey: nil, sessions: [:]) + let key = sessionKey(forEmail: session.email) + store.sessions[key] = Session( + version: version, + updatedAt: now, + userEmail: normalizedEmail(session.email), + cookies: persistedCookies(from: session.cookies) + ) + store.lastKey = key + return try encode(store) + } + + static func removingSession( + email: String?, + from existingData: Data? + ) throws -> Data? { + guard var store = try decode(existingData) else { return nil } + + let key = if let email, !normalizedEmail(email).isEmpty { + sessionKey(forEmail: email) + } else { + store.lastKey ?? "" + } + + guard !key.isEmpty else { return existingData } + + store.sessions.removeValue(forKey: key) + if store.sessions.isEmpty { + return nil + } + + if store.lastKey == key { + store.lastKey = mostRecentSessionKey(in: store.sessions) + } + + return try encode(store) + } + + private static func normalizedEmail(_ email: String?) -> String { + (email ?? "unknown") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + private static func sessionKey(forEmail email: String?) -> String { + let digest = SHA256.hash(data: Data(normalizedEmail(email).utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func persistedCookies(from cookies: [IrisSession.IrisCookie]) -> [String: [Cookie]] { + var buckets: [String: [Cookie]] = [:] + + for cookie in cookies { + let persistedCookie = Cookie( + name: cookie.name, + value: cookie.value, + path: cookie.path.isEmpty ? "/" : cookie.path, + domain: cookie.domain, + expires: nil, + maxAge: nil, + secure: true, + httpOnly: true, + sameSite: nil + ) + + let matchingBases = baseURLs.filter { baseURL in + guard let host = URL(string: baseURL)?.host else { return false } + return cookieMatches(host: host, domain: cookie.domain) + } + + for baseURL in matchingBases { + buckets[baseURL, default: []].append(persistedCookie) + } + } + + return buckets + } + + private static func cookieMatches(host: String, domain: String) -> Bool { + let normalizedHost = host.lowercased() + let normalizedDomain = domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + + guard !normalizedDomain.isEmpty else { return false } + return normalizedHost == normalizedDomain || normalizedHost.hasSuffix("." + normalizedDomain) + } + + private static func mostRecentSessionKey(in sessions: [String: Session]) -> String? { + sessions.max { lhs, rhs in + if lhs.value.updatedAt == rhs.value.updatedAt { + return lhs.key < rhs.key + } + return lhs.value.updatedAt < rhs.value.updatedAt + }?.key + } + + private static func decode(_ data: Data?) throws -> ASCWebSessionStore? { + guard let data, !data.isEmpty else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(ASCWebSessionStore.self, from: data) + } + + private static func encode(_ store: ASCWebSessionStore) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(store) + } +} diff --git a/src/services/IrisService.swift b/src/services/asc/IrisService.swift similarity index 100% rename from src/services/IrisService.swift rename to src/services/asc/IrisService.swift diff --git a/src/services/TeenybaseClient.swift b/src/services/database/TeenybaseClient.swift similarity index 100% rename from src/services/TeenybaseClient.swift rename to src/services/database/TeenybaseClient.swift diff --git a/src/services/TeenybaseProcessService.swift b/src/services/database/TeenybaseProcessService.swift similarity index 80% rename from src/services/TeenybaseProcessService.swift rename to src/services/database/TeenybaseProcessService.swift index 734538d..c8ba00c 100644 --- a/src/services/TeenybaseProcessService.swift +++ b/src/services/database/TeenybaseProcessService.swift @@ -44,7 +44,7 @@ final class TeenybaseProcessService { return } - let env = buildEnvironment(projectPath: projectPath) + let env = TeenybaseProjectEnvironment.environment(projectPath: projectPath, port: port) // Kill anything already on the port await killPort(port) @@ -119,38 +119,6 @@ final class TeenybaseProcessService { ) } - private func buildEnvironment(projectPath: String) -> [String: String] { - var env = ProcessInfo.processInfo.environment - // Ensure project's local binaries are first in PATH - let localBin = projectPath + "/node_modules/.bin" - if let existing = env["PATH"] { - env["PATH"] = localBin + ":" + existing - } else { - env["PATH"] = localBin - } - // Standard overrides - env["TEENY_DEV_PORT"] = String(port) - env["WRANGLER_SEND_METRICS"] = "false" - // Load .dev.vars into environment - loadDevVars(projectPath: projectPath, into: &env) - return env - } - - private func loadDevVars(projectPath: String, into env: inout [String: String]) { - let path = projectPath + "/.dev.vars" - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return } - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } - let parts = trimmed.split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { continue } - let key = String(parts[0]).trimmingCharacters(in: .whitespaces) - let value = String(parts[1]).trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - env[key] = value - } - } - private func waitForHealth(port: Int, timeout: Int) async -> Bool { let url = URL(string: "http://localhost:\(port)/api/v1/health")! for _ in 0..<(timeout * 2) { diff --git a/src/services/database/TeenybaseProjectEnvironment.swift b/src/services/database/TeenybaseProjectEnvironment.swift new file mode 100644 index 0000000..37e1d52 --- /dev/null +++ b/src/services/database/TeenybaseProjectEnvironment.swift @@ -0,0 +1,53 @@ +import Foundation + +enum TeenybaseProjectEnvironment { + static func adminToken(projectPath: String) -> String? { + readDevVar("ADMIN_SERVICE_TOKEN", projectPath: projectPath) + } + + static func environment( + projectPath: String, + port: Int, + base: [String: String] = ProcessInfo.processInfo.environment + ) -> [String: String] { + var env = base + let localBin = projectPath + "/node_modules/.bin" + if let existingPath = env["PATH"] { + env["PATH"] = localBin + ":" + existingPath + } else { + env["PATH"] = localBin + } + + env["TEENY_DEV_PORT"] = String(port) + env["WRANGLER_SEND_METRICS"] = "false" + + for (key, value) in loadDevVars(projectPath: projectPath) { + env[key] = value + } + return env + } + + static func readDevVar(_ key: String, projectPath: String) -> String? { + loadDevVars(projectPath: projectPath)[key] + } + + static func loadDevVars(projectPath: String) -> [String: String] { + let path = projectPath + "/.dev.vars" + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return [:] } + + var values: [String: String] = [:] + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + + let parts = trimmed.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + + let key = String(parts[0]).trimmingCharacters(in: .whitespaces) + let value = String(parts[1]).trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + values[key] = value + } + return values + } +} diff --git a/src/services/mcp/MCPExecutor.swift b/src/services/mcp/MCPExecutor.swift new file mode 100644 index 0000000..aff6fc3 --- /dev/null +++ b/src/services/mcp/MCPExecutor.swift @@ -0,0 +1,383 @@ +import AppKit +import Foundation +import Security + +/// Runs an async operation with a timeout. Throws CancellationError if the deadline is exceeded. +func withThrowingTimeout( + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw CancellationError() + } + guard let result = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return result + } +} + +/// Executes MCP tool calls against AppState. +/// Holds pending approval continuations for destructive operations. +actor MCPExecutor { + private struct NavigationState: Sendable { + let tab: AppTab + let appSubTab: AppSubTab + } + + let appState: AppState + private var pendingContinuations: [String: CheckedContinuation] = [:] + + init(appState: AppState) { + self.appState = appState + } + + func parseFieldMap(_ rawFields: Any?, applyAliases: Bool) -> [String: String] { + var fieldMap: [String: String] = [:] + let mapField: (String) -> String = { field in + applyAliases ? (Self.fieldAliases[field] ?? field) : field + } + + if let fieldsArray = rawFields as? [[String: Any]] { + for item in fieldsArray { + if let field = item["field"] as? String, let value = item["value"] as? String { + fieldMap[mapField(field)] = value + } + } + } else if let fieldsDict = rawFields as? [String: Any] { + for (key, value) in fieldsDict { + fieldMap[mapField(key)] = "\(value)" + } + } else if let fieldsString = rawFields as? String, + let data = fieldsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) { + fieldMap = parseFieldMap(parsed, applyAliases: applyAliases) + } + + return fieldMap + } + + /// Execute a tool call, requesting approval if needed. + func execute(name: String, arguments: [String: Any]) async throws -> [String: Any] { + let category = MCPRegistry.category(for: name) + + // Pre-navigate for ASC form tools so the user sees the target tab before approving. + var previousNavigation: NavigationState? + if name == "asc_fill_form" || name == "asc_open_submit_preview" + || name == "store_listing_switch_localization" + || name == "asc_create_iap" || name == "asc_create_subscription" || name == "asc_set_app_price" + || name == "screenshots_switch_localization" + || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { + previousNavigation = await preNavigateASCTool(name: name, arguments: arguments) + } + + let request = ApprovalRequest( + id: UUID().uuidString, + toolName: name, + description: "Execute '\(name)'", + parameters: arguments.mapValues { "\($0)" }, + category: category + ) + + if request.requiresApproval(permissionToggles: await SettingsService.shared.permissionToggles) { + let approved = await requestApproval(request) + guard approved else { + if let prev = previousNavigation { + await MainActor.run { + appState.activeTab = prev.tab + appState.activeAppSubTab = prev.appSubTab + } + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeAll() } + } + return mcpText("Tool '\(name)' was denied by the user.") + } + } + + return try await executeTool(name: name, arguments: arguments) + } + + /// Navigate to the appropriate tab before approval, and set pending form values. + /// Returns the previous navigation state so we can navigate back if denied. + private func preNavigateASCTool(name: String, arguments: [String: Any]) async -> NavigationState { + let previousNavigation = await MainActor.run { + NavigationState(tab: appState.activeTab, appSubTab: appState.activeAppSubTab) + } + + let targetTab: AppTab? + let targetAppSubTab: AppSubTab? + if name == "asc_fill_form" { + let tab = arguments["tab"] as? String ?? "" + switch tab { + case "storeListing": + targetTab = .storeListing + case "appDetails": + targetTab = .appDetails + case "monetization": + targetTab = .monetization + case "review.ageRating", "review.contact": + targetTab = .review + case "settings.bundleId": + targetTab = .settings + default: + targetTab = nil + } + targetAppSubTab = nil + } else if name == "store_listing_switch_localization" { + targetTab = .storeListing + targetAppSubTab = nil + } else if name == "asc_open_submit_preview" { + targetTab = .app + targetAppSubTab = .overview + } else if name == "screenshots_switch_localization" + || name == "screenshots_add_asset" + || name == "screenshots_set_track" || name == "screenshots_save" { + targetTab = .screenshots + targetAppSubTab = nil + } else if name == "asc_set_app_price" { + targetTab = .monetization + targetAppSubTab = nil + } else if name == "asc_create_iap" || name == "asc_create_subscription" { + targetTab = .monetization + targetAppSubTab = nil + } else { + targetTab = nil + targetAppSubTab = nil + } + + if let targetTab { + await MainActor.run { + appState.activeTab = targetTab + if let targetAppSubTab { + appState.activeAppSubTab = targetAppSubTab + } + } + if targetTab == .app, targetAppSubTab == .overview { + await appState.ascManager.ensureTabData(.app) + } else if targetTab.isASCTab { + await appState.ascManager.fetchTabData(targetTab) + } + } + + if name == "asc_fill_form", + let tab = arguments["tab"] as? String { + let fieldMap = parseFieldMap(arguments["fields"], applyAliases: false) + + if !fieldMap.isEmpty { + let fieldMapCopy = fieldMap + await MainActor.run { + appState.ascManager.pendingFormValues[tab] = fieldMapCopy + appState.ascManager.pendingFormVersion += 1 + } + } + } + + return previousNavigation + } + + /// Resume a pending approval. + nonisolated func resolveApproval(id: String, approved: Bool) { + Task { await _resolveApproval(id: id, approved: approved) } + } + + private func _resolveApproval(id: String, approved: Bool) { + guard let continuation = pendingContinuations.removeValue(forKey: id) else { return } + continuation.resume(returning: approved) + } + + // MARK: - Approval Flow + + private func requestApproval(_ request: ApprovalRequest) async -> Bool { + await MainActor.run { + appState.pendingApproval = request + appState.showApprovalAlert = true + NSApp.activate(ignoringOtherApps: true) + } + + let approved = await withCheckedContinuation { (continuation: CheckedContinuation) in + pendingContinuations[request.id] = continuation + + Task { + try? await Task.sleep(for: .seconds(300)) + if pendingContinuations[request.id] != nil { + _resolveApproval(id: request.id, approved: false) + } + } + } + + await MainActor.run { + appState.pendingApproval = nil + appState.showApprovalAlert = false + } + + return approved + } + + // MARK: - Tool Dispatch + + func executeTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { + switch name { + case "app_get_state": + return try await executeAppGetState() + + case "nav_switch_tab": + return try await executeNavSwitchTab(arguments) + case "nav_list_tabs": + return await executeNavListTabs() + + case "project_list": + return await executeProjectList() + case "project_get_active": + return await executeProjectGetActive() + case "project_open": + return try await executeProjectOpen(arguments) + case "project_create": + return try await executeProjectCreate(arguments) + case "project_import": + return try await executeProjectImport(arguments) + case "project_close": + return await executeProjectClose() + + case "simulator_list_devices": + return await executeSimulatorListDevices() + case "simulator_select_device": + return try await executeSimulatorSelectDevice(arguments) + + case "settings_get": + return await executeSettingsGet() + case "settings_update": + return await executeSettingsUpdate(arguments) + case "settings_save": + return await executeSettingsSave() + + case "get_rejection_feedback": + return try await executeGetRejectionFeedback(arguments) + case "get_tab_state": + return try await executeGetTabState(arguments) + + case "asc_set_credentials": + return await executeASCSetCredentials(arguments) + case "asc_fill_form": + return try await executeASCFillForm(arguments) + case "store_listing_switch_localization": + return try await executeStoreListingSwitchLocalization(arguments) + case "screenshots_switch_localization": + return try await executeScreenshotsSwitchLocalization(arguments) + case "screenshots_add_asset": + return try await executeScreenshotsAddAsset(arguments) + case "screenshots_set_track": + return try await executeScreenshotsSetTrack(arguments) + case "screenshots_save": + return try await executeScreenshotsSave(arguments) + case "asc_open_submit_preview": + return await executeASCOpenSubmitPreview() + case "asc_create_iap": + return try await executeASCCreateIAP(arguments) + case "asc_create_subscription": + return try await executeASCCreateSubscription(arguments) + case "asc_set_app_price": + return try await executeASCSetAppPrice(arguments) + case "asc_web_auth": + return await executeASCWebAuth() + + case "app_store_setup_signing": + return try await executeSetupSigning(arguments) + case "app_store_build": + return try await executeBuildIPA(arguments) + case "app_store_upload": + return try await executeUploadToTestFlight(arguments) + + case "get_blitz_screenshot": + let path = "/tmp/blitz-app-screenshot-\(Int(Date().timeIntervalSince1970)).png" + let saved = await MainActor.run { () -> Bool in + guard let window = NSApp.windows.first(where: { + $0.title != "Welcome to Blitz" && $0.canBecomeMain && $0.isVisible + }) ?? NSApp.mainWindow else { + return false + } + let windowId = CGWindowID(window.windowNumber) + guard let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowId, + [.boundsIgnoreFraming, .bestResolution] + ) else { + return false + } + let bitmap = NSBitmapImageRep(cgImage: cgImage) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + return false + } + return ((try? png.write(to: URL(fileURLWithPath: path))) != nil) + } + return saved ? mcpText(path) : mcpText("Error: could not capture Blitz window screenshot") + + default: + throw MCPServerService.MCPError.unknownTool(name) + } + } + + // MARK: - Shared Helpers + + func mcpText(_ text: String) -> [String: Any] { + ["content": [["type": "text", "text": text]]] + } + + func mcpJSON(_ value: Any) -> [String: Any] { + if let data = try? JSONSerialization.data(withJSONObject: value), + let str = String(data: data, encoding: .utf8) { + return mcpText(str) + } + return mcpText("{}") + } + + /// Check for ASC write error and return it, clearing pending form values. + func checkASCWriteError(tab: String) async -> [String: Any]? { + guard let error = await MainActor.run(body: { appState.ascManager.writeError }) else { return nil } + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + return mcpText("Error: \(error)") + } + + struct BuildContext { + let project: Project + let bundleId: String + let teamId: String + let service: AppStoreConnectService + } + + /// Resolve and validate bundle ID + ASC service for build pipeline tools. + /// Returns `(context, nil)` on success or `(nil, errorResponse)` on failure. + func requireBuildContext(needsTeamId: Bool = false) async -> (BuildContext?, [String: Any]?) { + guard let project = await MainActor.run(body: { appState.activeProject }) else { + return (nil, mcpText("Error: no active project.")) + } + guard let service = await MainActor.run(body: { appState.ascManager.service }) else { + return (nil, mcpText("Error: ASC credentials not configured.")) + } + let bundleId = await MainActor.run { () -> String? in + ProjectStorage().readMetadata(projectId: project.id)?.bundleIdentifier + } + guard let bundleId, !bundleId.isEmpty else { + return (nil, mcpText( + "Error: no bundle identifier set. Use asc_fill_form tab=settings.bundleId to set it first." + )) + } + let ascBundleId = await MainActor.run { appState.ascManager.app?.bundleId } + if let ascBundleId, !ascBundleId.isEmpty, ascBundleId != bundleId { + return ( + nil, + mcpText("Error: bundle ID mismatch. Project has '\(bundleId)' but ASC app uses '\(ascBundleId)'.") + ) + } + let teamId = await MainActor.run { () -> String? in + ProjectStorage().readMetadata(projectId: project.id)?.teamId + } + if needsTeamId, (teamId == nil || teamId?.isEmpty == true) { + return (nil, mcpText("Error: no team ID set. Run app_store_setup_signing first.")) + } + return (BuildContext(project: project, bundleId: bundleId, teamId: teamId ?? "", service: service), nil) + } +} diff --git a/src/services/mcp/MCPExecutorASC.swift b/src/services/mcp/MCPExecutorASC.swift new file mode 100644 index 0000000..19afdcc --- /dev/null +++ b/src/services/mcp/MCPExecutorASC.swift @@ -0,0 +1,854 @@ +import AppKit +import Foundation + +extension MCPExecutor { + // MARK: - ASC Form Tools + + static let validFieldsByTab: [String: Set] = [ + "storeListing": ["title", "name", "subtitle", "description", "keywords", "promotionalText", + "marketingUrl", "supportUrl", "whatsNew", "privacyPolicyUrl"], + "appDetails": ["copyright", "primaryCategory", "contentRightsDeclaration"], + "monetization": ["isFree"], + "review.ageRating": ["gambling", "messagingAndChat", "unrestrictedWebAccess", + "userGeneratedContent", "advertising", "lootBox", + "healthOrWellnessTopics", "parentalControls", "ageAssurance", + "alcoholTobaccoOrDrugUseOrReferences", "contests", "gamblingSimulated", + "gunsOrOtherWeapons", "horrorOrFearThemes", "matureOrSuggestiveThemes", + "medicalOrTreatmentInformation", "profanityOrCrudeHumor", + "sexualContentGraphicAndNudity", "sexualContentOrNudity", + "violenceCartoonOrFantasy", "violenceRealistic", + "violenceRealisticProlongedGraphicOrSadistic"], + "review.contact": ["contactFirstName", "contactLastName", "contactEmail", "contactPhone", + "notes", "demoAccountRequired", "demoAccountName", "demoAccountPassword"], + "settings.bundleId": ["bundleId"], + ] + + static let fieldAliases: [String: String] = [ + "firstName": "contactFirstName", + "lastName": "contactLastName", + "email": "contactEmail", + "phone": "contactPhone", + ] + + private func screenshotsDisplayTypesForActiveProject() async -> [String] { + await MainActor.run { + switch appState.activeProject?.platform ?? .iOS { + case .iOS: + return ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129"] + case .macOS: + return ["APP_DESKTOP"] + } + } + } + + private func resolveStoreListingLocale(from args: [String: Any]) async -> String { + if let requestedLocale = (args["locale"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedLocale.isEmpty { + return requestedLocale + } + + return await MainActor.run { + appState.ascManager.activeStoreListingLocale() ?? "en-US" + } + } + + private func prepareStoreListingLocale( + _ locale: String, + forceRefresh: Bool = false + ) async -> String? { + let trimmedLocale = locale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLocale.isEmpty else { + return "Error: locale is required." + } + + let needsRefresh = await MainActor.run { () -> Bool in + let asc = appState.ascManager + asc.selectedStoreListingLocale = trimmedLocale + return forceRefresh + || asc.localizations.isEmpty + || !asc.localizations.contains(where: { $0.attributes.locale == trimmedLocale }) + || asc.appInfoLocalizationsByLocale.isEmpty + } + + if needsRefresh { + await appState.ascManager.refreshTabData(.storeListing) + } + + let availableLocales = await MainActor.run { + appState.ascManager.localizations.map(\.attributes.locale).sorted() + } + guard availableLocales.contains(trimmedLocale) else { + let availableText = availableLocales.isEmpty ? "none" : availableLocales.joined(separator: ", ") + return "Error: store listing localization '\(trimmedLocale)' was not found after refreshing from ASC. " + + "Available localizations: \(availableText)" + } + + await MainActor.run { + appState.ascManager.selectedStoreListingLocale = trimmedLocale + } + return nil + } + + private func resolveScreenshotsLocale(from args: [String: Any]) async -> (locale: String, explicitlyRequested: Bool) { + if let requestedLocale = (args["locale"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedLocale.isEmpty { + return (requestedLocale, true) + } + + let locale = await MainActor.run { + appState.ascManager.selectedScreenshotsLocale + ?? appState.ascManager.localizations.first?.attributes.locale + ?? "en-US" + } + return (locale, false) + } + + func executeASCSetCredentials(_ args: [String: Any]) async -> [String: Any] { + guard let issuerId = args["issuerId"] as? String, + let keyId = args["keyId"] as? String, + let rawPath = args["privateKeyPath"] as? String else { + return mcpText("Error: issuerId, keyId, and privateKeyPath are required.") + } + + let path = NSString(string: rawPath).expandingTildeInPath + guard FileManager.default.fileExists(atPath: path), + let privateKey = try? String(contentsOfFile: path, encoding: .utf8), + !privateKey.isEmpty else { + return mcpText("Error: could not read private key file at \(rawPath)") + } + + await MainActor.run { + appState.ascManager.pendingCredentialValues = [ + "issuerId": issuerId, + "keyId": keyId, + "privateKey": privateKey, + "privateKeyFileName": URL(fileURLWithPath: path).lastPathComponent + ] + } + return mcpText("Credentials pre-filled. The user can verify and click 'Save Credentials'.") + } + + func executeASCFillForm(_ args: [String: Any]) async throws -> [String: Any] { + guard let tab = args["tab"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let fieldMap = parseFieldMap(args["fields"], applyAliases: true) + guard !fieldMap.isEmpty else { + throw MCPServerService.MCPError.invalidToolArgs + } + + var resolvedStoreListingLocale: String? + + if let validFields = Self.validFieldsByTab[tab] { + let invalid = fieldMap.keys.filter { !validFields.contains($0) } + if !invalid.isEmpty { + var hints: [String] = [] + for field in invalid { + for (otherTab, otherFields) in Self.validFieldsByTab where otherTab != tab { + if otherFields.contains(field) { + hints.append("'\(field)' belongs on tab '\(otherTab)'") + } + } + } + let hintStr = hints.isEmpty ? "" : " Hint: \(hints.joined(separator: "; "))." + return mcpText( + "Error: invalid field(s) for tab '\(tab)': \(invalid.sorted().joined(separator: ", ")). " + + "Valid fields: \(validFields.sorted().joined(separator: ", ")).\(hintStr)" + ) + } + } + + switch tab { + case "storeListing": + let appInfoLocFields: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] + var versionLocFields: [String: String] = [:] + var infoLocFields: [String: String] = [:] + let locale = await resolveStoreListingLocale(from: args) + resolvedStoreListingLocale = locale + + if let localeError = await prepareStoreListingLocale(locale) { + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + return mcpText(localeError) + } + + for (field, value) in fieldMap { + if appInfoLocFields.contains(field) { + infoLocFields[field] = value + } else { + versionLocFields[field] = value + } + } + + await appState.ascManager.updateStoreListingFields( + versionFields: versionLocFields, + appInfoFields: infoLocFields, + locale: locale + ) + if let err = await checkASCWriteError(tab: tab) { return err } + + case "appDetails": + for (field, value) in fieldMap { + await appState.ascManager.updateAppInfoField(field, value: value) + } + if let err = await checkASCWriteError(tab: tab) { return err } + + case "monetization": + guard let isFree = fieldMap["isFree"] else { + return mcpText( + "Error: monetization tab requires the 'isFree' field (value: \"true\" or \"false\")." + ) + } + if isFree == "true" { + await appState.ascManager.setPriceFree() + } else { + return mcpText( + "To set a paid price, use the asc_set_app_price tool with a price parameter (e.g. price=\"0.99\")." + ) + } + if let err = await checkASCWriteError(tab: tab) { return err } + + case "review.ageRating": + var attrs: [String: Any] = [:] + let boolFields = Set(["gambling", "messagingAndChat", "unrestrictedWebAccess", + "userGeneratedContent", "advertising", "lootBox", + "healthOrWellnessTopics", "parentalControls", "ageAssurance"]) + for (field, value) in fieldMap { + attrs[field] = boolFields.contains(field) ? (value == "true") : value + } + await appState.ascManager.updateAgeRating(attrs) + if let err = await checkASCWriteError(tab: tab) { return err } + + case "review.contact": + var attrs: [String: Any] = [:] + for (field, value) in fieldMap { + if field == "demoAccountRequired" { + attrs[field] = value == "true" + } else if field == "contactPhone" { + let stripped = value.hasPrefix("+") + ? "+" + value.dropFirst().filter(\.isNumber) + : value.filter(\.isNumber) + attrs[field] = stripped + } else { + attrs[field] = value + } + } + await appState.ascManager.updateReviewContact(attrs) + if let err = await checkASCWriteError(tab: tab) { return err } + + case "settings.bundleId": + if let bundleId = fieldMap["bundleId"] { + let projectPath = await MainActor.run { appState.activeProject?.path } + await MainActor.run { + guard let projectId = appState.activeProjectId else { return } + let storage = ProjectStorage() + guard var metadata = storage.readMetadata(projectId: projectId) else { return } + metadata.bundleIdentifier = bundleId + try? storage.writeMetadata(projectId: projectId, metadata: metadata) + } + if let projectPath { + let pipeline = BuildPipelineService() + await pipeline.updateBundleIdInPbxproj(projectPath: projectPath, bundleId: bundleId) + } + await appState.projectManager.loadProjects() + let hasCreds = await MainActor.run { appState.ascManager.credentials != nil } + if hasCreds { + await appState.ascManager.fetchApp(bundleId: bundleId) + } + } + + default: + return mcpText("Unknown tab: \(tab)") + } + + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + var response: [String: Any] = ["success": true, "tab": tab, "fieldsUpdated": fieldMap.count] + if let resolvedStoreListingLocale { + response["locale"] = resolvedStoreListingLocale + } + return mcpJSON(response) + } + + func executeStoreListingSwitchLocalization(_ args: [String: Any]) async throws -> [String: Any] { + guard let rawLocale = args["locale"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let locale = rawLocale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !locale.isEmpty else { + throw MCPServerService.MCPError.invalidToolArgs + } + + if let localeError = await prepareStoreListingLocale(locale, forceRefresh: true) { + return mcpText(localeError) + } + + let state = await MainActor.run { () -> [String: Any] in + let asc = appState.ascManager + let versionLocalization = asc.storeListingLocalization(locale: locale) + let appInfoLocalization = asc.appInfoLocalizationForLocale(locale) + let fields: [String: String] = [ + "name": appInfoLocalization?.attributes.name ?? versionLocalization?.attributes.title ?? "", + "subtitle": appInfoLocalization?.attributes.subtitle ?? versionLocalization?.attributes.subtitle ?? "", + "description": versionLocalization?.attributes.description ?? "", + "keywords": versionLocalization?.attributes.keywords ?? "", + "promotionalText": versionLocalization?.attributes.promotionalText ?? "", + "marketingUrl": versionLocalization?.attributes.marketingUrl ?? "", + "supportUrl": versionLocalization?.attributes.supportUrl ?? "", + "whatsNew": versionLocalization?.attributes.whatsNew ?? "", + "privacyPolicyUrl": appInfoLocalization?.attributes.privacyPolicyUrl ?? "" + ] + var response: [String: Any] = [ + "success": true, + "locale": locale, + "availableLocales": asc.localizations.map(\.attributes.locale).sorted(), + "hasAppInfoLocalization": appInfoLocalization != nil + ] + response["fields"] = fields + return response + } + + return mcpJSON(state) + } + + func executeScreenshotsAddAsset(_ args: [String: Any]) async throws -> [String: Any] { + guard let sourcePath = args["sourcePath"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let expanded = (sourcePath as NSString).expandingTildeInPath + guard FileManager.default.fileExists(atPath: expanded) else { + return mcpText("Error: file not found at \(expanded)") + } + + guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { + return mcpText("Error: no active project") + } + + let destDir = BlitzPaths.screenshots(projectId: projectId) + let fm = FileManager.default + try? fm.createDirectory(at: destDir, withIntermediateDirectories: true) + + let fileName = args["fileName"] as? String ?? (expanded as NSString).lastPathComponent + let dest = destDir.appendingPathComponent(fileName) + + do { + if fm.fileExists(atPath: dest.path) { + try fm.removeItem(at: dest) + } + try fm.copyItem(atPath: expanded, toPath: dest.path) + } catch { + return mcpText("Error copying file: \(error.localizedDescription)") + } + + await MainActor.run { appState.ascManager.scanLocalAssets(projectId: projectId) } + return mcpJSON(["success": true, "fileName": fileName]) + } + + func executeScreenshotsSwitchLocalization(_ args: [String: Any]) async throws -> [String: Any] { + guard let rawLocale = args["locale"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let locale = rawLocale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !locale.isEmpty else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let previousLocale = await MainActor.run { + let previous = appState.ascManager.selectedScreenshotsLocale + appState.ascManager.selectedScreenshotsLocale = locale + return previous + } + + await appState.ascManager.refreshTabData(.screenshots) + + let availableLocales = await MainActor.run { + appState.ascManager.localizations.map(\.attributes.locale).sorted() + } + guard availableLocales.contains(locale) else { + await MainActor.run { + appState.ascManager.selectedScreenshotsLocale = previousLocale + } + let availableText = availableLocales.isEmpty ? "none" : availableLocales.joined(separator: ", ") + return mcpText( + "Error: screenshot localization '\(locale)' was not found after refreshing from ASC. " + + "Available localizations: \(availableText)" + ) + } + + await appState.ascManager.loadScreenshots(locale: locale, force: true) + + let displayTypes = await screenshotsDisplayTypesForActiveProject() + let trackCounts = await MainActor.run { () -> [String: Int] in + var counts: [String: Int] = [:] + for displayType in displayTypes { + appState.ascManager.loadTrackFromASC(displayType: displayType, locale: locale) + counts[displayType] = appState.ascManager + .trackSlotsForDisplayType(displayType, locale: locale) + .compactMap { $0 } + .count + } + return counts + } + + return mcpJSON([ + "success": true, + "locale": locale, + "availableLocales": availableLocales, + "trackCounts": trackCounts + ]) + } + + func executeScreenshotsSetTrack(_ args: [String: Any]) async throws -> [String: Any] { + guard let assetFileName = args["assetFileName"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + guard let slotRaw = args["slotIndex"] as? Int ?? (args["slotIndex"] as? Double).map({ Int($0) }), + slotRaw >= 1 && slotRaw <= 10 else { + return mcpText("Error: slotIndex must be between 1 and 10") + } + let slotIndex = slotRaw - 1 + let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" + let (locale, explicitlyRequestedLocale) = await resolveScreenshotsLocale(from: args) + + let selectedLocale = await MainActor.run { + appState.ascManager.selectedScreenshotsLocale ?? appState.ascManager.localizations.first?.attributes.locale + } + if explicitlyRequestedLocale, selectedLocale != locale { + return mcpText( + "Error: screenshots locale '\(locale)' is not selected in Blitz. " + + "Call screenshots_switch_localization first." + ) + } + + guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { + return mcpText("Error: no active project") + } + + let dir = BlitzPaths.screenshots(projectId: projectId) + let filePath = dir.appendingPathComponent(assetFileName).path + + guard FileManager.default.fileExists(atPath: filePath) else { + return mcpText("Error: asset '\(assetFileName)' not found in local screenshots library") + } + + await MainActor.run { + let asc = appState.ascManager + if !asc.hasTrackState(displayType: displayType, locale: locale), + selectedLocale == locale { + asc.loadTrackFromASC(displayType: displayType, locale: locale) + } + } + let trackReady = await MainActor.run { + appState.ascManager.hasTrackState(displayType: displayType, locale: locale) + } + guard trackReady else { + return mcpText( + "Error: screenshot locale '\(locale)' is not prepared in Blitz. " + + "Call screenshots_switch_localization first." + ) + } + + let error = await MainActor.run { + appState.ascManager.addAssetToTrack( + displayType: displayType, + slotIndex: slotIndex, + localPath: filePath, + locale: locale + ) + } + if let error { + return mcpText("Error: \(error)") + } + return mcpJSON(["success": true, "slot": slotRaw, "locale": locale]) + } + + func executeScreenshotsSave(_ args: [String: Any]) async throws -> [String: Any] { + let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" + let (locale, explicitlyRequestedLocale) = await resolveScreenshotsLocale(from: args) + + let selectedLocale = await MainActor.run { + appState.ascManager.selectedScreenshotsLocale ?? appState.ascManager.localizations.first?.attributes.locale + } + if explicitlyRequestedLocale, selectedLocale != locale { + return mcpText( + "Error: screenshots locale '\(locale)' is not selected in Blitz. " + + "Call screenshots_switch_localization first." + ) + } + + await MainActor.run { + let asc = appState.ascManager + if !asc.hasTrackState(displayType: displayType, locale: locale), + selectedLocale == locale { + asc.loadTrackFromASC(displayType: displayType, locale: locale) + } + } + let trackReady = await MainActor.run { + appState.ascManager.hasTrackState(displayType: displayType, locale: locale) + } + guard trackReady else { + return mcpText( + "Error: screenshot locale '\(locale)' is not prepared in Blitz. " + + "Call screenshots_switch_localization first." + ) + } + + let hasChanges = await MainActor.run { + appState.ascManager.hasUnsavedChanges(displayType: displayType, locale: locale) + } + guard hasChanges else { + return mcpJSON(["success": true, "message": "No changes to save", "locale": locale]) + } + + await appState.ascManager.syncTrackToASC(displayType: displayType, locale: locale) + + if let err = await checkASCWriteError(tab: "screenshots") { return err } + + let slotCount = await MainActor.run { + appState.ascManager.trackSlotsForDisplayType(displayType, locale: locale).compactMap { $0 }.count + } + return mcpJSON(["success": true, "synced": slotCount, "locale": locale]) + } + + func executeASCOpenSubmitPreview() async -> [String: Any] { + await appState.ascManager.refreshSubmissionReadinessData() + + var readiness = await MainActor.run { appState.ascManager.submissionReadiness } + let buildMissing = readiness.missingRequired.contains { $0.label == "Build" } + if buildMissing { + let service = await MainActor.run { appState.ascManager.service } + let appId = await MainActor.run { appState.ascManager.app?.id } + if let service, let appId, + let latestBuild = try? await service.fetchLatestBuild(appId: appId), + latestBuild.attributes.processingState == "VALID" { + let versionId = await MainActor.run { appState.ascManager.pendingVersionId } + if let versionId { + do { + try await service.attachBuild(versionId: versionId, buildId: latestBuild.id) + await appState.ascManager.refreshTabData(.app) + readiness = await MainActor.run { appState.ascManager.submissionReadiness } + } catch { + // Non-fatal: readiness will still surface the missing build. + } + } + } + } + + if !readiness.isComplete { + let missing = readiness.missingRequired.map { $0.label } + return mcpJSON(["ready": false, "missing": missing]) + } + + await MainActor.run { + appState.ascManager.showSubmitPreview = true + } + + return mcpJSON(["ready": true, "opened": true]) + } + + // MARK: - ASC IAP / Subscriptions / Pricing Tools + + static func priceMatches(_ customerPrice: String?, target: String) -> Bool { + guard let customerPrice else { return false } + guard let a = Double(customerPrice), let b = Double(target) else { + return customerPrice == target + } + return abs(a - b) < 0.001 + } + + func executeASCWebAuth() async -> [String: Any] { + await MainActor.run { + NSApp.activate(ignoringOtherApps: true) + } + + guard let session = await appState.ascManager.requestWebAuthForMCP() else { + let authError = await MainActor.run { appState.ascManager.irisFeedbackError } + if let authError, !authError.isEmpty { + return mcpJSON([ + "success": false, + "cancelled": false, + "message": authError + ]) + } + return mcpJSON([ + "success": false, + "cancelled": true, + "message": "Web authentication was cancelled before a session was captured." + ]) + } + + let email = session.email ?? "unknown" + return mcpJSON([ + "success": true, + "email": email, + "message": "Web session authenticated and synced to ~/.blitz/asc-agent/web-session.json. The asc-iap-attach skill can now use the iris API." + ]) + } + + func executeASCSetAppPrice(_ args: [String: Any]) async throws -> [String: Any] { + guard let priceStr = args["price"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let effectiveDate = args["effectiveDate"] as? String + + guard let service = await MainActor.run(body: { appState.ascManager.service }) else { + return mcpText("Error: ASC service not configured") + } + guard let appId = await MainActor.run(body: { appState.ascManager.app?.id }) else { + return mcpText("Error: no ASC app loaded. Open a project with a bundle ID first.") + } + + if let priceVal = Double(priceStr), priceVal < 0.001 { + try await service.setPriceFree(appId: appId) + try await service.ensureAppAvailability(appId: appId) + await MainActor.run { + appState.ascManager.currentAppPricePointId = appState.ascManager.freeAppPricePointId + appState.ascManager.scheduledAppPricePointId = nil + appState.ascManager.scheduledAppPriceEffectiveDate = nil + appState.ascManager.monetizationStatus = "Free" + } + await appState.ascManager.refreshTabData(.monetization) + return mcpJSON([ + "success": true, + "price": "0.00", + "message": "App set to free with territory availability configured" + ]) + } + + let pricePoints = try await service.fetchAppPricePoints(appId: appId) + guard let match = pricePoints.first(where: { + Self.priceMatches($0.attributes.customerPrice, target: priceStr) + }) else { + let sorted = pricePoints.compactMap { $0.attributes.customerPrice } + .compactMap { Double($0) } + .filter { $0 > 0 } + .sorted() + let samples = sorted.count <= 30 ? sorted : { + let lo = Array(sorted.prefix(5)) + let hi = Array(sorted.suffix(5)) + let step = max(1, (sorted.count - 10) / 10) + let mid = stride(from: 5, to: sorted.count - 5, by: step).map { sorted[$0] } + return lo + mid + hi + }() + let formatted = samples.map { String(format: "%.2f", $0) } + return mcpText( + "Error: no price point matching $\(priceStr). \(sorted.count) tiers available, " + + "samples: \(formatted.joined(separator: ", "))" + ) + } + + if let effectiveDate { + let freePoint = pricePoints.first(where: { + let p = $0.attributes.customerPrice ?? "0" + return p == "0" || p == "0.0" || p == "0.00" + }) + let currentId = freePoint?.id ?? match.id + try await service.setScheduledAppPrice( + appId: appId, + currentPricePointId: currentId, + futurePricePointId: match.id, + effectiveDate: effectiveDate + ) + try await service.ensureAppAvailability(appId: appId) + await MainActor.run { + appState.ascManager.currentAppPricePointId = currentId + appState.ascManager.scheduledAppPricePointId = match.id + appState.ascManager.scheduledAppPriceEffectiveDate = effectiveDate + appState.ascManager.monetizationStatus = "Configured" + } + await appState.ascManager.refreshTabData(.monetization) + return mcpJSON([ + "success": true, + "price": priceStr, + "effectiveDate": effectiveDate, + "message": "Scheduled price change for \(effectiveDate) with territory availability configured" + ]) + } + + try await service.setAppPrice(appId: appId, pricePointId: match.id) + try await service.ensureAppAvailability(appId: appId) + await MainActor.run { + appState.ascManager.currentAppPricePointId = match.id + appState.ascManager.scheduledAppPricePointId = nil + appState.ascManager.scheduledAppPriceEffectiveDate = nil + appState.ascManager.monetizationStatus = "Configured" + } + await appState.ascManager.refreshTabData(.monetization) + return mcpJSON(["success": true, "price": priceStr, "pricePointId": match.id]) + } + + func executeASCCreateIAP(_ args: [String: Any]) async throws -> [String: Any] { + guard let productId = args["productId"] as? String, + let name = args["name"] as? String, + let type = args["type"] as? String, + let displayName = args["displayName"] as? String, + let priceStr = args["price"] as? String, + let screenshotPath = args["screenshotPath"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let description = args["description"] as? String + + let validTypes = ["CONSUMABLE", "NON_CONSUMABLE", "NON_RENEWING_SUBSCRIPTION"] + guard validTypes.contains(type) else { + return mcpText("Error: invalid type '\(type)'. Must be one of: \(validTypes.joined(separator: ", "))") + } + + await MainActor.run { + var values: [String: String] = [ + "kind": "iap", + "name": name, + "productId": productId, + "type": type, + "displayName": displayName, + "price": priceStr + ] + if let description { values["description"] = description } + appState.ascManager.pendingCreateValues = values + } + + await MainActor.run { + appState.ascManager.createIAP( + name: name, + productId: productId, + type: type, + displayName: displayName, + description: description, + price: priceStr, + screenshotPath: screenshotPath + ) + } + + if let error = await pollASCCreation() { + return mcpText("Error creating IAP: \(error)") + } + + return mcpJSON([ + "success": true, + "productId": productId, + "type": type, + "displayName": displayName, + "price": priceStr + ]) + } + + func executeASCCreateSubscription(_ args: [String: Any]) async throws -> [String: Any] { + guard let groupName = args["groupName"] as? String, + let productId = args["productId"] as? String, + let name = args["name"] as? String, + let displayName = args["displayName"] as? String, + let duration = args["duration"] as? String, + let priceStr = args["price"] as? String, + let screenshotPath = args["screenshotPath"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let description = args["description"] as? String + + let validDurations = ["ONE_WEEK", "ONE_MONTH", "TWO_MONTHS", "THREE_MONTHS", "SIX_MONTHS", "ONE_YEAR"] + guard validDurations.contains(duration) else { + return mcpText( + "Error: invalid duration '\(duration)'. Must be one of: \(validDurations.joined(separator: ", "))" + ) + } + + await MainActor.run { + var values: [String: String] = [ + "kind": "subscription", + "groupName": groupName, + "name": name, + "productId": productId, + "displayName": displayName, + "duration": duration, + "price": priceStr + ] + if let description { values["description"] = description } + appState.ascManager.pendingCreateValues = values + } + + await MainActor.run { + appState.ascManager.createSubscription( + groupName: groupName, + name: name, + productId: productId, + displayName: displayName, + description: description, + duration: duration, + price: priceStr, + screenshotPath: screenshotPath + ) + } + + if let error = await pollASCCreation() { + return mcpText("Error creating subscription: \(error)") + } + + return mcpJSON([ + "success": true, + "groupName": groupName, + "productId": productId, + "displayName": displayName, + "duration": duration, + "price": priceStr + ]) + } + + func pollASCCreation() async -> String? { + for _ in 0..<10 { + let creating = await MainActor.run { appState.ascManager.isCreating } + if creating { break } + try? await Task.sleep(for: .milliseconds(100)) + } + while await MainActor.run(body: { appState.ascManager.isCreating }) { + try? await Task.sleep(for: .milliseconds(500)) + } + return await MainActor.run { appState.ascManager.writeError } + } + + func executeGetRejectionFeedback(_ args: [String: Any]) async throws -> [String: Any] { + let raw = await MainActor.run { () -> [String: Any] in + let asc = appState.ascManager + guard let appId = asc.app?.id else { + return ["error": "No app connected. Set up ASC credentials first."] + } + + let requestedVersion = args["version"] as? String + let version: String + if let requestedVersion { + version = requestedVersion + } else if let rejected = asc.appStoreVersions.first(where: { + $0.attributes.appStoreState == "REJECTED" + }) { + version = rejected.attributes.versionString + } else { + return ["error": "No rejected version found.", "appId": appId] + } + + if let cached = IrisFeedbackCache.load(appId: appId, versionString: version) { + let reasons = cached.reasons.map { reason in + ["section": reason.section, "description": reason.description, "code": reason.code] + } + let messages = cached.messages.map { message -> [String: String] in + var msg = ["body": message.body] + if let date = message.date { msg["date"] = date } + return msg + } + return [ + "appId": appId, + "version": version, + "fetchedAt": ISO8601DateFormatter().string(from: cached.fetchedAt), + "reasons": reasons, + "messages": messages, + "source": "cache" + ] + } + + return [ + "error": "No rejection feedback cached for version \(version). The user needs to sign in with their Apple ID in the ASC Overview tab to fetch feedback.", + "appId": appId, + "version": version + ] + } + return mcpJSON(raw) + } +} diff --git a/src/services/mcp/MCPExecutorAppNavigation.swift b/src/services/mcp/MCPExecutorAppNavigation.swift new file mode 100644 index 0000000..de27034 --- /dev/null +++ b/src/services/mcp/MCPExecutorAppNavigation.swift @@ -0,0 +1,99 @@ +import Foundation + +extension MCPExecutor { + // MARK: - App State Tools + + func executeAppGetState() async throws -> [String: Any] { + let state = await MainActor.run { () -> [String: Any] in + var result: [String: Any] = [ + "activeTab": appState.activeTab.rawValue, + "activeAppSubTab": appState.activeAppSubTab.rawValue, + "isStreaming": appState.simulatorStream.isCapturing + ] + if let project = appState.activeProject { + result["activeProject"] = [ + "id": project.id, + "name": project.name, + "path": project.path, + "type": project.type.rawValue + ] + } + if let udid = appState.simulatorManager.bootedDeviceId { + result["bootedSimulator"] = udid + } + let db = appState.databaseManager + if db.connectionStatus == .connected || db.backendProcess.isRunning { + result["database"] = [ + "url": db.backendProcess.baseURL, + "status": db.connectionStatus == .connected ? "connected" : "running" + ] + } + return result + } + return mcpJSON(state) + } + + // MARK: - Navigation Tools + + func executeNavSwitchTab(_ args: [String: Any]) async throws -> [String: Any] { + guard let tabStr = args["tab"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let legacySubTabMap: [String: AppSubTab] = [ + "simulator": .simulator, + "database": .database, + "tests": .tests, + "assets": .icon, + "icon": .icon, + "ascOverview": .overview, + "overview": .overview, + ] + + if let subTab = legacySubTabMap[tabStr] { + await MainActor.run { + appState.activeTab = .app + appState.activeAppSubTab = subTab + } + + if subTab == .database { + let status = await MainActor.run { appState.databaseManager.connectionStatus } + if status != .connected, let project = await MainActor.run(body: { appState.activeProject }) { + await appState.databaseManager.startAndConnect(projectId: project.id, projectPath: project.path) + } + } + + return mcpText("Switched to App > \(subTab.label)") + } + + guard let tab = AppTab(rawValue: tabStr) else { + throw MCPServerService.MCPError.invalidToolArgs + } + await MainActor.run { appState.activeTab = tab } + + return mcpText("Switched to tab: \(tab.label)") + } + + func executeNavListTabs() async -> [String: Any] { + let topLevel: [[String: Any]] = [ + ["name": "dashboard", "label": "Dashboard", "icon": "square.grid.2x2"], + [ + "name": "app", + "label": "App", + "icon": "app", + "subTabs": AppSubTab.allCases.map { + ["name": $0.rawValue, "label": $0.label, "icon": $0.systemImage] as [String: Any] + } + ], + ] + var groups: [[String: Any]] = [["group": "Top", "tabs": topLevel]] + for group in AppTab.Group.allCases { + let tabs = group.tabs.map { + ["name": $0.rawValue, "label": $0.label, "icon": $0.icon] as [String: Any] + } + groups.append(["group": group.rawValue, "tabs": tabs]) + } + groups.append(["group": "Other", "tabs": [["name": "settings", "label": "Settings", "icon": "gear"]]]) + return mcpJSON(["groups": groups]) + } +} diff --git a/src/services/mcp/MCPExecutorBuildPipeline.swift b/src/services/mcp/MCPExecutorBuildPipeline.swift new file mode 100644 index 0000000..ebd51df --- /dev/null +++ b/src/services/mcp/MCPExecutorBuildPipeline.swift @@ -0,0 +1,338 @@ +import Foundation + +extension MCPExecutor { + // MARK: - Build Pipeline Tools + + func executeSetupSigning(_ args: [String: Any]) async throws -> [String: Any] { + let (optCtx, err) = await requireBuildContext() + guard let ctx = optCtx else { return err! } + let project = ctx.project + let bundleId = ctx.bundleId + let service = ctx.service + let teamId = args["teamId"] as? String ?? (ctx.teamId.isEmpty ? nil : ctx.teamId) + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .signingSetup + appState.ascManager.buildPipelineMessage = "Setting up signing…" + } + + let pipeline = BuildPipelineService() + let appStateRef = appState + do { + let projectPlatform = await MainActor.run { project.platform } + let result = try await withThrowingTimeout(seconds: 300) { + try await pipeline.setupSigning( + projectPath: project.path, + bundleId: bundleId, + teamId: teamId, + ascService: service, + platform: projectPlatform, + onProgress: { msg in + Task { @MainActor in + appStateRef.ascManager.buildPipelineMessage = msg + } + } + ) + } + + if !result.teamId.isEmpty { + await MainActor.run { + let storage = ProjectStorage() + guard var metadata = storage.readMetadata(projectId: project.id) else { return } + metadata.teamId = result.teamId + try? storage.writeMetadata(projectId: project.id, metadata: metadata) + } + } + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + + var resultDict: [String: Any] = [ + "success": true, + "bundleIdResourceId": result.bundleIdResourceId, + "certificateId": result.certificateId, + "profileUUID": result.profileUUID, + "teamId": result.teamId, + "log": result.log + ] + if let installerCertId = result.installerCertificateId { + resultDict["installerCertificateId"] = installerCertId + } + return mcpJSON(resultDict) + } catch { + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + return mcpText("Error in signing setup: \(error.localizedDescription)") + } + } + + func executeBuildIPA(_ args: [String: Any]) async throws -> [String: Any] { + let (optCtx, err) = await requireBuildContext(needsTeamId: true) + guard let ctx = optCtx else { return err! } + let project = ctx.project + let bundleId = ctx.bundleId + let teamId = ctx.teamId + + let scheme = args["scheme"] as? String + let configuration = args["configuration"] as? String + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .archiving + appState.ascManager.buildPipelineMessage = "Starting build…" + } + + let pipeline = BuildPipelineService() + let appStateRef = appState + do { + let buildPlatform = await MainActor.run { project.platform } + let result = try await pipeline.buildIPA( + projectPath: project.path, + bundleId: bundleId, + teamId: teamId, + scheme: scheme, + configuration: configuration, + platform: buildPlatform, + onProgress: { msg in + Task { @MainActor in + if msg.contains("ARCHIVE SUCCEEDED") || msg.contains("-exportArchive") { + appStateRef.ascManager.buildPipelinePhase = .exporting + } + appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) + } + } + ) + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + + return mcpJSON([ + "success": true, + "ipaPath": result.ipaPath, + "archivePath": result.archivePath, + "log": result.log + ]) + } catch { + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + return mcpText("Error building IPA: \(error.localizedDescription)") + } + } + + func executeUploadToTestFlight(_ args: [String: Any]) async throws -> [String: Any] { + guard let credentials = await MainActor.run(body: { appState.ascManager.credentials }) else { + return mcpText("Error: ASC credentials not configured.") + } + guard await MainActor.run(body: { appState.activeProject }) != nil else { + return mcpText("Error: no active project.") + } + + let ipaPath: String + if let path = args["ipaPath"] as? String { + ipaPath = (path as NSString).expandingTildeInPath + } else { + let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) + let tmpContents = try FileManager.default.contentsOfDirectory( + at: tmpURL, + includingPropertiesForKeys: [.contentModificationDateKey] + ) + let exportDirs = tmpContents.filter { $0.lastPathComponent.hasPrefix("BlitzExport-") } + .sorted { a, b in + let aDate = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? .distantPast + let bDate = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? .distantPast + return aDate > bDate + } + + let searchExts: Set = ["ipa", "pkg"] + var foundArtifact: String? + for dir in exportDirs { + let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + if let match = files.first(where: { searchExts.contains($0.pathExtension) }) { + foundArtifact = match.path + break + } + } + guard let found = foundArtifact else { + return mcpText("Error: no IPA/PKG path provided and no recent build found. Run app_store_build first.") + } + ipaPath = found + } + + guard FileManager.default.fileExists(atPath: ipaPath) else { + return mcpText("Error: IPA not found at \(ipaPath)") + } + + let skipPolling = args["skipPolling"] as? Bool ?? false + let appId = await MainActor.run { appState.ascManager.app?.id } + let service = await MainActor.run { appState.ascManager.service } + + let isIPA = ipaPath.hasSuffix(".ipa") + var existingVersions: Set = [] + do { + guard isIPA else { throw NSError(domain: "skip", code: 0) } + let plistXML = try await ProcessRunner.run( + "/bin/bash", + arguments: ["-c", "unzip -p '\(ipaPath)' 'Payload/*.app/Info.plist' | plutil -convert xml1 -o - -"] + ) + + let ipaVersion: String? = { + guard let range = plistXML.range(of: "CFBundleVersion"), + let valueStart = plistXML.range(of: "", range: range.upperBound..", range: valueStart.upperBound..ITSAppUsesNonExemptEncryption directly to Info.plist." + ) + } + + if let ipaVersion, !ipaVersion.isEmpty, let appId, let service { + let builds = try await service.fetchBuilds(appId: appId) + existingVersions = Set(builds.map(\.attributes.version)) + if existingVersions.contains(ipaVersion) { + let maxVersion = existingVersions.compactMap { Int($0) }.max() ?? 0 + return mcpText( + "Error: build version \(ipaVersion) already exists in App Store Connect. " + + "Existing build versions: \(existingVersions.sorted().joined(separator: ", ")). " + + "The next valid build version is \(maxVersion + 1). " + + "Update CFBundleVersion in Info.plist (or CURRENT_PROJECT_VERSION in the Xcode build settings) and rebuild." + ) + } + } + } catch { + // Non-fatal — proceed with upload and let altool catch any issues. + } + + if existingVersions.isEmpty, let appId, let service { + existingVersions = Set((try? await service.fetchBuilds(appId: appId))?.map(\.attributes.version) ?? []) + } + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .uploading + appState.ascManager.buildPipelineMessage = "Uploading IPA…" + } + + let pipeline = BuildPipelineService() + let appStateRef = appState + do { + let uploadPlatform = await MainActor.run { appState.activeProject?.platform ?? .iOS } + let result = try await pipeline.uploadToTestFlight( + ipaPath: ipaPath, + keyId: credentials.keyId, + issuerId: credentials.issuerId, + privateKeyPEM: credentials.privateKey, + appId: appId, + ascService: service, + skipPolling: true, + platform: uploadPlatform, + onProgress: { msg in + Task { @MainActor in + appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) + } + } + ) + + var allLog = result.log + var finalState = result.processingState + var finalVersion = result.buildVersion + + if !skipPolling, let appId, let service { + await MainActor.run { + appStateRef.ascManager.buildPipelinePhase = .processing + appStateRef.ascManager.buildPipelineMessage = "Waiting for new build to appear…" + } + + let pollInterval: TimeInterval = 10 + let maxAttempts = 30 + + for attempt in 1...maxAttempts { + try? await Task.sleep(for: .seconds(pollInterval)) + + guard let builds = try? await service.fetchBuilds(appId: appId) else { continue } + + if let newBuild = builds.first(where: { !existingVersions.contains($0.attributes.version) }) { + let state = newBuild.attributes.processingState ?? "UNKNOWN" + let version = newBuild.attributes.version + let msg = "Poll \(attempt): build \(version) — \(state)" + allLog.append(msg) + await MainActor.run { + appStateRef.ascManager.buildPipelineMessage = msg + appStateRef.ascManager.builds = builds + } + + finalVersion = version + finalState = state + + if state == "VALID" { + allLog.append("Build processing complete!") + try? await service.patchBuildEncryption( + buildId: newBuild.id, + usesNonExemptEncryption: false + ) + let versionId = await MainActor.run(body: { + appStateRef.ascManager.pendingVersionId + }) + if let versionId { + do { + try await service.attachBuild(versionId: versionId, buildId: newBuild.id) + allLog.append("Build \(version) attached to app store version.") + } catch { + allLog.append("Warning: could not auto-attach build - \(error.localizedDescription)") + } + } + break + } else if state == "INVALID" { + allLog.append("Build processing failed with INVALID state.") + break + } + } else { + let msg = "Poll \(attempt): new build not yet visible…" + allLog.append(msg) + await MainActor.run { + appStateRef.ascManager.buildPipelineMessage = msg + } + } + } + } + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + await appState.ascManager.refreshTabData(.builds) + + var response: [String: Any] = [ + "success": true, + "processingState": finalState ?? "UNKNOWN", + "log": allLog + ] + if let version = finalVersion { + response["buildVersion"] = version + } + return mcpJSON(response) + } catch { + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + return mcpText("Error uploading to TestFlight: \(error.localizedDescription)") + } + } +} diff --git a/src/services/mcp/MCPExecutorProjectEnvironment.swift b/src/services/mcp/MCPExecutorProjectEnvironment.swift new file mode 100644 index 0000000..271398c --- /dev/null +++ b/src/services/mcp/MCPExecutorProjectEnvironment.swift @@ -0,0 +1,194 @@ +import Foundation + +extension MCPExecutor { + // MARK: - Project Tools + + func executeProjectList() async -> [String: Any] { + await appState.projectManager.loadProjects() + let projects = await MainActor.run { + appState.projectManager.projects.map { project -> [String: Any] in + ["id": project.id, "name": project.name, "path": project.path, "type": project.type.rawValue] + } + } + return mcpJSON(["projects": projects]) + } + + func executeProjectGetActive() async -> [String: Any] { + let result = await MainActor.run { () -> [String: Any]? in + guard let project = appState.activeProject else { return nil } + return ["id": project.id, "name": project.name, "path": project.path, "type": project.type.rawValue] + } + if let result { + return mcpJSON(result) + } + return mcpText("No active project") + } + + func executeProjectOpen(_ args: [String: Any]) async throws -> [String: Any] { + guard let projectId = args["projectId"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let storage = ProjectStorage() + storage.updateLastOpened(projectId: projectId) + await MainActor.run { appState.activeProjectId = projectId } + await appState.projectManager.loadProjects() + return mcpText("Opened project: \(projectId)") + } + + func executeProjectCreate(_ args: [String: Any]) async throws -> [String: Any] { + guard let name = args["name"] as? String, + let typeStr = args["type"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let storage = ProjectStorage() + let projectId = name.lowercased() + .replacingOccurrences(of: " ", with: "-") + .filter { $0.isLetter || $0.isNumber || $0 == "-" } + let projectDir = storage.baseDirectory.appendingPathComponent(projectId) + + try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true) + + let projectType = ProjectType(rawValue: typeStr) ?? .reactNative + let platformStr = args["platform"] as? String + let platform = ProjectPlatform(rawValue: platformStr ?? "iOS") ?? .iOS + let metadata = BlitzProjectMetadata( + name: name, + type: projectType, + platform: platform, + createdAt: Date(), + lastOpenedAt: Date() + ) + try storage.writeMetadata(projectId: projectId, metadata: metadata) + let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { + ( + SettingsService.shared.whitelistBlitzMCPTools, + SettingsService.shared.allowASCCLICalls + ) + } + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + await appState.projectManager.loadProjects() + + await MainActor.run { + appState.projectSetup.pendingSetupProjectId = projectId + appState.activeProjectId = projectId + } + + try? await Task.sleep(for: .seconds(2)) + let setupStarted = await MainActor.run { appState.projectSetup.isSettingUp } + if !setupStarted { + guard let project = await MainActor.run(body: { appState.activeProject }) else { + return mcpText("Created project '\(name)' but could not start setup (project not found)") + } + await appState.projectSetup.setup( + projectId: project.id, + projectName: project.name, + projectPath: project.path, + projectType: project.type, + platform: project.platform + ) + } else { + for _ in 0..<180 { + let done = await MainActor.run { !appState.projectSetup.isSettingUp } + if done { break } + try? await Task.sleep(for: .seconds(1)) + } + } + + let errorMsg = await MainActor.run { appState.projectSetup.errorMessage } + if let errorMsg { + return mcpText("Created project '\(name)' but setup failed: \(errorMsg)") + } + return mcpText("Created project '\(name)' (type: \(typeStr), id: \(projectId)) — setup complete") + } + + func executeProjectImport(_ args: [String: Any]) async throws -> [String: Any] { + guard let path = args["path"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let url = URL(fileURLWithPath: path) + let storage = ProjectStorage() + let projectId = try storage.openProject(at: url) + let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { + ( + SettingsService.shared.whitelistBlitzMCPTools, + SettingsService.shared.allowASCCLICalls + ) + } + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + await appState.projectManager.loadProjects() + await MainActor.run { appState.activeProjectId = projectId } + + return mcpText("Imported project from '\(path)' (id: \(projectId))") + } + + func executeProjectClose() async -> [String: Any] { + await MainActor.run { appState.activeProjectId = nil } + return mcpText("Project closed") + } + + // MARK: - Simulator Tools + + func executeSimulatorListDevices() async -> [String: Any] { + await appState.simulatorManager.loadSimulators() + let devices = await MainActor.run { + appState.simulatorManager.simulators.map { sim -> [String: Any] in + [ + "udid": sim.udid, + "name": sim.name, + "state": sim.state, + "isBooted": sim.isBooted + ] + } + } + return mcpJSON(["devices": devices]) + } + + func executeSimulatorSelectDevice(_ args: [String: Any]) async throws -> [String: Any] { + guard let udid = args["udid"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let service = SimulatorService() + try await service.boot(udid: udid) + await MainActor.run { appState.simulatorManager.bootedDeviceId = udid } + await appState.simulatorManager.loadSimulators() + + return mcpText("Booted simulator: \(udid)") + } + + // MARK: - Settings Tools + + func executeSettingsGet() async -> [String: Any] { + let settings = await MainActor.run { () -> [String: Any] in + [ + "showCursor": appState.settingsStore.showCursor, + "cursorSize": appState.settingsStore.cursorSize, + "defaultSimulatorUDID": appState.settingsStore.defaultSimulatorUDID ?? "" + ] + } + return mcpJSON(settings) + } + + func executeSettingsUpdate(_ args: [String: Any]) async -> [String: Any] { + await MainActor.run { + if let cursor = args["showCursor"] as? Bool { appState.settingsStore.showCursor = cursor } + if let size = args["cursorSize"] as? Double { appState.settingsStore.cursorSize = size } + } + return mcpText("Settings updated") + } + + func executeSettingsSave() async -> [String: Any] { + await MainActor.run { appState.settingsStore.save() } + return mcpText("Settings saved to disk") + } +} diff --git a/src/services/mcp/MCPExecutorTabState.swift b/src/services/mcp/MCPExecutorTabState.swift new file mode 100644 index 0000000..151cdab --- /dev/null +++ b/src/services/mcp/MCPExecutorTabState.swift @@ -0,0 +1,320 @@ +import Foundation + +extension MCPExecutor { + // MARK: - Tab State Tool + + func executeGetTabState(_ args: [String: Any]) async throws -> [String: Any] { + let tabStr = args["tab"] as? String + let tab: AppTab + if let tabStr { + if tabStr == "ascOverview" || tabStr == "overview" { + tab = .app + } else if let parsed = AppTab(rawValue: tabStr) { + tab = parsed + } else { + tab = await MainActor.run { appState.activeTab } + } + } else { + tab = await MainActor.run { appState.activeTab } + } + + var result = await MainActor.run { () -> [String: Any] in + let asc = appState.ascManager + var value: [String: Any] = [ + "tab": tab.rawValue, + "isLoading": asc.isLoadingTab[tab] ?? false, + ] + if let error = asc.tabError[tab] { value["error"] = error } + if let writeErr = asc.writeError { value["writeError"] = writeErr } + if tab.isASCTab, let app = asc.app { + value["app"] = ["id": app.id, "name": app.name, "bundleId": app.bundleId] + } + return value + } + + if tab == .app { + await appState.ascManager.refreshSubmissionReadinessData() + } + + let tabData = await MainActor.run { () -> [String: Any] in + let projectId = appState.activeProjectId + return tabStateData(for: tab, asc: appState.ascManager, projectId: projectId) + } + for (key, value) in tabData { + result[key] = value + } + + return mcpJSON(result) + } + + /// Extract tab-specific state data. Must be called on MainActor. + @MainActor + func tabStateData(for tab: AppTab, asc: ASCManager, projectId: String?) -> [String: Any] { + switch tab { + case .app: + if let projectId { + asc.checkAppIcon(projectId: projectId) + } + return tabStateASCOverview(asc) + case .storeListing: + return tabStateStoreListing(asc) + case .appDetails: + return tabStateAppDetails(asc) + case .review: + return tabStateReview(asc) + case .screenshots: + return tabStateScreenshots(asc) + case .reviews: + return tabStateReviews(asc) + case .builds: + return tabStateBuilds(asc) + case .groups: + return tabStateGroups(asc) + case .betaInfo: + return tabStateBetaInfo(asc) + case .feedback: + return tabStateFeedback(asc) + default: + return ["note": "No structured state available for this tab"] + } + } + + @MainActor + func tabStateASCOverview(_ asc: ASCManager) -> [String: Any] { + let readiness = asc.submissionReadiness + var fields: [[String: Any]] = [] + for field in readiness.fields { + let filled = field.value != nil && !(field.value?.isEmpty ?? true) + var entry: [String: Any] = [ + "label": field.label, + "value": field.value as Any, + "required": field.required, + "filled": filled + ] + if let hint = field.hint { + entry["hint"] = hint + } + fields.append(entry) + } + var result: [String: Any] = [ + "submissionReadiness": [ + "isComplete": readiness.isComplete, + "fields": fields, + "missingRequired": readiness.missingRequired.map { $0.label } + ], + "totalVersions": asc.appStoreVersions.count, + "isSubmitting": asc.isSubmitting + ] + if let version = asc.appStoreVersions.first { + result["latestVersion"] = [ + "id": version.id, + "versionString": version.attributes.versionString, + "state": version.attributes.appStoreState ?? "unknown" + ] + } + if let error = asc.submissionError { + result["submissionError"] = error + } + if let cached = asc.cachedFeedback { + result["rejectionFeedback"] = [ + "version": cached.versionString, + "reasonCount": cached.reasons.count, + "messageCount": cached.messages.count, + "hint": "Use get_rejection_feedback tool for full details" + ] + } + return result + } + + @MainActor + func tabStateStoreListing(_ asc: ASCManager) -> [String: Any] { + let selectedLocale = asc.activeStoreListingLocale() ?? "" + let localization = asc.storeListingLocalization(locale: selectedLocale) + let infoLoc = asc.appInfoLocalizationForLocale(selectedLocale) + let localizationState: [String: Any] = [ + "locale": localization?.attributes.locale ?? "", + "name": infoLoc?.attributes.name ?? localization?.attributes.title ?? "", + "subtitle": infoLoc?.attributes.subtitle ?? localization?.attributes.subtitle ?? "", + "description": localization?.attributes.description ?? "", + "keywords": localization?.attributes.keywords ?? "", + "promotionalText": localization?.attributes.promotionalText ?? "", + "marketingUrl": localization?.attributes.marketingUrl ?? "", + "supportUrl": localization?.attributes.supportUrl ?? "", + "whatsNew": localization?.attributes.whatsNew ?? "" + ] + + return [ + "selectedLocale": selectedLocale, + "availableLocales": asc.localizations.map(\.attributes.locale), + "localization": localizationState, + "privacyPolicyUrl": infoLoc?.attributes.privacyPolicyUrl ?? "", + "hasAppInfoLocalization": infoLoc != nil, + "localeCount": asc.localizations.count + ] + } + + @MainActor + func tabStateAppDetails(_ asc: ASCManager) -> [String: Any] { + var result: [String: Any] = [ + "appInfo": [ + "primaryCategory": asc.appInfo?.primaryCategoryId ?? "", + "contentRightsDeclaration": asc.app?.contentRightsDeclaration ?? "" + ], + "versionCount": asc.appStoreVersions.count + ] + if let version = asc.appStoreVersions.first { + result["latestVersion"] = [ + "versionString": version.attributes.versionString, + "state": version.attributes.appStoreState ?? "unknown" + ] + } + return result + } + + @MainActor + func tabStateReview(_ asc: ASCManager) -> [String: Any] { + var result: [String: Any] = [:] + + if let ageRating = asc.ageRatingDeclaration { + let attrs = ageRating.attributes + var ageRatingDict: [String: Any] = ["id": ageRating.id] + ageRatingDict["gambling"] = attrs.gambling ?? false + ageRatingDict["messagingAndChat"] = attrs.messagingAndChat ?? false + ageRatingDict["unrestrictedWebAccess"] = attrs.unrestrictedWebAccess ?? false + ageRatingDict["userGeneratedContent"] = attrs.userGeneratedContent ?? false + ageRatingDict["advertising"] = attrs.advertising ?? false + ageRatingDict["lootBox"] = attrs.lootBox ?? false + ageRatingDict["healthOrWellnessTopics"] = attrs.healthOrWellnessTopics ?? false + ageRatingDict["parentalControls"] = attrs.parentalControls ?? false + ageRatingDict["ageAssurance"] = attrs.ageAssurance ?? false + ageRatingDict["alcoholTobaccoOrDrugUseOrReferences"] = attrs.alcoholTobaccoOrDrugUseOrReferences ?? "NONE" + ageRatingDict["contests"] = attrs.contests ?? "NONE" + ageRatingDict["gamblingSimulated"] = attrs.gamblingSimulated ?? "NONE" + ageRatingDict["gunsOrOtherWeapons"] = attrs.gunsOrOtherWeapons ?? "NONE" + ageRatingDict["horrorOrFearThemes"] = attrs.horrorOrFearThemes ?? "NONE" + ageRatingDict["matureOrSuggestiveThemes"] = attrs.matureOrSuggestiveThemes ?? "NONE" + ageRatingDict["medicalOrTreatmentInformation"] = attrs.medicalOrTreatmentInformation ?? "NONE" + ageRatingDict["profanityOrCrudeHumor"] = attrs.profanityOrCrudeHumor ?? "NONE" + ageRatingDict["sexualContentGraphicAndNudity"] = attrs.sexualContentGraphicAndNudity ?? "NONE" + ageRatingDict["sexualContentOrNudity"] = attrs.sexualContentOrNudity ?? "NONE" + ageRatingDict["violenceCartoonOrFantasy"] = attrs.violenceCartoonOrFantasy ?? "NONE" + ageRatingDict["violenceRealistic"] = attrs.violenceRealistic ?? "NONE" + ageRatingDict["violenceRealisticProlongedGraphicOrSadistic"] = attrs.violenceRealisticProlongedGraphicOrSadistic ?? "NONE" + result["ageRating"] = ageRatingDict + } + + if let reviewDetail = asc.reviewDetail { + let attrs = reviewDetail.attributes + result["reviewContact"] = [ + "contactFirstName": attrs.contactFirstName ?? "", + "contactLastName": attrs.contactLastName ?? "", + "contactEmail": attrs.contactEmail ?? "", + "contactPhone": attrs.contactPhone ?? "", + "notes": attrs.notes ?? "", + "demoAccountRequired": attrs.demoAccountRequired ?? false, + "demoAccountName": attrs.demoAccountName ?? "", + "demoAccountPassword": attrs.demoAccountPassword ?? "" + ] + } + + result["builds"] = asc.builds.prefix(10).map { build -> [String: Any] in + [ + "id": build.id, + "version": build.attributes.version, + "processingState": build.attributes.processingState ?? "unknown", + "uploadedDate": build.attributes.uploadedDate ?? "" + ] + } + return result + } + + @MainActor + func tabStateScreenshots(_ asc: ASCManager) -> [String: Any] { + let selectedLocale = asc.selectedScreenshotsLocale ?? asc.localizations.first?.attributes.locale ?? "" + let screenshotSets = asc.screenshotSetsForLocale(selectedLocale) + let screenshots = asc.screenshotsForLocale(selectedLocale) + let sets = screenshotSets.map { set -> [String: Any] in + var value: [String: Any] = ["id": set.id, "displayType": set.attributes.screenshotDisplayType] + if let shots = screenshots[set.id] { + value["screenshotCount"] = shots.count + value["screenshots"] = shots.map { + ["id": $0.id, "fileName": $0.attributes.fileName ?? ""] + } + } + return value + } + return [ + "selectedLocale": selectedLocale, + "availableLocales": asc.localizations.map(\.attributes.locale), + "screenshotSets": sets, + "localeCount": asc.localizations.count + ] + } + + @MainActor + func tabStateReviews(_ asc: ASCManager) -> [String: Any] { + let reviews = asc.customerReviews.prefix(20).map { review -> [String: Any] in + [ + "id": review.id, + "title": review.attributes.title ?? "", + "body": review.attributes.body ?? "", + "rating": review.attributes.rating, + "reviewerNickname": review.attributes.reviewerNickname ?? "" + ] + } + return ["reviews": reviews, "totalReviews": asc.customerReviews.count] + } + + @MainActor + func tabStateBuilds(_ asc: ASCManager) -> [String: Any] { + let builds = asc.builds.prefix(20).map { build -> [String: Any] in + [ + "id": build.id, + "version": build.attributes.version, + "processingState": build.attributes.processingState ?? "unknown", + "uploadedDate": build.attributes.uploadedDate ?? "" + ] + } + return ["builds": builds] + } + + @MainActor + func tabStateGroups(_ asc: ASCManager) -> [String: Any] { + let groups = asc.betaGroups.map { group -> [String: Any] in + [ + "id": group.id, + "name": group.attributes.name, + "isInternalGroup": group.attributes.isInternalGroup ?? false + ] + } + return ["betaGroups": groups] + } + + @MainActor + func tabStateBetaInfo(_ asc: ASCManager) -> [String: Any] { + let localizations = asc.betaLocalizations.map { localization -> [String: Any] in + [ + "id": localization.id, + "locale": localization.attributes.locale, + "description": localization.attributes.description ?? "" + ] + } + return ["betaLocalizations": localizations] + } + + @MainActor + func tabStateFeedback(_ asc: ASCManager) -> [String: Any] { + var items: [[String: Any]] = [] + for (buildId, feedbackItems) in asc.betaFeedback { + for item in feedbackItems { + items.append([ + "buildId": buildId, + "id": item.id, + "comment": item.attributes.comment ?? "", + "timestamp": item.attributes.timestamp ?? "" + ]) + } + } + return ["feedback": items, "selectedBuildId": asc.selectedBuildId ?? ""] + } +} diff --git a/src/services/MCPToolRegistry.swift b/src/services/mcp/MCPRegistry.swift similarity index 85% rename from src/services/MCPToolRegistry.swift rename to src/services/mcp/MCPRegistry.swift index c0eb3a6..dff2e90 100644 --- a/src/services/MCPToolRegistry.swift +++ b/src/services/mcp/MCPRegistry.swift @@ -1,7 +1,7 @@ import Foundation /// Static definitions for all MCP tools exposed by Blitz -enum MCPToolRegistry { +enum MCPRegistry { /// Returns all tool definitions for the MCP tools/list response static func allTools() -> [[String: Any]] { @@ -20,9 +20,10 @@ enum MCPToolRegistry { name: "nav_switch_tab", description: "Switch the active sidebar tab", properties: [ - "tab": ["type": "string", "description": "Tab name", "enum": [ - "simulator", "database", "tests", "assets", - "ascOverview", "storeListing", "screenshots", "appDetails", "monetization", "review", + "tab": ["type": "string", "description": "Tab name. Use 'dashboard' or 'app' for top-level tabs. Legacy names (simulator, database, tests, assets, ascOverview) map to App sub-tabs.", "enum": [ + "dashboard", "app", + "simulator", "database", "tests", "assets", "overview", "ascOverview", + "storeListing", "screenshots", "appDetails", "monetization", "review", "analytics", "reviews", "builds", "groups", "betaInfo", "feedback", "settings" @@ -156,8 +157,9 @@ enum MCPToolRegistry { name: "get_tab_state", description: "Get the structured data state of any Blitz tab. Returns form field values, submission readiness, versions, builds, localizations, etc. Use this instead of screenshots to read UI state.", properties: [ - "tab": ["type": "string", "description": "Tab to read state from (defaults to currently active tab)", "enum": [ - "ascOverview", "storeListing", "screenshots", "appDetails", "monetization", "review", + "tab": ["type": "string", "description": "Tab to read state from (defaults to currently active tab). 'app' or 'ascOverview' returns overview/submission readiness.", "enum": [ + "app", "ascOverview", "overview", + "storeListing", "screenshots", "appDetails", "monetization", "review", "analytics", "reviews", "builds", "groups", "betaInfo", "feedback" ]] ], @@ -179,11 +181,12 @@ enum MCPToolRegistry { // -- ASC Form Tools -- tools.append(tool( name: "asc_fill_form", - description: "Fill one or more App Store Connect form fields. Navigates to the tab automatically if auto-nav is enabled. See CLAUDE.md for complete field reference.", + description: "Fill one or more App Store Connect form fields. Navigates to the tab automatically if auto-nav is enabled. For storeListing, pass locale to target a specific localization safely. See CLAUDE.md for complete field reference.", properties: [ "tab": ["type": "string", "description": "Target form tab", "enum": [ "storeListing", "appDetails", "monetization", "review.ageRating", "review.contact", "settings.bundleId" ]], + "locale": ["type": "string", "description": "For storeListing only: locale code to target (for example en-US or ja)."], "fields": [ "type": "array", "items": [ @@ -199,7 +202,25 @@ enum MCPToolRegistry { required: ["tab", "fields"] )) + tools.append(tool( + name: "store_listing_switch_localization", + description: "Refresh store-listing localizations from App Store Connect and switch the Blitz store-listing tab to the requested locale.", + properties: [ + "locale": ["type": "string", "description": "Locale code to select in the store-listing tab (for example en-US or ja)"] + ], + required: ["locale"] + )) + // -- Screenshot Track Tools -- + tools.append(tool( + name: "screenshots_switch_localization", + description: "Refresh screenshot localizations from App Store Connect, switch the Blitz screenshots tab to the requested locale, and hydrate that locale's screenshot tracks. Call this before screenshots_set_track or screenshots_save when targeting a specific locale.", + properties: [ + "locale": ["type": "string", "description": "Locale code to select in the screenshots tab (for example en-US or en-GB)"] + ], + required: ["locale"] + )) + tools.append(tool( name: "screenshots_add_asset", description: "Copy a screenshot file into the project's local screenshots asset library.", @@ -212,21 +233,22 @@ enum MCPToolRegistry { tools.append(tool( name: "screenshots_set_track", - description: "Place a local screenshot asset into a specific track slot (1-10) for upload staging.", + description: "Place a local screenshot asset into a specific track slot (1-10) for upload staging. If you are targeting a specific locale, call screenshots_switch_localization first.", properties: [ "assetFileName": ["type": "string", "description": "File name of the asset in the local screenshots library"], "slotIndex": ["type": "integer", "description": "Track slot position (1-10)"], - "displayType": ["type": "string", "description": "Display type (default APP_IPHONE_67)", "enum": ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129", "APP_DESKTOP"]] + "displayType": ["type": "string", "description": "Display type (default APP_IPHONE_67)", "enum": ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129", "APP_DESKTOP"]], + "locale": ["type": "string", "description": "Locale code. Must match the currently selected screenshots locale in Blitz."] ], required: ["assetFileName", "slotIndex"] )) tools.append(tool( name: "screenshots_save", - description: "Save the current screenshot track to App Store Connect. Syncs all changes (additions, removals, reorder) for the specified device type.", + description: "Save the current screenshot track to App Store Connect. Syncs all changes (additions, removals, reorder) for the specified device type. If you are targeting a specific locale, call screenshots_switch_localization first.", properties: [ "displayType": ["type": "string", "description": "Display type (default APP_IPHONE_67)", "enum": ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129", "APP_DESKTOP"]], - "locale": ["type": "string", "description": "Locale code (default en-US)"] + "locale": ["type": "string", "description": "Locale code. Must match the currently selected screenshots locale in Blitz."] ], required: [] )) @@ -274,7 +296,7 @@ enum MCPToolRegistry { tools.append(tool( name: "asc_web_auth", - description: "Open the Apple ID login window in Blitz to authenticate a web session for App Store Connect. Use when the iris API returns 401 (session expired). The login captures cookies and saves them to the macOS Keychain for the asc-iap-attach skill. Requires user interaction (Apple ID + 2FA).", + description: "Open the Apple ID login window in Blitz to authenticate a web session for App Store Connect. Use when the iris API returns 401 (session expired). The login captures cookies and syncs them to ~/.blitz/asc-agent/web-session.json for CLI skills. Requires user interaction (Apple ID + 2FA).", properties: [:], required: [] )) @@ -350,7 +372,9 @@ enum MCPToolRegistry { return .query case "asc_fill_form": return .ascFormMutation - case "screenshots_add_asset", "screenshots_set_track", "screenshots_save": + case "store_listing_switch_localization": + return .ascFormMutation + case "screenshots_switch_localization", "screenshots_add_asset", "screenshots_set_track", "screenshots_save": return .ascScreenshotMutation case "asc_open_submit_preview": return .ascSubmitMutation @@ -370,6 +394,11 @@ enum MCPToolRegistry { // MARK: - Helper + /// Returns all tool names registered in the registry. + static func allToolNames() -> [String] { + allTools().compactMap { $0["name"] as? String } + } + private static func tool( name: String, description: String, diff --git a/src/services/mcp/MCPServerService.swift b/src/services/mcp/MCPServerService.swift new file mode 100644 index 0000000..c96cb14 --- /dev/null +++ b/src/services/mcp/MCPServerService.swift @@ -0,0 +1,316 @@ +import Darwin +import Foundation + +/// MCP server endpoint owned by the Blitz app. +/// Codex launches a separate stdio helper, and the helper forwards each JSON-RPC +/// request over a Unix domain socket to the running app. +actor MCPServerService { + private var acceptSource: DispatchSourceRead? + private var serverSocket: Int32 = -1 + private(set) var isRunning = false + + private let toolExecutor: MCPExecutor + + init(appState: AppState) { + self.toolExecutor = MCPExecutor(appState: appState) + + Task { @MainActor in + appState.toolExecutor = self.toolExecutor + } + } + + func start() async throws { + guard !isRunning else { return } + + let socketFD = try createServerSocket() + serverSocket = socketFD + isRunning = true + + let source = DispatchSource.makeReadSource( + fileDescriptor: socketFD, + queue: DispatchQueue(label: "blitz.mcp.accept") + ) + source.setEventHandler { [weak self] in + guard let self else { return } + Task { + await self.acceptPendingConnections() + } + } + source.resume() + acceptSource = source + + print("[MCP] Server listening on Unix socket \(BlitzPaths.mcpSocket.path)") + } + + func stop() { + acceptSource?.cancel() + acceptSource = nil + + if serverSocket >= 0 { + Darwin.close(serverSocket) + serverSocket = -1 + } + + isRunning = false + removeSocketFile() + } + + private func createServerSocket() throws -> Int32 { + removeSocketFile() + + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { + throw MCPError.socketCreationFailed + } + + var address = try makeSocketAddress() + let addressLength = socklen_t(address.sun_len) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.bind(socketFD, sockaddrPointer, addressLength) + } + } + guard bindResult == 0 else { + Darwin.close(socketFD) + throw MCPError.bindFailed(code: errno) + } + + guard Darwin.listen(socketFD, SOMAXCONN) == 0 else { + Darwin.close(socketFD) + throw MCPError.listenFailed(code: errno) + } + + let currentFlags = fcntl(socketFD, F_GETFL, 0) + if currentFlags >= 0 { + _ = fcntl(socketFD, F_SETFL, currentFlags | O_NONBLOCK) + } + + _ = chmod(BlitzPaths.mcpSocket.path, mode_t(0o600)) + return socketFD + } + + private func acceptPendingConnections() async { + while serverSocket >= 0 { + let clientFD = Darwin.accept(serverSocket, nil, nil) + if clientFD < 0 { + if errno == EWOULDBLOCK || errno == EAGAIN { + return + } + print("[MCP] accept() failed: \(errno)") + return + } + + // Client sockets inherit O_NONBLOCK from the listening socket. + // Reset to blocking so large responses (e.g. tools/list) don't + // fail with EAGAIN when the send buffer fills. + let flags = fcntl(clientFD, F_GETFL, 0) + if flags >= 0 { + _ = fcntl(clientFD, F_SETFL, flags & ~O_NONBLOCK) + } + + Task.detached { [weak self] in + await self?.handleConnection(clientFD) + } + } + } + + private func handleConnection(_ clientFD: Int32) async { + defer { Darwin.close(clientFD) } + + do { + guard let line = try readLine(from: clientFD) else { return } + if let response = await processMCPLine(line) { + try writeLine(response, to: clientFD) + } + } catch { + print("[MCP] Socket client failed: \(error.localizedDescription)") + } + } + + private func readLine(from fd: Int32) throws -> String? { + var timeout = timeval(tv_sec: 30, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + pointer, + socklen_t(MemoryLayout.size) + ) + } + + var data = Data() + var byte: UInt8 = 0 + + while true { + let count = Darwin.read(fd, &byte, 1) + if count == 0 { + return data.isEmpty ? nil : String(data: data, encoding: .utf8) + } + if count < 0 { + throw MCPError.readFailed(code: errno) + } + if byte == 0x0A { + return String(data: data, encoding: .utf8) + } + data.append(byte) + } + } + + private func writeLine(_ line: String, to fd: Int32) throws { + let data = Data((line + "\n").utf8) + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesWritten = 0 + + while bytesWritten < rawBuffer.count { + let pointer = baseAddress.advanced(by: bytesWritten) + let result = Darwin.write(fd, pointer, rawBuffer.count - bytesWritten) + if result < 0 { + throw MCPError.writeFailed(code: errno) + } + bytesWritten += result + } + } + } + + private func makeSocketAddress() throws -> sockaddr_un { + var address = sockaddr_un() + let path = BlitzPaths.mcpSocket.path + let pathLength = path.utf8.count + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) - 1 + + guard pathLength <= maxPathLength else { + throw MCPError.invalidSocketPath + } + + address.sun_len = UInt8(MemoryLayout.size) + address.sun_family = sa_family_t(AF_UNIX) + + withUnsafeMutableBytes(of: &address.sun_path) { destination in + path.withCString { source in + destination.copyBytes(from: UnsafeRawBufferPointer(start: source, count: pathLength + 1)) + } + } + + return address + } + + private func removeSocketFile() { + try? FileManager.default.removeItem(at: BlitzPaths.mcpSocket) + } + + private func processMCPLine(_ line: String) async -> String? { + guard let body = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { + return errorResponse(id: NSNull(), code: -32700, message: "Invalid MCP JSON.") + } + + guard let methodName = json["method"] as? String else { + return errorResponse(id: json["id"] ?? NSNull(), code: -32600, message: "Invalid MCP request.") + } + + let id: Any = json["id"] ?? NSNull() + let hasResponseID = json["id"] != nil + let isNotification = !hasResponseID || methodName.hasPrefix("notifications/") + let params = json["params"] as? [String: Any] ?? [:] + + let result: Any + switch methodName { + case "initialize": + let clientVersion = params["protocolVersion"] as? String ?? "2024-11-05" + result = [ + "protocolVersion": clientVersion, + "capabilities": [ + "tools": ["listChanged": false] + ], + "serverInfo": [ + "name": "blitz-mcp", + "version": "1.0.0" + ] + ] as [String: Any] + + case "notifications/initialized": + return nil + + case "tools/list": + result = [ + "tools": MCPRegistry.allTools() + ] + + case "tools/call": + let toolName = params["name"] as? String ?? "" + let toolArgs = params["arguments"] as? [String: Any] ?? [:] + do { + result = try await toolExecutor.execute(name: toolName, arguments: toolArgs) + } catch { + return errorResponse(id: id, code: -32603, message: error.localizedDescription) + } + + default: + if isNotification { return nil } + return errorResponse(id: id, code: -32601, message: "Unknown MCP method: \(methodName)") + } + + if isNotification { return nil } + + let response: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "result": result + ] + guard let data = try? JSONSerialization.data(withJSONObject: response), + let json = String(data: data, encoding: .utf8) else { + return errorResponse(id: id, code: -32603, message: "Failed to encode MCP response.") + } + return json + } + + private func errorResponse(id: Any, code: Int, message: String) -> String { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "error": [ + "code": code, + "message": message + ] as [String: Any] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return #"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"MCP error."}}"# + } + return json + } + + enum MCPError: Error, LocalizedError { + case socketCreationFailed + case bindFailed(code: Int32) + case listenFailed(code: Int32) + case invalidSocketPath + case readFailed(code: Int32) + case writeFailed(code: Int32) + case unknownTool(String) + case invalidToolArgs + + var errorDescription: String? { + switch self { + case .socketCreationFailed: + return "Failed to create the Blitz MCP socket." + case .bindFailed(let code): + return "Failed to bind Blitz MCP socket (\(code))." + case .listenFailed(let code): + return "Failed to listen on Blitz MCP socket (\(code))." + case .invalidSocketPath: + return "Invalid Blitz MCP socket path." + case .readFailed(let code): + return "Failed to read from Blitz MCP socket (\(code))." + case .writeFailed(let code): + return "Failed to write to Blitz MCP socket (\(code))." + case .unknownTool(let tool): + return "Unknown MCP tool: \(tool)" + case .invalidToolArgs: + return "Invalid tool arguments." + } + } + } +} diff --git a/src/services/project/MacSwiftProjectSetupService.swift b/src/services/project/MacSwiftProjectSetupService.swift new file mode 100644 index 0000000..267a012 --- /dev/null +++ b/src/services/project/MacSwiftProjectSetupService.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Scaffolds a new macOS Swift/SwiftUI project from the bundled template. +/// Sandboxed by default for Mac App Store submission. +struct MacSwiftProjectSetupService { + + /// Set up a new macOS Swift project from the bundled template. + static func setup( + projectId: String, + projectName: String, + projectPath: String, + onStep: @MainActor (ProjectSetupService.SetupStep) -> Void + ) async throws { + let appName = SwiftProjectSetupService.toSwiftAppName(projectId) + let bundleId = SwiftProjectSetupService.toBundleId(appName) + let spec = ProjectTemplateSpec( + templateName: "swift-mac-template", + missingTemplateMessage: "Bundled macOS Swift template not found", + replacements: [ + "__APP_NAME__": appName, + "__BUNDLE_ID__": bundleId + ], + sampleDevVars: nil, + cleanupPaths: [], + logPrefix: "mac-swift-setup" + ) + try await ProjectTemplateScaffolder.scaffold( + spec: spec, + projectPath: projectPath, + onStep: onStep + ) + } +} diff --git a/src/services/project/ProjectAgentConfigService.swift b/src/services/project/ProjectAgentConfigService.swift new file mode 100644 index 0000000..44fe781 --- /dev/null +++ b/src/services/project/ProjectAgentConfigService.swift @@ -0,0 +1,636 @@ +import Foundation + +/// Writes Blitz-owned agent config and installs Blitz-managed helper content. +struct ProjectAgentConfigService { + let baseDirectory: URL + + private enum ProjectSkillRoot: String, CaseIterable { + case claude = ".claude" + case agents = ".agents" + } + + func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + let fm = FileManager.default + let mcpsDir = BlitzPaths.mcps + + try? fm.createDirectory(at: mcpsDir, withIntermediateDirectories: true) + + ensureMCPConfig( + in: mcpsDir, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: false + ) + + let claudeDir = mcpsDir.appendingPathComponent(".claude") + let settingsFile = claudeDir.appendingPathComponent("settings.local.json") + try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) + var allowList: [String] = [ + "mcp__blitz-macos__asc_set_credentials", + "mcp__blitz-macos__asc_web_auth", + "Bash(python3:*)", + ] + if whitelistBlitzMCP { + allowList = Self.allBlitzMCPToolPermissions() + } + if allowASCCLICalls { + Self.ensureAllowPermission("Bash(asc:*)", in: &allowList) + } + let settings: [String: Any] = [ + "enabledMcpjsonServers": ["blitz-macos", "blitz-iphone"], + "permissions": ["allow": allowList] + ] + if let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) { + try? data.write(to: settingsFile) + } + + let claudeMd = mcpsDir.appendingPathComponent("CLAUDE.md") + let claudeMdContent = """ + # Blitz — Global Agent Context + + This directory is used by Blitz to run agent sessions outside of a project context + (e.g. App Store Connect API key setup during onboarding). + + ## Available MCP Tools + + The `blitz-macos` MCP server is connected. Key tools for ASC setup: + + - `asc_web_auth` — Opens the Apple ID login window in Blitz to authenticate a web session. + Call this first if you get a 401 from iris APIs or if no web session exists. + - `asc_set_credentials` — Pre-fills the ASC credential form in Blitz with issuer ID, key ID, + and a path to the .p8 private key file. The user must click "Save Credentials" to confirm. + Parameters: `issuerId` (string), `keyId` (string), `privateKeyPath` (string, absolute path to .p8 file). + """ + try? claudeMdContent.write(to: claudeMd, atomically: true, encoding: .utf8) + + let skillDirectories = ProjectSkillRoot.allCases.map { + mcpsDir.appendingPathComponent($0.rawValue).appendingPathComponent("skills") + } + + DispatchQueue.global(qos: .utility).async { + for skillsDir in skillDirectories { + try? fm.createDirectory(at: skillsDir, withIntermediateDirectories: true) + } + + if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { + Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) + } + } + } + + func ensureAllProjectMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return + } + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + ensureMCPConfig( + in: entry, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: true + ) + } + } + + func ensureMCPConfig( + projectId: String, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { + let projectDir = baseDirectory.appendingPathComponent(projectId) + ensureMCPConfig( + in: projectDir, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: true + ) + } + + /// Writes `.mcp.json`, `.codex/config.toml`, and `opencode.json` into `directory`. + func ensureMCPConfig( + in directory: URL, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false, + includeProjectDocFallback: Bool = true + ) { + let mcpFile = directory.appendingPathComponent(".mcp.json") + let helperPath = BlitzPaths.mcpHelper.path + + let blitzMacosEntry: [String: Any] = ["command": helperPath] + let nodeRuntimeBin = BlitzPaths.nodeDir.path + let blitzIphoneEntry: [String: Any] = [ + "command": nodeRuntimeBin + "/npx", + "args": ["-y", "@blitzdev/iphone-mcp"], + "env": [ + "PATH": "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin" + ] + ] + + var root: [String: Any] + if let data = try? Data(contentsOf: mcpFile), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + root = existing + var servers = root["mcpServers"] as? [String: Any] ?? [:] + servers["blitz-macos"] = blitzMacosEntry + servers["blitz-iphone"] = blitzIphoneEntry + servers.removeValue(forKey: "blitz-ios") + root["mcpServers"] = servers + } else { + root = ["mcpServers": [ + "blitz-macos": blitzMacosEntry, + "blitz-iphone": blitzIphoneEntry + ]] + } + + guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) else { + return + } + do { + try data.write(to: mcpFile) + } catch { + print("[ProjectAgentConfigService] Failed to write .mcp.json: \(error)") + } + + let codexDir = directory.appendingPathComponent(".codex") + let codexConfig = codexDir.appendingPathComponent("config.toml") + let codexMacEnabledTools = whitelistBlitzMCP ? Self.blitzMacosToolNames() : Self.minimalBlitzMacosToolNames() + let codexIphoneEnabledTools = whitelistBlitzMCP ? Self.blitzIphoneToolNames() : [] + let codexMacEnabledToolsToml = codexMacEnabledTools + .map { "\"\(Self.escapeTOMLString($0))\"" } + .joined(separator: ", ") + let codexIphoneEnabledToolsToml = codexIphoneEnabledTools + .map { "\"\(Self.escapeTOMLString($0))\"" } + .joined(separator: ", ") + let codexIphonePathEnv = "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin" + let codexProjectDocFallbackLine = includeProjectDocFallback + ? "project_doc_fallback_filenames = [\".claude/rules/blitz.md\", \".claude/rules/teenybase.md\"]" + : "" + let codexProjectDocMaxBytesLine = includeProjectDocFallback ? "project_doc_max_bytes = 65536" : "" + let toml = """ + \(codexProjectDocFallbackLine) + \(codexProjectDocMaxBytesLine) + + [mcp_servers.blitz_macos] + command = "\(Self.escapeTOMLString(helperPath))" + cwd = "\(Self.escapeTOMLString(directory.path))" + enabled_tools = [\(codexMacEnabledToolsToml)] + + [mcp_servers."blitz-iphone"] + command = "\(Self.escapeTOMLString(nodeRuntimeBin + "/npx"))" + args = ["-y", "@blitzdev/iphone-mcp"] + cwd = "\(Self.escapeTOMLString(directory.path))" + enabled_tools = [\(codexIphoneEnabledToolsToml)] + + [mcp_servers."blitz-iphone".env] + PATH = "\(Self.escapeTOMLString(codexIphonePathEnv))" + """ + do { + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + try toml.write(to: codexConfig, atomically: true, encoding: .utf8) + } catch { + print("[ProjectAgentConfigService] Failed to write .codex/config.toml: \(error)") + } + + let codexRulesDir = codexDir.appendingPathComponent("rules") + let codexBlitzRulesFile = codexRulesDir.appendingPathComponent("blitz.rules") + if allowASCCLICalls { + let ascPath = BlitzPaths.bin.appendingPathComponent("asc").path + let rules = """ + # Managed by Blitz. Allows ASC CLI commands without approval prompts. + prefix_rule(pattern=["asc"], decision="allow") + prefix_rule(pattern=["\(Self.escapeStarlarkString(ascPath))"], decision="allow") + """ + do { + try FileManager.default.createDirectory(at: codexRulesDir, withIntermediateDirectories: true) + try rules.write(to: codexBlitzRulesFile, atomically: true, encoding: .utf8) + } catch { + print("[ProjectAgentConfigService] Failed to write .codex/rules/blitz.rules: \(error)") + } + } else if FileManager.default.fileExists(atPath: codexBlitzRulesFile.path) { + try? FileManager.default.removeItem(at: codexBlitzRulesFile) + } + + let opencodeConfig = directory.appendingPathComponent("opencode.json") + var opencodeRoot: [String: Any] = [:] + if let data = try? Data(contentsOf: opencodeConfig), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + opencodeRoot = existing + } + if opencodeRoot["$schema"] == nil { + opencodeRoot["$schema"] = "https://opencode.ai/config.json" + } + + var opencodeMcp = opencodeRoot["mcp"] as? [String: Any] ?? [:] + opencodeMcp["blitz-macos"] = [ + "type": "local", + "command": [helperPath], + "enabled": true, + ] + opencodeMcp["blitz-iphone"] = [ + "type": "local", + "command": [nodeRuntimeBin + "/npx", "-y", "@blitzdev/iphone-mcp"], + "enabled": true, + "environment": [ + "PATH": "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin", + ], + ] + opencodeMcp.removeValue(forKey: "blitz-ios") + opencodeRoot["mcp"] = opencodeMcp + + var opencodePermission: [String: Any] = [:] + if let existingPermission = opencodeRoot["permission"] as? [String: Any] { + opencodePermission = existingPermission + } else if let existingPermissionString = opencodeRoot["permission"] as? String { + opencodePermission["*"] = existingPermissionString + } + + let opencodeMCPPermissionKeys = Self.allOpenCodeBlitzMCPPermissionKeys() + if whitelistBlitzMCP { + for key in opencodeMCPPermissionKeys { + opencodePermission[key] = "allow" + } + } else { + for key in opencodeMCPPermissionKeys { + opencodePermission[key] = "ask" + } + opencodePermission["blitz-macos_asc_set_credentials"] = "allow" + opencodePermission["blitz-macos_asc_web_auth"] = "allow" + } + + var opencodeBash: [String: Any] = [:] + if let existingBash = opencodePermission["bash"] as? [String: Any] { + opencodeBash = existingBash + } else if let existingBashString = opencodePermission["bash"] as? String { + opencodeBash["*"] = existingBashString + } + let ascPath = BlitzPaths.bin.appendingPathComponent("asc").path + let ascPatterns = [ + "asc", + "asc *", + ascPath, + "\(ascPath) *", + ] + if allowASCCLICalls { + for pattern in ascPatterns { + opencodeBash[pattern] = "allow" + } + } else { + for pattern in ascPatterns { + opencodeBash.removeValue(forKey: pattern) + } + } + if opencodeBash.isEmpty { + opencodePermission.removeValue(forKey: "bash") + } else { + opencodePermission["bash"] = opencodeBash + } + opencodeRoot["permission"] = opencodePermission + + if let data = try? JSONSerialization.data(withJSONObject: opencodeRoot, options: [.prettyPrinted, .sortedKeys]) { + do { + try data.write(to: opencodeConfig) + } catch { + print("[ProjectAgentConfigService] Failed to write opencode.json: \(error)") + } + } + } + + func ensureClaudeFiles( + projectId: String, + projectType: ProjectType, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { + let fm = FileManager.default + let projectDir = baseDirectory.appendingPathComponent(projectId) + let claudeDir = projectDir.appendingPathComponent(".claude") + + let settingsFile = claudeDir.appendingPathComponent("settings.local.json") + try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) + let correctServers = ["blitz-macos", "blitz-iphone"] + var settings: [String: Any] + if fm.fileExists(atPath: settingsFile.path), + let data = try? Data(contentsOf: settingsFile), + var existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing["enabledMcpjsonServers"] = correctServers + if var perms = existing["permissions"] as? [String: Any], + var allow = perms["allow"] as? [String] { + allow.removeAll { $0.contains("blitz-ios") } + if whitelistBlitzMCP { + let blitzTools = Self.allBlitzMCPToolPermissions() + for tool in blitzTools where !allow.contains(tool) { + allow.append(tool) + } + } + if allowASCCLICalls { + Self.ensureAllowPermission("Bash(asc:*)", in: &allow) + } else { + allow.removeAll { $0 == "Bash(asc:*)" } + } + perms["allow"] = allow + existing["permissions"] = perms + } + settings = existing + } else { + var defaultAllow: [String] = [ + "Bash(curl:*)", + "Bash(xcrun simctl terminate:*)", + "Bash(xcrun simctl launch:*)", + "mcp__blitz-macos__app_get_state", + ] + if whitelistBlitzMCP { + defaultAllow = Self.allBlitzMCPToolPermissions() + [ + "Bash(curl:*)", + "Bash(xcrun simctl terminate:*)", + "Bash(xcrun simctl launch:*)", + ] + } + if allowASCCLICalls { + Self.ensureAllowPermission("Bash(asc:*)", in: &defaultAllow) + } + settings = [ + "permissions": ["allow": defaultAllow], + "enabledMcpjsonServers": correctServers, + ] + } + if let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) { + try? data.write(to: settingsFile) + } + + let claudeMdFile = projectDir.appendingPathComponent("CLAUDE.md") + if !fm.fileExists(atPath: claudeMdFile.path) { + try? Self.claudeMdContent(projectType: projectType) + .write(to: claudeMdFile, atomically: true, encoding: .utf8) + } + + let rulesDir = claudeDir.appendingPathComponent("rules") + try? fm.createDirectory(at: rulesDir, withIntermediateDirectories: true) + + let blitzRules = rulesDir.appendingPathComponent("blitz.md") + try? Self.blitzRulesContent().write(to: blitzRules, atomically: true, encoding: .utf8) + + let teenybaseRules = rulesDir.appendingPathComponent("teenybase.md") + try? Self.teenybaseRulesContent(projectDir: projectDir, projectType: projectType) + .write(to: teenybaseRules, atomically: true, encoding: .utf8) + + ensureReviewerAgent(projectDir: projectDir) + ensureProjectSkills(projectDir: projectDir) + } + + func ensureReviewerAgent(projectDir: URL) { + let fm = FileManager.default + let claudeDir = projectDir.appendingPathComponent(".claude") + let agentRepoDir = claudeDir.appendingPathComponent("app-store-review-agent") + let agentsDir = claudeDir.appendingPathComponent("agents") + let symlinkPath = agentsDir.appendingPathComponent("reviewer.md") + let symlinkExists = fm.fileExists(atPath: symlinkPath.path) + + DispatchQueue.global(qos: .utility).async { + let repoURL = BlitzPaths.reviewerAgentRepo + + if fm.fileExists(atPath: agentRepoDir.appendingPathComponent(".git").path) { + let pull = Process() + pull.executableURL = URL(fileURLWithPath: "/usr/bin/git") + pull.arguments = ["-C", agentRepoDir.path, "pull", "--quiet", "--ff-only"] + pull.standardOutput = FileHandle.nullDevice + pull.standardError = FileHandle.nullDevice + try? pull.run() + pull.waitUntilExit() + } else { + let clone = Process() + clone.executableURL = URL(fileURLWithPath: "/usr/bin/git") + clone.arguments = ["clone", "--quiet", "--depth", "1", repoURL, agentRepoDir.path] + clone.standardOutput = FileHandle.nullDevice + clone.standardError = FileHandle.nullDevice + try? clone.run() + clone.waitUntilExit() + guard clone.terminationStatus == 0 else { + print("[ProjectAgentConfigService] Failed to clone app-store-review-agent") + return + } + } + + if !symlinkExists { + try? fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) + try? fm.createSymbolicLink( + atPath: symlinkPath.path, + withDestinationPath: "../app-store-review-agent/agents/reviewer.md" + ) + print("[ProjectAgentConfigService] Reviewer agent installed") + } + } + } + + func ensureProjectSkills(projectDir: URL) { + let fm = FileManager.default + let claudeDir = projectDir.appendingPathComponent(".claude") + let repoDir = claudeDir.appendingPathComponent("asc-skills") + let skillDirectories = projectSkillDirectories(projectDir: projectDir) + + for skillsDir in skillDirectories { + try? fm.createDirectory(at: skillsDir, withIntermediateDirectories: true) + } + + if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { + Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) + } + + DispatchQueue.global(qos: .utility).async { + let repoURL = BlitzPaths.ascSkillsRepo + + if fm.fileExists(atPath: repoDir.appendingPathComponent(".git").path) { + let pull = Process() + pull.executableURL = URL(fileURLWithPath: "/usr/bin/git") + pull.arguments = ["-C", repoDir.path, "pull", "--quiet", "--ff-only"] + pull.standardOutput = FileHandle.nullDevice + pull.standardError = FileHandle.nullDevice + try? pull.run() + pull.waitUntilExit() + } else { + let clone = Process() + clone.executableURL = URL(fileURLWithPath: "/usr/bin/git") + clone.arguments = ["clone", "--quiet", "--depth", "1", repoURL, repoDir.path] + clone.standardOutput = FileHandle.nullDevice + clone.standardError = FileHandle.nullDevice + try? clone.run() + clone.waitUntilExit() + guard clone.terminationStatus == 0 else { + print("[ProjectAgentConfigService] Failed to clone asc-skills") + return + } + } + + let repoSkillsDir = repoDir.appendingPathComponent("skills") + Self.syncSkillDirectories(from: repoSkillsDir, into: skillDirectories, using: fm) + + if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { + Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) + } + + let installedRoots = skillDirectories.map(\.path).joined(separator: ", ") + print("[ProjectAgentConfigService] Project skills installed in \(installedRoots)") + } + } + + private func projectSkillDirectories(projectDir: URL) -> [URL] { + ProjectSkillRoot.allCases.map { + projectDir + .appendingPathComponent($0.rawValue) + .appendingPathComponent("skills") + } + } + + static func allBlitzMCPToolPermissions() -> [String] { + let macTools = blitzMacosToolNames().map { "mcp__blitz-macos__\($0)" } + let iphoneTools = blitzIphoneToolNames().map { "mcp__blitz-iphone__\($0)" } + return macTools + iphoneTools + } + + private static func blitzMacosToolNames() -> [String] { + MCPRegistry.allToolNames() + } + + private static func minimalBlitzMacosToolNames() -> [String] { + ["asc_set_credentials", "asc_web_auth"] + } + + private static func blitzIphoneToolNames() -> [String] { + [ + "list_devices", "setup_device", "launch_app", "list_apps", + "get_screenshot", "scan_ui", "describe_screen", "device_action", + "device_actions", "get_execution_context", + ] + } + + private static func allOpenCodeBlitzMCPPermissionKeys() -> [String] { + let macTools = blitzMacosToolNames().map { "blitz-macos_\($0)" } + let iphoneTools = blitzIphoneToolNames().map { "blitz-iphone_\($0)" } + return macTools + iphoneTools + } + + private static func escapeTOMLString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static func escapeStarlarkString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static func bundledProjectSkillsDirectory() -> URL? { + let fm = FileManager.default + + if let bundleSkills = Bundle.main.resourceURL?.appendingPathComponent("claude-skills"), + fm.fileExists(atPath: bundleSkills.path) { + return bundleSkills + } + + #if DEBUG + let repoSkills = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("resources/skills") + if fm.fileExists(atPath: repoSkills.path) { + return repoSkills + } + #endif + + return nil + } + + private static func syncSkillDirectories(from sourceSkillsDir: URL, into destinations: [URL], using fm: FileManager) { + guard let skillDirs = try? fm.contentsOfDirectory( + at: sourceSkillsDir, + includingPropertiesForKeys: [.isDirectoryKey] + ) else { return } + + for destination in destinations { + for srcSkillDir in skillDirs { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: srcSkillDir.path, isDirectory: &isDir), + isDir.boolValue else { continue } + + let destSkillDir = destination.appendingPathComponent(srcSkillDir.lastPathComponent) + if fm.fileExists(atPath: destSkillDir.path) { + try? fm.removeItem(at: destSkillDir) + } + try? fm.copyItem(at: srcSkillDir, to: destSkillDir) + } + } + } + + private static func ensureAllowPermission(_ permission: String, in allowList: inout [String]) { + guard !allowList.contains(permission) else { return } + allowList.append(permission) + } + + private static func claudeMdContent(projectType: ProjectType) -> String { + guard let templateURL = Bundle.appResources.url(forResource: "CLAUDE.md", withExtension: "template"), + var template = try? String(contentsOf: templateURL, encoding: .utf8) else { + return "# Blitz AI Agent Guide\n" + } + + let header: String + switch projectType { + case .swift: + header = "Swift Project — Blitz AI Agent Guide" + case .reactNative: + header = "React Native Project — Blitz AI Agent Guide" + case .flutter: + header = "Flutter Project — Blitz AI Agent Guide" + } + template = template.replacingOccurrences(of: "{{PROJECT_TYPE_HEADER}}", with: header) + + return template + } + + private static func blitzRulesContent() -> String { + guard let url = Bundle.appResources.url(forResource: "blitz-rules", withExtension: "md"), + let content = try? String(contentsOf: url, encoding: .utf8) else { + return "# Blitz MCP Integration\n" + } + return content + } + + private static func teenybaseRulesContent(projectDir: URL, projectType: ProjectType) -> String { + let fm = FileManager.default + + let backendDir: URL + let schemaPath: String + let commandPrefix: String + switch projectType { + case .reactNative: + backendDir = projectDir + schemaPath = "teenybase.ts" + commandPrefix = "" + case .swift, .flutter: + backendDir = projectDir.appendingPathComponent("backend") + schemaPath = "backend/teenybase.ts" + commandPrefix = "cd backend && " + } + + let hasBackend = fm.fileExists(atPath: backendDir.appendingPathComponent("teenybase.ts").path) + let templateName = hasBackend ? "teenybase-rules-backend" : "teenybase-rules-no-backend" + + guard let url = Bundle.appResources.url(forResource: templateName, withExtension: "md"), + var content = try? String(contentsOf: url, encoding: .utf8) else { + return "# Teenybase Backend\n" + } + + content = content.replacingOccurrences( + of: "{{DEVVARS_PATH}}", + with: backendDir.appendingPathComponent(".dev.vars").path + ) + content = content.replacingOccurrences(of: "{{SCHEMA_PATH}}", with: schemaPath) + content = content.replacingOccurrences(of: "{{COMMAND_PREFIX}}", with: commandPrefix) + return content + } +} diff --git a/src/services/project/ProjectRepository.swift b/src/services/project/ProjectRepository.swift new file mode 100644 index 0000000..b4e072e --- /dev/null +++ b/src/services/project/ProjectRepository.swift @@ -0,0 +1,156 @@ +import Darwin +import Foundation + +/// Repository for `~/.blitz/projects` metadata and symlink registration. +struct ProjectRepository { + let baseDirectory: URL + + func listProjects() async -> [Project] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return [] + } + + var projects: [Project] = [] + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + + let metadataFile = metadataURL(for: entry.lastPathComponent) + guard let data = try? Data(contentsOf: metadataFile), + let metadata = try? decoder.decode(BlitzProjectMetadata.self, from: data) else { + continue + } + + projects.append( + Project( + id: entry.lastPathComponent, + metadata: metadata, + path: entry.path + ) + ) + } + + return projects.sorted { ($0.metadata.lastOpenedAt ?? .distantPast) > ($1.metadata.lastOpenedAt ?? .distantPast) } + } + + func readMetadata(projectId: String) -> BlitzProjectMetadata? { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let data = try? Data(contentsOf: metadataURL(for: projectId)) else { return nil } + return try? decoder.decode(BlitzProjectMetadata.self, from: data) + } + + func writeMetadata(projectId: String, metadata: BlitzProjectMetadata) throws { + try writeMetadataToDirectory(baseDirectory.appendingPathComponent(projectId), metadata: metadata) + } + + func writeMetadataToDirectory(_ dir: URL, metadata: BlitzProjectMetadata) throws { + let blitzDir = dir.appendingPathComponent(".blitz") + try FileManager.default.createDirectory(at: blitzDir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(metadata) + try data.write(to: blitzDir.appendingPathComponent("project.json")) + } + + func deleteProject(projectId: String) throws { + let projectDir = baseDirectory.appendingPathComponent(projectId) + let path = projectDir.path + var isSymlink = false + if let attrs = try? FileManager.default.attributesOfItem(atPath: path), + attrs[.type] as? FileAttributeType == .typeSymbolicLink { + isSymlink = true + } + + if isSymlink { + unlink(path) + } else { + try FileManager.default.removeItem(at: projectDir) + } + } + + /// Validates `.blitz/project.json` exists, registers a symlink under + /// `~/.blitz/projects/` if needed, and returns the project ID. + func openProject(at url: URL) throws -> String { + let metadataFile = url.appendingPathComponent(".blitz/project.json") + guard FileManager.default.fileExists(atPath: metadataFile.path) else { + throw ProjectOpenError.notABlitzProject + } + + var folderName = url.lastPathComponent + let existingDir = baseDirectory.appendingPathComponent(folderName) + + if FileManager.default.fileExists(atPath: existingDir.path) { + let resolvedExisting = existingDir.resolvingSymlinksInPath().path + let resolvedNew = url.resolvingSymlinksInPath().path + if resolvedExisting == resolvedNew { + updateLastOpened(projectId: folderName) + return folderName + } + + var counter = 2 + while FileManager.default.fileExists( + atPath: baseDirectory.appendingPathComponent("\(folderName)-\(counter)").path + ) { + counter += 1 + } + folderName = "\(folderName)-\(counter)" + } + + let symlinkDir = baseDirectory.appendingPathComponent(folderName) + try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: url) + + updateLastOpened(projectId: folderName) + return folderName + } + + func updateLastOpened(projectId: String) { + guard var metadata = readMetadata(projectId: projectId) else { return } + metadata.lastOpenedAt = Date() + do { + try writeMetadata(projectId: projectId, metadata: metadata) + } catch { + print("[ProjectRepository] Failed to update lastOpenedAt for \(projectId): \(error)") + } + } + + func clearRecentProjects() { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return + } + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + let projectId = entry.lastPathComponent + guard var metadata = readMetadata(projectId: projectId) else { continue } + metadata.lastOpenedAt = nil + try? writeMetadata(projectId: projectId, metadata: metadata) + } + } + + private func metadataURL(for projectId: String) -> URL { + baseDirectory + .appendingPathComponent(projectId) + .appendingPathComponent(".blitz/project.json") + } +} + +enum ProjectOpenError: LocalizedError { + case notABlitzProject + + var errorDescription: String? { + switch self { + case .notABlitzProject: + return "Not a Blitz project. The selected folder does not contain .blitz/project.json. Use Import to add an external project." + } + } +} diff --git a/src/services/project/ProjectSetupService.swift b/src/services/project/ProjectSetupService.swift new file mode 100644 index 0000000..6fde8b3 --- /dev/null +++ b/src/services/project/ProjectSetupService.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Scaffolds a new React Native / Blitz project from the bundled template. +/// Handles the full lifecycle: copy template → patch placeholders → write .dev.vars +/// The AI agent handles npm install, pod install, metro, and builds. +struct ProjectSetupService { + + enum SetupStep: String { + case copying = "Copying template..." + case ready = "Ready" + } + + struct SetupError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private static let sampleDevVars = """ + JWT_SECRET_MAIN=this_is_the_main_secret_used_for_all_tables_and_admin + JWT_SECRET_USERS=secret_used_for_users_table_appended_to_the_main_secret + ADMIN_SERVICE_TOKEN=password_for_accessing_the_backend_as_admin + ADMIN_JWT_SECRET=this_will_be_used_for_jwt_token_for_admin_operations + POCKET_UI_VIEWER_PASSWORD=admin_db_password_for_readonly_mode + POCKET_UI_EDITOR_PASSWORD=admin_db_password_for_readwrite_mode + MAILGUN_API_KEY=api-key-from-mailgun + API_ROUTE=NA + """ + + private static let projectNamePlaceholder = "__PROJECT_NAME__" + + /// Set up a new project from the bundled RN template. + /// Calls `onStep` on the main actor as each phase begins. + static func setup( + projectId: String, + projectName: String, + projectPath: String, + onStep: @MainActor (SetupStep) -> Void + ) async throws { + let spec = ProjectTemplateSpec( + templateName: "rn-notes-template", + missingTemplateMessage: "Bundled RN template not found", + replacements: [projectNamePlaceholder: projectName], + sampleDevVars: sampleDevVars, + cleanupPaths: [".local-persist"], + logPrefix: "setup" + ) + try await ProjectTemplateScaffolder.scaffold( + spec: spec, + projectPath: projectPath, + onStep: onStep + ) + } +} diff --git a/src/services/project/ProjectStorage.swift b/src/services/project/ProjectStorage.swift new file mode 100644 index 0000000..02232d2 --- /dev/null +++ b/src/services/project/ProjectStorage.swift @@ -0,0 +1,114 @@ +import Foundation + +/// High-level facade for project storage, agent config, and scaffolding. +/// Call sites keep using `ProjectStorage`, but responsibilities now live in +/// focused collaborators under `src/services/project`. +struct ProjectStorage { + let baseDirectory: URL + + private var repository: ProjectRepository { + ProjectRepository(baseDirectory: baseDirectory) + } + + private var agentConfigService: ProjectAgentConfigService { + ProjectAgentConfigService(baseDirectory: baseDirectory) + } + + private var teenybaseScaffolder: ProjectTeenybaseScaffolder { + ProjectTeenybaseScaffolder(baseDirectory: baseDirectory) + } + + init(baseDirectory: URL = BlitzPaths.projects) { + self.baseDirectory = baseDirectory + } + + func listProjects() async -> [Project] { + await repository.listProjects() + } + + func readMetadata(projectId: String) -> BlitzProjectMetadata? { + repository.readMetadata(projectId: projectId) + } + + func writeMetadata(projectId: String, metadata: BlitzProjectMetadata) throws { + try repository.writeMetadata(projectId: projectId, metadata: metadata) + } + + func writeMetadataToDirectory(_ dir: URL, metadata: BlitzProjectMetadata) throws { + try repository.writeMetadataToDirectory(dir, metadata: metadata) + } + + func deleteProject(projectId: String) throws { + try repository.deleteProject(projectId: projectId) + } + + func openProject(at url: URL) throws -> String { + try repository.openProject(at: url) + } + + func updateLastOpened(projectId: String) { + repository.updateLastOpened(projectId: projectId) + } + + func clearRecentProjects() { + repository.clearRecentProjects() + } + + func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + agentConfigService.ensureGlobalMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureAllProjectMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + agentConfigService.ensureAllProjectMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureMCPConfig( + projectId: String, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { + agentConfigService.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureMCPConfig( + in directory: URL, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false, + includeProjectDocFallback: Bool = true + ) { + agentConfigService.ensureMCPConfig( + in: directory, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: includeProjectDocFallback + ) + } + + func ensureClaudeFiles( + projectId: String, + projectType: ProjectType, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { + agentConfigService.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureTeenybaseBackend(projectId: String, projectType: ProjectType) { + teenybaseScaffolder.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) + } +} diff --git a/src/services/project/ProjectTeenybaseScaffolder.swift b/src/services/project/ProjectTeenybaseScaffolder.swift new file mode 100644 index 0000000..05d47c6 --- /dev/null +++ b/src/services/project/ProjectTeenybaseScaffolder.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Scaffolds Blitz-managed Teenybase backend files into projects. +struct ProjectTeenybaseScaffolder { + let baseDirectory: URL + + func ensureTeenybaseBackend(projectId: String, projectType: ProjectType) { + let fm = FileManager.default + let projectDir = baseDirectory.appendingPathComponent(projectId) + + guard let templateURL = Bundle.appResources.url( + forResource: "rn-notes-template", + withExtension: nil, + subdirectory: "templates" + ) else { + print("[ProjectTeenybaseScaffolder] Teenybase template not found in bundle") + return + } + + switch projectType { + case .reactNative: + copyTeenybaseFiles(from: templateURL, to: projectDir, fm: fm) + mergeTeenybaseScripts(into: projectDir.appendingPathComponent("package.json"), fm: fm) + case .swift, .flutter: + let backendDir = projectDir.appendingPathComponent("backend") + try? fm.createDirectory(at: backendDir, withIntermediateDirectories: true) + copyTeenybaseFiles(from: templateURL, to: backendDir, fm: fm) + ensureStandalonePackageJson( + at: backendDir.appendingPathComponent("package.json"), + projectId: projectId, + fm: fm + ) + } + } + + /// Copies backend files into `dest` and never overwrites an existing setup. + private func copyTeenybaseFiles(from templateURL: URL, to dest: URL, fm: FileManager) { + let teenybaseDest = dest.appendingPathComponent("teenybase.ts") + guard !fm.fileExists(atPath: teenybaseDest.path) else { return } + + try? fm.copyItem(at: templateURL.appendingPathComponent("teenybase.ts"), to: teenybaseDest) + + let wranglerDest = dest.appendingPathComponent("wrangler.toml") + if !fm.fileExists(atPath: wranglerDest.path) { + let src = templateURL.appendingPathComponent("wrangler.toml") + if var content = try? String(contentsOf: src, encoding: .utf8) { + let appName = resolvedProjectName(for: dest) + content = content.replacingOccurrences(of: "sample-app", with: appName) + try? content.write(to: wranglerDest, atomically: true, encoding: .utf8) + } + } + + let srcBackendDest = dest.appendingPathComponent("src-backend") + try? fm.createDirectory(at: srcBackendDest, withIntermediateDirectories: true) + let workerDest = srcBackendDest.appendingPathComponent("worker.ts") + if !fm.fileExists(atPath: workerDest.path) { + try? fm.copyItem( + at: templateURL.appendingPathComponent("src-backend/worker.ts"), + to: workerDest + ) + } + + let devVarsDest = dest.appendingPathComponent(".dev.vars") + if !fm.fileExists(atPath: devVarsDest.path) { + let sampleVars = templateURL.appendingPathComponent("sample.vars") + if fm.fileExists(atPath: sampleVars.path) { + try? fm.copyItem(at: sampleVars, to: devVarsDest) + } + } + } + + private func mergeTeenybaseScripts(into packageJsonURL: URL, fm: FileManager) { + guard fm.fileExists(atPath: packageJsonURL.path), + let data = try? Data(contentsOf: packageJsonURL), + var pkg = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + + var devDeps = pkg["devDependencies"] as? [String: Any] ?? [:] + guard devDeps["teenybase"] == nil else { return } + + devDeps["teenybase"] = "0.0.10" + pkg["devDependencies"] = devDeps + + var scripts = pkg["scripts"] as? [String: Any] ?? [:] + let backendScripts: [String: String] = [ + "generate:backend": "teeny generate --local", + "migrate:backend": "teeny migrate --local", + "dev:backend": "teeny dev --local", + "build:backend": "teeny build --local", + "exec:backend": "teeny exec --local", + "deploy:backend:remote": "teeny deploy --migrate --remote", + ] + for (key, value) in backendScripts where scripts[key] == nil { + scripts[key] = value + } + pkg["scripts"] = scripts + + if let updated = try? JSONSerialization.data(withJSONObject: pkg, options: [.prettyPrinted, .sortedKeys]) { + try? updated.write(to: packageJsonURL) + } + } + + private func ensureStandalonePackageJson(at url: URL, projectId: String, fm: FileManager) { + guard !fm.fileExists(atPath: url.path) else { return } + let content = """ + { + "name": "\(projectId)-backend", + "version": "1.0.0", + "scripts": { + "generate": "teeny generate --local", + "migrate": "teeny migrate --local", + "dev": "teeny dev --local", + "build": "teeny build --local", + "exec": "teeny exec --local", + "deploy": "teeny deploy --migrate --remote" + }, + "devDependencies": { + "teenybase": "0.0.10" + } + } + """ + try? content.write(to: url, atomically: true, encoding: .utf8) + } + + private func resolvedProjectName(for destination: URL) -> String { + destination.lastPathComponent == "backend" + ? destination.deletingLastPathComponent().lastPathComponent + : destination.lastPathComponent + } +} diff --git a/src/services/project/ProjectTemplateScaffolder.swift b/src/services/project/ProjectTemplateScaffolder.swift new file mode 100644 index 0000000..3ee9998 --- /dev/null +++ b/src/services/project/ProjectTemplateScaffolder.swift @@ -0,0 +1,104 @@ +import Foundation + +struct ProjectTemplateSpec { + let templateName: String + let missingTemplateMessage: String + let replacements: [String: String] + let sampleDevVars: String? + let cleanupPaths: [String] + let logPrefix: String +} + +/// Shared template copier used by React Native, Swift, and macOS Swift setup services. +enum ProjectTemplateScaffolder { + static func scaffold( + spec: ProjectTemplateSpec, + projectPath: String, + onStep: @MainActor (ProjectSetupService.SetupStep) -> Void + ) async throws { + let fm = FileManager.default + + await onStep(.copying) + print("[\(spec.logPrefix)] Copying bundled template") + + guard let templateURL = Bundle.appResources.url( + forResource: spec.templateName, + withExtension: nil, + subdirectory: "templates" + ) else { + throw ProjectSetupService.SetupError(message: spec.missingTemplateMessage) + } + + let metadataPath = projectPath + "/.blitz/project.json" + let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataPath)) + + if fm.fileExists(atPath: projectPath) { + try fm.removeItem(atPath: projectPath) + } + try fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true) + + try copyTemplateDir( + src: templateURL, + dest: URL(fileURLWithPath: projectPath), + replacements: spec.replacements + ) + + for cleanupPath in spec.cleanupPaths { + let absolutePath = URL(fileURLWithPath: projectPath).appendingPathComponent(cleanupPath).path + if fm.fileExists(atPath: absolutePath) { + try? fm.removeItem(atPath: absolutePath) + } + } + + let blitzDir = projectPath + "/.blitz" + if !fm.fileExists(atPath: blitzDir) { + try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) + } + if let data = metadataData { + try data.write(to: URL(fileURLWithPath: metadataPath)) + } + + if let sampleDevVars = spec.sampleDevVars { + let devVarsPath = projectPath + "/.dev.vars" + if !fm.fileExists(atPath: devVarsPath) { + let sampleVarsPath = projectPath + "/sample.vars" + if fm.fileExists(atPath: sampleVarsPath) { + try fm.copyItem(atPath: sampleVarsPath, toPath: devVarsPath) + } else { + try sampleDevVars.write(toFile: devVarsPath, atomically: true, encoding: .utf8) + } + } + } + + await onStep(.ready) + print("[\(spec.logPrefix)] Project setup complete") + } + + private static func copyTemplateDir(src: URL, dest: URL, replacements: [String: String]) throws { + let fm = FileManager.default + try fm.createDirectory(at: dest, withIntermediateDirectories: true) + + let entries = try fm.contentsOfDirectory(at: src, includingPropertiesForKeys: [.isDirectoryKey]) + for entry in entries { + let resolvedName = applyReplacements(to: entry.lastPathComponent, replacements: replacements) + let destPath = dest.appendingPathComponent(resolvedName) + + var isDir: ObjCBool = false + fm.fileExists(atPath: entry.path, isDirectory: &isDir) + + if isDir.boolValue { + try copyTemplateDir(src: entry, dest: destPath, replacements: replacements) + } else { + var content = try String(contentsOf: entry, encoding: .utf8) + content = applyReplacements(to: content, replacements: replacements) + try content.write(to: destPath, atomically: true, encoding: .utf8) + } + } + } + + private static func applyReplacements(to value: String, replacements: [String: String]) -> String { + replacements.reduce(value) { partialResult, replacement in + partialResult.replacingOccurrences(of: replacement.key, with: replacement.value) + } + } +} diff --git a/src/services/project/SwiftProjectSetupService.swift b/src/services/project/SwiftProjectSetupService.swift new file mode 100644 index 0000000..a4c5365 --- /dev/null +++ b/src/services/project/SwiftProjectSetupService.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Scaffolds a new Swift/SwiftUI project from the bundled template. +/// Mirrors the logic in blitz-cn's create-swift-project.ts. +struct SwiftProjectSetupService { + + /// Convert a project ID like "my-cool-app" → "MyCoolApp". + static func toSwiftAppName(_ projectId: String) -> String { + let parts = projectId.components(separatedBy: CharacterSet.alphanumerics.inverted) + let camel = parts + .filter { !$0.isEmpty } + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined() + + // Ensure starts with a letter + var result = camel + while let first = result.first, !first.isLetter { + result = String(result.dropFirst()) + } + return result.isEmpty ? "App" : result + } + + /// Derive a bundle ID: "MyCoolApp" → "dev.blitz.MyCoolApp". + static func toBundleId(_ appName: String) -> String { + let safe = appName.filter { $0.isLetter || $0.isNumber } + return "dev.blitz.\(safe.isEmpty ? "App" : safe)" + } + + /// Set up a new Swift project from the bundled template. + /// Calls `onStep` on the main actor as each phase begins. + static func setup( + projectId: String, + projectName: String, + projectPath: String, + onStep: @MainActor (ProjectSetupService.SetupStep) -> Void + ) async throws { + let appName = toSwiftAppName(projectId) + let bundleId = toBundleId(appName) + let spec = ProjectTemplateSpec( + templateName: "swift-hello-template", + missingTemplateMessage: "Bundled Swift template not found", + replacements: [ + "__APP_NAME__": appName, + "__BUNDLE_ID__": bundleId + ], + sampleDevVars: nil, + cleanupPaths: [], + logPrefix: "swift-setup" + ) + try await ProjectTemplateScaffolder.scaffold( + spec: spec, + projectPath: projectPath, + onStep: onStep + ) + } +} diff --git a/src/services/DeviceInteractionService.swift b/src/services/simulator/DeviceInteractionService.swift similarity index 100% rename from src/services/DeviceInteractionService.swift rename to src/services/simulator/DeviceInteractionService.swift diff --git a/src/IDBProtocol.swift b/src/services/simulator/IDBClient.swift similarity index 100% rename from src/IDBProtocol.swift rename to src/services/simulator/IDBClient.swift diff --git a/src/services/MetalRenderer.swift b/src/services/simulator/MetalRenderer.swift similarity index 100% rename from src/services/MetalRenderer.swift rename to src/services/simulator/MetalRenderer.swift diff --git a/src/SimctlClient.swift b/src/services/simulator/SimctlClient.swift similarity index 100% rename from src/SimctlClient.swift rename to src/services/simulator/SimctlClient.swift diff --git a/src/services/SimulatorCaptureService.swift b/src/services/simulator/SimulatorCaptureService.swift similarity index 100% rename from src/services/SimulatorCaptureService.swift rename to src/services/simulator/SimulatorCaptureService.swift diff --git a/src/services/SimulatorService.swift b/src/services/simulator/SimulatorService.swift similarity index 69% rename from src/services/SimulatorService.swift rename to src/services/simulator/SimulatorService.swift index 1a70198..625b623 100644 --- a/src/services/SimulatorService.swift +++ b/src/services/simulator/SimulatorService.swift @@ -72,9 +72,8 @@ actor SimulatorService { try await Task.sleep(for: .seconds(1)) } - // Open Simulator.app behind Blitz — ScreenCaptureKit needs the window to exist - // but it captures occluded windows fine, so it doesn't need to be in front. - try await openSimulatorAppBehind() + // Open Simulator.app + try await openSimulatorApp() } /// Shutdown a simulator @@ -97,53 +96,10 @@ actor SimulatorService { try await simctl.screenshot(udid: udid, path: path) } - /// Open the Simulator.app (brings to foreground — used for initial boot) + /// Open the Simulator.app (-g flag opens in background) func openSimulatorApp() async throws { - _ = try await ProcessRunner.run("open", arguments: ["-a", "Simulator"]) - try await Task.sleep(for: .milliseconds(500)) - } - - /// Open Simulator.app behind Blitz's window. - /// Matches blitz-cn: `open -g -a Simulator` then AppleScript to bring Blitz to front. - /// Also moves Simulator's window behind Blitz using the Accessibility API. - /// - /// ScreenCaptureKit captures occluded windows fine, so Simulator - /// just needs to exist — it doesn't need to be visible. - func openSimulatorAppBehind() async throws { - // 1. Capture Blitz's frame for positioning - let blitzFrame = await MainActor.run { NSApp.mainWindow?.frame } - - // 2. Open Simulator without bringing to foreground (matches blitz-cn) _ = try await ProcessRunner.run("open", arguments: ["-g", "-a", "Simulator"]) - - // 3. Immediately bring Blitz to front via AppleScript (matches blitz-cn's hide_simulator_window) - Self.bringBlitzToFront() - - // 4. Wait for Simulator window to appear - try await Task.sleep(for: .milliseconds(800)) - - // 5. Move Simulator window behind Blitz and bring Blitz to front again - if let frame = blitzFrame { - Self.moveSimulatorWindowBehind(blitzFrame: frame) - } - Self.bringBlitzToFront() - } - - /// Bring Blitz to the foreground using AppleScript (matches blitz-cn's hide_simulator_window). - private static func bringBlitzToFront() { - let script = """ - tell application "System Events" - repeat with proc in (every process whose background only is false) - if name of proc contains "blitz" or name of proc contains "Blitz" then - set frontmost of proc to true - exit repeat - end if - end repeat - end tell - """ - let appleScript = NSAppleScript(source: script) - var error: NSDictionary? - appleScript?.executeAndReturnError(&error) + try await Task.sleep(for: .milliseconds(500)) } /// Move Simulator.app's window to the same position as Blitz's window diff --git a/src/utilities/ProjectStorage.swift b/src/utilities/ProjectStorage.swift deleted file mode 100644 index 8d2d13c..0000000 --- a/src/utilities/ProjectStorage.swift +++ /dev/null @@ -1,948 +0,0 @@ -import Foundation - -/// Filesystem operations for ~/.blitz/projects/ -struct ProjectStorage { - let baseDirectory: URL - - private enum ProjectSkillRoot: String, CaseIterable { - case claude = ".claude" - case agents = ".agents" - } - - init() { - self.baseDirectory = BlitzPaths.projects - } - - /// List all projects in ~/.blitz/projects/ - func listProjects() async -> [Project] { - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { - return [] - } - - var projects: [Project] = [] - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - for entry in entries { - // fileExists(atPath:isDirectory:) follows symlinks; - // isDirectoryKey does NOT, so symlinked project dirs would be skipped. - var isDir: ObjCBool = false - guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } - - let metadataFile = entry.appendingPathComponent(".blitz/project.json") - guard let data = try? Data(contentsOf: metadataFile), - let metadata = try? decoder.decode(BlitzProjectMetadata.self, from: data) else { - continue - } - - let project = Project( - id: entry.lastPathComponent, - metadata: metadata, - path: entry.path - ) - projects.append(project) - } - - return projects.sorted { ($0.metadata.lastOpenedAt ?? .distantPast) > ($1.metadata.lastOpenedAt ?? .distantPast) } - } - - /// Read a specific project's metadata - func readMetadata(projectId: String) -> BlitzProjectMetadata? { - let metadataFile = baseDirectory - .appendingPathComponent(projectId) - .appendingPathComponent(".blitz/project.json") - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let data = try? Data(contentsOf: metadataFile) else { return nil } - return try? decoder.decode(BlitzProjectMetadata.self, from: data) - } - - /// Write project metadata into ~/.blitz/projects/{id}/.blitz/project.json - func writeMetadata(projectId: String, metadata: BlitzProjectMetadata) throws { - let projectDir = baseDirectory.appendingPathComponent(projectId) - try writeMetadataToDirectory(projectDir, metadata: metadata) - } - - /// Write project metadata into an arbitrary directory (e.g. the original project path before symlinking). - func writeMetadataToDirectory(_ dir: URL, metadata: BlitzProjectMetadata) throws { - let blitzDir = dir.appendingPathComponent(".blitz") - try FileManager.default.createDirectory(at: blitzDir, withIntermediateDirectories: true) - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(metadata) - try data.write(to: blitzDir.appendingPathComponent("project.json")) - } - - /// Delete a project directory - func deleteProject(projectId: String) throws { - let projectDir = baseDirectory.appendingPathComponent(projectId) - let path = projectDir.path - // Check if this is a symlink — if so, only remove the symlink itself, not the target - var isSymlink = false - if let attrs = try? FileManager.default.attributesOfItem(atPath: path), - attrs[.type] as? FileAttributeType == .typeSymbolicLink { - isSymlink = true - } - if isSymlink { - // unlink only removes the symlink, not the target directory - unlink(path) - } else { - try FileManager.default.removeItem(at: projectDir) - } - } - - /// Open a project at the given URL. Validates .blitz/project.json exists, - /// registers it in ~/.blitz/projects/ if needed, and returns the projectId. - func openProject(at url: URL) throws -> String { - let metadataFile = url.appendingPathComponent(".blitz/project.json") - guard FileManager.default.fileExists(atPath: metadataFile.path) else { - throw ProjectOpenError.notABlitzProject - } - - var folderName = url.lastPathComponent - let existingDir = baseDirectory.appendingPathComponent(folderName) - - if FileManager.default.fileExists(atPath: existingDir.path) { - // Check if it resolves to the same location - let resolvedExisting = existingDir.resolvingSymlinksInPath().path - let resolvedNew = url.resolvingSymlinksInPath().path - if resolvedExisting == resolvedNew { - updateLastOpened(projectId: folderName) - return folderName - } - // Name collision with different project — disambiguate - var counter = 2 - while FileManager.default.fileExists( - atPath: baseDirectory.appendingPathComponent("\(folderName)-\(counter)").path - ) { counter += 1 } - folderName = "\(folderName)-\(counter)" - } - - // Create symlink: ~/.blitz/projects/{folderName} → selectedPath - let symlinkDir = baseDirectory.appendingPathComponent(folderName) - try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) - try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: url) - - updateLastOpened(projectId: folderName) - return folderName - } - - /// Update lastOpenedAt timestamp for a project - func updateLastOpened(projectId: String) { - guard var metadata = readMetadata(projectId: projectId) else { return } - metadata.lastOpenedAt = Date() - do { - try writeMetadata(projectId: projectId, metadata: metadata) - } catch { - print("[ProjectStorage] Failed to update lastOpenedAt for \(projectId): \(error)") - } - } - - /// Ensure ~/.blitz/mcps/ has MCP configs, CLAUDE.md, and skills so that - /// agent sessions launched outside a project (e.g. onboarding ASC setup) can - /// access Blitz MCP tools. Idempotent — safe to call on every launch. - func ensureGlobalMCPConfigs() { - let fm = FileManager.default - let mcpsDir = BlitzPaths.mcps - - try? fm.createDirectory(at: mcpsDir, withIntermediateDirectories: true) - - // 1. .mcp.json + .codex/config.toml (reuse project-level logic) - ensureMCPConfig(in: mcpsDir) - - // 2. .claude/settings.local.json - let claudeDir = mcpsDir.appendingPathComponent(".claude") - let settingsFile = claudeDir.appendingPathComponent("settings.local.json") - try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) - let settings: [String: Any] = [ - "enabledMcpjsonServers": ["blitz-macos", "blitz-iphone"], - "permissions": [ - "allow": [ - "mcp__blitz-macos__asc_set_credentials", - "mcp__blitz-macos__asc_web_auth", - "Bash(python3:*)", - ] - ] - ] - if let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) { - try? data.write(to: settingsFile) - } - - // 3. CLAUDE.md - let claudeMd = mcpsDir.appendingPathComponent("CLAUDE.md") - let claudeMdContent = """ - # Blitz — Global Agent Context - - This directory is used by Blitz to run agent sessions outside of a project context - (e.g. App Store Connect API key setup during onboarding). - - ## Available MCP Tools - - The `blitz-macos` MCP server is connected. Key tools for ASC setup: - - - `asc_web_auth` — Opens the Apple ID login window in Blitz to authenticate a web session. - Call this first if you get a 401 from iris APIs or if no web session exists. - - `asc_set_credentials` — Pre-fills the ASC credential form in Blitz with issuer ID, key ID, - and a path to the .p8 private key file. The user must click "Save Credentials" to confirm. - Parameters: `issuerId` (string), `keyId` (string), `privateKeyPath` (string, absolute path to .p8 file). - """ - try? claudeMdContent.write(to: claudeMd, atomically: true, encoding: .utf8) - - // 4. Skills — copy bundled skills (e.g. asc-team-key-create) - let skillDirectories = ProjectSkillRoot.allCases.map { - mcpsDir.appendingPathComponent($0.rawValue).appendingPathComponent("skills") - } - - DispatchQueue.global(qos: .utility).async { - for skillsDir in skillDirectories { - try? fm.createDirectory(at: skillsDir, withIntermediateDirectories: true) - } - - if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { - Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) - } - } - } - - /// Ensure .mcp.json contains blitz-macos and blitz-iphone MCP server entries. - /// If the file exists, merges into the existing mcpServers key without overwriting other entries. - /// If it doesn't exist, creates it. - /// Also removes the deprecated blitz-ios entry if present. - func ensureMCPConfig(projectId: String) { - let projectDir = baseDirectory.appendingPathComponent(projectId) - ensureMCPConfig(in: projectDir) - } - - /// Shared implementation: writes .mcp.json and .codex/config.toml into `directory`. - func ensureMCPConfig(in directory: URL) { - let mcpFile = directory.appendingPathComponent(".mcp.json") - let bridgePath = BlitzPaths.mcpBridge.path - - let blitzMacosEntry: [String: Any] = [ - "command": "bash", - "args": [bridgePath] - ] - // Use full path to npx from Blitz's bundled Node.js runtime. - // Also set PATH env so that #!/usr/bin/env node resolves correctly — - // npx and the packages it runs use env shebang lookups. - let nodeRuntimeBin = BlitzPaths.nodeDir.path - let blitzIphoneEntry: [String: Any] = [ - "command": nodeRuntimeBin + "/npx", - "args": ["-y", "@blitzdev/iphone-mcp"], - "env": [ - "PATH": "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin" - ] - ] - - var root: [String: Any] - if let data = try? Data(contentsOf: mcpFile), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - root = existing - var servers = root["mcpServers"] as? [String: Any] ?? [:] - servers["blitz-macos"] = blitzMacosEntry - servers["blitz-iphone"] = blitzIphoneEntry - servers.removeValue(forKey: "blitz-ios") // deprecated - root["mcpServers"] = servers - } else { - root = ["mcpServers": [ - "blitz-macos": blitzMacosEntry, - "blitz-iphone": blitzIphoneEntry - ]] - } - - guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) else { return } - do { - try data.write(to: mcpFile) - } catch { - print("[ProjectStorage] Failed to write .mcp.json: \(error)") - } - - // Codex config — only blitz_macos (Codex reads .mcp.json for blitz-iphone). - // Uses underscores to avoid Codex hyphenated-name bug. - let codexDir = directory.appendingPathComponent(".codex") - let codexConfig = codexDir.appendingPathComponent("config.toml") - let toml = """ - [mcp_servers.blitz_macos] - command = "bash" - args = ["\(bridgePath)"] - cwd = "\(directory.path)" - """ - do { - try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) - try toml.write(to: codexConfig, atomically: true, encoding: .utf8) - } catch { - print("[ProjectStorage] Failed to write .codex/config.toml: \(error)") - } - } - - /// Ensure CLAUDE.md, .claude/settings.local.json, and .claude/rules/ exist for a project. - func ensureClaudeFiles(projectId: String, projectType: ProjectType) { - let fm = FileManager.default - let projectDir = baseDirectory.appendingPathComponent(projectId) - let claudeDir = projectDir.appendingPathComponent(".claude") - - // 1. .claude/settings.local.json - // Always update enabledMcpjsonServers (Blitz-owned structural setting). - let settingsFile = claudeDir.appendingPathComponent("settings.local.json") - try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) - let correctServers = ["blitz-macos", "blitz-iphone"] - var settings: [String: Any] - if fm.fileExists(atPath: settingsFile.path), - let data = try? Data(contentsOf: settingsFile), - var existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - // Preserve user customisations; only force-update the server list - existing["enabledMcpjsonServers"] = correctServers - // Remove deprecated blitz-ios permission entries - if var perms = existing["permissions"] as? [String: Any], - var allow = perms["allow"] as? [String] { - allow.removeAll { $0.contains("blitz-ios") } - perms["allow"] = allow - existing["permissions"] = perms - } - settings = existing - } else { - settings = [ - "permissions": [ - "allow": [ - "Bash(curl:*)", - "Bash(xcrun simctl terminate:*)", - "Bash(xcrun simctl launch:*)", - "mcp__blitz-macos__app_get_state", - ] - ], - "enabledMcpjsonServers": correctServers, - ] - } - if let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) { - try? data.write(to: settingsFile) - } - - // 2. CLAUDE.md — write only if absent; user may have their own - let claudeMdFile = projectDir.appendingPathComponent("CLAUDE.md") - if !fm.fileExists(atPath: claudeMdFile.path) { - let content = Self.claudeMdContent(projectType: projectType) - try? content.write(to: claudeMdFile, atomically: true, encoding: .utf8) - } - - // 3. .claude/rules/ — Blitz-owned files, always overwrite. - // These auto-load in every Claude Code session alongside any existing CLAUDE.md, - // so agents get Blitz/Teenybase context even on projects with pre-existing docs. - let rulesDir = claudeDir.appendingPathComponent("rules") - try? fm.createDirectory(at: rulesDir, withIntermediateDirectories: true) - - let blitzRules = rulesDir.appendingPathComponent("blitz.md") - try? Self.blitzRulesContent().write(to: blitzRules, atomically: true, encoding: .utf8) - - let teenybaseRules = rulesDir.appendingPathComponent("teenybase.md") - try? Self.teenybaseRulesContent(projectDir: projectDir, projectType: projectType) - .write(to: teenybaseRules, atomically: true, encoding: .utf8) - - // 4. App Store Review Agent — clone from public repo, symlink into .claude/agents/ - ensureReviewerAgent(projectDir: projectDir) - - // 5. Project skills — copy bundled Blitz skills and sync ASC CLI skills - // into supported local agent skill directories. - ensureProjectSkills(projectDir: projectDir) - - // 6. ASC CLI — headless install if not already present - ensureASCCLI() - } - - /// Clone or update the app-store-review-agent repo and symlink the agent - /// into .claude/agents/ where Claude Code can discover it. - /// Runs git operations on a background queue so it never blocks the UI. - func ensureReviewerAgent(projectDir: URL) { - let fm = FileManager.default - let claudeDir = projectDir.appendingPathComponent(".claude") - let agentRepoDir = claudeDir.appendingPathComponent("app-store-review-agent") - let agentsDir = claudeDir.appendingPathComponent("agents") - let symlinkPath = agentsDir.appendingPathComponent("reviewer.md") - - // If symlink already exists and resolves to a real file, nothing to do. - // (Still dispatch a background pull to pick up rule updates.) - let symlinkExists = fm.fileExists(atPath: symlinkPath.path) - - DispatchQueue.global(qos: .utility).async { - let repoURL = BlitzPaths.reviewerAgentRepo - - if fm.fileExists(atPath: agentRepoDir.appendingPathComponent(".git").path) { - // Already cloned — pull latest in background - let pull = Process() - pull.executableURL = URL(fileURLWithPath: "/usr/bin/git") - pull.arguments = ["-C", agentRepoDir.path, "pull", "--quiet", "--ff-only"] - pull.standardOutput = FileHandle.nullDevice - pull.standardError = FileHandle.nullDevice - try? pull.run() - pull.waitUntilExit() - } else { - // First time — clone - let clone = Process() - clone.executableURL = URL(fileURLWithPath: "/usr/bin/git") - clone.arguments = ["clone", "--quiet", "--depth", "1", repoURL, agentRepoDir.path] - clone.standardOutput = FileHandle.nullDevice - clone.standardError = FileHandle.nullDevice - try? clone.run() - clone.waitUntilExit() - guard clone.terminationStatus == 0 else { - print("[ProjectStorage] Failed to clone app-store-review-agent") - return - } - } - - // Create .claude/agents/ and symlink reviewer.md - if !symlinkExists { - try? fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) - // Relative symlink so it works regardless of absolute project path - try? fm.createSymbolicLink( - atPath: symlinkPath.path, - withDestinationPath: "../app-store-review-agent/agents/reviewer.md" - ) - print("[ProjectStorage] Reviewer agent installed") - } - } - } - - /// Copy bundled Blitz project skills and sync the ASC CLI skills repo into - /// each supported local agent skills directory. - /// Overwrites asc-app-create-ui/SKILL.md with the pre-cached-session version. - /// Runs git operations on a background queue so it never blocks the UI. - func ensureProjectSkills(projectDir: URL) { - let fm = FileManager.default - let claudeDir = projectDir.appendingPathComponent(".claude") - let repoDir = claudeDir.appendingPathComponent("asc-skills") - let skillDirectories = projectSkillDirectories(projectDir: projectDir) - - DispatchQueue.global(qos: .utility).async { - for skillsDir in skillDirectories { - try? fm.createDirectory(at: skillsDir, withIntermediateDirectories: true) - } - - if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { - Self.syncSkillDirectories( - from: bundledSkillsDir, - into: skillDirectories, - using: fm - ) - } - - let repoURL = BlitzPaths.ascSkillsRepo - - if fm.fileExists(atPath: repoDir.appendingPathComponent(".git").path) { - // Already cloned — pull latest - let pull = Process() - pull.executableURL = URL(fileURLWithPath: "/usr/bin/git") - pull.arguments = ["-C", repoDir.path, "pull", "--quiet", "--ff-only"] - pull.standardOutput = FileHandle.nullDevice - pull.standardError = FileHandle.nullDevice - try? pull.run() - pull.waitUntilExit() - } else { - // First time — clone - let clone = Process() - clone.executableURL = URL(fileURLWithPath: "/usr/bin/git") - clone.arguments = ["clone", "--quiet", "--depth", "1", repoURL, repoDir.path] - clone.standardOutput = FileHandle.nullDevice - clone.standardError = FileHandle.nullDevice - try? clone.run() - clone.waitUntilExit() - guard clone.terminationStatus == 0 else { - print("[ProjectStorage] Failed to clone asc-skills") - return - } - } - - let repoSkillsDir = repoDir.appendingPathComponent("skills") - Self.syncSkillDirectories(from: repoSkillsDir, into: skillDirectories, using: fm) - - // Overwrite asc-app-create-ui/SKILL.md with Blitz's pre-cached-session version - for skillsDir in skillDirectories { - let ascCreateSkillFile = skillsDir - .appendingPathComponent("asc-app-create-ui") - .appendingPathComponent("SKILL.md") - try? Self.ascAppCreateSkillContent() - .write(to: ascCreateSkillFile, atomically: true, encoding: .utf8) - } - - let installedRoots = skillDirectories - .map(\.path) - .joined(separator: ", ") - print("[ProjectStorage] Project skills installed in \(installedRoots)") - } - } - - private func projectSkillDirectories(projectDir: URL) -> [URL] { - ProjectSkillRoot.allCases.map { - projectDir - .appendingPathComponent($0.rawValue) - .appendingPathComponent("skills") - } - } - - private static func bundledProjectSkillsDirectory() -> URL? { - let fm = FileManager.default - - if let bundleSkills = Bundle.main.resourceURL? - .appendingPathComponent("claude-skills"), - fm.fileExists(atPath: bundleSkills.path) { - return bundleSkills - } - - #if DEBUG - let repoSkills = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent(".claude/skills") - if fm.fileExists(atPath: repoSkills.path) { - return repoSkills - } - #endif - - return nil - } - - private static func syncSkillDirectories(from sourceSkillsDir: URL, into destinations: [URL], using fm: FileManager) { - guard let skillDirs = try? fm.contentsOfDirectory( - at: sourceSkillsDir, - includingPropertiesForKeys: [.isDirectoryKey] - ) else { return } - - for destination in destinations { - for srcSkillDir in skillDirs { - var isDir: ObjCBool = false - guard fm.fileExists(atPath: srcSkillDir.path, isDirectory: &isDir), - isDir.boolValue else { continue } - - let destSkillDir = destination.appendingPathComponent(srcSkillDir.lastPathComponent) - if fm.fileExists(atPath: destSkillDir.path) { - try? fm.removeItem(at: destSkillDir) - } - try? fm.copyItem(at: srcSkillDir, to: destSkillDir) - } - } - } - - /// Content for the Blitz-specific asc-app-create-ui skill that uses - /// iris APIs via the web session cached in Keychain. - private static func ascAppCreateSkillContent() -> String { - return ##""" - --- - name: asc-app-create-ui - description: Create an App Store Connect app via iris API using web session from Blitz - --- - - Create an App Store Connect app using Apple's iris API. Authentication is handled via a web session stored in the macOS Keychain by Blitz. - - Extract from the conversation context: - - `bundleId` — the bundle identifier (e.g. `com.blitz.myapp`) - - `sku` — the SKU string (may be provided; if missing, generate one from the app name) - - ## Workflow - - ### 1. Check for an existing web session - - ```bash - security find-generic-password -s "asc-web-session" -a "asc:web-session:store" -w > /dev/null 2>&1 && echo "SESSION_EXISTS" || echo "NO_SESSION" - ``` - - - If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. - - If `SESSION_EXISTS`: proceed. - - ### 2. Ask the user for the primary language - - Ask what primary language/locale the app should use. Common choices: `en-US` (English US), `en-GB` (English UK), `ja` (Japanese), `zh-Hans` (Simplified Chinese), `ko` (Korean), `fr-FR` (French), `de-DE` (German). - - ### 3. Derive the app name - - Take the last component of the bundle ID after the final `.`, capitalize the first letter. Confirm with the user. - - ### 4. Create the app via iris API - - Use the following self-contained script. Replace `BUNDLE_ID`, `SKU`, `APP_NAME`, and `LOCALE` with the resolved values. **Do not print or log cookies.** - - Key differences from the public REST API: - - Uses `appstoreconnect.apple.com/iris/v1/` (not `api.appstoreconnect.apple.com`) - - Authenticated via web session cookies (not JWT) - - Uses `appInfos` relationship (not `bundleId` relationship) - - App name goes on `appInfoLocalizations` (not `appStoreVersionLocalizations`) - - Uses `${new-...}` placeholder IDs for inline-created resources - - ```bash - python3 -c " - import json, subprocess, urllib.request, sys - - BUNDLE_ID = 'BUNDLE_ID_HERE' - SKU = 'SKU_HERE' - APP_NAME = 'APP_NAME_HERE' - LOCALE = 'LOCALE_HERE' - - # Extract cookies from keychain (silent) - try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() - except subprocess.CalledProcessError: - print('ERROR: No web session found. Call asc_web_auth MCP tool first.') - sys.exit(1) - - store = json.loads(raw) - session = store['sessions'][store['last_key']] - cookie_str = '; '.join( - (f'{c[\"name\"]}=\"{c[\"value\"]}\"' if c['name'].startswith('DES') else f'{c[\"name\"]}={c[\"value\"]}') - for cl in session['cookies'].values() for c in cl - if c.get('name') and c.get('value') - ) - - headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'Origin': 'https://appstoreconnect.apple.com', - 'Referer': 'https://appstoreconnect.apple.com/', - 'Cookie': cookie_str - } - - create_body = json.dumps({ - 'data': { - 'type': 'apps', - 'attributes': { - 'bundleId': BUNDLE_ID, - 'sku': SKU, - 'primaryLocale': LOCALE, - }, - 'relationships': { - 'appStoreVersions': { - 'data': [{'type': 'appStoreVersions', 'id': '\${new-appStoreVersion-1}'}] - }, - 'appInfos': { - 'data': [{'type': 'appInfos', 'id': '\${new-appInfo-1}'}] - } - } - }, - 'included': [ - { - 'type': 'appStoreVersions', - 'id': '\${new-appStoreVersion-1}', - 'attributes': {'platform': 'IOS', 'versionString': '1.0'}, - 'relationships': { - 'appStoreVersionLocalizations': { - 'data': [{'type': 'appStoreVersionLocalizations', 'id': '\${new-appStoreVersionLocalization-1}'}] - } - } - }, - { - 'type': 'appStoreVersionLocalizations', - 'id': '\${new-appStoreVersionLocalization-1}', - 'attributes': {'locale': LOCALE} - }, - { - 'type': 'appInfos', - 'id': '\${new-appInfo-1}', - 'relationships': { - 'appInfoLocalizations': { - 'data': [{'type': 'appInfoLocalizations', 'id': '\${new-appInfoLocalization-1}'}] - } - } - }, - { - 'type': 'appInfoLocalizations', - 'id': '\${new-appInfoLocalization-1}', - 'attributes': {'locale': LOCALE, 'name': APP_NAME} - } - ] - }).encode() - - req = urllib.request.Request( - 'https://appstoreconnect.apple.com/iris/v1/apps', - data=create_body, method='POST', headers=headers) - try: - resp = urllib.request.urlopen(req) - result = json.loads(resp.read().decode()) - app_id = result['data']['id'] - print(f'App created successfully!') - print(f'App ID: {app_id}') - print(f'Bundle ID: {BUNDLE_ID}') - print(f'Name: {APP_NAME}') - print(f'SKU: {SKU}') - except urllib.error.HTTPError as e: - body = e.read().decode() - if e.code == 401: - print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') - elif e.code == 409: - print(f'ERROR: App may already exist or conflict. Details: {body[:500]}') - else: - print(f'ERROR creating app: HTTP {e.code} — {body[:500]}') - sys.exit(1) - " - ``` - - ### 5. Report results - - After success, report the App ID, bundle ID, name, and SKU to the user. - - ## Common Errors - - ### 401 Not Authorized - Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz. Then retry. - - ### 409 Conflict - An app with the same bundle ID or SKU may already exist. Try a different SKU. - - ## Agent Behavior - - - **Do NOT ask for Apple ID email** — authentication is handled via Keychain session, not email. - - **NEVER print, log, or echo session cookies.** - - Use the self-contained python script — do NOT extract cookies separately. - - If iris API returns 401, call `asc_web_auth` MCP tool and retry. - """## - } - - /// Install the `asc` CLI if not already present on the system. - /// Checks common install locations first; if missing, runs the headless installer. - /// Runs on a background queue so it never blocks the UI. - func ensureASCCLI() { - DispatchQueue.global(qos: .utility).async { - let fm = FileManager.default - let searchPaths = [ - "/opt/homebrew/bin/asc", - "/usr/local/bin/asc", - NSHomeDirectory() + "/.local/bin/asc", - ] - - for path in searchPaths { - if fm.isExecutableFile(atPath: path) { return } - } - - // Not found — install headlessly - let install = Process() - install.executableURL = URL(fileURLWithPath: "/bin/bash") - install.arguments = ["-c", "curl -fsSL https://asccli.sh/install | bash"] - install.standardOutput = FileHandle.nullDevice - install.standardError = FileHandle.nullDevice - try? install.run() - install.waitUntilExit() - - if install.terminationStatus == 0 { - print("[ProjectStorage] ASC CLI installed") - } else { - print("[ProjectStorage] Failed to install ASC CLI") - } - } - } - - private static func claudeMdContent(projectType: ProjectType) -> String { - guard let templateURL = Bundle.appResources.url(forResource: "CLAUDE.md", withExtension: "template"), - var template = try? String(contentsOf: templateURL, encoding: .utf8) else { - return "# Blitz AI Agent Guide\n" - } - - let header: String - switch projectType { - case .swift: - header = "Swift Project — Blitz AI Agent Guide" - case .reactNative: - header = "React Native Project — Blitz AI Agent Guide" - case .flutter: - header = "Flutter Project — Blitz AI Agent Guide" - } - template = template.replacingOccurrences(of: "{{PROJECT_TYPE_HEADER}}", with: header) - - return template - } - - private static func blitzRulesContent() -> String { - guard let url = Bundle.appResources.url(forResource: "blitz-rules", withExtension: "md"), - let content = try? String(contentsOf: url, encoding: .utf8) else { - return "# Blitz MCP Integration\n" - } - return content - } - - private static func teenybaseRulesContent(projectDir: URL, projectType: ProjectType) -> String { - let fm = FileManager.default - - let backendDir: URL - let schemaPath: String - let commandPrefix: String - switch projectType { - case .reactNative: - backendDir = projectDir - schemaPath = "teenybase.ts" - commandPrefix = "" - case .swift, .flutter: - backendDir = projectDir.appendingPathComponent("backend") - schemaPath = "backend/teenybase.ts" - commandPrefix = "cd backend && " - } - - let hasBackend = fm.fileExists(atPath: backendDir.appendingPathComponent("teenybase.ts").path) - let templateName = hasBackend ? "teenybase-rules-backend" : "teenybase-rules-no-backend" - - guard let url = Bundle.appResources.url(forResource: templateName, withExtension: "md"), - var content = try? String(contentsOf: url, encoding: .utf8) else { - return "# Teenybase Backend\n" - } - - content = content.replacingOccurrences(of: "{{DEVVARS_PATH}}", with: backendDir.appendingPathComponent(".dev.vars").path) - content = content.replacingOccurrences(of: "{{SCHEMA_PATH}}", with: schemaPath) - content = content.replacingOccurrences(of: "{{COMMAND_PREFIX}}", with: commandPrefix) - return content - } - - // MARK: - Teenybase backend scaffolding - - /// Copy Teenybase backend files into a project if not already present. - /// RN projects get files at the project root; Swift/Flutter get a backend/ subdirectory. - func ensureTeenybaseBackend(projectId: String, projectType: ProjectType) { - let fm = FileManager.default - let projectDir = baseDirectory.appendingPathComponent(projectId) - - guard let templateURL = Bundle.appResources.url( - forResource: "rn-notes-template", withExtension: nil, subdirectory: "templates" - ) else { - print("[ProjectStorage] Teenybase template not found in bundle") - return - } - - switch projectType { - case .reactNative: - copyTeenybaseFiles(from: templateURL, to: projectDir, fm: fm) - mergeTeenybaseScripts(into: projectDir.appendingPathComponent("package.json"), fm: fm) - case .swift, .flutter: - let backendDir = projectDir.appendingPathComponent("backend") - try? fm.createDirectory(at: backendDir, withIntermediateDirectories: true) - copyTeenybaseFiles(from: templateURL, to: backendDir, fm: fm) - ensureStandalonePackageJson(at: backendDir.appendingPathComponent("package.json"), - projectId: projectId, fm: fm) - } - } - - /// Copy teenybase.ts, wrangler.toml, src-backend/worker.ts, and .dev.vars into dest. - /// Skips each file if it already exists so existing configs are never overwritten. - private func copyTeenybaseFiles(from templateURL: URL, to dest: URL, fm: FileManager) { - // teenybase.ts — skip if present (indicates backend already set up) - let teenybaseDest = dest.appendingPathComponent("teenybase.ts") - guard !fm.fileExists(atPath: teenybaseDest.path) else { return } - - try? fm.copyItem(at: templateURL.appendingPathComponent("teenybase.ts"), to: teenybaseDest) - - // wrangler.toml - let wranglerDest = dest.appendingPathComponent("wrangler.toml") - if !fm.fileExists(atPath: wranglerDest.path) { - let src = templateURL.appendingPathComponent("wrangler.toml") - if var content = try? String(contentsOf: src, encoding: .utf8) { - content = content.replacingOccurrences(of: "sample-app", with: dest.deletingLastPathComponent().lastPathComponent) - try? content.write(to: wranglerDest, atomically: true, encoding: .utf8) - } - } - - // src-backend/worker.ts - let srcBackendDest = dest.appendingPathComponent("src-backend") - try? fm.createDirectory(at: srcBackendDest, withIntermediateDirectories: true) - let workerDest = srcBackendDest.appendingPathComponent("worker.ts") - if !fm.fileExists(atPath: workerDest.path) { - try? fm.copyItem( - at: templateURL.appendingPathComponent("src-backend/worker.ts"), - to: workerDest - ) - } - - // .dev.vars — from sample.vars - let devVarsDest = dest.appendingPathComponent(".dev.vars") - if !fm.fileExists(atPath: devVarsDest.path) { - let sampleVars = templateURL.appendingPathComponent("sample.vars") - if fm.fileExists(atPath: sampleVars.path) { - try? fm.copyItem(at: sampleVars, to: devVarsDest) - } - } - } - - /// For RN projects: merge teenybase scripts + devDependency into existing package.json. - /// No-op if teenybase is already in devDependencies. - private func mergeTeenybaseScripts(into packageJsonURL: URL, fm: FileManager) { - guard fm.fileExists(atPath: packageJsonURL.path), - let data = try? Data(contentsOf: packageJsonURL), - var pkg = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - - var devDeps = pkg["devDependencies"] as? [String: Any] ?? [:] - guard devDeps["teenybase"] == nil else { return } // already set up - - devDeps["teenybase"] = "0.0.10" - pkg["devDependencies"] = devDeps - - var scripts = pkg["scripts"] as? [String: Any] ?? [:] - let backendScripts: [String: String] = [ - "generate:backend": "teeny generate --local", - "migrate:backend": "teeny migrate --local", - "dev:backend": "teeny dev --local", - "build:backend": "teeny build --local", - "exec:backend": "teeny exec --local", - "deploy:backend:remote": "teeny deploy --migrate --remote", - ] - for (key, value) in backendScripts where scripts[key] == nil { - scripts[key] = value - } - pkg["scripts"] = scripts - - if let updated = try? JSONSerialization.data(withJSONObject: pkg, options: [.prettyPrinted, .sortedKeys]) { - try? updated.write(to: packageJsonURL) - } - } - - /// For Swift/Flutter: write a standalone package.json for the backend/ subdirectory. - private func ensureStandalonePackageJson(at url: URL, projectId: String, fm: FileManager) { - guard !fm.fileExists(atPath: url.path) else { return } - let content = """ - { - "name": "\(projectId)-backend", - "version": "1.0.0", - "scripts": { - "generate": "teeny generate --local", - "migrate": "teeny migrate --local", - "dev": "teeny dev --local", - "build": "teeny build --local", - "exec": "teeny exec --local", - "deploy": "teeny deploy --migrate --remote" - }, - "devDependencies": { - "teenybase": "0.0.10" - } - } - """ - try? content.write(to: url, atomically: true, encoding: .utf8) - } - - /// Clear lastOpenedAt on all projects - func clearRecentProjects() { - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { return } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - for entry in entries { - var isDir: ObjCBool = false - guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } - let projectId = entry.lastPathComponent - guard var metadata = readMetadata(projectId: projectId) else { continue } - metadata.lastOpenedAt = nil - try? writeMetadata(projectId: projectId, metadata: metadata) - } - } -} - -enum ProjectOpenError: LocalizedError { - case notABlitzProject - - var errorDescription: String? { - switch self { - case .notABlitzProject: - return "Not a Blitz project. The selected folder does not contain .blitz/project.json. Use Import to add an external project." - } - } -} diff --git a/src/views/AppTabView.swift b/src/views/AppTabView.swift new file mode 100644 index 0000000..9405b5a --- /dev/null +++ b/src/views/AppTabView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct AppTabView: View { + @Bindable var appState: AppState + + /// Minimum width needed to keep every App sub-tab button on a single line. + static let minimumSingleLineWidth: CGFloat = { + let textFont = NSFont.systemFont(ofSize: 12, weight: .medium) + let symbolAllowance: CGFloat = 14 + let buttonInnerSpacing: CGFloat = 4 + let buttonHorizontalPadding: CGFloat = 20 + let interButtonSpacing: CGFloat = 2 * CGFloat(max(AppSubTab.allCases.count - 1, 0)) + let navbarHorizontalPadding: CGFloat = 32 + let safetyMargin: CGFloat = 24 + + let totalButtonWidth = AppSubTab.allCases.reduce(CGFloat.zero) { partial, tab in + let textWidth = ceil((tab.label as NSString).size(withAttributes: [.font: textFont]).width) + return partial + textWidth + symbolAllowance + buttonInnerSpacing + buttonHorizontalPadding + } + + return totalButtonWidth + interButtonSpacing + navbarHorizontalPadding + safetyMargin + }() + + var body: some View { + VStack(spacing: 0) { + // Top navbar + topNavbar + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) + + Divider() + + // Sub-tab content + subTabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Top Navbar + + private var topNavbar: some View { + HStack(spacing: 2) { + ForEach(AppSubTab.allCases) { tab in + Button { + appState.activeAppSubTab = tab + } label: { + HStack(spacing: 4) { + Image(systemName: tab.systemImage) + .font(.system(size: 11)) + Text(tab.label) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + } + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + appState.activeAppSubTab == tab + ? Color.accentColor.opacity(0.12) + : Color.clear + ) + .foregroundStyle( + appState.activeAppSubTab == tab + ? Color.accentColor + : Color.secondary + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + Spacer() + } + } + + // MARK: - Sub-tab Content + + @ViewBuilder + private var subTabContent: some View { + switch appState.activeAppSubTab { + case .overview: + ASCOverview(appState: appState) + case .simulator: + SimulatorView(appState: appState) + case .database: + DatabaseView(appState: appState) + case .tests: + TestsView(appState: appState) + case .icon: + AssetsView(appState: appState) + } + } +} diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 6b9dee5..5460cae 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -23,7 +23,14 @@ struct ContentView: View { @Environment(\.openWindow) private var openWindow @State private var mainWindow: NSWindow? @State private var tabSwitchTask: Task? - @State private var showConnectAI = false + + private var terminalSplitMinContentSize: CGFloat { + let baseMinContentSize: CGFloat = 200 + guard appState.settingsStore.terminalPosition == "right" else { + return baseMinContentSize + } + return max(baseMinContentSize, AppTabView.minimumSingleLineWidth) + } private var appleIDLoginBinding: Binding { Binding( @@ -33,6 +40,40 @@ struct ContentView: View { } /// Consume pendingSetupProjectId and run project scaffolding if needed. + private func launchTerminal() { + let settings = appState.settingsStore + let terminal = settings.resolveDefaultTerminal().terminal + + if terminal.isBuiltIn { + // Show built-in terminal panel + appState.showTerminal = true + + // Create a new session with the AI agent command + let session = appState.terminalManager.createSession(projectPath: appState.activeProject?.path) + + // Build and send the agent CLI command + let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode + let prompt = settings.sendDefaultPrompt ? ConnectAIPopover.prompt(for: appState.activeTab) : nil + let command = TerminalLauncher.buildAgentCommand( + projectPath: appState.activeProject?.path, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) + + // Small delay so the shell is ready to receive input + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + // Launch external terminal + TerminalLauncher.launchFromSettings( + projectPath: appState.activeProject?.path, + activeTab: appState.activeTab + ) + } + } + private func startPendingSetupIfNeeded() async { guard let pendingId = appState.projectSetup.pendingSetupProjectId, pendingId == appState.activeProjectId, @@ -47,38 +88,52 @@ struct ContentView: View { ) } + private func refreshProjectFiles(projectId: String, projectType: ProjectType) { + let whitelistBlitzMCP = appState.settingsStore.whitelistBlitzMCPTools + let allowASCCLICalls = appState.settingsStore.allowASCCLICalls + Task.detached(priority: .utility) { + let storage = ProjectStorage() + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + storage.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + } + var body: some View { NavigationSplitView { SidebarView(appState: appState) .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 250) } detail: { - DetailView(appState: appState) + TerminalSplitView( + isHorizontal: appState.settingsStore.terminalPosition == "right", + showPanel: appState.showTerminal, + panelSize: $appState.terminalPanelSize, + minPanelSize: 120, + minContentSize: terminalSplitMinContentSize + ) { + DetailView(appState: appState) + } panel: { + TerminalPanelView(appState: appState) + } } .navigationSplitViewStyle(.balanced) .toolbar { ToolbarItem(placement: .navigation) { - Button(action: { - // Try to auto-launch terminal with agent CLI - let launched = TerminalLauncher.launchFromSettings( - projectPath: appState.activeProject?.path, - activeTab: appState.activeTab - ) - if !launched { - // Fallback: show the popover - showConnectAI = true - } - }) { - Label("Connect AI", systemImage: "sparkles") - } - .help("Connect AI agent") - .popover(isPresented: $showConnectAI, arrowEdge: .bottom) { - ConnectAIPopover(projectPath: appState.activeProject?.path, activeTab: appState.activeTab) - } - .contextMenu { - Button("Show Connect AI Panel") { - showConnectAI = true - } + Button { + launchTerminal() + } label: { + Label("Terminal", systemImage: "terminal") } + .help("Launch terminal with AI agent") } } .background(HostingWindowFinder { window in @@ -86,14 +141,18 @@ struct ContentView: View { }) .task { await appState.projectManager.loadProjects() + if let projectId = appState.activeProjectId, + let projectType = appState.activeProject?.type { + refreshProjectFiles(projectId: projectId, projectType: projectType) + } // If a project was just created (e.g. from WelcomeWindow), run setup await startPendingSetupIfNeeded() // Auto-boot simulator when project opens await appState.simulatorManager.bootIfNeeded() - // Auto-start stream if landing on simulator tab - if appState.activeTab == .simulator { + // Auto-start stream if landing on simulator sub-tab + if appState.activeTab == .app && appState.activeAppSubTab == .simulator { await appState.simulatorStream.startStreaming( bootedDeviceId: appState.simulatorManager.bootedDeviceId ) @@ -106,7 +165,9 @@ struct ContentView: View { bundleId: project.metadata.bundleIdentifier ) if appState.activeTab.isASCTab { - await appState.ascManager.fetchTabData(appState.activeTab) + await appState.ascManager.ensureTabData(appState.activeTab) + } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { + await appState.ascManager.ensureTabData(.app) } } } @@ -118,25 +179,25 @@ struct ContentView: View { mainWindow?.close() } else { // Project switched → ensure config files, run pending setup, reload ASC credentials + if let newId = newValue { + appState.ascManager.prepareForProjectSwitch(to: newId) + } + + if let newId = newValue, let projectType = appState.activeProject?.type { + refreshProjectFiles(projectId: newId, projectType: projectType) + } + Task { - if let newId = newValue, let project = appState.activeProject { - // Ensure config files are up to date on every open. - // Handles Tauri migration, first-open of imported projects, - // and ensures Teenybase backend files are scaffolded. - let storage = ProjectStorage() - storage.ensureMCPConfig(projectId: newId) - storage.ensureTeenybaseBackend(projectId: newId, projectType: project.type) - storage.ensureClaudeFiles(projectId: newId, projectType: project.type) - } await startPendingSetupIfNeeded() - appState.ascManager.clearForProjectSwitch() if let newId = newValue, let project = appState.activeProject { await appState.ascManager.loadCredentials( for: newId, bundleId: project.metadata.bundleIdentifier ) if appState.activeTab.isASCTab { - await appState.ascManager.fetchTabData(appState.activeTab) + await appState.ascManager.ensureTabData(appState.activeTab) + } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { + await appState.ascManager.ensureTabData(.app) } } } @@ -145,12 +206,15 @@ struct ContentView: View { .onChange(of: appState.activeTab) { oldTab, newTab in tabSwitchTask?.cancel() tabSwitchTask = Task { - // Pause stream when leaving simulator tab - if oldTab == .simulator && newTab != .simulator { + let isLeavingSimulator = oldTab == .app && appState.activeAppSubTab == .simulator + let isEnteringSimulator = newTab == .app && appState.activeAppSubTab == .simulator + + // Pause stream when leaving simulator + if isLeavingSimulator && newTab != .app { await appState.simulatorStream.pauseStream() } - // Resume/start stream when entering simulator tab - if newTab == .simulator { + // Resume/start stream when entering simulator + if isEnteringSimulator { if appState.simulatorStream.isPaused { await appState.simulatorStream.resumeStream() } else if !appState.simulatorStream.isCapturing { @@ -161,7 +225,31 @@ struct ContentView: View { } // Fetch ASC data when entering any ASC tab if newTab.isASCTab { - await appState.ascManager.fetchTabData(newTab) + await appState.ascManager.ensureTabData(newTab) + } + } + } + .onChange(of: appState.activeAppSubTab) { oldSub, newSub in + guard appState.activeTab == .app else { return } + tabSwitchTask?.cancel() + tabSwitchTask = Task { + // Pause stream when leaving simulator sub-tab + if oldSub == .simulator && newSub != .simulator { + await appState.simulatorStream.pauseStream() + } + // Resume/start stream when entering simulator sub-tab + if newSub == .simulator { + if appState.simulatorStream.isPaused { + await appState.simulatorStream.resumeStream() + } else if !appState.simulatorStream.isCapturing { + await appState.simulatorStream.startStreaming( + bootedDeviceId: appState.simulatorManager.bootedDeviceId + ) + } + } + // Fetch ASC overview data when entering overview sub-tab + if newSub == .overview { + await appState.ascManager.ensureTabData(.app) } } } @@ -224,16 +312,10 @@ struct DetailView: View { @ViewBuilder private var activeTabView: some View { switch appState.activeTab { - case .simulator: - SimulatorView(appState: appState) - case .database: - DatabaseView(appState: appState) - case .tests: - TestsView(appState: appState) - case .assets: - AssetsView(appState: appState) - case .ascOverview: - ASCOverview(appState: appState) + case .dashboard: + DashboardView(appState: appState) + case .app: + AppTabView(appState: appState) case .storeListing: StoreListingView(appState: appState) case .screenshots: diff --git a/src/views/DashboardView.swift b/src/views/DashboardView.swift new file mode 100644 index 0000000..96931d8 --- /dev/null +++ b/src/views/DashboardView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +struct DashboardView: View { + @Bindable var appState: AppState + @State private var dashboardSummary = DashboardSummaryStore.shared + + private var projects: [Project] { appState.projectManager.projects } + private var summaryHydrationKey: String { + let credentialsKey = appState.ascManager.credentials?.keyId ?? "no-creds" + let fingerprint = projects + .map { "\($0.id):\($0.metadata.bundleIdentifier ?? "")" } + .sorted() + .joined(separator: "|") + return "\(credentialsKey):\(appState.ascManager.credentialActivationRevision):\(fingerprint)" + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Stat cards + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], + spacing: 12 + ) { + statCard( + title: "Live on Store", + value: statValue(dashboardSummary.summary.liveCount), + color: .green, + icon: "checkmark.seal.fill" + ) + statCard( + title: "Pending Review", + value: statValue(dashboardSummary.summary.pendingCount), + color: .orange, + icon: "clock.fill" + ) + statCard( + title: "Rejected Apps", + value: statValue(dashboardSummary.summary.rejectedCount), + color: .red, + icon: "xmark.seal.fill" + ) + } + + // App grid header + HStack { + Text("My Apps") + .font(.title3.weight(.semibold)) + Spacer() + } + + // App grid + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)], + spacing: 16 + ) { + ForEach(projects) { project in + appCard(project: project) + .onTapGesture { + selectProject(project) + } + } + } + + Spacer(minLength: 0) + } + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottomTrailing) { + Button { + appState.showNewProjectSheet = true + } label: { + Label("Create App", systemImage: "plus") + .font(.body.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(20) + } + .overlay(alignment: .topTrailing) { + if dashboardSummary.isLoadingSummary { + ProgressView() + .controlSize(.small) + .padding(12) + .background(.background.secondary, in: Capsule()) + .padding(20) + } + } + .task(id: summaryHydrationKey) { + await hydrateSummary() + } + } + + // MARK: - Stat Card + + private func statCard(title: String, value: String, color: Color, icon: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.callout) + .foregroundStyle(color) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(value) + .font(.system(size: 28, weight: .bold, design: .rounded)) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background.secondary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + // MARK: - App Card + + private func appCard(project: Project) -> some View { + let isSelected = project.id == appState.activeProjectId + + return VStack(spacing: 8) { + ProjectAppIconView(project: project, size: 56, cornerRadius: 12) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(projectColor(project).opacity(0.15)) + Image(systemName: projectIcon(project)) + .font(.system(size: 24)) + .foregroundStyle(projectColor(project)) + } + } + + Text(project.name) + .font(.callout.weight(.medium)) + .lineLimit(1) + + statusLabel(for: project) + .font(.caption2) + .lineLimit(1) + } + .padding(14) + .frame(maxWidth: .infinity) + .background(isSelected ? Color.accentColor.opacity(0.1) : Color(.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + .contentShape(Rectangle()) + } + + // MARK: - Actions + + private func selectProject(_ project: Project) { + appState.activeProjectId = project.id + let projectId = project.id + Task.detached(priority: .utility) { + ProjectStorage().updateLastOpened(projectId: projectId) + } + } + + private func hydrateSummary() async { + if projects.isEmpty { + await appState.projectManager.loadProjects() + } + + let hydrationKey = summaryHydrationKey + if dashboardSummary.isLoading(for: hydrationKey) || !dashboardSummary.shouldRefresh(for: hydrationKey) { + return + } + + let eligibleProjects = appState.projectManager.projects.compactMap { project -> DashboardProjectInput? in + guard let bundleId = project.metadata.bundleIdentifier? + .trimmingCharacters(in: .whitespacesAndNewlines), + !bundleId.isEmpty else { + return nil + } + return DashboardProjectInput(bundleId: bundleId) + } + + guard !eligibleProjects.isEmpty else { + dashboardSummary.markEmpty(for: hydrationKey) + return + } + + guard let credentials = ASCCredentials.load() else { + dashboardSummary.markUnavailable(for: hydrationKey) + return + } + + dashboardSummary.beginLoading(for: hydrationKey) + var nextSummary = ASCDashboardSummary.empty + var nextStatuses: [String: ASCDashboardProjectStatus] = [:] + let service = AppStoreConnectService(credentials: credentials) + + for project in eligibleProjects { + if Task.isCancelled { + dashboardSummary.cancelLoading(for: hydrationKey) + return + } + + do { + let app = try await service.fetchApp(bundleId: project.bundleId) + let versions = try await service.fetchAppStoreVersions(appId: app.id) + let status = ASCDashboardProjectStatus(versions: versions) + nextSummary.include(status) + nextStatuses[project.bundleId] = status + } catch { + continue + } + } + + if Task.isCancelled { + dashboardSummary.cancelLoading(for: hydrationKey) + return + } + + dashboardSummary.store(summary: nextSummary, projectStatuses: nextStatuses, for: hydrationKey) + } + + // MARK: - Helpers + + private func statValue(_ count: Int) -> String { + dashboardSummary.hasLoadedSummary ? "\(count)" : (projects.isEmpty ? "0" : "-") + } + + @ViewBuilder + private func statusLabel(for project: Project) -> some View { + let bundleId = project.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if let status = dashboardSummary.projectStatuses[bundleId] { + if status.isRejected { + Label("Rejected", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } else if status.isPendingReview { + Label("In Review", systemImage: "clock.fill") + .foregroundStyle(.orange) + } else if status.isLiveOnStore { + Label("Live", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label("Preparing", systemImage: "pencil.circle.fill") + .foregroundStyle(.secondary) + } + } else if dashboardSummary.hasLoadedSummary || bundleId.isEmpty { + Text(project.type.rawValue) + .foregroundStyle(.secondary) + } else { + Text(bundleId) + .foregroundStyle(.secondary) + } + } + + private func projectIcon(_ project: Project) -> String { + if project.platform == .macOS { return "desktopcomputer" } + switch project.type { + case .reactNative: return "atom" + case .swift: return "swift" + case .flutter: return "bird" + } + } + + private func projectColor(_ project: Project) -> Color { + switch project.type { + case .reactNative: return .cyan + case .swift: return .orange + case .flutter: return .blue + } + } + + private struct DashboardProjectInput: Sendable { + let bundleId: String + } +} diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index 18d8ba1..933f90f 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers /// Terminal app options for onboarding configuration enum TerminalApp: Hashable { + case builtIn case terminal case ghostty case iterm @@ -12,6 +13,7 @@ enum TerminalApp: Hashable { var id: String { switch self { + case .builtIn: return "builtIn" case .terminal: return "terminal" case .ghostty: return "ghostty" case .iterm: return "iterm" @@ -21,6 +23,20 @@ enum TerminalApp: Hashable { var displayName: String { switch self { + case .builtIn: return "Terminal (built-in)" + case .terminal: return "Terminal (external)" + case .ghostty: return "Ghostty (external)" + case .iterm: return "iTerm (external)" + case .custom(let path): + let name = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + return "\(name) (external)" + } + } + + /// Name without the "(external)" suffix — used in onboarding disclosure. + var shortDisplayName: String { + switch self { + case .builtIn: return "Built-in Terminal" case .terminal: return "Terminal" case .ghostty: return "Ghostty" case .iterm: return "iTerm" @@ -31,6 +47,7 @@ enum TerminalApp: Hashable { var iconName: String { switch self { + case .builtIn: return "terminal" case .terminal: return "terminal" case .ghostty: return "terminal" case .iterm: return "terminal" @@ -40,6 +57,7 @@ enum TerminalApp: Hashable { var bundleIdentifier: String { switch self { + case .builtIn: return "" case .terminal: return "com.apple.Terminal" case .ghostty: return "com.mitchellh.ghostty" case .iterm: return "com.googlecode.iterm2" @@ -47,8 +65,14 @@ enum TerminalApp: Hashable { } } + var isBuiltIn: Bool { + if case .builtIn = self { return true } + return false + } + var isAvailable: Bool { switch self { + case .builtIn: return true case .custom(let path): return FileManager.default.fileExists(atPath: path) default: @@ -56,9 +80,9 @@ enum TerminalApp: Hashable { } } - /// Missing saved terminals fall back to Terminal so launches still work. + /// Missing saved terminals fall back to built-in so launches still work. var resolvedFallback: TerminalApp { - isAvailable ? self : .terminal + isAvailable ? self : .builtIn } /// Persist to settings as a string @@ -67,6 +91,7 @@ enum TerminalApp: Hashable { /// Restore from settings string static func from(_ value: String) -> TerminalApp { switch value { + case "builtIn": return .builtIn case "terminal": return .terminal case "ghostty": return .ghostty case "iterm": return .iterm @@ -80,16 +105,21 @@ struct OnboardingView: View { var onComplete: () -> Void @State private var currentStep = 0 - @State private var selectedTerminal: TerminalApp = .terminal + @State private var selectedTerminal: TerminalApp = .builtIn @State private var selectedAgent: AIAgent = .claudeCode @State private var detectedTerminals: [TerminalApp] = [] @State private var showCustomPicker = false + @State private var showExternalTerminals = false @State private var skipAgentPermissions: Bool + @State private var whitelistBlitzMCPTools: Bool + @State private var allowASCCLICalls: Bool init(appState: AppState, onComplete: @escaping () -> Void) { self.appState = appState self.onComplete = onComplete _skipAgentPermissions = State(initialValue: appState.settingsStore.skipAgentPermissions) + _whitelistBlitzMCPTools = State(initialValue: appState.settingsStore.whitelistBlitzMCPTools) + _allowASCCLICalls = State(initialValue: appState.settingsStore.allowASCCLICalls) } // ASC setup state @@ -226,40 +256,9 @@ struct OnboardingView: View { // Right: configuration options ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { - // Terminal selection - VStack(alignment: .leading, spacing: 6) { - Label("Default Terminal", systemImage: "terminal") - .font(.headline) - - VStack(spacing: 2) { - ForEach(detectedTerminals, id: \.self) { terminal in - terminalRow(terminal) - } - - // Custom picker button - Button { - showCustomPicker = true - } label: { - HStack(spacing: 10) { - Image(systemName: "folder") - .frame(width: 20) - Text("Choose Custom...") - Spacer() - } - .padding(.vertical, 4) - .padding(.horizontal, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - } - - Divider() - - // Agent CLI selection + // Agent CLI selection (first — most users care about this) VStack(alignment: .leading, spacing: 6) { - Label("Default AI Agent", systemImage: "cpu") + Text("Default AI Agent") .font(.headline) VStack(spacing: 2) { @@ -271,8 +270,6 @@ struct OnboardingView: View { // Skip permissions toggle (only if agent supports it) if selectedAgent.skipPermissionsFlag != nil { - Divider() - Toggle(isOn: $skipAgentPermissions) { VStack(alignment: .leading, spacing: 1) { Text("Skip agent permissions") @@ -281,11 +278,93 @@ struct OnboardingView: View { .font(.caption2) .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) } .toggleStyle(.switch) .controlSize(.small) } + // Whitelist Blitz MCP tools + Toggle(isOn: $whitelistBlitzMCPTools) { + VStack(alignment: .leading, spacing: 1) { + Text("Allow all Blitz MCP tool calls") + .font(.callout) + Text("AI agents run Blitz tools without asking. Blitz still shows its own approval for destructive actions.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(.switch) + .controlSize(.small) + + Toggle(isOn: $allowASCCLICalls) { + VStack(alignment: .leading, spacing: 1) { + Text("Allow all ASC CLI calls") + .font(.callout) + Text("Whitelists shell commands starting with `asc` for agents.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(.switch) + .controlSize(.small) + + Divider() + + // Terminal selection + VStack(alignment: .leading, spacing: 6) { + Text("Terminal") + .font(.headline) + + // Built-in (recommended) — always shown + terminalRow(.builtIn, label: "Built-in Terminal (recommended)") + + // External terminals — collapsed under clickable header + VStack(alignment: .leading, spacing: 2) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showExternalTerminals.toggle() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .rotationEffect(.degrees(showExternalTerminals ? 90 : 0)) + Text("Use external terminal") + .font(.callout) + } + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.leading, 10) + .padding(.vertical, 4) + + if showExternalTerminals { + ForEach(externalTerminals, id: \.self) { terminal in + terminalRow(terminal, label: terminal.shortDisplayName) + } + + Button { + showCustomPicker = true + } label: { + HStack(spacing: 10) { + Image(systemName: "folder") + .frame(width: 20) + Text("Choose Custom...") + Spacer() + } + .padding(.vertical, 4) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } } .padding(.horizontal, 24) .padding(.vertical, 10) @@ -306,7 +385,11 @@ struct OnboardingView: View { } } - private func terminalRow(_ terminal: TerminalApp) -> some View { + private var externalTerminals: [TerminalApp] { + detectedTerminals.filter { !$0.isBuiltIn } + } + + private func terminalRow(_ terminal: TerminalApp, label: String? = nil) -> some View { let isSelected = selectedTerminal == terminal return Button { selectedTerminal = terminal @@ -314,7 +397,7 @@ struct OnboardingView: View { HStack(spacing: 10) { terminalIcon(for: terminal) .frame(width: 20, height: 20) - Text(terminal.displayName) + Text(label ?? terminal.displayName) .font(.body) Spacer() if isSelected { @@ -332,7 +415,11 @@ struct OnboardingView: View { @ViewBuilder private func terminalIcon(for terminal: TerminalApp) -> some View { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { + if terminal.isBuiltIn { + Image(systemName: "terminal.fill") + .resizable() + .aspectRatio(contentMode: .fit) + } else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { let icon = NSWorkspace.shared.icon(forFile: appURL.path) Image(nsImage: icon) .resizable() @@ -608,9 +695,18 @@ struct OnboardingView: View { } } + /// Resolve the terminal for onboarding context where the built-in split pane + /// is unavailable due to the small window size. + private var onboardingTerminal: TerminalApp { + let resolved = selectedTerminal.resolvedFallback + guard resolved.isBuiltIn else { return resolved } + // Built-in can't render in the onboarding window — fall back to Terminal.app + return .terminal + } + private func launchASCSetupWithAI() { let agent = selectedAgent - let terminal = selectedTerminal.resolvedFallback + let terminal = onboardingTerminal let prompt = "Use the /asc-team-key-create skill to create a new App Store Connect API key, then call the asc_set_credentials MCP tool to fill the form so I can verify and save." TerminalLauncher.launch( projectPath: BlitzPaths.mcps.path, @@ -643,7 +739,7 @@ struct OnboardingView: View { VStack(spacing: 6) { slideHeader( title: "Ask AI from Any Tab", - subtitle: "Click \"Ask AI\" to launch \(selectedAgent.displayName) in \(selectedTerminal.displayName)." + subtitle: "Click \"Ask AI\" to launch \(selectedAgent.displayName) in \(selectedTerminal.isBuiltIn ? "the built-in terminal" : selectedTerminal.displayName)." ) // Demo video — transparent background, aspect fit @@ -716,6 +812,7 @@ struct OnboardingView: View { ) Button("Open System Settings") { + AppRelaunchService.shared.prepareForScreenRecordingPermissionRestart() if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { NSWorkspace.shared.open(url) } @@ -799,10 +896,10 @@ struct OnboardingView: View { // MARK: - Logic private func detectTerminals() -> [TerminalApp] { - var found: [TerminalApp] = [] + var found: [TerminalApp] = [.builtIn] let ws = NSWorkspace.shared - // Always include macOS Terminal + // macOS Terminal if ws.urlForApplication(withBundleIdentifier: "com.apple.Terminal") != nil { found.append(.terminal) } @@ -853,9 +950,20 @@ struct OnboardingView: View { settings.defaultTerminal = selectedTerminal.settingsValue settings.defaultAgentCLI = selectedAgent.rawValue settings.skipAgentPermissions = skipAgentPermissions + settings.whitelistBlitzMCPTools = whitelistBlitzMCPTools + settings.allowASCCLICalls = allowASCCLICalls settings.hasCompletedOnboarding = true settings.save() + let whitelistBlitzMCP = whitelistBlitzMCPTools + let allowASCCLI = allowASCCLICalls + Task.detached(priority: .utility) { + ProjectStorage().ensureGlobalMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLI + ) + } + // Also persist agent selection to AppStorage for ConnectAIPopover UserDefaults.standard.set(selectedAgent.rawValue, forKey: "selectedAIAgent") diff --git a/src/views/build/ConnectAIPopover.swift b/src/views/build/ConnectAIPopover.swift index d0abd33..0f169b7 100644 --- a/src/views/build/ConnectAIPopover.swift +++ b/src/views/build/ConnectAIPopover.swift @@ -74,9 +74,10 @@ struct ConnectAIPopover: View { } private var command: String { - let cli = agent.cliCommand - guard let path = projectPath else { return cli } - return "cd \(path) && \(cli)" + TerminalLauncher.buildAgentCommand( + projectPath: projectPath, + agent: agent + ) } private var tabPrompt: String? { @@ -86,15 +87,9 @@ struct ConnectAIPopover: View { /// Tab-specific default prompt, shared with TerminalLauncher. static func prompt(for tab: AppTab) -> String? { switch tab { - case .simulator: - return "Build and launch my app on the simulator, then describe what's on screen." - case .database: - return "Help me set up a database schema and authentication for my app." - case .tests: - return "Help me write and run tests for my app." - case .assets: - return "Help me generate and configure app icons and assets." - case .ascOverview: + case .dashboard: + return nil + case .app: return "Help me complete all the steps needed to submit my app to the App Store." case .storeListing: return "Help me write a compelling App Store listing — name, subtitle, description, and keywords." diff --git a/src/views/build/IntegratedTerminalView.swift b/src/views/build/IntegratedTerminalView.swift new file mode 100644 index 0000000..b8a935a --- /dev/null +++ b/src/views/build/IntegratedTerminalView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import SwiftTerm + +/// Hosts a `TerminalSession`'s `LocalProcessTerminalView` inside a container NSView. +/// The terminal view is owned by `TerminalSession` (not by SwiftUI), so it persists +/// across show/hide cycles and tab switches. +struct TerminalSessionView: NSViewRepresentable { + let session: TerminalSession + let isActive: Bool + + func makeNSView(context: Context) -> NSView { + let container = NSView(frame: .zero) + container.wantsLayer = true + embed(session.terminalView, in: container) + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + let termView = session.terminalView + // Re-embed only if the session's view isn't already in this container + if termView.superview !== nsView { + nsView.subviews.forEach { $0.removeFromSuperview() } + embed(termView, in: nsView) + } + + guard isActive else { return } + + termView.needsLayout = true + termView.needsDisplay = true + termView.displayIfNeeded() + + if nsView.window?.firstResponder !== termView { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(termView) + } + } + } + + static func dismantleNSView(_ nsView: NSView, coordinator: ()) { + // Do NOT remove the terminal view here. It is managed by TerminalSession + // and will be re-embedded by makeNSView when the view hierarchy is rebuilt + // (e.g. switching split position). Removing it here causes the terminal's + // rendering context to be lost, resulting in a blank view. + } + + private func embed(_ termView: LocalProcessTerminalView, in container: NSView) { + termView.removeFromSuperview() + termView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(termView) + NSLayoutConstraint.activate([ + termView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + termView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + termView.topAnchor.constraint(equalTo: container.topAnchor), + termView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + // Force a full redraw after re-embedding to restore rendering state + termView.needsLayout = true + termView.needsDisplay = true + } +} diff --git a/src/views/build/TerminalPanelView.swift b/src/views/build/TerminalPanelView.swift new file mode 100644 index 0000000..3259717 --- /dev/null +++ b/src/views/build/TerminalPanelView.swift @@ -0,0 +1,177 @@ +import SwiftUI + +struct TerminalPanelView: View { + @Bindable var appState: AppState + + private var manager: TerminalManager { appState.terminalManager } + private var isRight: Bool { appState.settingsStore.terminalPosition == "right" } + + var body: some View { + VStack(spacing: 0) { + tabBar + Divider() + terminalContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Tab Bar + + private var tabBar: some View { + HStack(spacing: 8) { + sessionTabStrip + tabBarControls + } + .padding(.leading, 8) + .padding(.vertical, 2) + .background(.bar) + } + + private var sessionTabStrip: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(manager.sessions) { session in + sessionTab(session) + .id(session.id) + } + } + .padding(.trailing, 4) + } + .scrollIndicators(.hidden) + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + scrollToActiveSession(using: proxy, animated: false) + } + .onChange(of: manager.activeSessionId) { _, _ in + scrollToActiveSession(using: proxy) + } + .onChange(of: manager.sessions.map(\.id)) { _, _ in + scrollToActiveSession(using: proxy) + } + } + } + + private var tabBarControls: some View { + HStack(spacing: 0) { + Button { + manager.createSession(projectPath: appState.activeProject?.path) + } label: { + Image(systemName: "plus") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("New terminal") + + Button { + let settings = appState.settingsStore + settings.terminalPosition = isRight ? "bottom" : "right" + settings.save() + } label: { + Image(systemName: isRight ? "rectangle.bottomhalf.filled" : "rectangle.righthalf.filled") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help(isRight ? "Move to bottom" : "Move to right") + + Button { + appState.showTerminal = false + } label: { + Image(systemName: isRight ? "chevron.right" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Hide terminal panel") + .padding(.trailing, 8) + } + } + + private func sessionTab(_ session: TerminalSession) -> some View { + let isActive = session.id == manager.activeSessionId + + return HStack(spacing: 4) { + Image(systemName: session.isTerminated ? "terminal" : "terminal.fill") + .font(.system(size: 10)) + + Text(session.title) + .font(.system(size: 11, weight: isActive ? .medium : .regular)) + .lineLimit(1) + + // Close button + Button { + manager.closeSession(session.id) + if manager.sessions.isEmpty { + appState.showTerminal = false + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.tertiary) + .frame(width: 14, height: 14) + } + .buttonStyle(.plain) + .opacity(isActive ? 1 : 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isActive ? Color.primary.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + .onTapGesture { + manager.activeSessionId = session.id + } + } + + private func scrollToActiveSession(using proxy: ScrollViewProxy, animated: Bool = true) { + guard let activeSessionId = manager.activeSessionId else { return } + + DispatchQueue.main.async { + if animated { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(activeSessionId, anchor: .trailing) + } + } else { + proxy.scrollTo(activeSessionId, anchor: .trailing) + } + } + } + + // MARK: - Content + + @ViewBuilder + private var terminalContent: some View { + if manager.sessions.isEmpty { + VStack(spacing: 8) { + Text("No terminal sessions") + .font(.callout) + .foregroundStyle(.secondary) + Button("New Terminal") { + manager.createSession(projectPath: appState.activeProject?.path) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ZStack { + // Keep each session's host NSView alive so switching tabs does not require + // SwiftUI to reparent a single LocalProcessTerminalView between containers. + ForEach(manager.sessions) { session in + let isActive = session.id == manager.activeSessionId + + TerminalSessionView(session: session, isActive: isActive) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .zIndex(isActive ? 1 : 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/src/views/build/TerminalSplitView.swift b/src/views/build/TerminalSplitView.swift new file mode 100644 index 0000000..ab8eeff --- /dev/null +++ b/src/views/build/TerminalSplitView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +/// A split view that keeps a **stable view hierarchy** regardless of orientation. +/// Switching between bottom/right only changes frame sizes — child views are never +/// destroyed or recreated, which preserves terminal NSView rendering state. +struct TerminalSplitView: View { + let isHorizontal: Bool + let showPanel: Bool + @Binding var panelSize: CGFloat + let minPanelSize: CGFloat + let minContentSize: CGFloat + @ViewBuilder let content: () -> Content + @ViewBuilder let panel: () -> Panel + + @State private var dragStartPanelSize: CGFloat? + @State private var isHoveringDivider = false + @State private var isDraggingDivider = false + + private let dividerThickness: CGFloat = 1 + private let grabAreaThickness: CGFloat = 20 + + var body: some View { + GeometryReader { geo in + let total = axisLength(in: geo.size) + let visibleDividerThickness = showPanel ? dividerThickness : 0 + let clampedPanelSize = showPanel ? clampedPanelSize(panelSize, total: total) : 0 + let contentSize = max(total - clampedPanelSize - visibleDividerThickness, 0) + let dividerOffset = contentSize + let panelOffset = contentSize + visibleDividerThickness + + // Always the same ZStack → stable view identity for both children + ZStack(alignment: .topLeading) { + // Content — pinned to top-left + content() + .frame( + width: isHorizontal ? contentSize : geo.size.width, + height: isHorizontal ? geo.size.height : contentSize, + alignment: .topLeading + ) + + // Panel — stays in the hierarchy even when hidden so orientation flips do not + // recreate the underlying NSView subtree. + panel() + .frame( + width: isHorizontal ? clampedPanelSize : geo.size.width, + height: isHorizontal ? geo.size.height : clampedPanelSize, + alignment: .topLeading + ) + .offset( + x: isHorizontal ? panelOffset : 0, + y: isHorizontal ? 0 : panelOffset + ) + .opacity(showPanel ? 1 : 0) + .allowsHitTesting(showPanel) + + // Visible divider line + Rectangle() + .fill(dividerColor) + .frame( + width: isHorizontal ? visibleDividerThickness : geo.size.width, + height: isHorizontal ? geo.size.height : visibleDividerThickness + ) + .offset( + x: isHorizontal ? dividerOffset : 0, + y: isHorizontal ? 0 : dividerOffset + ) + .opacity(showPanel ? 1 : 0) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.15), value: isHoveringDivider) + .animation(.easeInOut(duration: 0.15), value: isDraggingDivider) + + dividerHandle(in: geo.size, dividerOffset: dividerOffset, total: total) + .opacity(showPanel ? 1 : 0) + .allowsHitTesting(showPanel) + .zIndex(1) + } + .onDisappear { + dragStartPanelSize = nil + isDraggingDivider = false + isHoveringDivider = false + } + } + } + + private var dividerColor: Color { + if isHoveringDivider || isDraggingDivider { + return Color.accentColor.opacity(0.6) + } + return Color(nsColor: .separatorColor) + } + + private func dividerHandle(in size: CGSize, dividerOffset: CGFloat, total: CGFloat) -> some View { + // Use a nearly transparent fill instead of Color.clear so AppKit always has a reliable + // hit-testable surface above the embedded terminal NSView. + Rectangle() + .fill(Color.black.opacity(0.001)) + .contentShape(Rectangle()) + .frame( + width: isHorizontal ? grabAreaThickness : size.width, + height: isHorizontal ? size.height : grabAreaThickness + ) + .offset( + x: isHorizontal ? dividerOffset - ((grabAreaThickness - dividerThickness) / 2) : 0, + y: isHorizontal ? 0 : dividerOffset - ((grabAreaThickness - dividerThickness) / 2) + ) + .resizeCursor(isHorizontal ? .resizeLeftRight : .resizeUpDown, isHovering: $isHoveringDivider) + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let startSize = dragStartPanelSize ?? clampedPanelSize(panelSize, total: total) + dragStartPanelSize = startSize + isDraggingDivider = true + panelSize = clampedPanelSize(startSize + dragDelta(for: value), total: total) + } + .onEnded { value in + let startSize = dragStartPanelSize ?? clampedPanelSize(panelSize, total: total) + panelSize = clampedPanelSize(startSize + dragDelta(for: value), total: total) + dragStartPanelSize = nil + isDraggingDivider = false + } + ) + } + + private func axisLength(in size: CGSize) -> CGFloat { + isHorizontal ? size.width : size.height + } + + private func dragDelta(for value: DragGesture.Value) -> CGFloat { + isHorizontal ? -value.translation.width : -value.translation.height + } + + private func clampedPanelSize(_ proposed: CGFloat, total: CGFloat) -> CGFloat { + let maxPanelSize = max(total - minContentSize, 0) + let minAllowedPanelSize = min(minPanelSize, maxPanelSize) + return min(max(proposed, minAllowedPanelSize), maxPanelSize) + } +} + +// MARK: - Cursor + +private extension View { + func resizeCursor(_ cursor: NSCursor, isHovering: Binding) -> some View { + modifier(ResizeCursorModifier(cursor: cursor, isHovering: isHovering)) + } +} + +private struct ResizeCursorModifier: ViewModifier { + let cursor: NSCursor + @Binding var isHovering: Bool + + @State private var hasPushedCursor = false + + func body(content: Content) -> some View { + content + .onContinuousHover { phase in + switch phase { + case .active: + isHovering = true + if !hasPushedCursor { + cursor.push() + hasPushedCursor = true + } + case .ended: + isHovering = false + if hasPushedCursor { + NSCursor.pop() + hasPushedCursor = false + } + } + } + .onDisappear { + isHovering = false + if hasPushedCursor { + NSCursor.pop() + hasPushedCursor = false + } + } + } +} diff --git a/src/views/build/TestsView.swift b/src/views/build/TestsView.swift index f4e8d52..1268052 100644 --- a/src/views/build/TestsView.swift +++ b/src/views/build/TestsView.swift @@ -95,7 +95,7 @@ struct TestsView: View { 1. Navigate to each screen and configure the UI — scroll, tap into views, fill in data 2. Call `get_screenshot` to capture each state at full resolution - 3. Use `screenshots_add_asset` → `screenshots_set_track` → `screenshots_save` to upload to App Store Connect + 3. Use `screenshots_switch_localization` → `screenshots_add_asset` → `screenshots_set_track` → `screenshots_save` to upload to App Store Connect You get pixel-perfect, context-rich screenshots without touching the simulator. """ diff --git a/src/views/build/DatabaseView.swift b/src/views/build/database/DatabaseView.swift similarity index 100% rename from src/views/build/DatabaseView.swift rename to src/views/build/database/DatabaseView.swift diff --git a/src/views/build/DeviceSelectorView.swift b/src/views/build/simulator/DeviceSelectorView.swift similarity index 97% rename from src/views/build/DeviceSelectorView.swift rename to src/views/build/simulator/DeviceSelectorView.swift index 29cbcd5..e224554 100644 --- a/src/views/build/DeviceSelectorView.swift +++ b/src/views/build/simulator/DeviceSelectorView.swift @@ -95,7 +95,7 @@ struct DeviceSelectorView: View { await manager.loadSimulators() // 5. Start streaming the new device - if appState.activeTab == .simulator { + if appState.activeTab == .app && appState.activeAppSubTab == .simulator { await appState.simulatorStream.startStreaming( bootedDeviceId: sim.udid ) diff --git a/src/views/build/MetalFrameView.swift b/src/views/build/simulator/MetalFrameView.swift similarity index 100% rename from src/views/build/MetalFrameView.swift rename to src/views/build/simulator/MetalFrameView.swift diff --git a/src/views/build/SimulatorView.swift b/src/views/build/simulator/SimulatorView.swift similarity index 92% rename from src/views/build/SimulatorView.swift rename to src/views/build/simulator/SimulatorView.swift index 8752e1b..2693f80 100644 --- a/src/views/build/SimulatorView.swift +++ b/src/views/build/simulator/SimulatorView.swift @@ -1,5 +1,6 @@ import SwiftUI import MetalKit +import SwiftTerm /// Main Build tab view — simulator frame display + touch interaction struct SimulatorView: View { @@ -92,6 +93,7 @@ struct SimulatorView: View { HStack(spacing: 12) { Button("Open System Settings") { + AppRelaunchService.shared.prepareForScreenRecordingPermissionRestart() NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")!) } Button("Retry") { @@ -234,15 +236,14 @@ struct SimulatorView: View { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event in // Only capture when simulator is streaming and this tab is active guard stream.isCapturing, - appState.activeTab == .simulator, + appState.activeTab == .app && appState.activeAppSubTab == .simulator, !appState.ascManager.showAppleIDLogin, let udid = appState.simulatorManager.bootedDeviceId else { return event } - // Don't capture if a text field or other responder has focus - if let responder = event.window?.firstResponder, - responder is NSTextView || responder is NSTextField { + // Don't capture if another text-input surface currently owns focus. + if simulatorKeyPassthroughShouldIgnore(event.window?.firstResponder) { return event } @@ -265,6 +266,24 @@ struct SimulatorView: View { } } + private func simulatorKeyPassthroughShouldIgnore(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if responder is NSTextView || responder is NSTextField || responder is TerminalView { + return true + } + + var view = responder as? NSView + while let current = view { + if current is TerminalView { + return true + } + view = current.superview + } + + return false + } + private func removeKeyMonitor() { if let monitor = keyMonitor { NSEvent.removeMonitor(monitor) diff --git a/src/views/build/TouchOverlayView.swift b/src/views/build/simulator/TouchOverlayView.swift similarity index 100% rename from src/views/build/TouchOverlayView.swift rename to src/views/build/simulator/TouchOverlayView.swift diff --git a/src/views/insights/AnalyticsView.swift b/src/views/insights/AnalyticsView.swift index 6e873e3..3ca722f 100644 --- a/src/views/insights/AnalyticsView.swift +++ b/src/views/insights/AnalyticsView.swift @@ -15,15 +15,18 @@ struct AnalyticsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .analytics, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .analytics, platform: appState.activeProject?.platform ?? .iOS) { analyticsContent } } - .task { await asc.fetchTabData(.analytics) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.analytics) + } } @ViewBuilder @@ -43,6 +46,7 @@ struct AnalyticsView: View { } .pickerStyle(.segmented) .frame(width: 240) + ASCTabRefreshButton(asc: asc, tab: .analytics, helpText: "Refresh analytics tab") } if !hasVendor { diff --git a/src/views/insights/ReviewsView.swift b/src/views/insights/ReviewsView.swift index 0d22828..925955b 100644 --- a/src/views/insights/ReviewsView.swift +++ b/src/views/insights/ReviewsView.swift @@ -7,32 +7,55 @@ struct ReviewsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .reviews, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .reviews, platform: appState.activeProject?.platform ?? .iOS) { reviewsContent } } - .task { await asc.fetchTabData(.reviews) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.reviews) + } } @ViewBuilder private var reviewsContent: some View { - if asc.customerReviews.isEmpty { - ContentUnavailableView( - "No Reviews", - systemImage: "bubble.left.and.bubble.right", - description: Text("No customer reviews found for this app.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(asc.customerReviews) { review in - reviewRow(review) - .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + VStack(spacing: 0) { + HStack { + Text("Reviews") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .reviews, helpText: "Refresh reviews") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + Divider() + + if asc.customerReviews.isEmpty { + if asc.isTabLoading(.reviews) { + ASCTabLoadingPlaceholder( + title: "Loading Reviews", + message: "Fetching customer ratings and review text." + ) + } else { + ContentUnavailableView( + "No Reviews", + systemImage: "bubble.left.and.bubble.right", + description: Text("No customer reviews found for this app.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + List(asc.customerReviews) { review in + reviewRow(review) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + } + .listStyle(.plain) } - .listStyle(.plain) } } diff --git a/src/views/projects/ImportProjectSheet.swift b/src/views/projects/ImportProjectSheet.swift index abeb94c..6621c8f 100644 --- a/src/views/projects/ImportProjectSheet.swift +++ b/src/views/projects/ImportProjectSheet.swift @@ -166,9 +166,18 @@ struct ImportProjectSheet: View { // This ensures all Blitz files land in the actual project, not a detached directory. try storage.writeMetadataToDirectory(url, metadata: metadata) let projectId = try storage.openProject(at: url) - storage.ensureMCPConfig(projectId: projectId) + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, + allowASCCLICalls: appState.settingsStore.allowASCCLICalls + ) storage.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) - storage.ensureClaudeFiles(projectId: projectId, projectType: projectType) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, + allowASCCLICalls: appState.settingsStore.allowASCCLICalls + ) await appState.projectManager.loadProjects() appState.activeProjectId = projectId isPresented = false diff --git a/src/views/projects/NewProjectSheet.swift b/src/views/projects/NewProjectSheet.swift index b24e273..724f7e0 100644 --- a/src/views/projects/NewProjectSheet.swift +++ b/src/views/projects/NewProjectSheet.swift @@ -6,7 +6,7 @@ struct NewProjectSheet: View { @State private var projectName = "" @State private var platform: ProjectPlatform = .iOS - @State private var projectType: ProjectType = .reactNative + @State private var projectType: ProjectType = .swift @State private var errorMessage: String? var body: some View { diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index da5cadf..efb74b1 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -5,24 +5,20 @@ struct ASCOverview: View { private var asc: ASCManager { appState.ascManager } @State private var showPreview = false - @State private var appIcon: NSImage? var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .ascOverview, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .app, platform: appState.activeProject?.platform ?? .iOS) { overviewContent } } - .task(id: appState.activeProjectId) { - if let pid = appState.activeProjectId { - asc.checkAppIcon(projectId: pid) - appIcon = Self.loadAppIcon(projectId: pid) - } - await asc.fetchTabData(.ascOverview) + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.app) } .sheet(isPresented: $showPreview) { SubmitPreviewSheet(appState: appState) @@ -39,14 +35,22 @@ struct ASCOverview: View { private var overviewContent: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Overview") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .app, helpText: "Refresh overview data") + } + if let app = asc.app { HStack(spacing: 10) { - if let icon = appIcon { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 9)) + if let project = appState.activeProject { + ProjectAppIconView(project: project, size: 40, cornerRadius: 9) { + Image(systemName: "app.fill") + .font(.system(size: 30)) + .foregroundStyle(.blue) + .frame(width: 40, height: 40) + } } else { Image(systemName: "app.fill") .font(.system(size: 30)) @@ -122,14 +126,6 @@ struct ASCOverview: View { Text("Submission Readiness") .font(.headline) - Button { - Task { await asc.refreshTabData(.ascOverview) } - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Refresh submission readiness") - Spacer() let versionState = asc.appStoreVersions.first(where: { let s = $0.attributes.appStoreState ?? "" @@ -156,6 +152,12 @@ struct ASCOverview: View { Text(field.label) .font(.callout) .foregroundStyle(.orange) + } else if field.isLoading { + ProgressView() + .controlSize(.small) + Text(field.label) + .font(.callout) + .foregroundStyle(.secondary) } else if field.required && (field.value == nil || field.value!.isEmpty) { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) @@ -194,6 +196,10 @@ struct ASCOverview: View { .frame(maxWidth: 200, alignment: .trailing) } } + } else if field.isLoading { + Text("Loading…") + .font(.callout) + .foregroundStyle(.secondary) } else if let url = field.actionUrl, let nsUrl = URL(string: url) { if field.label != "Privacy Nutrition Labels" { Button { @@ -280,7 +286,22 @@ struct ASCOverview: View { let settings = SettingsService.shared let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode let terminal = settings.resolveDefaultTerminal().terminal - TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt, skipPermissions: settings.skipAgentPermissions) + + if terminal.isBuiltIn { + appState.showTerminal = true + let session = appState.terminalManager.createSession(projectPath: projectPath) + let command = TerminalLauncher.buildAgentCommand( + projectPath: projectPath, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt, skipPermissions: settings.skipAgentPermissions) + } } private var buildProgress: Double { @@ -346,6 +367,7 @@ struct ASCOverview: View { case "PENDING_DEVELOPER_RELEASE": return ("Pending Release", .yellow) case "IN_REVIEW": return ("In Review", .blue) case "WAITING_FOR_REVIEW": return ("Waiting", .blue) + case "INVALID_BINARY": return ("Submission Error", .red) case "REJECTED": return ("Rejected", .red) case "DEVELOPER_REJECTED": return ("Dev Rejected", .orange) case "DEVELOPER_REMOVED_FROM_SALE": return ("Removed", .secondary) @@ -357,6 +379,8 @@ struct ASCOverview: View { switch eventType { case .submitted: return ("Submitted", .blue) + case .submissionError: + return ("Submission Error", .red) case .inReview: return ("In Review", .blue) case .processing: @@ -373,29 +397,4 @@ struct ASCOverview: View { return ("Removed", .secondary) } } - - - private static func loadAppIcon(projectId: String) -> NSImage? { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let blitzPath = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon/icon_1024.png" - if let image = NSImage(contentsOfFile: blitzPath) { return image } - - let projectDir = "\(home)/.blitz/projects/\(projectId)" - let fm = FileManager.default - guard let enumerator = fm.enumerator(atPath: projectDir) else { return nil } - while let file = enumerator.nextObject() as? String { - guard file.hasSuffix("AppIcon.appiconset/Contents.json") else { continue } - let contentsPath = "\(projectDir)/\(file)" - guard let data = fm.contents(atPath: contentsPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let images = json["images"] as? [[String: Any]] else { continue } - for entry in images { - if let filename = entry["filename"] as? String { - let iconDir = (contentsPath as NSString).deletingLastPathComponent - if let image = NSImage(contentsOfFile: "\(iconDir)/\(filename)") { return image } - } - } - } - return nil - } } diff --git a/src/views/release/AppDetailsView.swift b/src/views/release/AppDetailsView.swift index 847debf..0909909 100644 --- a/src/views/release/AppDetailsView.swift +++ b/src/views/release/AppDetailsView.swift @@ -45,21 +45,33 @@ struct AppDetailsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .appDetails, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .appDetails, platform: appState.activeProject?.platform ?? .iOS) { detailsContent } } - .task { await asc.fetchTabData(.appDetails) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.appDetails) + } } @ViewBuilder private var detailsContent: some View { + let isLoading = asc.isTabLoading(.appDetails) ScrollView { VStack(alignment: .leading, spacing: 0) { + HStack { + Text("App Details") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .appDetails, helpText: "Refresh app details") + } + .padding(.bottom, 20) + sectionHeader("App Identity") if let app = asc.app { @@ -82,10 +94,20 @@ struct AppDetailsView: View { .padding(.top, 20) if asc.appStoreVersions.isEmpty { - Text("No versions found") - .font(.callout) - .foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading version information…") + .font(.callout) + .foregroundStyle(.secondary) + } .padding(.vertical, 8) + } else { + Text("No versions found") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + } } else { ForEach(Array(asc.appStoreVersions.prefix(5).enumerated()), id: \.element.id) { idx, version in HStack { diff --git a/src/views/release/PricingView.swift b/src/views/release/PricingView.swift index eb78662..a634653 100644 --- a/src/views/release/PricingView.swift +++ b/src/views/release/PricingView.swift @@ -8,26 +8,30 @@ struct MonetizationView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .monetization, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .monetization, platform: appState.activeProject?.platform ?? .iOS) { monetizationContent } } - .task { await asc.fetchTabData(.monetization) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.monetization) + } } @ViewBuilder private var monetizationContent: some View { + let isLoading = asc.isTabLoading(.monetization) ScrollView { VStack(alignment: .leading, spacing: 24) { HStack { Text("Monetization") .font(.title2.weight(.semibold)) Spacer() - RefreshButton(asc: asc) + ASCTabRefreshButton(asc: asc, tab: .monetization, helpText: "Refresh monetization data") } if let err = asc.writeError { @@ -40,6 +44,18 @@ struct MonetizationView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + if isLoading + && asc.appPricePoints.isEmpty + && asc.inAppPurchases.isEmpty + && asc.subscriptionGroups.isEmpty { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading pricing, products, and subscriptions…") + .font(.callout) + .foregroundStyle(.secondary) + } + } + AppPricingSection(asc: asc) InAppPurchasesSection(asc: asc) SubscriptionsSection(asc: asc) @@ -149,73 +165,100 @@ private struct AppPricingSection: View { @State private var scheduledPricePointId = "" var body: some View { + let isLoading = asc.isTabLoading(.monetization) SectionCard { Label("Pricing & Availability", systemImage: "dollarsign.circle") .font(.headline) - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Free App").font(.body.weight(.medium)) - Text("Your app will be available for free on the App Store.") - .font(.callout).foregroundStyle(.secondary) + if isLoading && asc.appPricePoints.isEmpty && asc.currentAppPricePointId == nil && asc.scheduledAppPricePointId == nil { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading pricing state…") + .font(.callout) + .foregroundStyle(.secondary) } - Spacer() - Toggle("", isOn: Binding( - get: { isFree }, - set: { newValue in - guard newValue != isFree else { return } - isFree = newValue - guard newValue else { return } - isSaving = true - selectedPricePointId = "" - Task { - await asc.setPriceFree() - isSaving = false - } + } else { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Free App").font(.body.weight(.medium)) + Text("Your app will be available for free on the App Store.") + .font(.callout).foregroundStyle(.secondary) } - )) - .labelsHidden() - } + Spacer() + Toggle("", isOn: Binding( + get: { isFree }, + set: { newValue in + guard newValue != isFree else { return } + isFree = newValue + guard newValue else { return } + isSaving = true + selectedPricePointId = "" + Task { + await asc.setPriceFree() + isSaving = false + } + } + )) + .labelsHidden() + } - if !isFree { - PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $selectedPricePointId) + if !isFree { + PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $selectedPricePointId) - if !selectedPricePointId.isEmpty { - Button("Set Price") { - isSaving = true - Task { - await asc.setAppPrice(pricePointId: selectedPricePointId) - isSaving = false + if !selectedPricePointId.isEmpty { + Button("Set Price") { + isSaving = true + Task { + await asc.setAppPrice(pricePointId: selectedPricePointId) + isSaving = false + } } + .buttonStyle(.borderedProminent).controlSize(.small) } - .buttonStyle(.borderedProminent).controlSize(.small) - } - Divider() - - DisclosureGroup("Schedule Price Change", isExpanded: $showScheduled) { - VStack(alignment: .leading, spacing: 12) { - DatePicker("Effective Date", selection: $scheduledDate, in: Date()..., displayedComponents: .date) - PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $scheduledPricePointId) + Divider() - if !scheduledPricePointId.isEmpty { - Button("Create Price Change") { - isSaving = true - let currentId = selectedPricePointId.isEmpty ? freePointId : selectedPricePointId - let dateStr = formatDate(scheduledDate) - Task { - await asc.setScheduledAppPrice( - currentPricePointId: currentId, - futurePricePointId: scheduledPricePointId, - effectiveDate: dateStr - ) - isSaving = false + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation { showScheduled.toggle() } + } label: { + HStack { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(showScheduled ? 90 : 0)) + .animation(.easeInOut(duration: 0.15), value: showScheduled) + Text("Schedule Price Change") + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if showScheduled { + VStack(alignment: .leading, spacing: 12) { + DatePicker("Effective Date", selection: $scheduledDate, in: Date()..., displayedComponents: .date) + PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $scheduledPricePointId) + + if !scheduledPricePointId.isEmpty { + Button("Create Price Change") { + isSaving = true + let currentId = selectedPricePointId.isEmpty ? freePointId : selectedPricePointId + let dateStr = formatDate(scheduledDate) + Task { + await asc.setScheduledAppPrice( + currentPricePointId: currentId, + futurePricePointId: scheduledPricePointId, + effectiveDate: dateStr + ) + isSaving = false + } + } + .buttonStyle(.borderedProminent).controlSize(.small) } } - .buttonStyle(.borderedProminent).controlSize(.small) + .padding(.top, 8) } } - .padding(.top, 8) } } @@ -306,6 +349,7 @@ private struct InAppPurchasesSection: View { } var body: some View { + let isLoading = asc.isTabLoading(.monetization) SectionCard { HStack { Label("In-App Purchases", systemImage: "cart") @@ -319,8 +363,17 @@ private struct InAppPurchasesSection: View { } if asc.inAppPurchases.isEmpty && !showCreateForm { - Text("No in-app purchases configured.") - .font(.callout).foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading in-app purchases…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Text("No in-app purchases configured.") + .font(.callout).foregroundStyle(.secondary) + } } else { ForEach(asc.inAppPurchases) { iap in IAPDetailRow( @@ -611,6 +664,7 @@ private struct SubscriptionsSection: View { } var body: some View { + let isLoading = asc.isTabLoading(.monetization) SectionCard { HStack { Label("Subscriptions", systemImage: "arrow.triangle.2.circlepath") @@ -624,8 +678,17 @@ private struct SubscriptionsSection: View { } if asc.subscriptionGroups.isEmpty && !showCreateForm { - Text("No subscriptions configured.") - .font(.callout).foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading subscriptions…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Text("No subscriptions configured.") + .font(.callout).foregroundStyle(.secondary) + } } else { ForEach(asc.subscriptionGroups) { group in SubscriptionGroupRow( @@ -798,8 +861,17 @@ private struct SubscriptionGroupRow: View { let subs = asc.subscriptionsPerGroup[group.id] ?? [] if subs.isEmpty { - Text("No subscriptions in this group.") - .font(.caption).foregroundStyle(.tertiary) + if asc.isTabLoading(.monetization) { + HStack(spacing: 8) { + ProgressView().controlSize(.mini) + Text("Loading subscriptions in this group…") + .font(.caption) + .foregroundStyle(.tertiary) + } + } else { + Text("No subscriptions in this group.") + .font(.caption).foregroundStyle(.tertiary) + } } ForEach(subs) { sub in SubscriptionDetailRow( @@ -942,32 +1014,6 @@ private struct SubscriptionDetailRow: View { } } -// MARK: - Refresh Button - -private struct RefreshButton: View { - var asc: ASCManager - @State private var isRefreshing = false - - var body: some View { - Button { - isRefreshing = true - Task { - await asc.refreshMonetization() - isRefreshing = false - } - } label: { - if isRefreshing { - ProgressView().controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .buttonStyle(.borderless) - .disabled(isRefreshing) - .help("Refresh IAP & subscription states") - } -} - // MARK: - Submit Button private struct SubmitForReviewButton: View { diff --git a/src/views/release/ReviewView.swift b/src/views/release/ReviewView.swift index 512f8ae..86ad5be 100644 --- a/src/views/release/ReviewView.swift +++ b/src/views/release/ReviewView.swift @@ -60,15 +60,18 @@ struct ReviewView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .review, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .review, platform: appState.activeProject?.platform ?? .iOS) { reviewContent } } - .task { await asc.fetchTabData(.review) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.review) + } .onChange(of: asc.appStoreVersions.map(\.id)) { _, _ in guard let appId = asc.app?.id else { return } // Load cached rejection feedback for the pending version @@ -86,9 +89,17 @@ struct ReviewView: View { @ViewBuilder private var reviewContent: some View { let latest = asc.appStoreVersions.first + let isLoading = asc.isTabLoading(.review) ScrollView { VStack(alignment: .leading, spacing: 24) { + HStack { + Text("Review") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .review, helpText: "Refresh review data") + } + // Current version status card if let version = latest { VStack(alignment: .leading, spacing: 12) { @@ -142,6 +153,13 @@ struct ReviewView: View { .background(Color.green.opacity(0.15)) .foregroundStyle(.green) .clipShape(Capsule()) + } else if isLoading { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } } else { Text("Not set") .font(.caption) @@ -161,23 +179,42 @@ struct ReviewView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) // Review Contact - DisclosureGroup(isExpanded: $contactExpanded) { - reviewContactForm - } label: { - HStack { - Text("Review Contact") - .font(.headline) - Spacer() - if let rd = asc.reviewDetail, - rd.attributes.contactFirstName != nil { - Text("\(rd.attributes.contactFirstName ?? "") \(rd.attributes.contactLastName ?? "")") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text("Not configured") - .font(.caption) - .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation { contactExpanded.toggle() } + } label: { + HStack { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(contactExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.15), value: contactExpanded) + Text("Review Contact") + .font(.headline) + Spacer() + if let rd = asc.reviewDetail, + rd.attributes.contactFirstName != nil { + Text("\(rd.attributes.contactFirstName ?? "") \(rd.attributes.contactLastName ?? "")") + .font(.caption) + .foregroundStyle(.secondary) + } else if isLoading { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Not configured") + .font(.caption) + .foregroundStyle(.orange) + } } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if contactExpanded { + reviewContactForm } } .padding(16) @@ -190,9 +227,18 @@ struct ReviewView: View { .font(.headline) if asc.builds.isEmpty { - Text("No builds available. Upload a build via Xcode or Transporter.") - .font(.callout) - .foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading builds and review history…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Text("No builds available. Upload a build via Xcode or Transporter.") + .font(.callout) + .foregroundStyle(.secondary) + } } else { Picker("Build", selection: $selectedBuild) { Text("Select a build…").tag("") @@ -264,10 +310,12 @@ struct ReviewView: View { populateAgeRating() populateContact() applyPendingValues() + syncSelectedBuild() } .onChange(of: asc.ageRatingDeclaration?.id) { _, _ in populateAgeRating() } .onChange(of: asc.reviewDetail?.id) { _, _ in populateContact() } .onChange(of: asc.pendingFormVersion) { _, _ in applyPendingValues() } + .onChange(of: asc.builds.map(\.id)) { _, _ in syncSelectedBuild() } .onChange(of: contactFocused) { _, _ in // Don't auto-save contact — requires all required fields. // User saves explicitly via the "Save Contact" button. @@ -598,6 +646,7 @@ struct ReviewView: View { case "PENDING_DEVELOPER_RELEASE": return ("Pending Release", .yellow) case "IN_REVIEW": return ("In Review", .blue) case "WAITING_FOR_REVIEW": return ("Waiting", .blue) + case "INVALID_BINARY": return ("Submission Error", .red) case "REJECTED": return ("Rejected", .red) case "DEVELOPER_REJECTED": return ("Dev Rejected", .orange) case "PREPARE_FOR_SUBMISSION": return ("Draft", .secondary) @@ -619,4 +668,14 @@ struct ReviewView: View { return false } + private func syncSelectedBuild() { + if !selectedBuild.isEmpty, + asc.builds.contains(where: { $0.id == selectedBuild }) { + return + } + selectedBuild = asc.builds.first(where: { $0.attributes.processingState == "VALID" })?.id + ?? asc.builds.first?.id + ?? "" + } + } diff --git a/src/views/release/ScreenshotsView.swift b/src/views/release/ScreenshotsView.swift index c834641..cbca147 100644 --- a/src/views/release/ScreenshotsView.swift +++ b/src/views/release/ScreenshotsView.swift @@ -13,7 +13,7 @@ private enum ScreenshotDeviceType: String, CaseIterable, Identifiable { var label: String { switch self { - case .iPhone: "iPhone 6.5\"" + case .iPhone: "iPhone 6.7\"" case .iPad: "iPad Pro 12.9\"" case .mac: "Mac" } @@ -82,12 +82,31 @@ struct ScreenshotsView: View { ScreenshotDeviceType.types(for: platform) } + private var currentLocale: String { + if let selectedScreenshotsLocale = asc.selectedScreenshotsLocale, + asc.localizations.contains(where: { $0.attributes.locale == selectedScreenshotsLocale }) { + return selectedScreenshotsLocale + } + // fallback + return asc.localizations.first?.attributes.locale ?? "en-US" + } + + private var selectedLocaleBinding: Binding { + Binding( + get: { currentLocale }, + set: { newValue in + asc.selectedScreenshotsLocale = newValue + Task { await loadSelectedLocaleData() } + } + ) + } + private var currentTrack: [TrackSlot?] { - asc.trackSlots[selectedDevice.ascDisplayType] ?? Array(repeating: nil, count: 10) + asc.trackSlotsForDisplayType(selectedDevice.ascDisplayType, locale: currentLocale) } private var hasChanges: Bool { - asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType) + asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType, locale: currentLocale) } private var filledSlotCount: Int { @@ -96,25 +115,50 @@ struct ScreenshotsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .screenshots, platform: appState.activeProject?.platform ?? .iOS) { - HStack(spacing: 0) { - assetLibraryPanel - .frame(width: 220) + ASCTabContent(appState: appState, asc: asc, tab: .screenshots, platform: appState.activeProject?.platform ?? .iOS) { + VStack(spacing: 0) { + HStack { + Text("Screenshots") + .font(.title2.weight(.semibold)) + if !asc.localizations.isEmpty { + Picker("Locale", selection: selectedLocaleBinding) { + ForEach(asc.localizations) { localization in + Text(localization.attributes.locale).tag(localization.attributes.locale) + } + } + .pickerStyle(.menu) + .frame(width: 160) + } + Spacer() + ASCTabRefreshButton(asc: asc, tab: .screenshots, helpText: "Refresh screenshots") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + Divider() - VStack(spacing: 0) { - detailView + + HStack(spacing: 0) { + assetLibraryPanel + .frame(width: 220) Divider() - trackView - .frame(minHeight: 200) + VStack(spacing: 0) { + detailView + Divider() + trackView + .frame(minHeight: 200) + } } } } } - .task { await loadData() } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await loadData() + } .onChange(of: selectedDevice) { _, _ in loadTrackForDevice() } .alert("Import Error", isPresented: Binding( get: { importError != nil }, @@ -141,7 +185,7 @@ struct ScreenshotsView: View { Text(device.label) .font(.callout) Spacer() - if asc.hasUnsavedChanges(displayType: device.ascDisplayType) { + if asc.hasUnsavedChanges(displayType: device.ascDisplayType, locale: currentLocale) { Circle() .fill(.orange) .frame(width: 6, height: 6) @@ -390,7 +434,7 @@ struct ScreenshotsView: View { private func trackSlotView(index: Int) -> some View { let slot = currentTrack[index] - let saved = (asc.savedTrackState[selectedDevice.ascDisplayType] ?? Array(repeating: nil, count: 10))[index] + let saved = asc.savedTrackStateForDisplayType(selectedDevice.ascDisplayType, locale: currentLocale)[index] let isSynced = slot?.id == saved?.id && slot != nil let hasError = slot?.ascScreenshot?.hasError == true @@ -438,7 +482,13 @@ struct ScreenshotsView: View { // Delete button (top-right) Button { - withAnimation { asc.removeFromTrack(displayType: selectedDevice.ascDisplayType, slotIndex: index) } + withAnimation { + asc.removeFromTrack( + displayType: selectedDevice.ascDisplayType, + slotIndex: index, + locale: currentLocale + ) + } if selectedTrackIndex == index { selectedTrackIndex = nil } } label: { Image(systemName: "xmark.circle.fill") @@ -486,6 +536,7 @@ struct ScreenshotsView: View { .onDrop(of: [.text], delegate: TrackSlotDropDelegate( targetIndex: index, displayType: selectedDevice.ascDisplayType, + locale: currentLocale, asc: asc, localAssets: asc.localScreenshotAssets, draggedAssetId: $draggedAssetId, @@ -532,22 +583,28 @@ struct ScreenshotsView: View { selectedDevice = first } - await asc.fetchTabData(.screenshots) - - // Scan local assets if let projectId = appState.activeProjectId { asc.scanLocalAssets(projectId: projectId) } - // Load track from ASC - loadTrackForDevice() + await asc.ensureTabData(.screenshots) + if asc.selectedScreenshotsLocale == nil { + asc.selectedScreenshotsLocale = asc.localizations.first?.attributes.locale + } + await loadSelectedLocaleData() } - private func loadTrackForDevice() { + private func loadSelectedLocaleData(force: Bool = false) async { + guard !currentLocale.isEmpty else { return } + await asc.loadScreenshots(locale: currentLocale, force: force) + loadTrackForDevice(force: force) + } + + private func loadTrackForDevice(force: Bool = false) { let displayType = selectedDevice.ascDisplayType - // Only load from ASC if not already populated (preserves unsaved changes) - if asc.trackSlots[displayType] == nil { - asc.loadTrackFromASC(displayType: displayType) + let locale = currentLocale + if force || !asc.hasTrackState(displayType: displayType, locale: locale) { + asc.loadTrackFromASC(displayType: displayType, locale: locale) } } @@ -649,7 +706,6 @@ struct ScreenshotsView: View { for provider in providers { if provider.canLoadObject(ofClass: NSURL.self) { hasValidProvider = true - let ascRef = asc provider.loadObject(ofClass: NSURL.self) { reading, _ in guard let url = reading as? URL, url.isFileURL, @@ -667,7 +723,7 @@ struct ScreenshotsView: View { private func save() async { await asc.syncTrackToASC( displayType: selectedDevice.ascDisplayType, - locale: "en-US" + locale: currentLocale ) } } @@ -677,6 +733,7 @@ struct ScreenshotsView: View { private struct TrackSlotDropDelegate: DropDelegate { let targetIndex: Int let displayType: String + let locale: String let asc: ASCManager let localAssets: [LocalScreenshotAsset] @Binding var draggedAssetId: UUID? @@ -692,7 +749,12 @@ private struct TrackSlotDropDelegate: DropDelegate { // Drop from asset library if let assetId = draggedAssetId, let asset = localAssets.first(where: { $0.id == assetId }) { - let error = asc.addAssetToTrack(displayType: displayType, slotIndex: targetIndex, localPath: asset.url.path) + let error = asc.addAssetToTrack( + displayType: displayType, + slotIndex: targetIndex, + localPath: asset.url.path, + locale: locale + ) if let error { importError = "Cannot add \(asset.fileName): \(error)" return false @@ -703,7 +765,12 @@ private struct TrackSlotDropDelegate: DropDelegate { // Reorder within track if let fromIndex = draggedTrackIndex, fromIndex != targetIndex { withAnimation { - asc.reorderTrack(displayType: displayType, fromIndex: fromIndex, toIndex: targetIndex) + asc.reorderTrack( + displayType: displayType, + fromIndex: fromIndex, + toIndex: targetIndex, + locale: locale + ) } return true } diff --git a/src/views/release/StoreListingView.swift b/src/views/release/StoreListingView.swift index b4f7f7e..e3fcc7b 100644 --- a/src/views/release/StoreListingView.swift +++ b/src/views/release/StoreListingView.swift @@ -4,7 +4,6 @@ struct StoreListingView: View { var appState: AppState private var asc: ASCManager { appState.ascManager } - @State private var selectedLocale: String = "" @FocusState private var focusedField: String? // Editable field values @@ -19,19 +18,46 @@ struct StoreListingView: View { @State private var privacyPolicyUrl: String = "" @State private var isSaving = false - @State private var lastSavedField: String? + + private var currentLocale: String { + asc.activeStoreListingLocale() ?? "" + } + + private var selectedLocaleBinding: Binding { + Binding( + get: { currentLocale }, + set: { newValue in + asc.selectedStoreListingLocale = newValue + populateCurrentFields() + } + ) + } var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .storeListing, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .storeListing, platform: appState.activeProject?.platform ?? .iOS) { listingContent } } - .task { await asc.fetchTabData(.storeListing) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.storeListing) + populateCurrentFields() + applyPendingValues() + } + .onChange(of: asc.selectedStoreListingLocale) { _, _ in + guard focusedField == nil else { return } + populateCurrentFields() + } + .onChange(of: asc.isTabLoading(.storeListing)) { wasLoading, isLoading in + guard wasLoading, !isLoading else { return } + guard focusedField == nil else { return } + populateCurrentFields() + } .onDisappear { Task { await flushChanges() } } @@ -40,30 +66,20 @@ struct StoreListingView: View { @ViewBuilder private var listingContent: some View { let locales = asc.localizations - let current = locales.first { $0.attributes.locale == selectedLocale } - ?? locales.first + let current = asc.storeListingLocalization(locale: currentLocale) + let isLoading = asc.isTabLoading(.storeListing) VStack(spacing: 0) { // Toolbar HStack { if !locales.isEmpty { - Picker("Locale", selection: $selectedLocale) { + Picker("Locale", selection: selectedLocaleBinding) { ForEach(locales) { loc in Text(loc.attributes.locale).tag(loc.attributes.locale) } } .pickerStyle(.menu) .frame(width: 160) - .onChange(of: locales.count) { _, _ in - if selectedLocale.isEmpty, let first = asc.localizations.first { - selectedLocale = first.attributes.locale - } - } - .onAppear { - if selectedLocale.isEmpty, let first = locales.first { - selectedLocale = first.attributes.locale - } - } } Spacer() if isSaving { @@ -79,6 +95,7 @@ struct StoreListingView: View { } .help("Open in App Store Connect") } + ASCTabRefreshButton(asc: asc, tab: .storeListing, helpText: "Refresh store listing data") } .padding(.horizontal, 20) .padding(.vertical, 10) @@ -101,22 +118,22 @@ struct StoreListingView: View { } .padding(24) } else if asc.localizations.isEmpty { - ContentUnavailableView( - "No Localizations", - systemImage: "text.page", - description: Text("No localizations found for the latest version.") - ) - .padding(.top, 60) + if isLoading { + ASCTabLoadingPlaceholder( + title: "Loading Store Listing", + message: "Fetching localizations and editable metadata." + ) + } else { + ContentUnavailableView( + "No Localizations", + systemImage: "text.page", + description: Text("No localizations found for the latest version.") + ) + .padding(.top, 60) + } } } } - .onChange(of: current?.id) { _, _ in - populateFields(from: current) - } - .onAppear { - populateFields(from: current) - applyPendingValues() - } .onChange(of: asc.pendingFormVersion) { _, _ in applyPendingValues() } @@ -127,12 +144,18 @@ struct StoreListingView: View { } } - private func populateFields(from loc: ASCVersionLocalization?) { + private func populateCurrentFields() { + populateFields( + from: asc.storeListingLocalization(locale: currentLocale), + infoLocalization: asc.appInfoLocalizationForLocale(currentLocale) + ) + } + + private func populateFields(from loc: ASCVersionLocalization?, infoLocalization: ASCAppInfoLocalization?) { // name and subtitle come from appInfoLocalization, not version localization - let infoLoc = asc.appInfoLocalization - title = infoLoc?.attributes.name ?? loc?.attributes.title ?? "" - subtitle = infoLoc?.attributes.subtitle ?? loc?.attributes.subtitle ?? "" - privacyPolicyUrl = infoLoc?.attributes.privacyPolicyUrl ?? "" + title = infoLocalization?.attributes.name ?? loc?.attributes.title ?? "" + subtitle = infoLocalization?.attributes.subtitle ?? loc?.attributes.subtitle ?? "" + privacyPolicyUrl = infoLocalization?.attributes.privacyPolicyUrl ?? "" // The rest come from version localization descriptionText = loc?.attributes.description ?? "" keywords = loc?.attributes.keywords ?? "" @@ -182,14 +205,9 @@ struct StoreListingView: View { isSaving = true if Self.appInfoLocFields.contains(field) { // These fields live on appInfoLocalizations, not version localizations - await asc.updateAppInfoLocalizationField(field, value: value) + await asc.updateAppInfoLocalizationField(field, value: value, locale: currentLocale) } else { - guard let locId = (asc.localizations.first { $0.attributes.locale == selectedLocale } - ?? asc.localizations.first)?.id else { - isSaving = false - return - } - await asc.updateLocalizationField(field, value: value, locId: locId) + await asc.updateLocalizationField(field, value: value, locale: currentLocale) } isSaving = false } diff --git a/src/views/release/SubmitPreviewSheet.swift b/src/views/release/SubmitPreviewSheet.swift index 66936c8..4f6fb9d 100644 --- a/src/views/release/SubmitPreviewSheet.swift +++ b/src/views/release/SubmitPreviewSheet.swift @@ -267,6 +267,7 @@ struct SubmitPreviewSheet: View { case "PENDING_DEVELOPER_RELEASE": return ("Pending Release", .green) case "READY_FOR_SALE": return ("Ready for Sale", .green) case "PREPARE_FOR_SUBMISSION": return ("Preparing", .orange) + case "INVALID_BINARY": return ("Submission Error", .red) case "REJECTED": return ("Rejected", .red) case "DEVELOPER_REJECTED": return ("Developer Rejected", .orange) default: return (state.replacingOccurrences(of: "_", with: " ").capitalized, .secondary) @@ -279,7 +280,7 @@ struct SubmitPreviewSheet: View { case "IN_REVIEW": return "eye.fill" case "PENDING_DEVELOPER_RELEASE": return "checkmark.seal.fill" case "READY_FOR_SALE": return "checkmark.circle.fill" - case "REJECTED", "DEVELOPER_REJECTED": return "xmark.circle.fill" + case "INVALID_BINARY", "REJECTED", "DEVELOPER_REJECTED": return "xmark.circle.fill" default: return "info.circle.fill" } } diff --git a/src/views/settings/MCPSetupSection.swift b/src/views/settings/MCPSetupSection.swift index b98a61b..e5e498f 100644 --- a/src/views/settings/MCPSetupSection.swift +++ b/src/views/settings/MCPSetupSection.swift @@ -4,7 +4,6 @@ import SwiftUI struct MCPSetupSection: View { let mcpServer: MCPServerService? - @State private var serverPort: Int = 0 @State private var serverRunning: Bool = false @State private var copied = false @@ -18,7 +17,7 @@ struct MCPSetupSection: View { Circle() .fill(.green) .frame(width: 8, height: 8) - Text("Running on port \(serverPort)") + Text("Ready via local socket") .foregroundStyle(.secondary) } } else { @@ -55,7 +54,6 @@ struct MCPSetupSection: View { private func refreshStatus() async { guard let server = mcpServer else { return } - serverPort = await server.port serverRunning = await server.isRunning } @@ -64,8 +62,7 @@ struct MCPSetupSection: View { let config = """ { "blitz-macos": { - "command": "bash", - "args": ["\(home)/.blitz/blitz-mcp-bridge.sh"] + "command": "\(home)/.blitz/blitz-macos-mcp" } } """ diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index d2983b5..b58cfed 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -10,6 +10,7 @@ struct SettingsView: View { @State private var showSkipPermsDetail = false @State private var showAskAIDetail = false @State private var terminalResetWarning: String? + @State private var shellIntegrationError: String? private let gateableCategories: [(ApprovalRequest.ToolCategory, String)] = [ (.ascFormMutation, "ASC form editing"), @@ -119,9 +120,7 @@ struct SettingsView: View { titleVisibility: .visible ) { Button("Clear Credentials", role: .destructive) { - if let projectId = appState.ascManager.loadedProjectId { - appState.ascManager.deleteCredentials(projectId: projectId) - } + appState.ascManager.deleteCredentials() } } message: { Text("This action cannot be undone. You will need to re-enter your API credentials to access App Store Connect data.") @@ -140,6 +139,7 @@ struct SettingsView: View { .frame(maxWidth: 500) .frame(maxWidth: .infinity, maxHeight: .infinity) .task { + appState.ascManager.loadStoredCredentialsIfNeeded() refreshTerminalResetWarning() } .fileImporter( @@ -163,6 +163,10 @@ struct SettingsView: View { Text("Terminal") Spacer() Menu { + terminalMenuItem(.builtIn) + + Divider() + terminalMenuItem(.terminal) if NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.mitchellh.ghostty") != nil { @@ -230,6 +234,25 @@ struct SettingsView: View { .fixedSize() } + // Whitelist Blitz MCP tools + Toggle("Allow all Blitz MCP tool calls", isOn: Binding( + get: { settings.whitelistBlitzMCPTools }, + set: { newValue in + settings.whitelistBlitzMCPTools = newValue + settings.save() + refreshAgentPermissionFiles() + } + )) + + Toggle("Allow all ASC CLI calls", isOn: Binding( + get: { settings.allowASCCLICalls }, + set: { newValue in + settings.allowASCCLICalls = newValue + settings.save() + refreshAgentPermissionFiles() + } + )) + // Send default prompt toggle VStack(alignment: .leading, spacing: 4) { Toggle("Send tab-specific prompt on launch", isOn: Binding( @@ -241,7 +264,7 @@ struct SettingsView: View { )) learnMore(isExpanded: $showAskAIDetail) { - Text("When you click \"Ask AI\", Blitz launches \(currentAgent.displayName) in \(currentTerminal.displayName). Right-click the button to open the panel instead.") + Text("When enabled, Blitz automatically sends a context-aware prompt to \(currentAgent.displayName) based on the current tab (e.g. Store Listing, Screenshots, App Review). Disable to start with a blank session.") } } @@ -261,6 +284,39 @@ struct SettingsView: View { } } } + + VStack(alignment: .leading, spacing: 4) { + let shellIntegration = ShellIntegrationService() + + Toggle("Enable ASC shell integration", isOn: Binding( + get: { settings.enableASCShellIntegration }, + set: { newValue in + shellIntegrationError = nil + do { + try settings.setASCShellIntegrationEnabled(newValue) + } catch { + shellIntegrationError = error.localizedDescription + } + } + )) + .disabled(!shellIntegration.isSupported && !settings.enableASCShellIntegration) + + if shellIntegration.isSupported { + Text("Adds a managed Blitz block to \(shellIntegration.targetRCFileLabel) so manually opened shells can find `asc` on PATH.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Automatic shell integration only supports zsh and bash. Detected \(shellIntegration.shellKind.displayName).") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let shellIntegrationError { + Text(shellIntegrationError) + .font(.caption) + .foregroundStyle(.red) + } + } } } @@ -291,6 +347,39 @@ struct SettingsView: View { } } + private func refreshAgentPermissionFiles() { + let whitelistBlitzMCP = settings.whitelistBlitzMCPTools + let allowASCCLICalls = settings.allowASCCLICalls + let activeProjectId = appState.activeProjectId + let activeProjectType = appState.activeProject?.type + + Task.detached(priority: .utility) { + let storage = ProjectStorage() + storage.ensureGlobalMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + storage.ensureAllProjectMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + + if let activeProjectId, let activeProjectType { + storage.ensureMCPConfig( + projectId: activeProjectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + storage.ensureClaudeFiles( + projectId: activeProjectId, + projectType: activeProjectType, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + } + } + private func terminalMenuItem(_ terminal: TerminalApp) -> some View { Button { terminalResetWarning = nil @@ -307,7 +396,10 @@ struct SettingsView: View { @ViewBuilder private func terminalAppIcon(_ terminal: TerminalApp, size: CGFloat) -> some View { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { + if terminal.isBuiltIn { + Image(systemName: "terminal.fill") + .frame(width: size, height: size) + } else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { let icon = Self.resizedIcon(NSWorkspace.shared.icon(forFile: appURL.path), size: size) Image(nsImage: icon) } else if case .custom(let path) = terminal { diff --git a/src/views/shared/ProjectAppIconView.swift b/src/views/shared/ProjectAppIconView.swift new file mode 100644 index 0000000..e911fb7 --- /dev/null +++ b/src/views/shared/ProjectAppIconView.swift @@ -0,0 +1,182 @@ +import AppKit +import SwiftUI + +private enum ProjectAppIconLookupState { + case unresolved + case resolved(String) + case missing +} + +enum ProjectAppIconLoader { + private static let imageCache = NSCache() + private static let lock = NSLock() + private static var pathCache: [String: ProjectAppIconLookupState] = [:] + private static let skippedDirectories: Set = [ + "node_modules", + "Pods", + ".git", + ".build", + "DerivedData", + "build" + ] + + static func cachedImage(for projectId: String) -> NSImage? { + imageCache.object(forKey: projectId as NSString) + } + + static func loadImage(for projectId: String) async -> NSImage? { + if let cached = cachedImage(for: projectId) { + return cached + } + + guard let path = await loadPath(for: projectId), + let image = NSImage(contentsOfFile: path) else { + return nil + } + + imageCache.setObject(image, forKey: projectId as NSString) + return image + } + + private static func loadPath(for projectId: String) async -> String? { + switch cachedPath(for: projectId) { + case .resolved(let path): + return path + case .missing: + return nil + case .unresolved: + break + } + + let path = await Task.detached(priority: .utility) { + findIconPath(for: projectId) + }.value + + cachePath(path, for: projectId) + return path + } + + private static func cachedPath(for projectId: String) -> ProjectAppIconLookupState { + lock.lock() + defer { lock.unlock() } + return pathCache[projectId] ?? .unresolved + } + + private static func cachePath(_ path: String?, for projectId: String) { + lock.lock() + defer { lock.unlock() } + pathCache[projectId] = path.map(ProjectAppIconLookupState.resolved) ?? .missing + } + + private static func findIconPath(for projectId: String) -> String? { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + let projectDir = URL(fileURLWithPath: "\(home)/.blitz/projects/\(projectId)") + + let generatedIcon = projectDir.appendingPathComponent("assets/AppIcon/icon_1024.png") + if fm.fileExists(atPath: generatedIcon.path) { + return generatedIcon.path + } + + let searchRoots = [ + projectDir.appendingPathComponent("ios"), + projectDir.appendingPathComponent("macos"), + projectDir + ] + + for root in searchRoots where fm.fileExists(atPath: root.path) { + if let path = findIconPath(in: root, using: fm) { + return path + } + } + + return nil + } + + private static func findIconPath(in root: URL, using fm: FileManager) -> String? { + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + while let entry = enumerator.nextObject() as? URL { + let name = entry.lastPathComponent + + if skippedDirectories.contains(name) { + enumerator.skipDescendants() + continue + } + + guard name == "Contents.json", + entry.deletingLastPathComponent().lastPathComponent == "AppIcon.appiconset", + let data = fm.contents(atPath: entry.path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let images = json["images"] as? [[String: Any]] else { + continue + } + + for image in images { + guard let filename = image["filename"] as? String else { continue } + let iconPath = entry.deletingLastPathComponent().appendingPathComponent(filename).path + if fm.fileExists(atPath: iconPath) { + return iconPath + } + } + } + + return nil + } +} + +struct ProjectAppIconView: View { + let project: Project + let size: CGFloat + let cornerRadius: CGFloat + let placeholder: () -> Placeholder + + @State private var icon: NSImage? + + init( + project: Project, + size: CGFloat, + cornerRadius: CGFloat, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.project = project + self.size = size + self.cornerRadius = cornerRadius + self.placeholder = placeholder + _icon = State(initialValue: ProjectAppIconLoader.cachedImage(for: project.id)) + } + + var body: some View { + Group { + if let icon { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } else { + placeholder() + } + } + .frame(width: size, height: size) + .task(id: project.id) { + await loadIcon() + } + } + + @MainActor + private func loadIcon() async { + if let cached = ProjectAppIconLoader.cachedImage(for: project.id) { + icon = cached + return + } + + icon = nil + icon = await ProjectAppIconLoader.loadImage(for: project.id) + } +} diff --git a/src/views/shared/asc/ASCCredentialForm.swift b/src/views/shared/asc/ASCCredentialForm.swift index 7c49d6a..13c773f 100644 --- a/src/views/shared/asc/ASCCredentialForm.swift +++ b/src/views/shared/asc/ASCCredentialForm.swift @@ -2,6 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct ASCCredentialForm: View { + var appState: AppState var ascManager: ASCManager var projectId: String var bundleId: String? @@ -155,13 +156,28 @@ struct ASCCredentialForm: View { let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode let terminal = settings.resolveDefaultTerminal().terminal let prompt = "Use the /asc-team-key-create skill to create a new App Store Connect API key, then call the asc_set_credentials MCP tool to fill the form so I can verify and save." - TerminalLauncher.launch( - projectPath: BlitzPaths.mcps.path, - agent: agent, - terminal: terminal, - prompt: prompt, - skipPermissions: settings.skipAgentPermissions - ) + + if terminal.isBuiltIn { + appState.showTerminal = true + let session = appState.terminalManager.createSession(projectPath: BlitzPaths.mcps.path) + let command = TerminalLauncher.buildAgentCommand( + projectPath: BlitzPaths.mcps.path, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + TerminalLauncher.launch( + projectPath: BlitzPaths.mcps.path, + agent: agent, + terminal: terminal, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) + } } label: { HStack(spacing: 6) { Image(systemName: "sparkles") diff --git a/src/views/shared/asc/ASCCredentialGate.swift b/src/views/shared/asc/ASCCredentialGate.swift index 3a5a506..644159a 100644 --- a/src/views/shared/asc/ASCCredentialGate.swift +++ b/src/views/shared/asc/ASCCredentialGate.swift @@ -4,6 +4,7 @@ import SwiftUI /// Shows a spinner while loading, the credential form when unconfigured, /// and the wrapped content once credentials are present. struct ASCCredentialGate: View { + var appState: AppState var ascManager: ASCManager var projectId: String var bundleId: String? @@ -20,6 +21,7 @@ struct ASCCredentialGate: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if ascManager.credentials == nil { ASCCredentialForm( + appState: appState, ascManager: ascManager, projectId: projectId, bundleId: bundleId diff --git a/src/views/shared/asc/ASCTabContent.swift b/src/views/shared/asc/ASCTabContent.swift index cd4db3e..cdc9f21 100644 --- a/src/views/shared/asc/ASCTabContent.swift +++ b/src/views/shared/asc/ASCTabContent.swift @@ -2,13 +2,22 @@ import SwiftUI /// Wraps a tab's content with loading, error, and empty-app states. struct ASCTabContent: View { + var appState: AppState var asc: ASCManager var tab: AppTab var platform: ProjectPlatform = .iOS @ViewBuilder var content: () -> Content + private var isLoading: Bool { + asc.isTabLoading(tab) + } + + private var shouldRenderContentWhileLoading: Bool { + asc.credentials != nil && asc.app != nil + } + var body: some View { - if asc.isLoadingTab[tab] == true || asc.isLoadingApp { + if isLoading && !shouldRenderContentWhileLoading { VStack(spacing: 12) { ProgressView() Text("Loading\u{2026}") @@ -16,10 +25,10 @@ struct ASCTabContent: View { .font(.callout) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if asc.app == nil && asc.credentials != nil { + } else if asc.app == nil && asc.credentials != nil && !isLoading { // App not found — show bundle ID setup instead of flashing content - BundleIDSetupView(asc: asc, tab: tab, platform: platform) - } else if let error = asc.tabError[tab] { + BundleIDSetupView(appState: appState, asc: asc, tab: tab, platform: platform) + } else if let error = asc.tabError[tab], !asc.hasLoadedTabData(tab) { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 32)) @@ -39,7 +48,83 @@ struct ASCTabContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { content() + .overlay(alignment: .topTrailing) { + if isLoading && shouldRenderContentWhileLoading { + ProgressView() + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.background.secondary, in: Capsule()) + .padding(12) + } + } + .overlay(alignment: .topLeading) { + if let error = asc.tabError[tab], asc.hasLoadedTabData(tab) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Button("Retry") { + Task { await asc.refreshTabData(tab) } + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) + .padding(12) + } + } + } + } +} + +struct ASCTabLoadingPlaceholder: View { + var title: String + var message: String + + var body: some View { + VStack(spacing: 10) { + ProgressView() + Text(title) + .font(.callout.weight(.medium)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(32) + } +} + +struct ASCTabRefreshButton: View { + var asc: ASCManager + var tab: AppTab + var helpText: String = "Refresh this tab" + + private var isRefreshing: Bool { + asc.isLoadingTab[tab] == true + } + + var body: some View { + Button { + Task { await asc.refreshTabData(tab) } + } label: { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } } + .buttonStyle(.borderless) + .disabled(isRefreshing) + .help(helpText) } } diff --git a/src/views/shared/asc/BundleIDSetupView.swift b/src/views/shared/asc/BundleIDSetupView.swift index 09ebfe6..f4e0057 100644 --- a/src/views/shared/asc/BundleIDSetupView.swift +++ b/src/views/shared/asc/BundleIDSetupView.swift @@ -3,6 +3,7 @@ import SwiftUI /// Multi-phase inline view for registering a bundle ID, enabling capabilities, /// and guiding the user to create their app in App Store Connect. struct BundleIDSetupView: View { + var appState: AppState var asc: ASCManager var tab: AppTab var platform: ProjectPlatform = .iOS @@ -540,7 +541,22 @@ struct BundleIDSetupView: View { let settings = SettingsService.shared let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode let terminal = settings.resolveDefaultTerminal().terminal - TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt) + + if terminal.isBuiltIn { + appState.showTerminal = true + let session = appState.terminalManager.createSession(projectPath: projectPath) + let command = TerminalLauncher.buildAgentCommand( + projectPath: projectPath, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt, skipPermissions: settings.skipAgentPermissions) + } } // MARK: - Helpers diff --git a/src/views/sidebar/SidebarView.swift b/src/views/sidebar/SidebarView.swift index e4df04b..5c065aa 100644 --- a/src/views/sidebar/SidebarView.swift +++ b/src/views/sidebar/SidebarView.swift @@ -3,38 +3,29 @@ import SwiftUI struct SidebarView: View { @Bindable var appState: AppState - private func projectIcon(_ project: Project) -> String { - if project.platform == .macOS { return "desktopcomputer" } - switch project.type { - case .reactNative: return "atom" - case .swift: return "swift" - case .flutter: return "bird" - } - } - var body: some View { List(selection: $appState.activeTab) { - // Active project header - if let project = appState.activeProject { - Section { - HStack(spacing: 8) { - Image(systemName: projectIcon(project)) - .foregroundStyle(.blue) - .font(.system(size: 14)) - Text(project.name) - .font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - } - .padding(.vertical, 2) - } - } + // Top-level standalone tabs + Section { + Label("Dashboard", systemImage: "square.grid.2x2") + .tag(AppTab.dashboard) - // Build group - Section("Build") { - ForEach(AppTab.Group.build.tabs) { tab in - Label(tab.label, systemImage: tab.icon) - .tag(tab) + // App tab — shows dynamic project icon + name + HStack(spacing: 8) { + if let project = appState.activeProject { + ProjectAppIconView(project: project, size: 18, cornerRadius: 4) { + Image(systemName: projectIcon(project)) + .foregroundStyle(projectColor(project)) + .frame(width: 18, height: 18) + } + } else { + Image(systemName: "app") + .frame(width: 18, height: 18) + } + Text(appState.activeProject?.name ?? "App") + .lineLimit(1) } + .tag(AppTab.app) } // Release group @@ -70,4 +61,21 @@ struct SidebarView: View { .listStyle(.sidebar) .scrollDisabled(true) } + + private func projectIcon(_ project: Project) -> String { + if project.platform == .macOS { return "desktopcomputer" } + switch project.type { + case .reactNative: return "atom" + case .swift: return "swift" + case .flutter: return "bird" + } + } + + private func projectColor(_ project: Project) -> Color { + switch project.type { + case .reactNative: return .cyan + case .swift: return .orange + case .flutter: return .blue + } + } } diff --git a/src/views/testflight/BetaInfoView.swift b/src/views/testflight/BetaInfoView.swift index 3c6761e..185995e 100644 --- a/src/views/testflight/BetaInfoView.swift +++ b/src/views/testflight/BetaInfoView.swift @@ -15,15 +15,18 @@ struct BetaInfoView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .betaInfo, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .betaInfo, platform: appState.activeProject?.platform ?? .iOS) { betaInfoContent } } - .task { await asc.fetchTabData(.betaInfo) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.betaInfo) + } } @ViewBuilder @@ -83,6 +86,7 @@ struct BetaInfoView: View { } .buttonStyle(.borderedProminent) .disabled(isSaving || current == nil) + ASCTabRefreshButton(asc: asc, tab: .betaInfo, helpText: "Refresh beta info") } .padding(.horizontal, 20) .padding(.vertical, 10) @@ -93,11 +97,18 @@ struct BetaInfoView: View { if current == nil && !locs.isEmpty { ContentUnavailableView("Select a locale", systemImage: "doc.text") } else if locs.isEmpty { - ContentUnavailableView( - "No Localizations", - systemImage: "doc.text", - description: Text("No beta app localizations found.") - ) + if asc.isTabLoading(.betaInfo) { + ASCTabLoadingPlaceholder( + title: "Loading Beta Info", + message: "Fetching beta app localizations and tester-facing copy." + ) + } else { + ContentUnavailableView( + "No Localizations", + systemImage: "doc.text", + description: Text("No beta app localizations found.") + ) + } } else { ScrollView { VStack(alignment: .leading, spacing: 20) { diff --git a/src/views/testflight/BuildsView.swift b/src/views/testflight/BuildsView.swift index 62a80b8..46f0e54 100644 --- a/src/views/testflight/BuildsView.swift +++ b/src/views/testflight/BuildsView.swift @@ -8,48 +8,73 @@ struct BuildsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .builds, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .builds, platform: appState.activeProject?.platform ?? .iOS) { buildsContent } } - .task { await asc.fetchTabData(.builds) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.builds) + } + .onAppear { syncSelectedBuild() } + .onChange(of: asc.builds.map(\.id)) { _, _ in syncSelectedBuild() } } @ViewBuilder private var buildsContent: some View { - if asc.builds.isEmpty { - ContentUnavailableView( - "No Builds", - systemImage: "hammer", - description: Text("No TestFlight builds found. Upload a build from Xcode.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - HStack(spacing: 0) { - // Build list - List(selection: $selectedBuildId) { - ForEach(asc.builds) { build in - buildRow(build) - .tag(build.id) - } - } - .listStyle(.inset) - .frame(width: 300) + VStack(spacing: 0) { + HStack { + Text("Builds") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .builds, helpText: "Refresh builds") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) - Divider() + Divider() - // Detail panel - if let bid = selectedBuildId, - let build = asc.builds.first(where: { $0.id == bid }) { - buildDetail(build) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if asc.builds.isEmpty { + if asc.isTabLoading(.builds) { + ASCTabLoadingPlaceholder( + title: "Loading Builds", + message: "Fetching TestFlight build metadata." + ) } else { - ContentUnavailableView("Select a Build", systemImage: "hammer") - .frame(maxWidth: .infinity, maxHeight: .infinity) + ContentUnavailableView( + "No Builds", + systemImage: "hammer", + description: Text("No TestFlight builds found. Upload a build from Xcode.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + HStack(spacing: 0) { + // Build list + List(selection: $selectedBuildId) { + ForEach(asc.builds) { build in + buildRow(build) + .tag(build.id) + } + } + .listStyle(.inset) + .frame(width: 300) + + Divider() + + // Detail panel + if let bid = selectedBuildId, + let build = asc.builds.first(where: { $0.id == bid }) { + buildDetail(build) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("Select a Build", systemImage: "hammer") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } } @@ -127,4 +152,12 @@ struct BuildsView: View { .foregroundStyle(color) .clipShape(Capsule()) } + + private func syncSelectedBuild() { + if let selectedBuildId, + asc.builds.contains(where: { $0.id == selectedBuildId }) { + return + } + selectedBuildId = asc.builds.first?.id + } } diff --git a/src/views/testflight/FeedbackView.swift b/src/views/testflight/FeedbackView.swift index 77e6366..6849d83 100644 --- a/src/views/testflight/FeedbackView.swift +++ b/src/views/testflight/FeedbackView.swift @@ -8,24 +8,26 @@ struct FeedbackView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .feedback, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .feedback, platform: appState.activeProject?.platform ?? .iOS) { feedbackContent } } - .task { await asc.fetchTabData(.feedback) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.feedback) + } } @ViewBuilder private var feedbackContent: some View { let builds = asc.builds - let effectiveBuildId = localSelectedBuildId.isEmpty - ? (asc.selectedBuildId ?? builds.first?.id ?? "") - : localSelectedBuildId + let effectiveBuildId = resolvedBuildId(from: builds) let feedback = asc.betaFeedback[effectiveBuildId] ?? [] + let isLoadingFeedback = asc.isFeedbackLoading(for: effectiveBuildId) VStack(spacing: 0) { // Build picker toolbar @@ -49,20 +51,11 @@ struct FeedbackView: View { } } Spacer() - Button { - Task { - guard let service = asc.service, !effectiveBuildId.isEmpty else { return } - do { - let items = try await service.fetchBetaFeedback(buildId: effectiveBuildId) - asc.betaFeedback[effectiveBuildId] = items - } catch { - // Silently fail — feedback may be unavailable - } - } - } label: { - Image(systemName: "arrow.clockwise") + if isLoadingFeedback { + ProgressView() + .controlSize(.small) } - .buttonStyle(.borderless) + ASCTabRefreshButton(asc: asc, tab: .feedback, helpText: "Refresh feedback tab") } .padding(.horizontal, 20) .padding(.vertical, 10) @@ -71,19 +64,33 @@ struct FeedbackView: View { Divider() if builds.isEmpty { - ContentUnavailableView( - "No Builds", - systemImage: "hammer", - description: Text("Upload a TestFlight build to receive tester feedback.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if asc.isTabLoading(.feedback) { + ASCTabLoadingPlaceholder( + title: "Loading Feedback", + message: "Fetching builds and the latest tester feedback." + ) + } else { + ContentUnavailableView( + "No Builds", + systemImage: "hammer", + description: Text("Upload a TestFlight build to receive tester feedback.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } else if feedback.isEmpty { - ContentUnavailableView( - "No Feedback", - systemImage: "exclamationmark.bubble", - description: Text("No tester feedback for this build yet.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if isLoadingFeedback { + ASCTabLoadingPlaceholder( + title: "Loading Feedback", + message: "Fetching tester comments and screenshots for this build." + ) + } else { + ContentUnavailableView( + "No Feedback", + systemImage: "exclamationmark.bubble", + description: Text("No tester feedback for this build yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } else { List(feedback) { item in feedbackRow(item) @@ -92,6 +99,18 @@ struct FeedbackView: View { .listStyle(.plain) } } + .onChange(of: localSelectedBuildId) { _, newValue in + guard !newValue.isEmpty else { return } + asc.selectedBuildId = newValue + guard asc.betaFeedback[newValue] == nil else { return } + Task { await asc.refreshBetaFeedback(buildId: newValue) } + } + .onAppear { + syncSelectedBuild(with: builds) + } + .onChange(of: builds.map(\.id)) { _, _ in + syncSelectedBuild(with: builds) + } } private func feedbackRow(_ item: ASCBetaFeedback) -> some View { @@ -155,4 +174,22 @@ struct FeedbackView: View { } } } + + private func resolvedBuildId(from builds: [ASCBuild]) -> String { + if !localSelectedBuildId.isEmpty, + builds.contains(where: { $0.id == localSelectedBuildId }) { + return localSelectedBuildId + } + if let selectedBuildId = asc.selectedBuildId, + builds.contains(where: { $0.id == selectedBuildId }) { + return selectedBuildId + } + return builds.first?.id ?? "" + } + + private func syncSelectedBuild(with builds: [ASCBuild]) { + let effectiveBuildId = resolvedBuildId(from: builds) + localSelectedBuildId = effectiveBuildId + asc.selectedBuildId = effectiveBuildId.isEmpty ? nil : effectiveBuildId + } } diff --git a/src/views/testflight/GroupsView.swift b/src/views/testflight/GroupsView.swift index a22e42d..7da9583 100644 --- a/src/views/testflight/GroupsView.swift +++ b/src/views/testflight/GroupsView.swift @@ -7,40 +7,63 @@ struct GroupsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .groups, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .groups, platform: appState.activeProject?.platform ?? .iOS) { groupsContent } } - .task { await asc.fetchTabData(.groups) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.groups) + } } @ViewBuilder private var groupsContent: some View { - if asc.betaGroups.isEmpty { - ContentUnavailableView( - "No Beta Groups", - systemImage: "person.3", - description: Text("No beta testing groups found for this app.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - VStack(spacing: 12) { - let internal_ = asc.betaGroups.filter { $0.attributes.isInternalGroup == true } - let external = asc.betaGroups.filter { $0.attributes.isInternalGroup != true } + VStack(spacing: 0) { + HStack { + Text("Groups") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .groups, helpText: "Refresh groups") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) - if !internal_.isEmpty { - groupSection("Internal Groups", groups: internal_, color: .blue) - } - if !external.isEmpty { - groupSection("External Groups", groups: external, color: .green) + Divider() + + if asc.betaGroups.isEmpty { + if asc.isTabLoading(.groups) { + ASCTabLoadingPlaceholder( + title: "Loading Beta Groups", + message: "Fetching internal and external TestFlight groups." + ) + } else { + ContentUnavailableView( + "No Beta Groups", + systemImage: "person.3", + description: Text("No beta testing groups found for this app.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + ScrollView { + VStack(spacing: 12) { + let internal_ = asc.betaGroups.filter { $0.attributes.isInternalGroup == true } + let external = asc.betaGroups.filter { $0.attributes.isInternalGroup != true } + + if !internal_.isEmpty { + groupSection("Internal Groups", groups: internal_, color: .blue) + } + if !external.isEmpty { + groupSection("External Groups", groups: external, color: .green) + } } + .padding(20) } - .padding(20) } } }