diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 633c9cb..5759153 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v4 with: repository: qtmleap/Mudmouth - ref: 698d5bcb25245a26a3be925c34ded8a4c8a2ff6a + ref: 5c97774f645023b22366db5abe86797915da2d33 path: Mudmouth persist-credentials: false diff --git a/Interceptor.xcodeproj/project.pbxproj b/Interceptor.xcodeproj/project.pbxproj index 60240bd..934b804 100644 --- a/Interceptor.xcodeproj/project.pbxproj +++ b/Interceptor.xcodeproj/project.pbxproj @@ -574,7 +574,7 @@ CODE_SIGN_ENTITLEMENTS = Interceptor/Interceptor.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -612,7 +612,7 @@ CODE_SIGN_ENTITLEMENTS = Interceptor/Interceptor.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -647,7 +647,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; @@ -666,7 +666,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; @@ -684,7 +684,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -701,7 +701,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -720,7 +720,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PacketTunnel/Info.plist; @@ -752,7 +752,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PacketTunnel/Info.plist; diff --git a/Interceptor/Classes/Tuberose.swift b/Interceptor/Classes/Tuberose.swift index 6c523c1..74fd691 100644 --- a/Interceptor/Classes/Tuberose.swift +++ b/Interceptor/Classes/Tuberose.swift @@ -67,6 +67,14 @@ public final class Tuberose: ObservableObject { mudmouth.stopVPNTunnel() } + func withdrawCaptureConsent() { + CaptureAuthorization.revoke() + activateOnForeground = false + stopVPNTunnel() + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + func setToken(_ value: UNNotificationResponse) throws { try CaptureAuthorization.requireConsent() guard let rawID = value.notification.request.content.userInfo["recordID"] as? String, diff --git a/Interceptor/ContentView.swift b/Interceptor/ContentView.swift index d287dd1..0fc9c91 100644 --- a/Interceptor/ContentView.swift +++ b/Interceptor/ContentView.swift @@ -13,10 +13,42 @@ import SwiftyLogger struct ContentView: View { @Environment(\.isFirstLaunch) private var isFirstLaunch: Binding - @AppStorage("CaptureConsentDecided") private var consentDecided = false - @State private var showConsent = false + @EnvironmentObject private var client: Tuberose + @Environment(\.scenePhase) private var scenePhase + @AppStorage(CaptureAuthorization.key, store: CaptureAuthorization.defaults) private var consentVersion = 0 + + private var requiresConsent: Bool { consentVersion != CaptureAuthorization.version } var body: some View { + Group { + if requiresConsent { + // Keep history, settings and their presentations unavailable until agreement. + Color(.systemBackground).ignoresSafeArea() + } else { + mainTabs + } + } + .onAppear { stopCaptureIfUnauthorized() } + .onChange(of: consentVersion) { stopCaptureIfUnauthorized() } + .onChange(of: scenePhase) { + if scenePhase == .active { + consentVersion = CaptureAuthorization.defaults.integer(forKey: CaptureAuthorization.key) + stopCaptureIfUnauthorized() + } + } + .fullScreenCover(isPresented: Binding(get: { requiresConsent }, set: { _ in })) { + NavigationStack { + DataUseConsentView { isFirstLaunch.wrappedValue = false } + } + .interactiveDismissDisabled() + } + } + + private func stopCaptureIfUnauthorized() { + if !CaptureAuthorization.isGranted { client.stopVPNTunnel() } + } + + private var mainTabs: some View { TabView(content: { NavigationView(content: { HomeView() @@ -50,20 +82,6 @@ struct ContentView: View { tabView.tabBar.backgroundColor = .systemBackground tabView.tabBar.isTranslucent = true }) - .onAppear { - let version = CaptureAuthorization.defaults.integer(forKey: CaptureAuthorization.key) - showConsent = !consentDecided || (version != 0 && version != CaptureAuthorization.version) - if !CaptureAuthorization.isGranted { Tuberose.default.stopVPNTunnel() } - } - .fullScreenCover(isPresented: $showConsent) { - NavigationStack { - DataUseConsentView { _ in - consentDecided = true - isFirstLaunch.wrappedValue = false - showConsent = false - } - }.interactiveDismissDisabled() - } } } diff --git a/Interceptor/Localizable.xcstrings b/Interceptor/Localizable.xcstrings index 476bc5e..b16d08c 100644 --- a/Interceptor/Localizable.xcstrings +++ b/Interceptor/Localizable.xcstrings @@ -1,6 +1,182 @@ { "sourceLanguage" : "ja", "strings" : { + "Data Use" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Data Use" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データ利用" + } + } + } + }, + "Consent Status" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Consent Status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同意状態" + } + } + } + }, + "Consented" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Consented" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同意済み" + } + } + } + }, + "Not Consented" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Consented" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未同意" + } + } + } + }, + "Consent Required" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Consent Required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再同意が必要" + } + } + } + }, + "Read Details" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "説明を読む" + } + } + } + }, + "Review and Agree" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review and Agree" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認して同意" + } + } + } + }, + "Close" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + } + } + }, + "Withdraw Consent?" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Withdraw Consent?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同意を撤回しますか?" + } + } + } + }, + "Cancel" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "CONSENT_WITHDRAW_CONFIRMATION" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This stops the VPN and automatic reconnection and removes capture notifications. Saved data is not deleted." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VPNと自動再接続を停止し、キャプチャ通知を削除します。保存済みのデータは削除しません。" + } + } + } + }, "DISABLED" : { "localizations" : { "en" : { @@ -785,18 +961,18 @@ } } }, - "Agree and Continue" : { + "Agree" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Agree and Continue" + "value" : "Agree" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "同意して続ける" + "value" : "同意する" } } } @@ -961,6 +1137,18 @@ } } }, + "About Data Use" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "About Data Use" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "データ利用について" } } + } + }, + "CONSENT_INTRODUCTION" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Before using the app, please review how your data is handled." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "利用前に、データの扱いをご確認ください。" } } + } + }, "CONSENT_PURPOSE" : { "localizations" : { "en" : { @@ -1030,13 +1218,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "You can decline and browse existing data. Capture and certificate download require consent. Notifications are optional and contain only a generic message and a local record identifier. You can withdraw consent here to stop capture and clear notifications. Withdrawal does not delete existing records, tokens, or installed certificates. Clear on Home deletes history only; uninstalling the app does not guarantee removal of Keychain items or older iCloud copies." + "value" : "Agree to this notice to access the app. Until you agree, this screen remains open and capture is unavailable. Notifications are optional and contain only a generic message and a local record identifier. You can withdraw consent in Settings → Privacy to stop capture and clear notifications. Withdrawal does not delete existing records, tokens, or installed certificates. Clear on Home deletes history only; uninstalling the app does not guarantee removal of Keychain items or older iCloud copies." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "同意しなくても既存データを閲覧できます。記録と証明書のダウンロードには同意が必要です。通知は任意で、一般的なメッセージと端末内の記録IDだけを含みます。この画面で同意を取り消すと記録を停止し、通知を消去します。既存の記録・トークン・インストール済み証明書は削除されません。ホームの「消去」は履歴のみを削除します。アプリの削除でもKeychainや旧iCloudコピーが必ず消えるとは限りません。" + "value" : "この説明に同意するとアプリを利用できます。同意するまではこの画面を表示し、通信の記録は行いません。通知は任意で、一般的なメッセージと端末内の記録IDだけを含みます。設定の「プライバシー」から同意を撤回すると記録を停止し、通知を消去します。既存の記録・トークン・インストール済み証明書は削除されません。ホームの「消去」は履歴のみを削除します。アプリの削除でもKeychainや旧iCloudコピーが必ず消えるとは限りません。" } } } @@ -1057,6 +1245,12 @@ } } }, + "Privacy" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Privacy" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "プライバシー" } } + } + }, "Privacy Policy" : { "localizations" : { "en" : { diff --git a/Interceptor/Views/DataUseConsentView.swift b/Interceptor/Views/DataUseConsentView.swift index 03c1607..bfa07f0 100644 --- a/Interceptor/Views/DataUseConsentView.swift +++ b/Interceptor/Views/DataUseConsentView.swift @@ -1,58 +1,122 @@ import Mudmouth import SwiftUI -import UserNotifications -struct DataUseConsentView: View { +/// Shared disclosure; the Privacy page also provides an explicit withdrawal action. +struct DataUseDetailsView: View { @EnvironmentObject private var client: Tuberose - @AppStorage(CaptureAuthorization.key, store: CaptureAuthorization.defaults) private var consentVersion = 0 - var onDecision: (Bool) -> Void = { _ in } + @State private var confirmWithdrawal = false + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var showsWithdrawal = false + var usesCards = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - disclosure("Purpose", "CONSENT_PURPOSE") - disclosure("Data Recorded", "CONSENT_DATA") - disclosure("Storage and Sharing", "CONSENT_STORAGE") - disclosure("Certificate and VPN", "CONSENT_CERTIFICATE") - disclosure("Your Choices", "CONSENT_CHOICES") - }.padding() + if usesCards { introduction } + disclosure("Purpose", "CONSENT_PURPOSE", symbol: "network") + disclosure("Data Recorded", "CONSENT_DATA", symbol: "doc.text.magnifyingglass", emphasized: true) + disclosure("Storage and Sharing", "CONSENT_STORAGE", symbol: "internaldrive") + disclosure("Certificate and VPN", "CONSENT_CERTIFICATE", symbol: "key.horizontal") + disclosure("Your Choices", "CONSENT_CHOICES", symbol: "slider.horizontal.3") + if showsWithdrawal { + Button("Withdraw Consent", role: .destructive) { confirmWithdrawal = true } + } + } + .frame(maxWidth: usesCards ? 620 : 640, alignment: .leading) + .padding(.horizontal, usesCards ? 24 : 16) + .padding(.vertical, usesCards ? 20 : 16) + .frame(maxWidth: .infinity) } - .navigationTitle("Data Use and Consent") + .background { + if usesCards { Color(uiColor: .systemGroupedBackground).ignoresSafeArea() } + } + .navigationTitle(showsWithdrawal ? LocalizedStringKey("Privacy") : LocalizedStringKey("Data Use")) .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .bottom) { - VStack(spacing: 12) { - if consentVersion == CaptureAuthorization.version { - Button("Withdraw Consent", role: .destructive) { - CaptureAuthorization.revoke() - client.activateOnForeground = false - client.stopVPNTunnel() - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - UNUserNotificationCenter.current().removeAllDeliveredNotifications() - onDecision(false) - }.buttonStyle(.bordered) - } else { - Button("Agree and Continue") { - client.activateOnForeground = false - CaptureAuthorization.grant() - onDecision(true) - }.buttonStyle(.borderedProminent) - Button("Not Now") { - CaptureAuthorization.revoke() - client.activateOnForeground = false - client.stopVPNTunnel() - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - UNUserNotificationCenter.current().removeAllDeliveredNotifications() - onDecision(false) - }.buttonStyle(.bordered) - } - }.frame(maxWidth: .infinity).padding().background(.regularMaterial) + .alert("Withdraw Consent?", isPresented: $confirmWithdrawal) { + Button("Withdraw Consent", role: .destructive) { client.withdrawCaptureConsent() } + Button("Cancel", role: .cancel) { } + } message: { + Text("CONSENT_WITHDRAW_CONFIRMATION") } } - private func disclosure(_ title: LocalizedStringKey, _ body: LocalizedStringKey) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline) + private var introduction: some View { + VStack(alignment: .leading, spacing: 16) { + Image(systemName: "network") + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(.indigo) + .frame(width: 64, height: 64) + .background(.indigo.opacity(0.10), in: RoundedRectangle(cornerRadius: 20)) + .accessibilityHidden(true) + Text("About Data Use") + .font(.system(.largeTitle, design: .rounded).weight(.bold)) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + Text("CONSENT_INTRODUCTION") + .font(.subheadline).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func disclosure(_ title: LocalizedStringKey, _ body: LocalizedStringKey, + symbol: String, emphasized: Bool = false) -> some View { + VStack(alignment: .leading, spacing: usesCards ? 14 : 8) { + if usesCards { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 12) { + disclosureIcon(symbol, emphasized: emphasized) + Text(title).font(.headline).accessibilityAddTraits(.isHeader) + } + } else { + HStack(spacing: 12) { + disclosureIcon(symbol, emphasized: emphasized) + Text(title).font(.headline).accessibilityAddTraits(.isHeader) + } + } + } else { + Text(title).font(.headline).accessibilityAddTraits(.isHeader) + } Text(body).font(.body).fixedSize(horizontal: false, vertical: true) } + .padding(usesCards ? 20 : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + if usesCards { + Color(uiColor: .secondarySystemGroupedBackground) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + } + } + + private func disclosureIcon(_ symbol: String, emphasized: Bool) -> some View { + let tint: Color = emphasized ? .orange : .indigo + return Image(systemName: symbol) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityHidden(true) + } +} + +/// Explicit consent flow, presented only for initial or renewed authorization. +struct DataUseConsentView: View { + @EnvironmentObject private var client: Tuberose + var onAgree: () -> Void = { } + + var body: some View { + DataUseDetailsView(usesCards: true) + .tint(.indigo) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Agree") { + client.activateOnForeground = false + CaptureAuthorization.grant() + onAgree() + } + } + } } } diff --git a/Interceptor/Views/SettingsView.swift b/Interceptor/Views/SettingsView.swift index 71f368c..969038c 100644 --- a/Interceptor/Views/SettingsView.swift +++ b/Interceptor/Views/SettingsView.swift @@ -12,7 +12,6 @@ import QuantumLeap import SwiftUI struct SettingsView: View { - @EnvironmentObject private var client: Tuberose @AppStorage(CaptureAuthorization.key, store: CaptureAuthorization.defaults) private var consentVersion = 0 @State private var showSetup = false @@ -20,7 +19,6 @@ struct SettingsView: View { Form(content: { QuantumLeap.Support() Section { - NavigationLink("Data Use and Consent") { DataUseConsentView() } Button("Set Up Capture") { showSetup = true } .disabled(consentVersion != CaptureAuthorization.version) Button("Capture Notifications") { @@ -30,6 +28,7 @@ struct SettingsView: View { QuantumLeap.VPNSettingList() QuantumLeap.Tools() Section { + NavigationLink("Privacy") { DataUseDetailsView(showsWithdrawal: true) } Link("Terms of Service", destination: URL(string: "https://qleap.jp/term/eula")!) Link("Privacy Policy", destination: URL(string: "https://qleap.jp/term/interceptor_privacy_policy")!) Link("Developers", destination: URL(string: "https://qleap.jp")!) diff --git a/InterceptorUITests/InterceptorUITests.swift b/InterceptorUITests/InterceptorUITests.swift index 1e9d43e..0fd5c6c 100644 --- a/InterceptorUITests/InterceptorUITests.swift +++ b/InterceptorUITests/InterceptorUITests.swift @@ -34,33 +34,155 @@ final class InterceptorUITests: XCTestCase { @MainActor func testCaptureConsentLifecycle() throws { let app = XCUIApplication() - app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launch() - if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } - else { - tab(app, named: "Settings").tap() - app.buttons["Data Use and Consent"].tap() - if app.buttons["Withdraw Consent"].exists { app.buttons["Withdraw Consent"].tap() } - returnToSettings(app) - } - XCTAssertTrue(tab(app, named: "Home").waitForExistence(timeout: 5)) + // Establish revoked authorization through the UI, regardless of previous test state. + agreeIfNeeded(app) + revokeForStartupTest(app) + assertStartupConsent(app) + app.swipeDown() + assertStartupConsent(app) + attachScreenshot(app, named: "Mandatory launch consent") + + app.terminate() + // A legacy decision flag must not bypass revoked shared authorization (version 0). + app.launchArguments += ["-CaptureConsentDecided", "YES"] + app.launch() + assertStartupConsent(app) + app.terminate() + app.launch() + assertStartupConsent(app) + agreeIfNeeded(app) tab(app, named: "Settings").tap() - let connection = app.switches["Connection Status"] - XCTAssertTrue(connection.waitForExistence(timeout: 5)) - XCTAssertFalse(connection.isEnabled) - app.buttons["Data Use and Consent"].tap() - XCTAssertTrue(app.buttons["Agree and Continue"].waitForExistence(timeout: 5)) - attachScreenshot(app, named: "Data Use and Consent") - app.buttons["Agree and Continue"].tap() - XCTAssertTrue(app.buttons["Withdraw Consent"].waitForExistence(timeout: 5)) - app.buttons["Withdraw Consent"].tap() - attachScreenshot(app, named: "Consent Withdrawn") - returnToSettings(app) - XCTAssertFalse(connection.isEnabled) + XCTAssertTrue(app.switches["Connection Status"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.switches["Connection Status"].isEnabled) + XCTAssertFalse(app.staticTexts["Consent Status"].exists) + XCTAssertFalse(app.buttons["Read Details"].exists) + XCTAssertFalse(app.buttons["Review and Agree"].exists) + XCTAssertFalse(app.buttons["Withdraw Consent"].exists) + settingsButton(app, named: "Privacy").tap() + XCTAssertTrue(app.navigationBars["Privacy"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Purpose"].exists) + XCTAssertFalse(app.buttons["Agree"].exists) + settingsButton(app, named: "Withdraw Consent").tap() + XCTAssertTrue(app.alerts.buttons["Cancel"].waitForExistence(timeout: 5)) + app.alerts.buttons["Cancel"].tap() + XCTAssertFalse(app.buttons["Agree"].exists) + XCTAssertTrue(app.navigationBars["Privacy"].exists) + app.terminate() + app.launch() + XCTAssertTrue(tab(app, named: "Settings").waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["Agree"].exists) + openPrivacy(app) + withdrawConsent(app) + assertStartupConsent(app) app.terminate() app.launch() + assertStartupConsent(app) + // Leave consent granted so other navigation tests do not depend on ordering. + agreeIfNeeded(app) + } + + @MainActor + func testConsentDetailsLargeTextLandscape() throws { + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] + app.launch() + agreeIfNeeded(app) + revokeForStartupTest(app) + app.terminate() + XCUIDevice.shared.orientation = .landscapeLeft + addTeardownBlock { @MainActor in + XCUIDevice.shared.orientation = .portrait + app.terminate() + } + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + app.launch() + assertStartupConsent(app) + XCTAssertTrue(app.navigationBars.buttons["Agree"].isHittable) + attachScreenshot(app, named: "Mandatory consent large text landscape") + agreeIfNeeded(app) + openPrivacy(app) + XCTAssertTrue(app.staticTexts["Purpose"].exists) + XCTAssertFalse(app.buttons["Agree"].exists) + XCTAssertFalse(app.buttons["Close"].exists) + attachScreenshot(app, named: "Privacy large text landscape") + } + + @MainActor + private func assertStartupConsent(_ app: XCUIApplication) { + XCTAssertTrue(app.buttons["Agree"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["Not Now"].exists) + XCTAssertFalse(app.buttons["Cancel"].exists) + XCTAssertFalse(app.buttons["Close"].exists) + XCTAssertFalse(tab(app, named: "Settings").exists) + XCTAssertFalse(app.switches["Connection Status"].exists) + } + + @MainActor + private func agreeIfNeeded(_ app: XCUIApplication) { + if app.buttons["Agree"].waitForExistence(timeout: 3) { + app.buttons["Agree"].tap() + } + XCTAssertTrue(tab(app, named: "Settings").waitForExistence(timeout: 5)) + // Existing installations may have dismissed the old optional consent screen. + // Grant through its old settings action before establishing a revoked baseline. tab(app, named: "Settings").tap() - XCTAssertFalse(app.switches["Connection Status"].isEnabled) + if app.buttons["Review and Agree"].exists { + settingsButton(app, named: "Review and Agree").tap() + XCTAssertTrue(app.buttons["Agree"].waitForExistence(timeout: 5)) + app.buttons["Agree"].tap() + } + } + + @MainActor + private func revokeForStartupTest(_ app: XCUIApplication) { + tab(app, named: "Settings").tap() + // Normalize consent using either the old settings section or its replacement. + // This also lets the regression fail on the mandatory gate before the UI changes. + if !app.buttons["Read Details"].exists { + settingsButton(app, named: "Privacy").tap() + } + withdrawConsent(app) + } + + @MainActor + private func openPrivacy(_ app: XCUIApplication) { + tab(app, named: "Settings").tap() + settingsButton(app, named: "Privacy").tap() + XCTAssertTrue(app.navigationBars["Privacy"].waitForExistence(timeout: 5)) + } + + @MainActor + private func settingsButton(_ app: XCUIApplication, named name: String) -> XCUIElement { + let button = app.buttons[name] + let form: XCUIElement + if app.navigationBars["Privacy"].exists && app.scrollViews.firstMatch.exists { + form = app.scrollViews.firstMatch + } else { + form = app.collectionViews.firstMatch.exists + ? app.collectionViews.firstMatch : app.scrollViews.firstMatch + } + for _ in 0..<10 { + if button.exists && button.isHittable { return button } + // Keep the gesture in the visible part of iPad's displaced sidebar. + let start = form.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.75)) + let end = form.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.2)) + start.press(forDuration: 0.05, thenDragTo: end) + } + XCTFail("Settings must allow scrolling to \(name)") + return button + } + + @MainActor + private func withdrawConsent(_ app: XCUIApplication) { + settingsButton(app, named: "Withdraw Consent").tap() + let confirm = app.alerts.buttons["Withdraw Consent"] + XCTAssertTrue(confirm.waitForExistence(timeout: 5)) + confirm.tap() } @MainActor @@ -70,20 +192,19 @@ final class InterceptorUITests: XCTestCase { #else XCUIDevice.shared.orientation = .portrait let app = XCUIApplication() - app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launch() - if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } + agreeIfNeeded(app) tab(app, named: "Settings").tap() - app.buttons["Data Use and Consent"].tap() - if app.buttons["Agree and Continue"].exists { app.buttons["Agree and Continue"].tap() } - returnToSettings(app) let connection = app.switches["Connection Status"] addTeardownBlock { @MainActor in app.activate() if app.alerts.buttons["OK"].exists { app.alerts.buttons["OK"].tap() } - self.tab(app, named: "Settings").tap() - app.buttons["Data Use and Consent"].tap() - if app.buttons["Withdraw Consent"].exists { app.buttons["Withdraw Consent"].tap() } + if !app.buttons["Agree"].exists { + self.openPrivacy(app) + self.withdrawConsent(app) + } } if connection.value as? String != "1" { try switchControl(app, row: connection).tap() } let connected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '1'"), object: connection) @@ -106,7 +227,7 @@ final class InterceptorUITests: XCTestCase { capturedHost.tap() XCTAssertTrue(app.staticTexts["/api/bullet_tokens"].waitForExistence(timeout: 10), "The Nintendo token request must appear in captured history.") tab(app, named: "Settings").tap() - app.buttons["Token List"].tap() + settingsButton(app, named: "Token List").tap() XCTAssertTrue(app.navigationBars["Token List"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["api.lp1.av5ja.srv.nintendo.net"].waitForExistence(timeout: 15), "The app must extract the Splatoon 3 token from captured Nintendo traffic.") // Stay on the token host list: opening token details would expose live credentials. @@ -120,13 +241,11 @@ final class InterceptorUITests: XCTestCase { #else XCUIDevice.shared.orientation = .portrait let app = XCUIApplication() - app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launch() - if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } + agreeIfNeeded(app) tab(app, named: "Settings").tap() - app.buttons["Data Use and Consent"].tap() - if app.buttons["Agree and Continue"].exists { app.buttons["Agree and Continue"].tap() } - returnToSettings(app) let connection = app.switches["Connection Status"] XCTAssertTrue(connection.waitForExistence(timeout: 5)) XCTAssertTrue(connection.isEnabled) @@ -134,9 +253,10 @@ final class InterceptorUITests: XCTestCase { addTeardownBlock { @MainActor in app.activate() if app.alerts.buttons["OK"].exists { app.alerts.buttons["OK"].tap() } - self.tab(app, named: "Settings").tap() - app.buttons["Data Use and Consent"].tap() - if app.buttons["Withdraw Consent"].exists { app.buttons["Withdraw Consent"].tap() } + if !app.buttons["Agree"].exists { + self.openPrivacy(app) + self.withdrawConsent(app) + } } if connection.value as? String == "1" { control.tap() @@ -178,12 +298,10 @@ final class InterceptorUITests: XCTestCase { control.tap() let restarted = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '1'"), object: connection) XCTAssertEqual(XCTWaiter.wait(for: [restarted], timeout: 20), .completed) - app.buttons["Data Use and Consent"].tap() - app.buttons["Withdraw Consent"].tap() - returnToSettings(app) - let revoked = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '0' AND enabled == false"), object: connection) - XCTAssertEqual(XCTWaiter.wait(for: [revoked], timeout: 15), .completed) - attachScreenshot(app, named: "Physical VPN stopped after withdrawal") + openPrivacy(app) + withdrawConsent(app) + assertStartupConsent(app) + attachScreenshot(app, named: "Physical mandatory consent after withdrawal") let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") settings.launch() let vpnSettings = settings.buttons["com.apple.settings.vpn"] @@ -200,7 +318,8 @@ final class InterceptorUITests: XCTestCase { @MainActor func testSimulatorOnboardingAndNavigation() throws { let app = XCUIApplication() - app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] addUIInterruptionMonitor(withDescription: "Tracking permission") { alert in let decline = alert.buttons["Ask App Not to Track"] guard decline.exists else { return false } @@ -209,12 +328,9 @@ final class InterceptorUITests: XCTestCase { } app.launch() - if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } + agreeIfNeeded(app) tab(app, named: "Settings").tap() - app.buttons["Data Use and Consent"].tap() - if app.buttons["Agree and Continue"].exists { app.buttons["Agree and Continue"].tap() } - app.navigationBars.buttons.firstMatch.tap() - app.buttons["Set Up Capture"].tap() + settingsButton(app, named: "Set Up Capture").tap() // The simulator permits advancing through the device-only setup steps. if app.buttons["Next"].waitForExistence(timeout: 5) { @@ -244,19 +360,19 @@ final class InterceptorUITests: XCTestCase { XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 5), .completed) attachScreenshot(app, named: "Settings") - app.buttons["SSL Proxying List"].tap() + settingsButton(app, named: "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() + settingsButton(app, named: "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() + settingsButton(app, named: "Certificate").tap() XCTAssertTrue(app.navigationBars["Certificate"].waitForExistence(timeout: 5)) app.navigationBars.buttons.firstMatch.tap() tab(app, named: "Home").tap() @@ -289,13 +405,6 @@ final class InterceptorUITests: XCTestCase { app.buttons.matching(NSPredicate(format: "label == %@", name)).firstMatch } - @MainActor - private func returnToSettings(_ app: XCUIApplication) { - let back = app.navigationBars.buttons["Settings"].firstMatch - // iPad keeps the Settings form beside the detail view, so there is no back button. - if back.exists { back.tap() } - } - @MainActor private func attachScreenshot(_ app: XCUIApplication, named name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) diff --git a/docs/app-review/README.md b/docs/app-review/README.md index ef811d6..482b58b 100644 --- a/docs/app-review/README.md +++ b/docs/app-review/README.md @@ -1,10 +1,10 @@ -# App Review preparation — draft, 2026-10-04 +# App Review preparation — updated 2026-10-05 -These files prepare a replacement submission for App Store app 6749347474. Build 29 has not been uploaded, submitted, or approved. Build 28's rejection and the reply sent on 2026-10-04 do not establish acceptance of the proposed changes. +These files prepare a replacement submission for App Store app 6749347474. Build 29 was uploaded on 2026-10-04, but has not been selected, submitted, or approved. Build 30 adds the revised consent settings UI and was uploaded on 2026-10-05. Apple processing is VALID and internalBuildState is IN_BETA_TESTING; the existing internal Developer group has automatic access to all builds. External testing remains READY_FOR_BETA_SUBMISSION, and no external Beta App Review was submitted. Build 28's rejection and the reply sent on 2026-10-04 do not establish acceptance of the proposed changes. The description, promotional text, and keywords under `fastlane/metadata/en-US` and `fastlane/metadata/ja` were saved in App Store Connect on 2026-10-04. They have not been released. Their intended behavior must match the final archive. The current Fastfile uploads TestFlight builds, not these metadata files; this directory does not add an automatic publishing step. -`review-notes-draft.md` describes the review flow and evidence still required. `privacy-policy-draft.md` contains the policy source text. The app-specific policy is now public on qleap.jp, with its existing public support page as the contact route. The app links to it; the App Store Connect policy URL has been updated in Japanese and English, and App Privacy now displays Data Not Collected for the proposed release. The currently selected old build still triggers an ATT-purpose-string warning; upload/select build 29 before review. +`review-notes-draft.md` describes the review flow and evidence still required. `privacy-policy-draft.md` contains the policy source text. The app-specific policy is now public on qleap.jp, with its existing public support page as the contact route. The app links to it; the App Store Connect policy URL has been updated in Japanese and English, and App Privacy now displays Data Not Collected for the proposed release. The currently selected old build still triggers an ATT-purpose-string warning; select a verified replacement build before review. ## Checks before using the drafts @@ -50,3 +50,37 @@ After the test selector changes, the simulator build and consent lifecycle test M2 evidence: [captured request list](evidence/physical-m2-capture.png). It shows request methods, status codes, generic endpoint paths and locale parameters, including successful bullet-token requests; no token values, cookies, account identifiers, or response bodies are displayed. Account and native-settings diagnostics remain private local files. The App Privacy classification follows [Apple’s data-collection definition](https://developer.apple.com/app-store/app-privacy-details/): local processing is not collected data for the label. This does not mean the app does not access sensitive local traffic; the in-app notice and public policy disclose that access and the original Nintendo request flow. + +## Build 30 consent settings UI verification + +The settings Form now shows consent status, a read-only disclosure sheet, and explicit consent or withdrawal actions. Valid consent persists across relaunch. Withdrawal requires confirmation; cancelling preserves consent, and confirming requests VPN shutdown, disables automatic reconnection, and clears notifications. Reading or closing the disclosure does not change authorization. + +The current Simulator build passed 11 unit tests and 9 UI tests on iOS 26.5; the two physical-only tests skipped as intended. The consent lifecycle and landscape accessibility-size disclosure tests also passed on an iPad Air M2 Simulator running iPadOS 18.5. Local results: `/tmp/interceptor-ui-final-suite.xcresult` and `/tmp/interceptor-ui-ipad18-final.xcresult`. An independent code review found no actionable issues. VoiceOver and Split View were not exercised. + +Build 30 physical M2 UI verification has not been repeated. The build 29 physical VPN/capture results above do not validate the new settings UI. Build 30 has been uploaded to TestFlight; it has not been selected or submitted for App Store review. + +The historical build 30 consent-screen follow-up removes its custom bottom action band and uses system toolbar cancellation/confirmation actions (`Not Now` and `Agree`). The updated consent lifecycle passed on iOS 26.5 iPhone Simulator and iPadOS 18.5 M2 Simulator; the iPad landscape maximum-text-size disclosure test also passed. Results: `/tmp/interceptor-consent-toolbar-final.xcresult` (short title), `/tmp/interceptor-consent-toolbar-iphone.xcresult`, and `/tmp/interceptor-consent-toolbar-ipad.xcresult`. The preceding full-suite results apply to the settings implementation before this toolbar follow-up. + +## TestFlight build 30 — 2026-10-05 + +Source: commit `618f19f8a77c186cbc713fec87291f27dc5126e9` on `codex/consent-settings-ui`. Fresh Xcode 27 unit tests passed: 11/11. The Release archive and Xcode App Store Connect upload completed successfully using the existing Xcode account. Both app and PacketTunnel bundle versions are 1.0.0 (30). Signature verification succeeded; non-exempt encryption is false and neither tracking-purpose strings, remote-notification background modes nor Firebase assets were present in the inspected bundles. + +Apple's authenticated build record confirms VALID, internal IN_BETA_TESTING and external READY_FOR_BETA_SUBMISSION. This establishes internal TestFlight availability, not external approval or App Store approval. The internal Developer group already has access to all builds; no testers were added, prior builds expired, or public link enabled by this upload. + +A supplemental M2 iPadOS 18.5 Simulator UI run was interrupted because the test-launch stage did not progress, even after the simulator booted successfully. It produced no new UI pass/fail result and is not counted as verification. Previously recorded consent UI results and CI refer to the same source commit; physical build 30 UI/VPN testing remains outstanding. Local archive and fresh unit evidence: `/tmp/Interceptor-testflight-30-20261005.xcarchive`, `/tmp/interceptor-testflight-20261005-unit.xcresult`. No credentials or binary artifacts are committed. + + +## Required startup consent — 2026-10-06 + +The working tree now requires agreement to the current notice before constructing Home or Settings. A root full-screen cover has no cancellation action and blocks interactive dismissal. Agree saves the shared authorization and reveals the app; valid consent persists across relaunch. A legacy decision flag cannot bypass revoked authorization. Settings no longer has a Data Use section: Settings → Privacy holds the disclosure and confirmed withdrawal, which stops capture, disables automatic reconnection, clears capture notifications, and returns to the required cover. + +The regression first failed on the old implementation because withdrawal did not return to consent. Native Xcode 27 verification passed 11 unit tests and three selected iPhone Simulator UI tests (lifecycle, landscape maximum text size, and setup/navigation). The final localized-title and stricter selector follow-up passed both lifecycle and maximum-text-size landscape tests on the M2 iPadOS 18.5 Simulator. Results: `/tmp/interceptor-mandatory-consent-green.xcresult` and `/tmp/interceptor-mandatory-consent-ipad.xcresult`. Japanese startup screenshots were visually checked on both simulator sizes. Independent Claude reviews found no remaining blocking code findings; LocalGPT could not complete a review because its response job failed with `draft_unsupported`. + +This source has not been uploaded or submitted. Uploaded TestFlight build 30 retains the previous consent behavior. Current public-policy and store-description changes remain drafts; the published qleap.jp policy must be reconciled before submitting a replacement build. VoiceOver, Split View, and this change on physical devices have not been verified. The obsolete build 30 physical test waiting for M2 unlock was cancelled; it did not produce a completed result. + + +## Consent cards — 2026-10-06 + +The approved A concept is now the startup disclosure: an SF Symbols badge, localized “About Data Use” heading (“データ利用について”), and five icon cards. The five English/Japanese disclosure bodies are unchanged. Consent and withdrawal behavior is unchanged; Settings → Privacy keeps the plain disclosure. Semantic grouped backgrounds follow system appearance, decorative icons are hidden from accessibility, and card headings stack below their icons at accessibility text sizes. + +The consent lifecycle and landscape maximum-text-size tests passed on both the iPhone iOS 26.5 Simulator and M2 iPadOS 18.5 Simulator (four UI test executions). Results: `/tmp/interceptor-consent-cards-iphone.xcresult` and `/tmp/interceptor-consent-cards-ipad.xcresult`. Japanese startup screenshots on both sizes and iPhone dark appearance were visually checked. Independent Claude plan and actual-diff reviews found no blocking code findings. This is source verification; no replacement TestFlight upload or physical-device verification was performed for this visual change. diff --git a/docs/app-review/privacy-policy-draft.md b/docs/app-review/privacy-policy-draft.md index e8ac4f7..b721436 100644 --- a/docs/app-review/privacy-policy-draft.md +++ b/docs/app-review/privacy-policy-draft.md @@ -2,6 +2,8 @@ Source draft prepared 2026-10-04. An adapted bilingual policy was published on qleap.jp on 2026-10-04; use the public page as the authoritative published text. This source describes the revised release, not rejected build 28. +Draft updated 2026-10-06 for the proposed mandatory startup consent flow. This update has not been submitted to Apple or published to the public policy page. Uploaded build 30 retains the previous consent flow; verify the replacement build before using this draft. + Developer: Interceptor developer (qtmleap). The App Store legal developer entity has not been reconciled with the website business information. Privacy contact: https://qleap.jp/support (existing public support path). Published public policy URL: https://qleap.jp/term/interceptor_privacy_policy. @@ -27,7 +29,7 @@ Earlier app versions may have saved synchronizable Keychain data. Disabling new ### Consent and controls -The app explains these uses and asks for consent before certificate setup or capture. You may decline and still browse the app. You may withdraw consent in Settings; this stops capture and blocks automatic restart until you consent again. Withdrawal does not delete saved records, saved tokens, older cloud copies, or certificate profiles. +At startup, if you have not agreed to the current version of the data-use notice, the app presents a full-screen consent view that cannot be dismissed to access the main interface. Agreeing dismisses this view; without agreement, the main interface remains unavailable. Agreement does not start capture, install or trust a certificate, install a VPN configuration, or request notification permission. These remain separate user actions. You may read the notice and withdraw consent in Settings → Privacy. Withdrawal stops the VPN, disables automatic reconnection and clears capture notifications, and returns the app to the required consent view until you agree again. Withdrawal does not delete saved records, saved tokens, older cloud copies, certificates, or private keys, and does not remove installed certificate profiles. Use Home's Clear action to delete the app's saved request history. This action does not claim to delete separately stored Keychain tokens or certificates. Data can remain until you delete it through an applicable control, and prior backups or manually copied data may remain separately. To end the certificate's trust and remove its installed profile, use iOS Settings; remove the VPN configuration there when no longer needed. Revoking or rotating a Nintendo session/token requires Nintendo's account or service controls. @@ -57,7 +59,7 @@ Interceptorは、iOSのVPN設定と端末内のプロキシを使い、自分の ### 同意と操作 -証明書の設定や通信の取得を始める前に、アプリ内で用途を説明して同意を求めます。同意しなくてもアプリ内を閲覧できます。設定から同意を撤回すると取得を停止し、再び同意するまで自動再開しません。撤回だけでは、保存済みの通信記録、トークン、過去のクラウド上のコピー、証明書プロファイルは削除されません。 +起動時に現在のバージョンのデータ利用説明への同意がない場合、閉じてメイン画面へ進むことのできない全画面の同意画面を表示します。「同意する」を選ぶとこの画面が閉じます。同意するまではメイン画面を利用できません。同意しただけで通信の取得、証明書のインストールや信頼、VPN設定のインストール、通知の許可要求は行いません。これらは、それぞれ別の利用者の操作で行います。設定の「プライバシー」から説明を再確認し、同意を撤回できます。撤回するとVPNを停止し、自動再接続を無効にし、取得通知を消去して、再び同意するまで必須の同意画面を表示します。撤回だけでは、保存済みの通信記録、トークン、過去のクラウド上のコピー、証明書、秘密鍵や、インストールした証明書プロファイルは削除されません。 通信履歴はホームの消去操作で削除できます。この操作で、別に保存されたKeychainのトークンや証明書も削除されるとは限りません。該当する削除操作を行うまでデータが残ることがあり、過去のバックアップや自分でコピーしたデータは別に残る場合があります。証明書の信頼を停止してプロファイルを削除する場合や、VPN設定が不要になった場合は、iOSの設定から操作してください。任天堂のセッションやトークンを無効化・更新する場合は、任天堂のアカウントやサービスの操作が必要です。 diff --git a/docs/app-review/review-notes-build30.txt b/docs/app-review/review-notes-build30.txt new file mode 100644 index 0000000..22893dd --- /dev/null +++ b/docs/app-review/review-notes-build30.txt @@ -0,0 +1,26 @@ +Draft — do not send until build 30 physical verification completes. The Safari probe procedure below still needs direct validation; existing probe tests use URLSession. Current M2 run is waiting for device unlock. + +Interceptor 1.0.0 (30) — replacement for build 28 + +Guideline 5.5: the app now presents Data Use before certificate setup or capture. It explains purpose, data recorded, storage/sharing, certificate/VPN behavior and the user's choices. Agree is explicit. Not Now leaves browsing available but disables capture. Settings > Data Use > Review and Agree reopens authorization; Read Details is read-only. Withdraw Consent stops capture and disables automatic restart until renewed agreement. + +This is a local HTTPS inspection tool for configured Nintendo-related hosts, not a remote VPN service. The packet tunnel uses a proxy at 127.0.0.1:6836 on the same device. The locally generated certificate profile must be installed and trusted manually in iOS Settings. No remote device administration is performed. + +Recorded traffic can contain headers, cookies, authentication tokens, account identifiers and bodies. It is processed to show request/response records and supported service tokens. New records use local App Group storage; new tokens, certificates and keys use non-synchronizing Keychain items. Captured records are not uploaded to developer servers, sold, used for advertising or automatically shared. Original requests still go to Nintendo. User-initiated copying, previous iCloud copies and OS backups are separate flows described in the policy. Withdrawal does not erase saved records or remove profiles. + +Firebase, App Check, Messaging, remote push registration and ATT permission requests have been removed. Capture Notifications are optional local notifications with generic content, without captured headers, bodies, cookies or tokens. Notification permission is not required to inspect stored results. + +Review steps (English labels): +1. Launch and read Data Use before setup. Choose Not Now; Settings > Connection Status must be disabled. +2. Choose Settings > Data Use > Review and Agree, then Agree. +3. Open Set Up Capture. Download the local certificate profile, install it in iOS Settings and enable full trust under General > About > Certificate Trust Settings. Return to setup and Install VPN Configuration; approve the system prompt. +4. Enable Settings > Connection Status. SSL Proxying List shows the configured hosts. +5. No Interceptor account is needed. To inspect an anonymous request without a Nintendo login, visit https://api.lp1.av5ja.srv.nintendo.net/__interceptor_review_probe_build30 in Safari. An HTTP error for this nonexistent path is expected. Return to Home, select that host and find the probe path in the stored requests. Use a different probe suffix for another run. +6. Supported service token extraction additionally requires the user's own authorized Nintendo account and Nintendo Switch App. No Nintendo credentials are entered into Interceptor. No third-party test account is supplied. +7. Withdraw Consent and confirm. Connection stops and stays disabled after relaunch. Existing records remain. Home's Clear action deletes request history, separately from Keychain tokens. +8. To remove the certificate profile or VPN configuration, use iOS Settings > General > VPN & Device Management. Withdrawal alone does not uninstall them. + +Policy: https://qleap.jp/term/interceptor_privacy_policy +Support: https://qleap.jp/support + +日本語: ビルド30は証明書設定・通信取得前にアプリ内で用途、機密情報を含む取得データ、保存先と送信、証明書/VPN、同意と撤回を説明します。同意しなくても閲覧できます。撤回すると取得と自動再開を停止します。端末内解析でも本来の任天堂宛通信は継続します。上記の匿名リクエストはログイン不要で、実際のサービスのトークン取得には利用者自身の任天堂アカウントが必要です。 diff --git a/docs/app-review/review-notes-build31.txt b/docs/app-review/review-notes-build31.txt new file mode 100644 index 0000000..2822d3b --- /dev/null +++ b/docs/app-review/review-notes-build31.txt @@ -0,0 +1,25 @@ +Interceptor 1.0.0 (31) + +This submission addresses the in-app disclosure requested in your October 5 reply about Guideline 5.5. + +At startup, Data Use / About Data Use explains the purpose, sensitive data recorded, storage/sharing, certificate/VPN setup and withdrawal before access to the main interface. Agree saves explicit consent; it does not start capture, install/trust a certificate or VPN, or request notifications. Without agreement the notice remains. Settings > Privacy provides the disclosure and Withdraw Consent, which stops capture, disables automatic reconnection, clears capture notifications and returns to the notice. + +Interceptor inspects selected Nintendo-related HTTPS hosts on the user's device. The packet-tunnel-provider entitlement routes traffic through a local proxy at 127.0.0.1:6836. Certificate download is served at http://127.0.0.1:8888. It provides an X.509 CA certificate for manual installation and trust, not MDM enrollment. There is no remote device administration or remote VPN server. Please clarify if an MDM entitlement is still required for this certificate-only and Network Extension setup. + +Inspection reads requests/responses that may contain headers, cookies, tokens, account identifiers and bodies. New records use local App Group storage; new tokens, certificates and keys use non-synchronizing Keychain items. Captured records are not uploaded to developer servers, sold, used for advertising or automatically shared. Original requests still reach Nintendo. Manual copying, old cloud copies and OS backups are separate flows described in the notice and public policy. Withdrawal does not delete saved data or certificate/VPN profiles. + +Firebase, App Check, Messaging, remote push registration and ATT prompts were removed. Optional local notifications contain a generic message and a local record identifier, not captured headers, bodies, cookies or tokens. Records are saved and viewable without notification permission. Nintendo app installation and notification permission no longer block setup. + +Review steps (English labels): +1. Launch, read Data Use and choose Agree. No Interceptor login is required. +2. Settings > Set Up Capture: Next is available on the optional Nintendo app and notification steps without installing the app or allowing notifications. +3. Download the certificate; install it in iOS Settings > General > VPN & Device Management and enable full trust under General > About > Certificate Trust Settings. Return to setup, install the VPN configuration and approve the system prompt. +4. Enable Settings > Connection Status. SSL Proxying List shows the configured hosts. +5. An anonymous browser request to https://api.lp1.av5ja.srv.nintendo.net/__interceptor_review_probe_build31 can generate traffic for inspection. An HTTP error for this nonexistent path is expected. Return to Home to inspect the host/path. Token extraction also requires the user's own authorized Nintendo service account; no Nintendo credentials are entered into Interceptor or supplied as a test account. +6. Settings > Privacy > Withdraw Consent stops capture and returns to the required notice, including after relaunch. Home's Clear deletes request history, separately from tokens and profiles. +7. Remove certificate profiles or VPN configuration in iOS Settings > General > VPN & Device Management when needed. + +Verification: consent/relaunch/large-text UI passed on iPhone and M2 iPad simulators. The optional-step fix compiled for the device Release target. Capture, token extraction and VPN stop/restart passed on physical M2 on an earlier build; the current UI has not been reverified physically. Recorded physical probe tests used URLSession; the browser path is a suggested review procedure. + +Policy: https://qleap.jp/term/interceptor_privacy_policy +Support: https://qleap.jp/support diff --git a/docs/app-review/review-notes-draft.md b/docs/app-review/review-notes-draft.md index d03603e..2f6279f 100644 --- a/docs/app-review/review-notes-draft.md +++ b/docs/app-review/review-notes-draft.md @@ -4,6 +4,8 @@ App: Interceptor (6749347474) Prepared: 2026-10-04 Replacement build/version: not yet selected +Draft updated: 2026-10-06. The mandatory startup consent flow below is proposed for a replacement build and has not been submitted. Uploaded build 30 retains its previous consent flow. Historical testing evidence below does not verify this new flow. The local English and Japanese App Store description drafts have been updated to match; they have not been submitted as part of this update. + This text describes the proposed replacement build. Do not submit it as a statement of completed or verified changes until the release archive and the physical-device flow below have been checked. ## Purpose and network architecture @@ -14,29 +16,33 @@ Captured records may include headers, cookies, authentication tokens, account id The proposed replacement removes Firebase, Firebase App Check, Firebase Messaging, remote push registration, and tracking permission requests. It disables iCloud Keychain synchronization for new certificates, private keys, and tokens. It does not erase previous cloud copies. Optional notifications are generated locally and contain no captured headers, bodies, cookies, or tokens. Capture results are read from local app storage rather than from notification payloads. -Captured data is not uploaded to developer-operated servers, sold, used for advertising, or automatically shared with third parties by Interceptor. Normal requests to Nintendo, user-initiated copying, older cloud copies, and OS backups are separate flows explained in the policy. Revoking consent stops capture and disables automatic restart; it does not erase saved data or uninstall a certificate. +Captured data is not uploaded to developer-operated servers, sold, used for advertising, or automatically shared with third parties by Interceptor. Normal requests to Nintendo, user-initiated copying, older cloud copies, and OS backups are separate flows explained in the policy. Revoking consent stops the VPN, disables automatic reconnection and clears capture notifications, and immediately returns to the required startup consent view. It does not erase saved records, tokens, certificates, or private keys, or uninstall a certificate profile. ## Proposed review steps (confirm exact labels in the archive) -1. Launch the app on a physical iPhone or iPad. The initial Data Use and Consent screen explains the data flow. Complete any introductory screens shown only after the consent choice. Review the notice before certificate setup or starting capture. -2. Choose Not Now to decline consent. Confirm Home and Settings remain accessible and capture cannot start, including with Auto Connect or after relaunch. -3. Open Settings → Data Use and Consent, review the notice, and choose Agree and Continue. Agreement permits setup; it must not silently install or trust a certificate. +1. Launch the replacement app on a physical iPhone or iPad without consent to the current notice version. A full-screen Data Use and Consent view appears above the main interface. Review the explanation. Confirm swiping or otherwise attempting to dismiss it cannot reveal Home or Settings, and relaunching does not bypass it. The only consent choice is Agree; without agreement the main interface remains unavailable. +2. Choose Agree. Confirm the view closes and the main interface becomes accessible. Agreement must not start capture, install or trust a certificate, install a VPN configuration, or request notification permission. Complete any separate introductory screens shown after agreement. +3. Open Settings → Privacy to read the data-use notice again without changing consent. Confirm Settings no longer contains the former Data Use consent section. 4. Open Settings → Set Up Capture and follow the app's instructions to obtain the locally generated certificate. In iOS Settings, install the downloaded profile and enable full trust for this certificate under General → About → Certificate Trust Settings. Continue setup to Install VPN Configuration and approve the iOS VPN configuration prompt if shown. The configuration must be registered before capture can start. 5. Return to Settings and turn on Connection Status. The VPN indicator represents the local packet tunnel. Open SSL Proxying List to see configured hosts. 6. With an authorized Nintendo account and the relevant Nintendo app/service, perform a supported request. Return to Home, open its host, and inspect the request/response record. Settings → Token List displays supported saved tokens when such traffic was captured. Nintendo credentials are entered in Nintendo's own interface, not submitted to Interceptor's developer. 7. If desired, enable Capture Notifications in Settings and approve the iOS permission prompt. Capture another supported record. Confirm the notification is generic and opening it reads the result from local storage. Denying notifications must not prevent capture or access to locally saved results. -8. Open Settings → Data Use and Consent and choose Withdraw Consent. Confirm the connection stops; returning to the foreground or relaunching does not restart it. Existing saved records remain available. Clear request history using Home's trash/Clear action. +8. Open Settings → Privacy → Withdraw Consent and confirm withdrawal. Confirm the VPN stops, Auto Connect and Capture Notifications are disabled, and the required consent view appears again. Foregrounding, relaunching, and attempts to dismiss the view must not bypass consent or restart capture. Agree again to access the main interface; confirm agreement alone does not re-enable capture or notifications. Existing records, tokens, and certificate/key material remain stored. Clear request history using Home's trash/Clear action after agreement. 9. After testing, disable the VPN configuration and remove the installed certificate profile in iOS Settings → General → VPN & Device Management. Confirm certificate trust is no longer enabled. Consent withdrawal alone does not remove this profile. +The required agreement concerns the app's core traffic-inspection data use. Notification permission, certificate installation/trust, and VPN configuration approval remain separate choices. Apple's guideline 5.1.1(iv) restricts requiring unnecessary data access; this draft does not establish that the proposed startup gate satisfies App Review requirements. + We are awaiting Apple's clarification about whether the proposed certificate/packet-tunnel design is acceptable and whether additional MDM-related permission is required. This draft does not assert that MDM privileges are granted or unnecessary. ## Evidence to add before submission -On 2026-10-04, the physical VPN lifecycle test passed on iPad Air 13-inch (M3), iPadOS 26.3.1: tunnel start, an anonymous HTTPS probe to a configured Nintendo host recorded in local history, stop/restart, consent withdrawal, and an independent Not Connected check in iPadOS Settings. See [the public-request screenshot](evidence/physical-vpn-https-probe.png). On M2 (iPadOS 18.6.2), the user completed fresh certificate installation/trust and Nintendo sign-in; both physical tests passed at 14:30 and 14:33 JST. Nintendo capture checked the bullet-token request in history and the host in Token List, without exposing or comparing raw token values. Profile removal was not exercised. Build 29 is still not uploaded or selected for review. +Historical preparation snapshot (2026-10-04; build upload status in this paragraph is as recorded then): + +On 2026-10-04, the physical VPN lifecycle test passed on iPad Air 13-inch (M3), iPadOS 26.3.1: tunnel start, an anonymous HTTPS probe to a configured Nintendo host recorded in local history, stop/restart, consent withdrawal, and an independent Not Connected check in iPadOS Settings. See [the public-request screenshot](evidence/physical-vpn-https-probe.png). On M2 (iPadOS 18.6.2), the user completed fresh certificate installation/trust and Nintendo sign-in; both physical tests passed at 14:30 and 14:33 JST. Nintendo capture checked the bullet-token request in history and the host in Token List, without exposing or comparing raw token values. Profile removal was not exercised. Build 29 was uploaded but has not been selected for review. Build 30 contains the revised consent settings UI and has not been uploaded. - Replacement build number, version, supported device/OS, and completed archive checks. -- Verify the labels Data Use and Consent, Agree and Continue, Not Now, Withdraw Consent, Set Up Capture, and Capture Notifications in the archive; add English and Japanese screenshots before capture. -- Physical-device proof of consent gating, revocation, restart prevention, and supported HTTPS capture. +- Verify the labels Data Use, Agree, Privacy, Withdraw Consent, Set Up Capture, and Capture Notifications in the replacement archive; add English and Japanese screenshots before capture. Verify that no decline/dismiss action exposes the main interface. +- Physical-device proof of required startup consent, blocked dismissal, current-notice-version handling, revocation, restart prevention, and supported HTTPS capture. Verify notification denial remains independent of capture consent. - A synthetic/redacted demonstration video or reviewer attachment. Never attach live account cookies, private keys, or authentication tokens. - A verified reviewer test path and any account/access requirement. No working Nintendo test account or capture fixture is provided by this draft. - The public app-specific policy and support URL are reachable. App Store Connect now has the app-specific URL in both languages and Data Not Collected for the revised release. The old selected binary still raises an ATT-string warning; select the new binary. Apple's clarification remains pending. diff --git a/docs/superpowers/plans/2026-10-04-consent-settings-ui.md b/docs/superpowers/plans/2026-10-04-consent-settings-ui.md new file mode 100644 index 0000000..f7e36db --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-consent-settings-ui.md @@ -0,0 +1,33 @@ +# Consent Settings UI Implementation Plan + +**Goal:** 設定に残る初回用の長文・固定ボタンをなくし、既存Formと揃える。 + +**Architecture:** 同意説明の本文を共有し、初回/再同意の操作と閲覧専用シートを分離する。設定は状態、説明を読む、未同意時の同意、同意済み時の撤回を通常の行として表示する。 + +**Tech Stack:** SwiftUI、既存CaptureAuthorizationとTuberose。新規依存なし。 + +**Spec:** このスレッドのユーザー要望とGPT-6 Proの変更案レビュー。 + +## Constraints + +- 通常の再起動で同意を取り直さない。証明書設定・VPN開始の同意ガードを維持する。 +- 閲覧シートを閉じても同意状態を変えない。 +- 撤回には確認とキャンセルを用意し、既存のVPN・自動再接続・通知停止を維持する。 +- TabViewとNavigationView、保存形式、キャプチャ処理は変更しない。 +- 実機はM2のみ。Tailscaleはオフを維持する。 + +## Steps + +- [x] 閲覧・閉じる・同意・撤回取消/確定・再起動のUIテストを先に更新し、旧UIで失敗を確認。 +- [x] 本文/同意フロー/閲覧シートを分離し、設定のForm行と日英ラベルを実装。 +- [x] 既存の設定ナビゲーションと実機テストを新しい導線へ更新。 +- [x] 専用シミュレータで全単体・UIテストを実行し、iPad・文字拡大の表示を確認。 +- [ ] 差分をレビューし、コミット・通常プッシュ・PRを作成してCIを確認。 + +## Review Focus + +閲覧で同意を変えないこと、取消でVPN利用可否を変えないこと、再起動時の再提示、モーダルの競合、文字拡大時の本文と操作への到達を確認する。VPN停止APIは非同期のvoid APIのため、停止要求を成功確認として表示しない。 + +## Consent action placement follow-up + +ユーザーのスクリーンショットレビューを受け、初回/再同意画面の下部固定帯を削除。OS標準のナビゲーションバー左側に拒否、右側に同意を置き、本文をスクロール領域にする。`cancellationAction`/`confirmationAction` を使い、同意の保存・撤回処理は変更しない。設定の閲覧シートは引き続き閉じる操作だけを表示する。 diff --git a/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt index 0a61d44..883e8d9 100644 --- a/fastlane/metadata/en-US/description.txt +++ b/fastlane/metadata/en-US/description.txt @@ -2,10 +2,10 @@ Interceptor helps you inspect selected Nintendo-related HTTPS traffic on your ow The app uses an iOS VPN configuration to direct selected hosts through a proxy running on your device. It does not provide a remote VPN server, change your public IP address, or protect public Wi-Fi traffic. HTTPS inspection requires you to install and trust a locally generated certificate authority certificate in iOS Settings. -Before certificate setup or capture, Interceptor explains what it can access and asks for your consent. Captured traffic can contain sensitive cookies, authentication tokens, account identifiers, and request or response content. Use it only with accounts and devices you are authorized to inspect. Capture is limited by the app's configured Nintendo-related hosts; service changes or certificate restrictions may prevent capture. +At startup, Interceptor explains what it can access and requires agreement to the current data-use notice before you can access the main interface. Agreement alone does not start capture, install a certificate or VPN configuration, or request notification permission. Captured traffic can contain sensitive cookies, authentication tokens, account identifiers, and request or response content. Use it only with accounts and devices you are authorized to inspect. Capture is limited by the app's configured Nintendo-related hosts; service changes or certificate restrictions may prevent capture. New capture records are stored in the app's local shared storage. New certificates, private keys, and saved tokens use Keychain items with iCloud Keychain synchronization disabled. Interceptor does not upload captured data to a developer-operated server, sell it, use it for advertising, or automatically share it with third parties. Nintendo requests still reach their original Nintendo services. Data you choose to copy, earlier iCloud Keychain copies, and operating-system backups are separate from this local capture flow. -You can decline consent and browse the app, or withdraw consent in Settings to stop capture and prevent automatic restart until you consent again. Withdrawal does not erase saved data or remove an installed certificate. Clear saved request history from Home. Optional local notifications announce captured records without including tokens or captured headers and bodies in the notification. +You can review the notice and withdraw consent in Settings → Privacy. Withdrawal stops the VPN, disables automatic reconnection and clears capture notifications, and returns to the required consent screen until you agree again. Withdrawal does not erase saved records, tokens, certificates, or private keys, or remove an installed certificate profile. Clear saved request history from Home. Optional local notifications announce captured records without including tokens or captured headers and bodies in the notification. An independent tool, not affiliated with or endorsed by Nintendo. Nintendo and related names are trademarks of their respective owners. diff --git a/fastlane/metadata/ja/description.txt b/fastlane/metadata/ja/description.txt index af850b2..e73a030 100644 --- a/fastlane/metadata/ja/description.txt +++ b/fastlane/metadata/ja/description.txt @@ -2,10 +2,10 @@ Interceptorは、自分のiPhoneやiPadで、任天堂関連の一部のHTTPS通 iOSのVPN設定を使い、対象ホストの通信を端末内のプロキシへ送ります。外部のVPNサーバーは提供しません。接続元の公開IPアドレスを変更したり、公共Wi-Fiの通信を保護したりする機能ではありません。HTTPS通信の解析には、端末内で生成した認証局証明書をiOSの設定でインストールし、信頼する操作が必要です。 -証明書の設定や通信の取得を始める前に、取得するデータと用途を説明し、同意を求めます。通信には、Cookie、認証トークン、アカウント識別子、リクエストやレスポンスの内容など、機密情報が含まれることがあります。自分が解析する権限を持つアカウントと端末で使用してください。取得対象はアプリに設定された任天堂関連のホストに限られます。サービスの仕様変更や証明書の制約により、取得できない場合があります。 +起動時に取得するデータと用途を説明し、現在の説明に同意するまでメイン画面を利用できません。同意しただけで通信の取得、証明書やVPN設定のインストール、通知の許可要求は行いません。通信には、Cookie、認証トークン、アカウント識別子、リクエストやレスポンスの内容など、機密情報が含まれることがあります。自分が解析する権限を持つアカウントと端末で使用してください。取得対象はアプリに設定された任天堂関連のホストに限られます。サービスの仕様変更や証明書の制約により、取得できない場合があります。 新たな通信記録は、アプリの端末内の共有領域に保存します。新たな証明書、秘密鍵、保存するトークンには、iCloudキーチェーン同期を無効にしたKeychain項目を使用します。取得データを開発者のサーバーへアップロードしたり、販売したり、広告に利用したり、第三者へ自動共有したりしません。任天堂への本来の通信は、引き続き任天堂のサービスへ送信されます。自分でコピーしたデータ、過去に同期したiCloudキーチェーンのデータ、OSのバックアップは、この端末内の取得処理とは別に扱われます。 -同意しなくてもアプリ内を閲覧できます。設定から同意を撤回すると通信の取得を停止し、再び同意するまで自動再開しません。同意の撤回だけでは、保存済みデータやインストールした証明書は削除されません。通信履歴はホームから消去できます。任意のローカル通知には、トークンや取得したヘッダー・本文を含めません。 +設定の「プライバシー」から説明を再確認し、同意を撤回できます。撤回するとVPNを停止し、自動再接続を無効にし、取得通知を消去して、再び同意するまで必須の同意画面を表示します。同意の撤回だけでは、保存済みの通信記録、トークン、証明書、秘密鍵や、インストールした証明書プロファイルは削除されません。通信履歴はホームから消去できます。任意のローカル通知には、トークンや取得したヘッダー・本文を含めません。 任天堂が提供、公認するアプリではありません。任天堂および関連する名称は、各権利者の商標です。