diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..eec7e61 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,114 @@ +name: iOS Simulator + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ios-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + simulator: + name: Build and test (Xcode 27) + runs-on: xcode-27 + timeout-minutes: 45 + defaults: + run: + working-directory: Interceptor + shell: bash + steps: + - uses: actions/checkout@v4 + with: + path: Interceptor + persist-credentials: false + + - name: Check out local Mudmouth dependency + uses: actions/checkout@v4 + with: + repository: qtmleap/Mudmouth + ref: ddcefe656c0f27289ee4f506f2112b8bbdaa407d + path: Mudmouth + persist-credentials: false + + - name: Authenticate private QuantumLeap dependency + env: + QUANTUMLEAP_READ_TOKEN: ${{ secrets.QUANTUMLEAP_READ_TOKEN }} + run: | + if [ -z "$QUANTUMLEAP_READ_TOKEN" ]; then + echo "::error::QUANTUMLEAP_READ_TOKEN is required to read the private QuantumLeap package" + exit 1 + fi + umask 077 + printf 'machine github.com\n login x-access-token\n password %s\n' "$QUANTUMLEAP_READ_TOKEN" > "$HOME/.netrc" + + - name: Select dedicated iPhone simulator + run: | + xcodebuild -version + echo "PLL_SOURCE_PACKAGES_PATH=$RUNNER_TEMP/SourcePackages" >> "$GITHUB_ENV" + xcrun simctl list runtimes --json > "$RUNNER_TEMP/runtimes.json" + runtime=$(python3 - "$RUNNER_TEMP/runtimes.json" <<'PY' + import json, sys + runtimes = json.load(open(sys.argv[1]))["runtimes"] + available = [r for r in runtimes if r.get("isAvailable") and ".iOS-" in r["identifier"] and tuple(map(int, r["version"].split("."))) >= (18, 5)] + if not available: + raise SystemExit("No available iOS 18.5+ simulator runtime") + print(max(available, key=lambda r: tuple(map(int, r["version"].split("."))))["identifier"]) + PY + ) + device=$(xcrun simctl create "Interceptor CI" com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro "$runtime") + echo "SIMULATOR_UUID=$device" >> "$GITHUB_ENV" + xcrun simctl boot "$device" + xcrun simctl bootstatus "$device" -b + xcrun simctl list devices booted + + - name: Build and run unit and UI tests + run: | + set -o pipefail + NSUnbufferedIO=YES xcodebuild test \ + -project Interceptor.xcodeproj -scheme Interceptor \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UUID" \ + -destination-timeout 60 \ + -clonedSourcePackagesDirPath "$PLL_SOURCE_PACKAGES_PATH" \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -packageAuthorizationProvider netrc -onlyUsePackageVersionsFromResolvedFile \ + -skipPackagePluginValidation \ + -parallel-testing-enabled NO \ + -collect-test-diagnostics never \ + -resultBundlePath "$RUNNER_TEMP/Interceptor.xcresult" \ + CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- > >(tee "$RUNNER_TEMP/test.log") 2>&1 & + build_pid=$! + ( + while kill -0 "$build_pid" 2>/dev/null; do + sleep 120 > /dev/null 2>&1 + if kill -0 "$build_pid" 2>/dev/null; then + date -u + ps -p "$build_pid" -o pid,etime,pcpu,stat,comm + sample_path="$RUNNER_TEMP/xcodebuild-sample-$(date +%s).txt" + sample "$build_pid" 3 -file "$sample_path" || true + if [ -f "$sample_path" ]; then sed -n '1,100p' "$sample_path"; fi + fi + done + ) > >(tee "$RUNNER_TEMP/diagnostics.log") 2>&1 & + diagnostics_pid=$! + trap 'kill "$diagnostics_pid" 2>/dev/null || true' EXIT + wait "$build_pid" + + - name: Save test results and build logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: simulator-results + path: | + ${{ runner.temp }}/Interceptor.xcresult + ${{ runner.temp }}/test.log + ${{ runner.temp }}/diagnostics.log + ${{ runner.temp }}/xcodebuild-sample-*.txt + if-no-files-found: ignore + retention-days: 7 diff --git a/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index db032dd..c17a964 100644 --- a/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "aaa6085c096db72590a6bebadcdf293e0a23f1b4238edb1da67e8d5cf5ec4940", + "originHash" : "ae275c1f2220b3ee02617d3d89e557987fda1816c03980da0f975edea7d47070", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -177,7 +177,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/qtmleap/QuantumLeap.git", "state" : { - "revision" : "d7e8cf36ade39306a8e1f3943c380352285db9ee", + "revision" : "30dac2509deea3d935bb70fcc3f1730a24248641", "version" : "0.0.7" } }, @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/simonbs/Runestone.git", "state" : { - "revision" : "1fad339aab99cf2136ce6bf8c32da3265b2e85e5", - "version" : "0.5.1" + "revision" : "592434a103a4d1ab83e14f87ac6eef569dd7a99d", + "version" : "0.5.2" } }, { @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "334e682869394ee239a57dbe9262bff3cd9495bd", - "version" : "3.14.0" + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" } }, { diff --git a/InterceptorUITests/InterceptorUITests.swift b/InterceptorUITests/InterceptorUITests.swift index c074240..350f1b8 100644 --- a/InterceptorUITests/InterceptorUITests.swift +++ b/InterceptorUITests/InterceptorUITests.swift @@ -31,6 +31,82 @@ final class InterceptorUITests: XCTestCase { // Use XCTAssert and related functions to verify your tests produce the correct results. } + @MainActor + func testSimulatorOnboardingAndNavigation() throws { + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + addUIInterruptionMonitor(withDescription: "Tracking permission") { alert in + let decline = alert.buttons["Ask App Not to Track"] + guard decline.exists else { return false } + decline.tap() + return true + } + app.launch() + + // The simulator permits advancing through the device-only setup steps. + if app.buttons["Next"].waitForExistence(timeout: 5) { + for _ in 0..<8 { + XCTAssertTrue(app.buttons["Next"].waitForExistence(timeout: 5)) + app.buttons["Next"].tap() + } + XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5)) + app.buttons["Done"].tap() + } + XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 10)) + attachScreenshot(app, named: "Home") + + app.tabBars.buttons["Settings"].tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + let autoConnect = app.switches["Auto Connect"] + XCTAssertTrue(autoConnect.waitForExistence(timeout: 5)) + let initialValue = try XCTUnwrap(autoConnect.value as? String) + // SwiftUI exposes the labeled row as the switch's accessibility frame. + // Tap the trailing control rather than the center of the row's label. + let control = autoConnect.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)) + control.tap() + let changed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value != %@", initialValue), object: autoConnect) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 5), .completed) + control.tap() + let restored = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", initialValue), object: autoConnect) + XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 5), .completed) + attachScreenshot(app, named: "Settings") + + app.buttons["SSL Proxying List"].tap() + XCTAssertTrue(app.navigationBars["SSL Proxying List"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["api.lp1.av5ja.srv.nintendo.net"].exists) + XCTAssertTrue(app.staticTexts["app.splatoon2.nintendo.net"].exists) + attachScreenshot(app, named: "Proxy Hosts") + app.navigationBars.buttons.firstMatch.tap() + + app.buttons["Token List"].tap() + XCTAssertTrue(app.navigationBars["Token List"].waitForExistence(timeout: 5)) + attachScreenshot(app, named: "Token List") + app.navigationBars.buttons.firstMatch.tap() + + app.buttons["Certificate"].tap() + XCTAssertTrue(app.navigationBars["Certificate"].waitForExistence(timeout: 5)) + app.navigationBars.buttons.firstMatch.tap() + app.tabBars.buttons["Home"].tap() + + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(app.buttons["Clear"].waitForExistence(timeout: 5)) + app.buttons["Clear"].tap() + XCTAssertTrue(app.navigationBars["Home"].exists) + + app.terminate() + app.launch() + XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["Next"].exists, "Completed onboarding must remain dismissed after relaunch") + } + + @MainActor + private func attachScreenshot(_ app: XCUIApplication, named name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + @MainActor func testLaunchPerformance() throws { // This measures how long it takes to launch your application. diff --git a/README.md b/README.md index cc752c5..fcea291 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,110 @@ This is an iOS application that uses a self-signed certificate to obtain an acce ### Requirements -- iOS 16.x -- Xcode 16.x +- iOS 17 or later +- Xcode with Swift 6.1 or later (required by Mudmouth) - fastlane +The simulator build was verified with Xcode 27.0 on 2026-10-04 using +`swift-crypto` 3.15.1 and Runestone 0.5.2. Keep the checked-in `Package.resolved` +to use these compatible dependency versions. + +### Local development + +Dependencies are not bundled. Xcode downloads remote Swift packages, and the +project expects a local Mudmouth checkout beside Interceptor: + +The QuantumLeap package is private. Your GitHub account must have read access; +configure Xcode's GitHub account or a local Git credential before resolving it. + +```text +development-directory/ + Interceptor/ + Mudmouth/ +``` + +From the Interceptor directory, clone Mudmouth if it does not already exist: + +```bash +git clone https://github.com/qtmleap/Mudmouth.git ../Mudmouth +git -C ../Mudmouth checkout ddcefe656c0f27289ee4f506f2112b8bbdaa407d +xcodebuild -resolvePackageDependencies -project Interceptor.xcodeproj -scheme Interceptor +open Interceptor.xcodeproj +``` + +The revision above is the Mudmouth revision selected for the local setup on +2026-10-04. Keep the checkout on its development branch when editing Mudmouth; +record any dependency revision changes alongside the application changes. + +To check simulator compilation without signing: + +```bash +xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO +``` + +This is a compile-only check. To run the app in Simulator, keep signing enabled +so the app's App Group entitlement is embedded; the unsigned build crashes when +opening the shared model container. Select a simulator in Xcode and run normally, +or use the following command with its UUID: + +```bash +xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- +``` + +If another Xcode is selected globally, prefix the command with +`DEVELOPER_DIR=/Applications/Xcode-27.0.0.app/Contents/Developer` to use the +verified installation without changing the system selection. For unattended +public-package downloads that wait on Keychain authorization, add +`-packageAuthorizationProvider netrc` to the xcodebuild command. + +On first use, approve the package build plugins in Xcode when prompted. For +unattended builds of these pinned dependencies, CI supplies +`-skipPackagePluginValidation` to avoid the interactive approval step. + +The current test targets require an iOS 18.5 or later simulator. Testing the VPN +and traffic capture requires an iOS device and signing configured for the app +and PacketTunnel extension. + +### Simulator smoke test + +`InterceptorUITests/testSimulatorOnboardingAndNavigation` checks onboarding, +navigation, Auto Connect, history clearing, and relaunch. Run it on a dedicated +simulator because it clears the app's history: + +```bash +xcodebuild test -project Interceptor.xcodeproj -scheme Interceptor -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' -parallel-testing-enabled NO -only-testing:InterceptorUITests/InterceptorUITests/testSimulatorOnboardingAndNavigation CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- +``` + +Verified on an iPhone 17 Pro simulator with iOS 26.5 and Xcode 27.0 on 2026-10-04. +The test uses English UI labels. VPN traffic capture and real Nintendo tokens +remain device-only checks. + +### Continuous integration + +GitHub Actions runs the simulator build and the complete unit/UI test suite on +pull requests to `master` and pushes to `master`. It checks out the pinned +Mudmouth revision beside Interceptor, honors `Package.resolved`, and creates a +fresh iPhone simulator for each run. Test results and logs are retained for +seven days in the `simulator-results` artifact. + +CI requires the repository secret `QUANTUMLEAP_READ_TOKEN`, a GitHub token with +read access to `qtmleap/QuantumLeap`. For a dedicated CI credential, prefer a +fine-grained token limited to that repository with **Contents: read-only**. +Fork pull requests cannot access this secret and require a maintainer to run +the changes on a trusted branch. Deploy keys are disabled for QuantumLeap. + +CI uses one signed `xcodebuild test` invocation, which builds the app and test +targets for its selected simulator and runs the full suite. This avoids a +separate unsigned build of both simulator architectures. Build/test output and +periodic process samples are saved to help diagnose waits. Automatic simulator +sysdiagnose collection is disabled (`-collect-test-diagnostics never`) to avoid +the hosted Simulator's post-test collection hang; normal test results and +screenshots remain in the result bundle. + +The workflow uses GitHub's [`xcode-27` preview runner](https://github.com/actions/runner-images/issues/14404). +The local Xcode 27.0 / iOS 26.5 validation and the hosted runner's selected +Xcode/runtime are recorded separately in their build logs. + ## Contributors - [zhxie](https://github.com/zhxie) diff --git a/docs/superpowers/plans/2026-10-04-review-fixes.md b/docs/superpowers/plans/2026-10-04-review-fixes.md new file mode 100644 index 0000000..260e987 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-review-fixes.md @@ -0,0 +1,250 @@ +# Interceptor 修正計画書 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** レビューで確認した6件を修正し、依存解決とビルドを再現可能にし、取得データによるクラッシュとVPN接続状態の誤表示を防ぐ。 + +**Architecture:** 先に外部依存を復旧し、実際のMudmouthのAPIとデータ形式を確認する。その後、HTTPヘッダーとCookieの解析、AccessToken初期化、VPN操作と表示の同期を小さな変更として実装する。データモデルの保存形式と既存の取得対象は維持する。 + +**Tech Stack:** Swift、SwiftUI、Swift Package Manager、NetworkExtension、KeychainAccess、Mudmouth、XCTest、Xcode、fastlane。 + +**Spec:** 本書の「修正対象と調査根拠」。2026年10月4日のリポジトリレビューを元に作成。 + +## 修正対象と調査根拠 + +| 番号 | 優先度 | 問題 | 根拠と検証範囲 | +| --- | --- | --- | --- | +| 1 | P1 | QuantumLeapの固定SHAを取得できない | Package.resolvedのSHAは`d7e8cf36ade39306a8e1f3943c380352285db9ee`。レビュー時の0.0.7タグは`30dac2509deea3d935bb70fcc3f1730a24248641`。xcodebuildは固定SHAのチェックアウトに失敗した。 | +| 2 | P1 | Mudmouthがリポジトリ外にあり、取得手順がない | project.pbxprojは`../Mudmouth`を参照。このワークツリーと元のチェックアウトの隣にパッケージは存在しない。 | +| 3 | P1 | 同名Cookieでクラッシュ | Tuberose.swiftの解析コードを抽出し、`session=one; session=two`でDictionaryの重複キートラップを再現。 | +| 4 | P2 | ヘッダー名の大小文字で取得に失敗 | 抽出した実コードで、`host`と`x-gamewebtoken`が読み取れずnilになることを再現。 | +| 5 | P2 | 不正なGameWebTokenでクラッシュ | AccessToken.swiftの`try!`に外部入力が渡る。実ライブラリによる不正入力の再現は依存復旧後に行う。 | +| 6 | P2 | VPN開始失敗後もスイッチがON | VPNSetting.swiftに開始失敗時の表示復旧がない。実機の接続失敗とMudmouthの状態通知は未検証。 | + +P1は高、P2は中の修正優先度。前回レビューではコンパイルとアプリ全体のテストまで到達していないため、依存復旧後の結果で計画を補正する。 + +## パッケージ同梱状況 + +**すべて同梱されている状態ではない。** 追跡されているのはアプリと拡張のソース、および依存設定・ロックファイルである。 + +| 依存 | 現在の指定 | パッケージ本体の同梱 | +| --- | --- | --- | +| Mudmouth | ローカルパッケージ`../Mudmouth` | 同梱なし。追加依頼により`https://github.com/qtmleap/Mudmouth`を兄弟ディレクトリにクローンした。取得SHAは`ddcefe656c0f27289ee4f506f2112b8bbdaa407d`。 | +| QuantumLeap | 非公開GitHubのリモートSwiftパッケージ | なし。固定SHAの取得失敗と、読み取り認証が必要。 | +| Firebase iOS SDK | GitHubのリモートSwiftパッケージ | なし。SPMによる取得が必要。 | +| Runestone | GitHubのリモートSwiftパッケージ | なし。SPMによる取得が必要。 | +| treesitterlanguages | GitHubのリモートSwiftパッケージ | なし。SPMによる取得が必要。 | +| KeychainAccess、SwiftyLogger、NIOなど | importまたはPackage.resolvedに記録 | ソースの同梱なし。直接・推移依存の関係はMudmouth復旧後に確定する。 | +| fastlaneとRuby依存 | GemfileとGemfile.lock | gem本体の同梱なし。bundle installによる取得が必要。 | + +`Package.resolved`と`Gemfile.lock`は依存バージョンの記録であり、実装コードやバイナリではない。XcodeのSourcePackages等のキャッシュもリポジトリ同梱物には含まれない。 + +## Global Constraints + +- 当初は計画書のみの依頼。追加依頼によりローカル開発環境のセットアップと必要な依存設定の修正まで進める。取得処理とVPN表示の修正、コミット・配布は未着手。 +- 実装時も既存のトークン保存キーとCodableの保存形式を維持する。 +- HTTPヘッダー名は大小文字を区別せず、Cookie名は大小文字を区別する。 +- Mudmouthの取得元はユーザー指定の`https://github.com/qtmleap/Mudmouth`。クローン済みのソースを基準に互換性を確認する。 +- QuantumLeap以外の依存は必要性が確認できない限り一括更新しない。 +- アプリとPacketTunnelのDeployment Targetは現在の17.0を維持する。テストターゲットの18.5との相違は検証環境の選定時に扱う。 +- 実トークン・個人情報はテストfixtureやドキュメントに保存しない。 + +## Review Focus + +- 同名Cookieが複数ある入力:プロセスを終了させず、定めた選択規則を適用する。 +- 小文字・混在ケースのHTTPヘッダー:HostとX-GameWebTokenを取得できる。 +- Cookie値の`=`と空文字:値を壊さずに解析し、名前だけの不正な要素は除外する。 +- 不正なJWTや未知のpayload:エラーとして扱い、既存の保存済みトークンを上書きしない。 +- VPN開始失敗と外部状態変化:表示を実状態に合わせ、状態同期そのものではVPN操作を発火させない。 + +## Task 1 依存関係とビルドの復旧 + +**Files:** +- Modify: `Interceptor.xcodeproj/project.pbxproj` +- Modify: `Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` +- Modify: `README.md` + +**Interfaces:** +- Consumes: 現在のSPM設定、MudmouthをimportするアプリとPacketTunnel。 +- Produces: クリーンチェックアウトから取得できるMudmouthとQuantumLeap、および実際のGameWebToken・VPN API。 + +- [x] ユーザー指定のMudmouthリポジトリを取得し、Package.swiftが公開するMudmouth製品と既存参照の一致を確認した。取得SHAは`ddcefe656c0f27289ee4f506f2112b8bbdaa407d`。 +- [x] ユーザーのローカル開発希望に合わせて既存のローカルSPM参照を維持し、兄弟ディレクトリへの配置手順と取得SHAをREADMEに明記した。 +- [x] QuantumLeapの0.0.7の取得可能なSHAを確認してロックファイルを修正し、SPMによる依存解決が終了コード0になることを確認した。アプリとの互換性はビルドで確認する。 +- [x] 依存差分を確認し、無関係なLicenseListの自動更新を除外した。 +- [x] READMEに依存取得、実際の対応iOS/Xcode条件、ビルドとテストの手順を記載した。 +- [ ] 空のパッケージ取得先で次のコマンドを実行する。期待結果:依存解決が終了コード0となり、アプリとVPN拡張がBUILD SUCCEEDEDになる。 + +```bash +xcodebuild -resolvePackageDependencies -project Interceptor.xcodeproj -scheme Interceptor -clonedSourcePackagesDirPath /tmp/interceptor-review-packages +xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'generic/platform=iOS Simulator' -clonedSourcePackagesDirPath /tmp/interceptor-review-packages CODE_SIGNING_ALLOWED=NO +xcodebuild -showdestinations -project Interceptor.xcodeproj -scheme Interceptor +``` + +- [ ] 利用可能なiOS 18.5以降のSimulator IDを選び、以後のテストに同じIDを使用する。実機VPNテストは別途行う。 +- [ ] この単位をレビューし、`fix: restore reproducible package resolution`としてコミットする。 + +## Task 2 HTTPヘッダーとCookie解析の修正 + +**Files:** +- Modify: `Interceptor/Classes/Tuberose.swift` +- Test: `InterceptorTests/InterceptorTests.swift` + +**Interfaces:** +- Consumes: 通知からデコードされた`[String: String]`。 +- Produces: `headerValue(forKey: String) -> String?`、既存の`host: String?`と`cookies: [String: String]?`。 +- Cookie重複の規則:最初の出現を保持する。これは本アプリの明示的な選択であり、HTTP仕様が順序による優先を保証するという意味ではない。 + +- [ ] 以下の失敗テストを追加する。`testDuplicateCookieKeepsFirstValue`はCookie `session=one; session=two`の結果が`session: one`になることを確認する。`testHeaderNamesAreCaseInsensitive`は`host`と`x-gamewebtoken`で値が取得できることを確認する。 +- [ ] `testCookieValuesPreserveEqualsAndEmptyStrings`で`a=x=y; b=; invalid`が`a: x=y`、`b: 空文字`となり、invalidが含まれないことを確認する。`testCookieNamesRemainCaseSensitive`で`A=one; a=two`が別キーになることを確認する。 +- [ ] 現行実装でテストが失敗することを確認する。重複Cookieのテストはプロセス終了するため単独実行する。 +- [ ] ヘッダー専用の大小文字を区別しない検索を実装し、hostとX-GameWebTokenの取得に使用する。Cookieの値検索は既存の完全一致を維持する。 +- [ ] Cookie解析を重複キートラップのない逐次格納に変更し、`=`は最初の1つでのみ分割する。空の値と欠落した区切り文字を区別する。 +- [ ] Mudmouthが通知ヘッダーを正規化するか確認し、実際の通知形式でも解析できることを確認する。 +- [ ] 対象テストを実行して終了コード0を確認し、`fix: handle cookie duplicates and header casing`としてコミットする。 + +## Task 3 トークン初期化の安全化 + +**Files:** +- Modify: `Interceptor/Structs/AccessToken.swift` +- Modify: `Interceptor/Classes/Tuberose.swift` +- Modify: `../Mudmouth/Sources/Mudmouth/Structs/GameWebToken.swift`(別リポジトリ) +- Test: `InterceptorTests/InterceptorTests.swift` + +**Interfaces:** +- Consumes: Task 1で復旧したGameWebTokenのthrowing initializer。 +- Produces: `AccessToken.init(contentId: ContentId, host: String, gtoken: String, accessToken: String, timeInterval: TimeInterval = 60 * 60 * 2) throws`。 +- Mudmouthの`GameWebToken.init(_ value: String) throws`は公開シグネチャを維持し、内部の不正入力もthrowとして返す。 + +- [ ] MudmouthのGameWebTokenデコーダーを読み、必要なpayload項目を満たす合成fixtureを作る。実際の認証情報は使わない。 +- [ ] `testMalformedGameWebTokenThrows`で`gtoken: not-a-jwt`がエラーになることを確認する。`testUnsupportedGameWebTokenPayloadThrows`で必要項目を欠くpayloadがエラーになることを確認する。`testValidGameWebTokenPreservesStoredFields`で合成fixtureの正常系とCodable往復を確認する。 +- [ ] throwing initializerがまだない状態で、テストが失敗することを確認する。 +- [ ] クローン後の追加確認:MudmouthのGameWebTokenにもJSONデコードの`try!`と未検証の`data[0]`・`data[1]`がある。JWTの要素数・各要素のBase64URLデコードを検証し、JSONデコードを`try`で伝播する。不正なBase64URL、3要素を超える入力、header/payload欠落でもクラッシュしないことを回帰テストに追加する。アプリ側だけの修正では完了としない。 +- [ ] `try!`を除去し、呼び出し元の3つの取得分岐でAccessToken生成を成功させてからKeychainへ書き込む。失敗時は既存値を保持する。 +- [ ] 不正なトークンを含む通知を処理し、アプリが終了せず、以前の保存値が維持されることを確認する。Keychainを使う検証は専用テストサービスで行い、終了時に片付ける。 +- [ ] 正常系・異常系テストを実行して終了コード0を確認し、`fix: reject malformed captured tokens safely`としてコミットする。 + +## Task 4 VPN操作と表示状態の同期 + +**Files:** +- Create: `Interceptor/Classes/VPNConnectionState.swift` +- Modify: `Interceptor/Components/VPNSetting.swift` +- Modify: `Interceptor/Classes/Tuberose.swift` +- Test: `InterceptorTests/InterceptorTests.swift` + +**Interfaces:** +- `@MainActor protocol VPNConnectionControlling`:`var isConnected: Bool { get }`、`func startVPNTunnel() async throws`、`func stopVPNTunnel()`。Tuberoseを準拠させる。 +- `@MainActor final class VPNConnectionState: ObservableObject`:`@Published private(set) var isConnected: Bool`、`@Published private(set) var errorMessage: String?`、`func setConnected(_ requested: Bool, using client: any VPNConnectionControlling) async`、`func synchronize(with client: any VPNConnectionControlling)`、`func clearError()`。 +- `synchronize`は表示のみ更新し、start/stopを呼ばない。開始成功直後も実状態を読み、Mudmouthの確定通知に追従する。 + +- [ ] 開始失敗・正常開始・停止を制御できるFakeVPNClientをテスト内に作る。 +- [ ] `testStartFailureRestoresDisconnectedState`で開始がthrowすると表示falseかつエラーありを確認する。`testSuccessfulStartReflectsActualStatus`で正常開始時の状態を確認する。 +- [ ] `testStatusSynchronizationDoesNotIssueVPNCommands`で外部状態の同期時に開始・停止の呼び出し回数が増えないことを確認する。 +- [ ] `testStopReflectsAsynchronousStatusChange`で停止直後は実状態を表示し、後続の同期でfalseになることを確認する。 +- [ ] 新しい状態クラスがない状態でテストが失敗することを確認し、上記インターフェースを実装する。 +- [ ] VPNSettingのスイッチのユーザー操作からのみsetConnectedを呼ぶ。onAppear・状態通知ではsynchronizeを使用する。開始失敗はalertで表示する。 +- [ ] Mudmouthの状態変更の観測方法を実装に合わせて確認する。既存のonChangeが通知を観測できない場合は、実ライブラリが提供するVPN状態通知に接続する。 +- [ ] テストを実行して終了コード0を確認し、`fix: synchronize VPN switch after connection failure`としてコミットする。 + +## Task 5 全体検証 + +- [ ] Task 1で選んだSimulator IDで単体テストと既存UIテストを実行する。下記のSIMULATOR_IDは実際のUUIDに置換する。 + +```bash +xcodebuild test -project Interceptor.xcodeproj -scheme Interceptor -destination 'platform=iOS Simulator,id=SIMULATOR_ID' -only-testing:InterceptorTests +xcodebuild test -project Interceptor.xcodeproj -scheme Interceptor -destination 'platform=iOS Simulator,id=SIMULATOR_ID' -only-testing:InterceptorUITests +xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO +``` + +- [ ] 実機でVPN開始・停止・開始拒否と前面復帰を確認する。Simulatorの結果だけでNetworkExtensionの実通信が動作したと判断しない。 +- [ ] 実機でSplatoon 2、Splatoon 3、Smash Worldの取得から保存、画面表示まで確認する。記録にトークン値は含めない。 +- [ ] クリーンチェックアウトでREADMEの手順を実行し、個人の依存キャッシュや未記載の兄弟リポジトリに頼らないことを確認する。 +- [ ] 変更全体をレビューし、コマンド結果と未検証項目を修正報告に記録する。 + +## 完了条件 + +- 依存解決が成功し、アプリとPacketTunnelをDebug・Releaseでビルドできる。 +- 重複Cookieと不正トークンでクラッシュせず、ヘッダーの大小文字で取得結果が変わらない。 +- 保存形式を維持し、失敗した取得では既存のトークンを失わない。 +- VPN開始失敗後の表示が実状態に戻り、表示同期で余分な開始・停止を発火しない。 +- 回帰テストが成功し、実機での検証結果または残った制約を明記する。 + +## 計画の確認記録 + +6件すべてをTask 1〜4に割り当てた。解析の重複・大小文字・値の境界はTask 2、トークンの正常系と異常系はTask 3、VPN操作と状態同期はTask 4で検証する。追加依頼によりTask 1のセットアップを開始した。Mudmouthの取得元は確定済みで、実機検証は未実施。クローン後に見つかったMudmouth内部のJWT解析の問題はTask 3へ追加した。 + +セットアップ時、QuantumLeap 0.0.7の旧SHAがMacのSPM fingerprint記録にも残っていた。該当ファイルを`/tmp/interceptor-quantumleap-fingerprint-before.json`へバックアップし、そのバージョンの旧記録だけを除去して再取得した。XcodeのKeychain認証待ちを避けるため、公開パッケージの取得に`-packageAuthorizationProvider netrc`を使用した。 + +検証用に通常と異なるパッケージ保存先を指定するとLicenseListプラグインが`SourcePackages not found`で停止した。カスタム保存先を使う検証では、同プラグインの`PLL_SOURCE_PACKAGES_PATH`環境変数にも同じパスを指定する。READMEの標準保存先を使う手順ではこの指定は不要。 + +## ローカルビルド確認結果 + +2026年10月4日、依存更新前にはXcode 26.0.1(17A400)で下記のビルドを実行し、終了コード0と`BUILD SUCCEEDED`を確認した。Interceptor.appとPacketTunnel.appexを生成し、Simulatorのarm64とx86_64を対象にビルドした。実機の署名・VPN通信、Releaseビルド、テストは今回の確認対象外。 + +```bash +DEVELOPER_DIR=/Applications/Xcode-26.0.1.app/Contents/Developer PLL_SOURCE_PACKAGES_PATH=/tmp/interceptor-local-packages xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'generic/platform=iOS Simulator' -clonedSourcePackagesDirPath /tmp/interceptor-local-packages -derivedDataPath /tmp/interceptor-local-build-xcode26 -packageAuthorizationProvider netrc -onlyUsePackageVersionsFromResolvedFile CODE_SIGNING_ALLOWED=NO +``` + +ログ:`/tmp/interceptor-local-build-xcode26.log`。 + +依存更新前のXcode 27.0(27A266a)では、swift-cryptoの_CryptoExtras内の`@TaskLocal`展開に`unknown attribute 'usableFromInlinenonisolated'`が発生し、終了コード65で失敗した。ログ:`/tmp/interceptor-local-build.log`。追加依頼により下記の依存更新で対応し、現在はXcode 27.0でもビルドできる。 + +## Xcode 27向けの依存更新結果 + +| 依存 | 更新前 | 更新後 | 更新理由 | +| --- | --- | --- | --- | +| swift-crypto | 3.14.0 | 3.15.1 | エラーが発生したTaskLocalを使用する実装が更新済み。swift-certificates 1.12.0の依存条件内で解決できる。 | +| Runestone | 0.5.1 | 0.5.2 | 新SDKでUIFindInteractionDelegateのavailabilityに関するコンパイルエラーが発生。0.5.2の修正を適用。 | + +参考:[swift-crypto 3.15.1のソース](https://github.com/apple/swift-crypto/blob/3.15.1/Sources/_CryptoExtras/ECToolbox/BoringSSL/ECToolbox_boring.swift)、[Runestone 0.5.2のリリース](https://github.com/simonbs/Runestone/releases/tag/0.5.2)。 + +Package.resolvedの上記2つの固定バージョンとSHAを更新した。アプリ、Mudmouth、取得済み外部ライブラリのソースに独自パッチは当てていない。公開されたバージョンをSPMで取得し、既存の依存条件内で解決した。 + +```bash +DEVELOPER_DIR=/Applications/Xcode-27.0.0.app/Contents/Developer PLL_SOURCE_PACKAGES_PATH=/tmp/interceptor-local-packages xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'generic/platform=iOS Simulator' -clonedSourcePackagesDirPath /tmp/interceptor-local-packages -derivedDataPath /tmp/interceptor-local-build -packageAuthorizationProvider netrc -onlyUsePackageVersionsFromResolvedFile CODE_SIGNING_ALLOWED=NO +``` + +終了コード0、`BUILD SUCCEEDED`。Interceptor.appと内包するPacketTunnel.appexのarm64・x86_64 Simulatorビルドを確認した。ログ:`/tmp/interceptor-local-build-updated.log`。更新後の依存での実機動作、Release、テスト、およびXcode 26での再ビルドは未検証。 + +## Simulator動作確認結果 + +2026年10月4日、iPhone 17 Pro・iOS 26.5の専用Simulator「Interceptor Review iPhone」で動作を確認した。UUIDは`87D028AC-FFB0-4062-8725-EA9CD7A915E2`。 + +署名を無効にしたコンパイル確認用ビルドでは、App Groupの保存先を取得できず起動時にクラッシュした。アプリのソースを変更せず、Simulator用に`CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=-`を指定して再ビルド・インストールすると正常に起動した。READMEにコンパイル確認と実行用ビルドの違いを追記した。 + +`InterceptorUITests/InterceptorUITests.swift`に`testSimulatorOnboardingAndNavigation`を追加した。SwiftUIのラベル付きスイッチは行全体がアクセシビリティの対象になるため、末尾のスイッチ位置をタップし、値の更新をpredicateで待つ。iOS 26の履歴削除確認はCancelボタンを前提とせず、専用の空履歴環境でClear操作を検証する。 + +| 検証項目 | 結果 | +| --- | --- | +| 初回案内の9画面をNextとDoneで進める | 初回実行で確認済み | +| ホームと設定のタブ切り替え | 成功 | +| Auto Connectを切り替え、元に戻す | 成功 | +| プロキシ対象リストとNintendoの対象ホスト表示 | 成功 | +| トークン一覧の表示と戻る操作 | 成功(トークン未取得の状態) | +| 証明書画面の表示と戻る操作 | 成功 | +| 空の履歴をClearし、画面を維持する | 成功 | +| アプリ再起動と初回案内の完了状態維持 | 成功 | + +追加したUIテスト1件は終了コード0、`TEST SUCCEEDED`。最終実行時間は42.353秒。結果バンドルは`/tmp/interceptor-simulator-smoke-final.xcresult`、ログは`/tmp/interceptor-simulator-smoke-final.log`。初回案内は最初の実行で完了しており、最終再実行では完了状態から検証した。 + +追加の全テスト実行は終了コード0、`TEST SUCCEEDED`。6種類のテストを計9回実行し、失敗・スキップは0件。結果バンドルは`/tmp/interceptor-full-validation.xcresult`、ログは`/tmp/interceptor-full-validation.log`。既存の起動テストにはメインスレッド呼び出しに関する実行時警告があるが、テストの失敗はない。 + +その後、専用Simulatorからアプリをアンインストールして新規インストール状態で追加UIテストを再実行し、初回案内を含めて終了コード0、`TEST SUCCEEDED`を確認した(49.553秒、1件、失敗0件)。結果は`/tmp/interceptor-fresh-onboarding.xcresult`、ログは`/tmp/interceptor-fresh-onboarding.log`。 + +実際のVPN接続、通信記録の取得、実Nintendoトークン、トークン詳細の表示、iPad、実機署名、Releaseの実行は未検証。修正計画のTask 2〜4にあるアプリ本体の修正は未着手。 + +## CI追加とマージ前レビュー + +GitHub Actionsの既存ワークフローはなかったため、`.github/workflows/ios.yml`を追加した。masterへのPRとpushで、Xcode 27ランナーを使ってSimulatorのコンパイルと全テストを実行する。Mudmouthはローカル確認と同じSHAに固定し、専用Simulatorを作成する。ビルドログとxcresultは7日間保存する。 + +独立レビューで、ローカルXcodeのプラグイン承認省略設定にCIが依存してしまう点を確認した。CIの両コマンドに`-skipPackagePluginValidation`を指定し、READMEにも初回のプラグイン承認手順を追記した。修正後の独立レビューにはマージを妨げる指摘は残っていない。ホストされたCIの成功を確認してからmasterへ反映する。 + +ホストされたCIではQuantumLeapの認証不足で依存取得が失敗した(run 37165605990)。QuantumLeapは非公開で、InterceptorのGITHUB_TOKENでは取得できない。CI専用の読み取りDeploy Keyの登録も、QuantumLeap側のポリシーによりHTTP 422で禁止されていた。個人アカウントの広い権限のトークンを転用せず、QuantumLeapだけのContents読み取り権限を持つ`QUANTUMLEAP_READ_TOKEN`をRepository Secretとして設定する構成にした。Secret設定後にCIを再実行し、成功後にmasterへマージする。 + +その後ユーザーから`gh auth token`を使用する明示指示があり、2026年10月4日に現在のgh認証トークンを`QUANTUMLEAP_READ_TOKEN`として登録した。値はパイプで直接渡し、ログ・チャット・リポジトリには保存していない。これは専用の読み取り権限に限定したトークンではないため、READMEでは専用トークンを推奨しつつ、必要条件をQuantumLeapへの読み取りアクセスと記載した。 + +認証追加後のrun 37166194827では、Xcode 27のarm64・x86_64のビルドが15分40秒で成功した。ライブ表示が依存一覧のところで更新されず、当初はテスト開始待ちを疑ったが、停止後の完全ログでは全テストの失敗0件を確認した。実際の待ちは01:14:55の全テスト完了後に発生し、01:21:34にキャンセルした時点でも終了していなかった。 + +Sessionログの末尾には、テスト結果の受信完了後に`forceToNotWaitForAsynchronousDiagnostics is NO`で終了処理に入った記録があった。ホストされたSimulatorの診断収集待ちと整合するため、公開CLIオプション`-collect-test-diagnostics never`で追加のsysdiagnose収集を省く。参考:[同じ症状を調査した開発者の報告](https://discuss.circleci.com/t/tests-hanging-on-xcode-26/54156)。通常のxcresult、テストログ、スクリーンショットは保存し、必要な調査は独自のプロセスサンプルで補う。 + +ユーザーからCIの遅さについて指摘があり、CIを専用Simulatorへの署名付き`xcodebuild test`一回にまとめて重複ビルドを削減した。出力のバッファリングを抑え、実行中のxcodebuildのスタックサンプルとSimulatorの初期状態を結果と一緒に保存する。CIスクリプトは成功コード0・失敗コード7の両方がそのまま返ることと、ログ出力・終了処理が完了することを短い代替コマンドで確認した。