diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index eec7e61..633c9cb 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: ddcefe656c0f27289ee4f506f2112b8bbdaa407d + ref: 698d5bcb25245a26a3be925c34ded8a4c8a2ff6a path: Mudmouth persist-credentials: false diff --git a/Interceptor.xcodeproj/project.pbxproj b/Interceptor.xcodeproj/project.pbxproj index 59e24e4..60240bd 100644 --- a/Interceptor.xcodeproj/project.pbxproj +++ b/Interceptor.xcodeproj/project.pbxproj @@ -17,18 +17,9 @@ 10B520E52E52A344006C2045 /* TreeSitterJavaScript in Frameworks */ = {isa = PBXBuildFile; productRef = 10B520E42E52A344006C2045 /* TreeSitterJavaScript */; }; 10B520E72E52A344006C2045 /* TreeSitterJavaScriptQueries in Frameworks */ = {isa = PBXBuildFile; productRef = 10B520E62E52A344006C2045 /* TreeSitterJavaScriptQueries */; }; 10B520E92E52A344006C2045 /* TreeSitterJavaScriptRunestone in Frameworks */ = {isa = PBXBuildFile; productRef = 10B520E82E52A344006C2045 /* TreeSitterJavaScriptRunestone */; }; - 10D6E0D52E56492F00439E25 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 10D6E0D42E56492F00439E25 /* FirebaseMessaging */; }; - 10D6E0D72E56493500439E25 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 10D6E0D62E56493500439E25 /* FirebaseRemoteConfig */; }; - 10D6E0D92E56494100439E25 /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 10D6E0D82E56494100439E25 /* FirebasePerformance */; }; 10F701BC2E491546006ECDD2 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10F701BB2E491546006ECDD2 /* NetworkExtension.framework */; }; 10F701C42E491546006ECDD2 /* PacketTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 10F701B92E491546006ECDD2 /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 10F701CB2E491567006ECDD2 /* Mudmouth in Frameworks */ = {isa = PBXBuildFile; productRef = 10F701CA2E491567006ECDD2 /* Mudmouth */; }; - 10F702712E49829B006ECDD2 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 10F702702E49829B006ECDD2 /* FirebaseAnalytics */; }; - 10F702732E49829B006ECDD2 /* FirebaseAnalyticsCore in Frameworks */ = {isa = PBXBuildFile; productRef = 10F702722E49829B006ECDD2 /* FirebaseAnalyticsCore */; }; - 10F702752E49829B006ECDD2 /* FirebaseAnalyticsIdentitySupport in Frameworks */ = {isa = PBXBuildFile; productRef = 10F702742E49829B006ECDD2 /* FirebaseAnalyticsIdentitySupport */; }; - 10F702772E49829B006ECDD2 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 10F702762E49829B006ECDD2 /* FirebaseAppCheck */; }; - 10F702792E49829B006ECDD2 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 10F702782E49829B006ECDD2 /* FirebaseCore */; }; - 10F7027B2E49829B006ECDD2 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 10F7027A2E49829B006ECDD2 /* FirebaseCrashlytics */; }; 10FBBF2D2E522C5E00CC57B0 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 10FBBF2C2E522C5E00CC57B0 /* Runestone */; }; /* End PBXBuildFile section */ @@ -137,26 +128,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 10D6E0D52E56492F00439E25 /* FirebaseMessaging in Frameworks */, 10925F5D2E4CFA25003611AA /* QuantumLeap in Frameworks */, - 10D6E0D92E56494100439E25 /* FirebasePerformance in Frameworks */, 10B520E52E52A344006C2045 /* TreeSitterJavaScript in Frameworks */, 104F3BAF2E49131900A2D6F1 /* Mudmouth in Frameworks */, - 10F702752E49829B006ECDD2 /* FirebaseAnalyticsIdentitySupport in Frameworks */, 104F3BB22E49135800A2D6F1 /* Mudmouth in Frameworks */, - 10F702712E49829B006ECDD2 /* FirebaseAnalytics in Frameworks */, - 10F702792E49829B006ECDD2 /* FirebaseCore in Frameworks */, 10FBBF2D2E522C5E00CC57B0 /* Runestone in Frameworks */, 1089BCFA2E5239CA0088488B /* TreeSitterJSON in Frameworks */, - 10D6E0D72E56493500439E25 /* FirebaseRemoteConfig in Frameworks */, 10B520E92E52A344006C2045 /* TreeSitterJavaScriptRunestone in Frameworks */, 1089BCFC2E5239CA0088488B /* TreeSitterJSONQueries in Frameworks */, - 10F7027B2E49829B006ECDD2 /* FirebaseCrashlytics in Frameworks */, 10B520E72E52A344006C2045 /* TreeSitterJavaScriptQueries in Frameworks */, - 10F702772E49829B006ECDD2 /* FirebaseAppCheck in Frameworks */, 1089BCFE2E5239CA0088488B /* TreeSitterJSONRunestone in Frameworks */, 1089BD002E5239CA0088488B /* TreeSitterLanguagesCommon in Frameworks */, - 10F702732E49829B006ECDD2 /* FirebaseAnalyticsCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -241,12 +223,6 @@ packageProductDependencies = ( 104F3BAE2E49131900A2D6F1 /* Mudmouth */, 104F3BB12E49135800A2D6F1 /* Mudmouth */, - 10F702702E49829B006ECDD2 /* FirebaseAnalytics */, - 10F702722E49829B006ECDD2 /* FirebaseAnalyticsCore */, - 10F702742E49829B006ECDD2 /* FirebaseAnalyticsIdentitySupport */, - 10F702762E49829B006ECDD2 /* FirebaseAppCheck */, - 10F702782E49829B006ECDD2 /* FirebaseCore */, - 10F7027A2E49829B006ECDD2 /* FirebaseCrashlytics */, 10925F5C2E4CFA25003611AA /* QuantumLeap */, 10FBBF2C2E522C5E00CC57B0 /* Runestone */, 1089BCF92E5239CA0088488B /* TreeSitterJSON */, @@ -256,9 +232,6 @@ 10B520E42E52A344006C2045 /* TreeSitterJavaScript */, 10B520E62E52A344006C2045 /* TreeSitterJavaScriptQueries */, 10B520E82E52A344006C2045 /* TreeSitterJavaScriptRunestone */, - 10D6E0D42E56492F00439E25 /* FirebaseMessaging */, - 10D6E0D62E56493500439E25 /* FirebaseRemoteConfig */, - 10D6E0D82E56494100439E25 /* FirebasePerformance */, ); productName = Interceptor; productReference = 104F3B812E49099200A2D6F1 /* Interceptor.app */; @@ -372,7 +345,6 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 104F3BB02E49135800A2D6F1 /* XCLocalSwiftPackageReference "../Mudmouth" */, - 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 10925F5B2E4CFA25003611AA /* XCRemoteSwiftPackageReference "QuantumLeap" */, 10FBBF2B2E522C5E00CC57B0 /* XCRemoteSwiftPackageReference "Runestone" */, 1089BCF82E5239CA0088488B /* XCRemoteSwiftPackageReference "treesitterlanguages" */, @@ -602,7 +574,7 @@ CODE_SIGN_ENTITLEMENTS = Interceptor/Interceptor.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -610,7 +582,6 @@ INFOPLIST_KEY_CFBundleDisplayName = Interceptor; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSUserTrackingUsageDescription = NSUserTrackingUsageDescription; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -641,7 +612,7 @@ CODE_SIGN_ENTITLEMENTS = Interceptor/Interceptor.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -649,7 +620,6 @@ INFOPLIST_KEY_CFBundleDisplayName = Interceptor; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSUserTrackingUsageDescription = NSUserTrackingUsageDescription; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -677,7 +647,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; @@ -696,7 +666,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; @@ -714,7 +684,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -731,7 +701,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -750,7 +720,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PacketTunnel/Info.plist; @@ -782,7 +752,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = 5Q94QJ7G98; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PacketTunnel/Info.plist; @@ -882,14 +852,6 @@ minimumVersion = 0.0.4; }; }; - 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 12.1.0; - }; - }; 10FBBF2B2E522C5E00CC57B0 /* XCRemoteSwiftPackageReference "Runestone" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/simonbs/Runestone.git"; @@ -949,56 +911,11 @@ package = 1089BCF82E5239CA0088488B /* XCRemoteSwiftPackageReference "treesitterlanguages" */; productName = TreeSitterJavaScriptRunestone; }; - 10D6E0D42E56492F00439E25 /* FirebaseMessaging */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseMessaging; - }; - 10D6E0D62E56493500439E25 /* FirebaseRemoteConfig */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseRemoteConfig; - }; - 10D6E0D82E56494100439E25 /* FirebasePerformance */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebasePerformance; - }; 10F701CA2E491567006ECDD2 /* Mudmouth */ = { isa = XCSwiftPackageProductDependency; package = 104F3BB02E49135800A2D6F1 /* XCLocalSwiftPackageReference "../Mudmouth" */; productName = Mudmouth; }; - 10F702702E49829B006ECDD2 /* FirebaseAnalytics */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalytics; - }; - 10F702722E49829B006ECDD2 /* FirebaseAnalyticsCore */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsCore; - }; - 10F702742E49829B006ECDD2 /* FirebaseAnalyticsIdentitySupport */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsIdentitySupport; - }; - 10F702762E49829B006ECDD2 /* FirebaseAppCheck */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAppCheck; - }; - 10F702782E49829B006ECDD2 /* FirebaseCore */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCore; - }; - 10F7027A2E49829B006ECDD2 /* FirebaseCrashlytics */ = { - isa = XCSwiftPackageProductDependency; - package = 10F7026F2E49829B006ECDD2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCrashlytics; - }; 10FBBF2C2E522C5E00CC57B0 /* Runestone */ = { isa = XCSwiftPackageProductDependency; package = 10FBBF2B2E522C5E00CC57B0 /* XCRemoteSwiftPackageReference "Runestone" */; diff --git a/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c17a964..2704aa3 100644 --- a/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Interceptor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,24 +1,6 @@ { - "originHash" : "ae275c1f2220b3ee02617d3d89e557987fda1816c03980da0f975edea7d47070", + "originHash" : "22d5619e2fdce386caa392fc1fb4a202417795060d3e5576bcf78e0f21bb0a08", "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" - } - }, { "identity" : "bettersafariview", "kind" : "remoteSourceControl", @@ -46,69 +28,6 @@ "version" : "1.9.0" } }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "b9bf3adac18e6e3059167194aeb632f15a5ba4b2", - "version" : "12.1.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "e15f979c3eaf477d24e5bfec9d87f1d76fbac297", - "version" : "2.2.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "883305109ead4599e4ca59591754ee72e81e6b5e", - "version" : "12.1.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", - "version" : "1.69.0" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", - "version" : "5.0.0" - } - }, { "identity" : "gzipswift", "kind" : "remoteSourceControl", @@ -118,15 +37,6 @@ "version" : "6.1.0" } }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", @@ -136,15 +46,6 @@ "version" : "4.2.2" } }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, { "identity" : "licenselist", "kind" : "remoteSourceControl", @@ -154,24 +55,6 @@ "version" : "2.2.0" } }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, { "identity" : "quantumleap", "kind" : "remoteSourceControl", @@ -271,15 +154,6 @@ "version" : "2.33.0" } }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Interceptor/Classes/Tuberose.swift b/Interceptor/Classes/Tuberose.swift index bd7647e..6c523c1 100644 --- a/Interceptor/Classes/Tuberose.swift +++ b/Interceptor/Classes/Tuberose.swift @@ -9,12 +9,13 @@ import KeychainAccess import Mudmouth import SwiftUI -import SwiftyLogger +import SwiftData +import UserNotifications @MainActor public final class Tuberose: ObservableObject { @AppStorage("ACTIVATE_ON_FOREGROUND") - var activateOnForeground: Bool = true + var activateOnForeground: Bool = false /// VPN設定 /// NOTE: とりあえず最初はスプラ2とスプラ3のみに対応 @@ -41,9 +42,10 @@ public final class Tuberose: ObservableObject { @Published private(set) var tokens: [AccessToken] = [] - private let decoder: JSONDecoder = .init() - private let encoder: JSONEncoder = .init() - private let keychain: Keychain = .init(service: Bundle.main.bundleIdentifier!).synchronizable(true) + private let keychain: Keychain = .init(service: Bundle.main.bundleIdentifier!) + .synchronizable(false) + .accessibility(.afterFirstUnlockThisDeviceOnly) + private let legacyKeychain: Keychain = .init(service: Bundle.main.bundleIdentifier!).synchronizable(true) let mudmouth: Mudmouth = .default @@ -52,13 +54,12 @@ public final class Tuberose: ObservableObject { } init() { - tokens = options.map(\.host).compactMap { host in - try? keychain.getToken(forKey: host) - } + loadStoredTokens() NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActiveNotification), name: UIApplication.didBecomeActiveNotification, object: nil) } func startVPNTunnel() async throws { + try CaptureAuthorization.requireConsent() try await mudmouth.startVPNTunnel(options: options) } @@ -67,53 +68,81 @@ public final class Tuberose: ObservableObject { } func setToken(_ value: UNNotificationResponse) throws { - let userInfo = value.notification.request.content.userInfo - if let data: Data = userInfo.data(forKey: "headers"), - let headers: [String: String] = try? JSONSerialization.jsonObject(with: data) as? [String: String], - let cookies: [String: String] = headers.cookies, - let host: String = headers.host - { - switch host { - case "app.smashbros.nintendo.net": - if let token: String = cookies.value(forKey: "super_smash_session"), - let gtoken: String = headers.value(forKey: "X-GameWebToken") - { - SwiftyLogger.debug("Captured token: \(token) \(gtoken)") - try? keychain.setToken(.init(contentId: .SMSP, host: host, gtoken: gtoken, accessToken: token), forKey: host) - } - case "app.splatoon2.nintendo.net": - if let token: String = cookies.value(forKey: "iksm_session"), - let gtoken: String = headers.value(forKey: "X-GameWebToken") - { - SwiftyLogger.debug("Captured token: \(token) \(gtoken)") - try? keychain.setToken(.init(contentId: .SP2, host: host, gtoken: gtoken, accessToken: token), forKey: host) - } - case "api.lp1.av5ja.srv.nintendo.net": - if let data: Data = userInfo.data(forKey: "body"), - let body: [String: Any] = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let token: String = body.value(forKey: "bulletToken") as? String, - let gtoken: String = cookies.value(forKey: "_gtoken") - { - SwiftyLogger.debug("Captured token: \(token) \(gtoken)") - try? keychain.setToken(.init(contentId: .SP3, host: host, gtoken: gtoken, accessToken: token), forKey: host) - } - default: - break + try CaptureAuthorization.requireConsent() + guard let rawID = value.notification.request.content.userInfo["recordID"] as? String, + let recordID = UUID(uuidString: rawID) else { return } + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == recordID }) + descriptor.fetchLimit = 1 + guard try ModelContainer.default.mainContext.fetch(descriptor).first != nil else { return } + // A delayed notification must not overwrite a newer token with an older record. + try refreshTokensFromRecords() + } + + func refreshTokensFromRecords() throws { + try CaptureAuthorization.requireConsent() + let context = ModelContainer.default.mainContext + for host in options.map(\.host) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.request.host == host }, + sortBy: [SortDescriptor(\Record.capturedAt, order: .reverse)] + ) + for record in try context.fetch(descriptor) { + // Bad or unrelated responses are ignored; they must not erase saved tokens. + guard let token = try? Self.token(from: record) else { continue } + try keychain.setToken(token, forKey: host) + break } } + loadStoredTokens() + } + + static func token(from record: Record) throws -> AccessToken? { + let host = record.request.host + let headers = record.request.headers.values + let cookies = record.request.cookies.values + let gtokenHeader = headers.first { $0.key.caseInsensitiveCompare("X-GameWebToken") == .orderedSame }?.value + switch host { + case "app.smashbros.nintendo.net": + guard let token = cookies.first(where: { $0.key == "super_smash_session" })?.value, + let gtoken = gtokenHeader else { return nil } + return try AccessToken(contentId: .SMSP, host: host, gtoken: gtoken, accessToken: token) + case "app.splatoon2.nintendo.net": + guard let token = cookies.first(where: { $0.key == "iksm_session" })?.value, + let gtoken = gtokenHeader else { return nil } + return try AccessToken(contentId: .SP2, host: host, gtoken: gtoken, accessToken: token) + case "api.lp1.av5ja.srv.nintendo.net": + guard let data = record.response.body, + let body = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = body["bulletToken"] as? String, + let gtoken = cookies.first(where: { $0.key == "_gtoken" })?.value else { return nil } + return try AccessToken(contentId: .SP3, host: host, gtoken: gtoken, accessToken: token) + default: + return nil + } + } + + private func loadStoredTokens() { tokens = options.map(\.host).compactMap { host in - try? keychain.getToken(forKey: host) + if let local = try? keychain.getToken(forKey: host) { return local } + guard let legacy = try? legacyKeychain.getToken(forKey: host) else { return nil } + // Copy previously synced data without deleting the cloud/other-device copy. + try? keychain.setToken(legacy, forKey: host) + return legacy } } @objc private func didBecomeActiveNotification() { + guard CaptureAuthorization.isGranted else { return } + // Capture remains useful when notification permission is denied or no notification is tapped. + try? refreshTokensFromRecords() if activateOnForeground { Task(priority: .background, operation: { try await startVPNTunnel() }) } } + } public extension Tuberose { @@ -130,14 +159,15 @@ extension [String: String] { else { return nil } - return Dictionary(uniqueKeysWithValues: value.split(separator: ";").compactMap { component in - let parts = component.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - return parts.count == 2 ? (parts[0], parts[1]) : nil - }) + return value.split(separator: ";").reduce(into: [String: String]()) { result, component in + let parts = component.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + if parts.count == 2, !parts[0].isEmpty { result[parts[0]] = parts[1] } + } } func value(forKey key: String) -> String? { - first(where: { $0.key == key })?.value + first(where: { $0.key.caseInsensitiveCompare(key) == .orderedSame })?.value } } @@ -176,7 +206,7 @@ extension Keychain { } func getToken(forKey: String) throws -> AccessToken { - guard let data: Data = try getData(forKey) + guard let data: Data = try getData(forKey, ignoringAttributeSynchronizable: false) else { throw DecodingError.valueNotFound(AccessToken.self, .init(codingPath: [], debugDescription: "")) } diff --git a/Interceptor/Components/VPNSetting.swift b/Interceptor/Components/VPNSetting.swift index f27bd79..73a066a 100644 --- a/Interceptor/Components/VPNSetting.swift +++ b/Interceptor/Components/VPNSetting.swift @@ -15,11 +15,21 @@ struct VPNSetting: View { @EnvironmentObject private var client: Tuberose @Environment(\.scenePhase) private var scenePhase @State private var isConnected: Bool = false + @AppStorage(CaptureAuthorization.key, store: CaptureAuthorization.defaults) private var consentVersion = 0 + @State private var connectionError: String? var body: some View { Section(content: { Label(systemName: "wifi", color: .blue, title: { - Toggle(isOn: $isConnected, label: { + Toggle(isOn: Binding(get: { isConnected }, set: { enabled in + Task { + do { + if enabled { try await client.startVPNTunnel() } + else { client.stopVPNTunnel() } + isConnected = client.isConnected + } catch { connectionError = error.localizedDescription; isConnected = false } + } + }), label: { Text("LABEL_VPN_IS_CONNECTED") }) }) @@ -40,12 +50,17 @@ struct VPNSetting: View { .onChange(of: client.isConnected) { isConnected = client.isConnected } - .onChange(of: isConnected) { - // 値が変わったときにVPN設定を切り替える - Task(priority: .background, operation: { - isConnected ? try await client.startVPNTunnel() : client.stopVPNTunnel() - }) + .disabled(consentVersion != CaptureAuthorization.version) + .onChange(of: consentVersion) { + if !CaptureAuthorization.isGranted { + isConnected = false + client.activateOnForeground = false + client.stopVPNTunnel() + } } + .alert("Unable to Start Capture", isPresented: Binding(get: { connectionError != nil }, set: { if !$0 { connectionError = nil } })) { + Button("OK", role: .cancel) { connectionError = nil } + } message: { Text(connectionError ?? "") } } } diff --git a/Interceptor/ContentView.swift b/Interceptor/ContentView.swift index f371e16..d287dd1 100644 --- a/Interceptor/ContentView.swift +++ b/Interceptor/ContentView.swift @@ -13,7 +13,8 @@ import SwiftyLogger struct ContentView: View { @Environment(\.isFirstLaunch) private var isFirstLaunch: Binding - @State private var isPresented: Bool = false + @AppStorage("CaptureConsentDecided") private var consentDecided = false + @State private var showConsent = false var body: some View { TabView(content: { @@ -49,9 +50,20 @@ struct ContentView: View { tabView.tabBar.backgroundColor = .systemBackground tabView.tabBar.isTranslucent = true }) - .fullScreenCover(isPresented: isFirstLaunch, content: { - FirstLaunchView() - }) + .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/GoogleService-Info.plist b/Interceptor/GoogleService-Info.plist deleted file mode 100644 index c659ade..0000000 --- a/Interceptor/GoogleService-Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - API_KEY - AIzaSyDGSxwYyrB36MFkvEdnTkfIYtxc0QOdy1Q - GCM_SENDER_ID - 880078116422 - PLIST_VERSION - 1 - BUNDLE_ID - jp.qleap.intrcptr - PROJECT_ID - interceptor-51c8a - STORAGE_BUCKET - interceptor-51c8a.firebasestorage.app - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:880078116422:ios:c05751f58e9fdc9ba61c47 - - diff --git a/Interceptor/Interceptor.entitlements b/Interceptor/Interceptor.entitlements index dc8868f..07a158d 100644 --- a/Interceptor/Interceptor.entitlements +++ b/Interceptor/Interceptor.entitlements @@ -2,8 +2,6 @@ - aps-environment - development com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/Interceptor/Interceptor.swift b/Interceptor/Interceptor.swift index d71b918..e33ab8c 100644 --- a/Interceptor/Interceptor.swift +++ b/Interceptor/Interceptor.swift @@ -1,90 +1,26 @@ -// -// Interceptor.swift -// Interceptor -// -// Created by devonly on 2025/08/11. -// Copyright © 2025 QuantumLeap, Corporation. All rights reserved. -// - -import AdSupport -import AppTrackingTransparency -import Firebase -import FirebaseAppCheck -import FirebaseMessaging import Mudmouth import SwiftData import SwiftUI -import SwiftyLogger - -class AppCheckReleaseProviderFactory: NSObject, AppCheckProviderFactory { - func createProvider(with app: FirebaseApp) -> (any AppCheckProvider)? { - if #available(iOS 14.0, *) { - AppAttestProvider(app: app) - } else { - DeviceCheckProvider(app: app) - } - } -} +import UserNotifications -class AppDelegate: NSObject, UIApplicationDelegate { +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { weak var tuberose: Tuberose? - - func application( - _ application: UIApplication, - willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil, - ) -> Bool { true } - - func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - #if DEBUG || targetEnvironment(simulator) - AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory()) - #else - AppCheck.setAppCheckProviderFactory(AppCheckReleaseProviderFactory()) - #endif - FirebaseApp.configure() - SwiftyLogger.configure() + func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self - Messaging.messaging().delegate = self return true } - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - Messaging.messaging().apnsToken = deviceToken - } -} - -extension AppDelegate: MessagingDelegate { - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - #if DEBUG || targetEnvironment(simulator) - if let fcmToken { - SwiftyLogger.debug("FCM Token: \(fcmToken)") - } - #endif - } -} - -extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - await MainActor.run(body: { - try? tuberose?.setToken(response) - }) + await MainActor.run { try? self.tuberose?.setToken(response) } } - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.banner, .sound]) + completionHandler(CaptureAuthorization.isGranted ? [.banner, .sound] : []) } } @main struct Interceptor: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @Environment(\.scenePhase) private var scenePhase - - private let tuberose: Tuberose = .default - - init() { - appDelegate.tuberose = tuberose - } - + private let tuberose = Tuberose.default var body: some Scene { WindowGroup { ContentView() @@ -92,15 +28,7 @@ struct Interceptor: App { .environment(tuberose.mudmouth) .environmentIsFirstLaunch() .modelContainer(ModelContainer.default) - .onChange(of: scenePhase) { - if scenePhase == .active { - guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - ATTrackingManager.requestTrackingAuthorization(completionHandler: { _ in - }) - } - } - } + .onAppear { appDelegate.tuberose = tuberose } } } } diff --git a/Interceptor/Localizable.xcstrings b/Interceptor/Localizable.xcstrings index 39e5715..476bc5e 100644 --- a/Interceptor/Localizable.xcstrings +++ b/Interceptor/Localizable.xcstrings @@ -768,6 +768,342 @@ } } } + }, + "Data Use and Consent" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data Use and Consent" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データの利用と同意" + } + } + } + }, + "Agree and Continue" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agree and Continue" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "同意して続ける" + } + } + } + }, + "Not Now" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Now" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今は同意しない" + } + } + } + }, + "Withdraw Consent" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Withdraw Consent" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "同意を取り消す" + } + } + } + }, + "Set Up Capture" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set Up Capture" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャプチャの設定" + } + } + } + }, + "Capture Notifications" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture Notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "取得時の通知" + } + } + } + }, + "Purpose" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Purpose" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用目的" + } + } + } + }, + "Data Recorded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data Recorded" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録するデータ" + } + } + } + }, + "Storage and Sharing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage and Sharing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存先と共有" + } + } + } + }, + "Certificate and VPN" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certificate and VPN" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "証明書とVPN" + } + } + } + }, + "Your Choices" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your Choices" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択と取り消し" + } + } + } + }, + "Unable to Start Capture" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to Start Capture" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャプチャを開始できません" + } + } + } + }, + "CONSENT_PURPOSE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interceptor inspects selected Nintendo HTTPS traffic on this device so you can view requests and responses and obtain game authentication tokens. It is not a general internet protection VPN." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interceptorは、この端末上の指定した任天堂サービスのHTTPS通信を解析し、リクエスト・レスポンスの閲覧やゲームの認証トークン取得に使用します。インターネット全体を保護するVPNではありません。" + } + } + } + }, + "CONSENT_DATA" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Records may contain URLs, headers, cookies, authentication tokens, account information, and request and response bodies. These are sensitive: anyone with an authentication token may be able to access the associated account. Capture only your own authorized traffic." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL、ヘッダー、Cookie、認証トークン、アカウント情報、リクエストとレスポンスの本文が記録される場合があります。認証トークンを持つ人が関連アカウントにアクセスできる可能性があるため、慎重に扱ってください。自分が利用を許可されている通信だけを記録してください。" + } + } + } + }, + "CONSENT_STORAGE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New records are stored in the local app storage shared with its VPN extension. New tokens and certificate keys use this device’s Keychain with iCloud synchronization disabled. Interceptor does not send captured data to its developer or analytics services. Original requests still reach Nintendo. Copying or exporting data shares it with your chosen destination. Copies synchronized by older versions may remain in iCloud. Local history storage is excluded from backup; other app settings may be backed up by the operating system." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新しい記録はアプリとVPN拡張が共有する端末内の領域に保存します。新しいトークンと証明書の鍵はiCloud同期を無効にした、この端末用のKeychainに保存します。取得したデータを開発者や解析サービスへ送信しません。元の通信は任天堂に届きます。コピーや書き出しを行ったデータは、選んだ宛先に共有されます。旧バージョンが同期したコピーはiCloudに残る可能性があります。履歴の保存領域はバックアップ対象から除外しますが、その他の設定はOSによりバックアップされる場合があります。" + } + } + } + }, + "CONSENT_CERTIFICATE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setup installs a local certificate and a VPN configuration. Trusting the certificate allows this app to decrypt selected HTTPS traffic. Agreeing here does not install either item or start capture. You choose those actions separately in Set Up Capture. You can remove the certificate and VPN configuration in iOS Settings." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定ではローカル証明書とVPN構成をインストールします。証明書を信頼すると、このアプリが指定したHTTPS通信を復号できるようになります。この画面での同意だけでは、インストールも記録の開始も行いません。「キャプチャの設定」でそれぞれ操作してください。証明書とVPN構成はiOSの設定から削除できます。" + } + } + } + }, + "CONSENT_CHOICES" : { + "localizations" : { + "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." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "同意しなくても既存データを閲覧できます。記録と証明書のダウンロードには同意が必要です。通知は任意で、一般的なメッセージと端末内の記録IDだけを含みます。この画面で同意を取り消すと記録を停止し、通知を消去します。既存の記録・トークン・インストール済み証明書は削除されません。ホームの「消去」は履歴のみを削除します。アプリの削除でもKeychainや旧iCloudコピーが必ず消えるとは限りません。" + } + } + } + }, + "Terms of Service" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terms of Service" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用規約" + } + } + } + }, + "Privacy Policy" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Policy" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライバシーポリシー" + } + } + } + }, + "Developers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developers" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開発者" + } + } + } + }, + "Licenses" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licenses" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライセンス" + } + } + } } }, "version" : "1.0" diff --git a/Interceptor/PrivacyInfo.xcprivacy b/Interceptor/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..727e3d3 --- /dev/null +++ b/Interceptor/PrivacyInfo.xcprivacy @@ -0,0 +1,24 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + 1C8F.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/Interceptor/Structs/AccessToken.swift b/Interceptor/Structs/AccessToken.swift index 866786a..4283483 100644 --- a/Interceptor/Structs/AccessToken.swift +++ b/Interceptor/Structs/AccessToken.swift @@ -33,10 +33,10 @@ struct AccessToken: Codable, Identifiable, @unchecked Sendable { gtoken.isRefreshNeeded } - init(contentId: ContentId, host: String, gtoken: String, accessToken: String, timeInterval timeIntervalSinceNow: TimeInterval = 60 * 60 * 2) { + init(contentId: ContentId, host: String, gtoken: String, accessToken: String, timeInterval timeIntervalSinceNow: TimeInterval = 60 * 60 * 2) throws { self.contentId = contentId self.host = host - self.gtoken = try! .init(gtoken) + self.gtoken = try .init(gtoken) self.accessToken = accessToken expiresIn = .init(timeIntervalSinceNow: timeIntervalSinceNow) } diff --git a/Interceptor/Views/DataUseConsentView.swift b/Interceptor/Views/DataUseConsentView.swift new file mode 100644 index 0000000..03c1607 --- /dev/null +++ b/Interceptor/Views/DataUseConsentView.swift @@ -0,0 +1,58 @@ +import Mudmouth +import SwiftUI +import UserNotifications + +struct DataUseConsentView: View { + @EnvironmentObject private var client: Tuberose + @AppStorage(CaptureAuthorization.key, store: CaptureAuthorization.defaults) private var consentVersion = 0 + var onDecision: (Bool) -> Void = { _ in } + + 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() + } + .navigationTitle("Data Use and Consent") + .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) + } + } + + private func disclosure(_ title: LocalizedStringKey, _ body: LocalizedStringKey) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + Text(body).font(.body).fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Interceptor/Views/SettingsView.swift b/Interceptor/Views/SettingsView.swift index 3bfdebf..71f368c 100644 --- a/Interceptor/Views/SettingsView.swift +++ b/Interceptor/Views/SettingsView.swift @@ -7,20 +7,40 @@ // import Mudmouth +import LicenseList import QuantumLeap import SwiftUI struct SettingsView: View { - @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var client: Tuberose + @AppStorage(CaptureAuthorization.key, store: CaptureAuthorization.defaults) private var consentVersion = 0 + @State private var showSetup = false var body: some 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") { + Task { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) } + }.disabled(consentVersion != CaptureAuthorization.version) + } QuantumLeap.VPNSettingList() QuantumLeap.Tools() - QuantumLeap.Policy() + Section { + 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")!) + NavigationLink("Licenses") { + LicenseListView().licenseViewStyle(.withRepositoryAnchorLink) + .navigationTitle("Licenses") + } + } QuantumLeap.Version() }) + .fullScreenCover(isPresented: $showSetup) { FirstLaunchView() } .navigationTitle(Text("TITLE_SETTINGS")) .navigationBarTitleDisplayMode(.inline) } diff --git a/Interceptor/en.lproj/InfoPlist.strings b/Interceptor/en.lproj/InfoPlist.strings index 242184e..76c53d0 100644 --- a/Interceptor/en.lproj/InfoPlist.strings +++ b/Interceptor/en.lproj/InfoPlist.strings @@ -6,5 +6,3 @@ Copyright © 2025 QuantumLeap. All rights reserved. */ - -"NSUserTrackingUsageDescription" = "Allow tracking to help us understand app usage and improve your experience."; diff --git a/Interceptor/ja.lproj/InfoPlist.strings b/Interceptor/ja.lproj/InfoPlist.strings index 5106992..2b7056f 100644 --- a/Interceptor/ja.lproj/InfoPlist.strings +++ b/Interceptor/ja.lproj/InfoPlist.strings @@ -6,5 +6,3 @@ Copyright © 2025 QuantumLeap. All rights reserved. */ - -"NSUserTrackingUsageDescription" = "「許可」すると、アプリの利用状況を解析して体験を改善します。"; diff --git a/InterceptorTests/InterceptorTests.swift b/InterceptorTests/InterceptorTests.swift index e89e062..f1349be 100644 --- a/InterceptorTests/InterceptorTests.swift +++ b/InterceptorTests/InterceptorTests.swift @@ -2,33 +2,186 @@ // InterceptorTests.swift // InterceptorTests // -// Created by devonly on 2025/08/11. -// @testable import Interceptor +import Foundation +@testable import Mudmouth import XCTest +@MainActor final class InterceptorTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + private func gameWebToken() throws -> String { + let header: [String: Any] = [ + "alg": "RS256", "jku": "https://example.invalid/keys", "kid": "fixture", "typ": "JWT", + "fixtureNote": "?????????~~~~~~~~~", + ] + let payload: [String: Any] = [ + "isChildRestricted": false, "aud": "fixture-audience", "exp": 4_102_444_800, + "iat": 1_700_000_000, "iss": "fixture-issuer", "sub": 123, + "jti": "00000000-0000-0000-0000-000000000001", "typ": "JWT", + "links": ["networkServiceAccount": ["id": "fixture-account"]], + "membership": ["active": true], + ] + func encode(_ object: [String: Any]) throws -> String { + try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return try "\(encode(header)).\(encode(payload)).YWJj" } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + private func record(host: String, headers: [String: String] = [:], body: Data? = nil) throws -> Record { + // The public preview fixture builds the same SwiftData relationships as capture records. + let record = try XCTUnwrap(RecordGroup().records.first) + record.request.host = host + record.request.header = try JSONSerialization.data(withJSONObject: headers) + record.response.data = body + return record } - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + func testDecodesUnpaddedBase64URLGameWebToken() throws { + let encoded = try gameWebToken() + XCTAssertTrue(encoded.contains("-")) + XCTAssertTrue(encoded.contains("_")) + XCTAssertFalse(encoded.contains("=")) + let token = try GameWebToken(encoded) + XCTAssertEqual(token.header.kid, "fixture") + XCTAssertEqual(token.payload.aud, "fixture-audience") + XCTAssertEqual(token.payload.sub, 123) + XCTAssertEqual(token.signature, "YWJj") + XCTAssertFalse(token.isRefreshNeeded) } - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. + func testRejectsMalformedGameWebTokensWithoutCrashing() { + for malformed in ["", "..", "a.b.c.d", "@@@@.@@@@.sig", "e30.e30.sig"] { + XCTAssertThrowsError(try GameWebToken(malformed), "Malformed fixture: \(malformed)") } } + + func testCookieDuplicatesEmptyValuesAndCaseInsensitiveHeaderNames() { + let headers = [ + "cOoKiE": "iksm_session=old; iksm_session=new; empty=; signed=a=b", + "hOsT": "example.invalid", + ] + XCTAssertEqual(headers.cookies?["iksm_session"], "new") + XCTAssertEqual(headers.cookies?["empty"], "") + XCTAssertEqual(headers.cookies?["signed"], "a=b") + XCTAssertEqual(headers.host, "example.invalid") + } + + func testExtractsSplatoon2TokenFromSavedRecord() throws { + let record = try record(host: "app.splatoon2.nintendo.net", headers: [ + "Cookie": "iksm_session=old; iksm_session=fixture-session", + "x-gamewebtoken": gameWebToken(), + ]) + let token = try XCTUnwrap(Tuberose.token(from: record)) + XCTAssertEqual(token.contentId.rawValue, ContentId.SP2.rawValue) + XCTAssertEqual(token.accessToken, "fixture-session") + XCTAssertEqual(token.host, "app.splatoon2.nintendo.net") + } + + func testExtractsSmashTokenFromSavedRecord() throws { + let record = try record(host: "app.smashbros.nintendo.net", headers: [ + "Cookie": "super_smash_session=fixture-smash-session", "X-GameWebToken": gameWebToken(), + ]) + let token = try XCTUnwrap(Tuberose.token(from: record)) + XCTAssertEqual(token.contentId.rawValue, ContentId.SMSP.rawValue) + XCTAssertEqual(token.accessToken, "fixture-smash-session") + } + + func testExtractsSplatoon3TokenFromSavedResponseBody() throws { + let body = try JSONSerialization.data(withJSONObject: ["bulletToken": "fixture-bullet"]) + let record = try record(host: "api.lp1.av5ja.srv.nintendo.net", headers: [ + "Cookie": "_gtoken=\(gameWebToken())", + ], body: body) + let token = try XCTUnwrap(Tuberose.token(from: record)) + XCTAssertEqual(token.contentId.rawValue, ContentId.SP3.rawValue) + XCTAssertEqual(token.accessToken, "fixture-bullet") + } + + func testIgnoresUnrelatedHostAndIncompleteCapturedRecord() throws { + let unrelated = try record(host: "example.invalid", headers: [ + "Cookie": "iksm_session=fixture", "X-GameWebToken": gameWebToken(), + ]) + XCTAssertNil(try Tuberose.token(from: unrelated)) + XCTAssertNil(try Tuberose.token(from: record(host: "app.splatoon2.nintendo.net"))) + let incompleteBody = try JSONSerialization.data(withJSONObject: ["unrelated": "value"]) + XCTAssertNil(try Tuberose.token(from: record(host: "api.lp1.av5ja.srv.nintendo.net", body: incompleteBody))) + } + + func testRejectsMalformedCapturedJWTAndResponseJSON() throws { + let invalidJWT = try record(host: "app.splatoon2.nintendo.net", headers: [ + "Cookie": "iksm_session=fixture", "X-GameWebToken": "@@@@.@@@@.sig", + ]) + XCTAssertThrowsError(try Tuberose.token(from: invalidJWT)) + let invalidBody = try record(host: "api.lp1.av5ja.srv.nintendo.net", body: Data("not JSON".utf8)) + XCTAssertThrowsError(try Tuberose.token(from: invalidBody)) + } + + func testConsentFailsClosedForMissingOldAndFutureVersions() throws { + let suite = "InterceptorTests.Consent.\(UUID().uuidString)" + let store = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { store.removePersistentDomain(forName: suite) } + XCTAssertFalse(CaptureAuthorization.isGranted(in: store)) + store.set(CaptureAuthorization.version - 1, forKey: CaptureAuthorization.key) + XCTAssertFalse(CaptureAuthorization.isGranted(in: store)) + store.set(CaptureAuthorization.version + 1, forKey: CaptureAuthorization.key) + XCTAssertFalse(CaptureAuthorization.isGranted(in: store)) + CaptureAuthorization.grant(in: store) + XCTAssertTrue(CaptureAuthorization.isGranted(in: store)) + CaptureAuthorization.revoke(in: store) + XCTAssertFalse(CaptureAuthorization.isGranted(in: store)) + } + + func testCertificateServerConsentAndRestart() async throws { + let store = CaptureAuthorization.defaults + let original = store.object(forKey: CaptureAuthorization.key) + let proxy = X509Proxy.default + defer { + try? proxy.stop() + if let original { store.set(original, forKey: CaptureAuthorization.key) } + else { store.removeObject(forKey: CaptureAuthorization.key) } + } + CaptureAuthorization.revoke() + XCTAssertThrowsError(try proxy.start()) + CaptureAuthorization.grant() + try proxy.start() + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 2 + let session = URLSession(configuration: config) + defer { session.invalidateAndCancel() } + let (data, response) = try await session.data(from: proxy.url) + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + XCTAssertTrue(String(decoding: data, as: UTF8.self).contains("BEGIN CERTIFICATE")) + CaptureAuthorization.revoke() + try proxy.stop() + do { + _ = try await session.data(from: proxy.url) + XCTFail("Certificate listener must be closed after withdrawal") + } catch { XCTAssertTrue(error is URLError) } + CaptureAuthorization.grant() + try proxy.start() + let (_, restarted) = try await session.data(from: proxy.url) + XCTAssertEqual((restarted as? HTTPURLResponse)?.statusCode, 200) + } + + func testTunnelCannotStartWithoutConsentOrValidOptions() async throws { + let store = CaptureAuthorization.defaults + let original = store.object(forKey: CaptureAuthorization.key) + defer { + if let original { store.set(original, forKey: CaptureAuthorization.key) } + else { store.removeObject(forKey: CaptureAuthorization.key) } + } + CaptureAuthorization.revoke() + do { try await MITM.startTunnel(); XCTFail("Missing consent must reject start") } + catch { XCTAssertTrue(error is CaptureAuthorization.Failure) } + CaptureAuthorization.grant() + do { try await MITM.startTunnel(); XCTFail("Invalid configuration must reject start") } + catch { XCTAssertTrue(error is LocalProxyChannels.Failure) } + await MITM.stopTunnel() + await MITM.stopTunnel() + } + } diff --git a/InterceptorUITests/InterceptorUITests.swift b/InterceptorUITests/InterceptorUITests.swift index 350f1b8..1e9d43e 100644 --- a/InterceptorUITests/InterceptorUITests.swift +++ b/InterceptorUITests/InterceptorUITests.swift @@ -31,6 +31,172 @@ final class InterceptorUITests: XCTestCase { // Use XCTAssert and related functions to verify your tests produce the correct results. } + @MainActor + func testCaptureConsentLifecycle() throws { + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + 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)) + 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) + app.terminate() + app.launch() + tab(app, named: "Settings").tap() + XCTAssertFalse(app.switches["Connection Status"].isEnabled) + } + + @MainActor + func testPhysicalNintendoCapture() throws { + #if targetEnvironment(simulator) + throw XCTSkip("Nintendo capture requires a logged-in Nintendo Switch App on a physical device.") + #else + XCUIDevice.shared.orientation = .portrait + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } + 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 connection.value as? String != "1" { try switchControl(app, row: connection).tap() } + let connected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '1'"), object: connection) + XCTAssertEqual(XCTWaiter.wait(for: [connected], timeout: 20), .completed) + let nintendo = XCUIApplication(bundleIdentifier: "com.nintendo.znca") + nintendo.launch() + XCTAssertTrue(nintendo.wait(for: .runningForeground, timeout: 10)) + let game = nintendo.cells["SplatNet 3"] + guard game.waitForExistence(timeout: 10) else { + attachScreenshot(nintendo, named: "Private Nintendo navigation inspection") + XCTFail("The SplatNet 3 entry was not found; inspect the private Nintendo UI attachment.") + return + } + game.tap() + _ = nintendo.webViews.firstMatch.waitForExistence(timeout: 20) + app.activate() + tab(app, named: "Home").tap() + let capturedHost = app.staticTexts["api.lp1.av5ja.srv.nintendo.net"] + XCTAssertTrue(capturedHost.waitForExistence(timeout: 10)) + 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() + 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. + #endif + } + + @MainActor + func testPhysicalVPNLifecycle() async throws { + #if targetEnvironment(simulator) + throw XCTSkip("VPN tunnel validation requires a physical device with the certificate and VPN installed.") + #else + XCUIDevice.shared.orientation = .portrait + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } + 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) + let control = try switchControl(app, row: connection) + 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 connection.value as? String == "1" { + control.tap() + let initiallyStopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '0'"), object: connection) + XCTAssertEqual(XCTWaiter.wait(for: [initiallyStopped], timeout: 15), .completed) + } + control.tap() + let connected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '1'"), object: connection) + let result = XCTWaiter.wait(for: [connected], timeout: 20) + attachScreenshot(app, named: "Physical VPN start result") + if result != .completed { + let diagnostic = XCTAttachment(string: app.debugDescription) + diagnostic.name = "Physical VPN start accessibility" + diagnostic.lifetime = .keepAlways + add(diagnostic) + } + XCTAssertEqual(result, .completed, "Install the Interceptor VPN configuration before running this test; inspect the attached start result for errors.") + guard result == .completed else { return } + let probePath = "/__interceptor_review_probe_" + UUID().uuidString + let probeURL = try XCTUnwrap(URL(string: "https://api.lp1.av5ja.srv.nintendo.net" + probePath)) + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.timeoutIntervalForRequest = 20 + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let (_, response) = try await session.data(from: probeURL) + XCTAssertNotNil(response as? HTTPURLResponse, "A public probe must complete TLS and receive an HTTP response.") + app.activate() + tab(app, named: "Home").tap() + let host = app.staticTexts["api.lp1.av5ja.srv.nintendo.net"] + XCTAssertTrue(host.waitForExistence(timeout: 10)) + host.tap() + XCTAssertTrue(app.staticTexts[probePath].waitForExistence(timeout: 10), "The unique public probe must appear in captured history.") + attachScreenshot(app, named: "Physical VPN captured public HTTPS probe") + tab(app, named: "Settings").tap() + control.tap() + let stopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '0'"), object: connection) + XCTAssertEqual(XCTWaiter.wait(for: [stopped], timeout: 15), .completed) + 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") + let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + settings.launch() + let vpnSettings = settings.buttons["com.apple.settings.vpn"] + XCTAssertTrue(vpnSettings.waitForExistence(timeout: 5)) + vpnSettings.tap() + let systemStatus = settings.switches.matching(NSPredicate(format: "label BEGINSWITH 'VPN Status'")).firstMatch + XCTAssertTrue(systemStatus.waitForExistence(timeout: 5)) + let systemStopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == '0'"), object: systemStatus) + XCTAssertEqual(XCTWaiter.wait(for: [systemStopped], timeout: 15), .completed, "iPadOS must report that the VPN stopped after consent withdrawal.") + attachScreenshot(settings, named: "Physical system VPN disconnected") + #endif + } + @MainActor func testSimulatorOnboardingAndNavigation() throws { let app = XCUIApplication() @@ -43,6 +209,13 @@ final class InterceptorUITests: XCTestCase { } app.launch() + if app.buttons["Not Now"].waitForExistence(timeout: 3) { app.buttons["Not Now"].tap() } + 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() + // The simulator permits advancing through the device-only setup steps. if app.buttons["Next"].waitForExistence(timeout: 5) { for _ in 0..<8 { @@ -52,10 +225,10 @@ final class InterceptorUITests: XCTestCase { XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5)) app.buttons["Done"].tap() } - XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 10)) + XCTAssertTrue(tab(app, named: "Home").waitForExistence(timeout: 10)) attachScreenshot(app, named: "Home") - app.tabBars.buttons["Settings"].tap() + tab(app, named: "Settings").tap() XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) let autoConnect = app.switches["Auto Connect"] XCTAssertTrue(autoConnect.waitForExistence(timeout: 5)) @@ -86,7 +259,7 @@ final class InterceptorUITests: XCTestCase { app.buttons["Certificate"].tap() XCTAssertTrue(app.navigationBars["Certificate"].waitForExistence(timeout: 5)) app.navigationBars.buttons.firstMatch.tap() - app.tabBars.buttons["Home"].tap() + tab(app, named: "Home").tap() app.navigationBars.buttons.firstMatch.tap() XCTAssertTrue(app.buttons["Clear"].waitForExistence(timeout: 5)) @@ -95,10 +268,34 @@ final class InterceptorUITests: XCTestCase { app.terminate() app.launch() - XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 10)) + XCTAssertTrue(tab(app, named: "Home").waitForExistence(timeout: 10)) XCTAssertFalse(app.buttons["Next"].exists, "Completed onboarding must remain dismissed after relaunch") } + @MainActor + private func switchControl(_ app: XCUIApplication, row: XCUIElement) throws -> XCUIElement { + // iPadOS 18 exposes the UISwitch beside its labeled row; newer versions nest it. + let frame = row.frame + return try XCTUnwrap(app.switches.allElementsBoundByIndex.first { candidate in + let controlFrame = candidate.frame + return controlFrame.width < frame.width + && frame.contains(CGPoint(x: controlFrame.midX, y: controlFrame.midY)) + }, "The labeled row must expose its actual switch control.") + } + + @MainActor + private func tab(_ app: XCUIApplication, named name: String) -> XCUIElement { + // iPadOS exposes its top tabs as ordinary buttons, rather than a TabBar. + 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/PacketTunnel/PacketTunnelProvider.swift b/PacketTunnel/PacketTunnelProvider.swift index 7fc627a..d0b71de 100644 --- a/PacketTunnel/PacketTunnelProvider.swift +++ b/PacketTunnel/PacketTunnelProvider.swift @@ -11,14 +11,31 @@ import NetworkExtension import SwiftyLogger class PacketTunnelProvider: NEPacketTunnelProvider { + private var consentMonitor: Timer? + /// どういうときに呼ばれるの、これ /// NOTE: startVPNTunnelが実行されたときのオプションがここで渡される /// - Parameter options: <#options description#> override func startTunnel(options: [String: NSObject]? = nil) async throws { - NSLog("Starting tunnel with options: \(String(describing: options))") - SwiftyLogger.debug("Starting tunnel with options: \(String(describing: options))") + try CaptureAuthorization.requireConsent() try await setTunnelNetworkSettings(settings) try await MITM.startTunnel(options: options) + await MainActor.run { + consentMonitor = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + guard !CaptureAuthorization.isGranted else { return } + self?.consentMonitor?.invalidate() + self?.consentMonitor = nil + Task { + await MITM.stopTunnel() + self?.cancelTunnelWithError(CaptureAuthorization.Failure.consentRequired) + } + } + } + } + + override func stopTunnel(with reason: NEProviderStopReason) async { + await MainActor.run { consentMonitor?.invalidate(); consentMonitor = nil } + await MITM.stopTunnel() } /// スプラトゥーン3のトークンを取得するためだけの設定 diff --git a/PacketTunnel/PrivacyInfo.xcprivacy b/PacketTunnel/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..727e3d3 --- /dev/null +++ b/PacketTunnel/PrivacyInfo.xcprivacy @@ -0,0 +1,24 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + 1C8F.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/README.md b/README.md index fcea291..5f09106 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ 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 +git -C ../Mudmouth checkout 698d5bcb25245a26a3be925c34ded8a4c8a2ff6a xcodebuild -resolvePackageDependencies -project Interceptor.xcodeproj -scheme Interceptor open Interceptor.xcodeproj ``` diff --git a/docs/app-review/README.md b/docs/app-review/README.md new file mode 100644 index 0000000..ef811d6 --- /dev/null +++ b/docs/app-review/README.md @@ -0,0 +1,52 @@ +# App Review preparation — draft, 2026-10-04 + +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. + +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. + +## Checks before using the drafts + +- Verify explicit consent appears before certificate setup and every capture-start route; decline, revoke, automatic restart, relaunch, and existing-install upgrade must respect it. +- Verify Firebase, App Check, Messaging, remote notification registration, and tracking prompts are absent from the shipped archive, not merely unused at runtime. +- Verify new token, certificate, and private-key writes are not synchronizable. Do not promise that existing cloud copies are erased or that OS backups exclude these items without testing their actual accessibility and backup attributes. +- Verify optional local notifications contain no cookies, authentication tokens, captured headers, or bodies, including userInfo. Captured data must be read from local storage, including after relaunch, without relying on a notification tap. +- Confirm capture-domain/path matching and the displayed SSL Proxying List match actual routing. Do not describe this as general-purpose or all-device-traffic capture. +- Verify Home's Clear action deletes stored request history. Token/Keychain deletion is a separate concern; do not imply it is included in Clear or consent withdrawal. +- Verify the certificate profile and VPN configuration can be removed through the documented iOS settings. Do not claim the app removes them automatically. +- Use a physical-device capture test and a review demonstration with synthetic or redacted data. Simulator UI tests alone do not prove real-device VPN and certificate behavior. +- Confirm the App Privacy answers, purpose strings, screenshots, support URL, public policy URL, and archive match the revised behavior. The drafts do not predetermine Apple's privacy classification or approval. + +## References checked + +- [Apple's platform version information](https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information): field formats and limits. +- [Apple's App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/): review requirements. The previously reported rejection identifier is historical; use Apple's exact current response rather than assuming its numbering or that these changes resolve it. + +These files do not automatically send reviewer messages or publish externally. + +## Verified build preparation (2026-10-04) + +Primary physical test device: iPad Air 11-inch (M2), named iPad Air M2, iPadOS 18.6.2. The user selected it because the M3 is shared with other app development. Use M2 for subsequent device operations; the historical M3 results below remain evidence of those completed runs. The initial Xcode unlock gate cleared, and the consent UI test passed on M2 at 13:32 JST (17.672 seconds, zero failures). The user completed fresh certificate installation and trust; the app recognized both and proceeded to VPN configuration registration. After the user signed in to Nintendo Switch App, the physical Nintendo capture and VPN lifecycle tests passed at 14:30 JST (two tests, zero failures). They also passed with an explicit portrait orientation at 14:33 JST (62.909 seconds total, zero failures). Nintendo capture verified `/api/bullet_tokens` in history and the service host in Token List; it does not compare raw token values against previously saved values. The VPN test recorded a unique anonymous HTTPS probe, checked stop/restart, withdrew consent, and independently checked the native system VPN switch was off. The device is left with consent withdrawn and VPN stopped; its installed certificate and VPN configuration remain. Certificate removal was not exercised. Results: `/tmp/interceptor-m2-signedin-retest.xcresult` and `/tmp/interceptor-m2-portrait-capture.xcresult`. Tailscale was switched off in iPadOS VPN settings at 14:09 JST; the selected Tailscale configuration reported Not Connected with its switch value zero. The user explicitly requested that it remain off after testing. The local diagnostic result is `/tmp/interceptor-m2-tailscale-off.xcresult`; system-settings images contain private account details and are not committed. + +Build 29 passes signed Simulator unit/UI tests; a development-signed Release archive and App Store-signed IPA export succeeded. Both the app and packet-tunnel extension contain a privacy manifest declaring local/App Group UserDefaults use (Apple reasons CA92.1 and 1C8F.1). The archive has no Firebase bundles, GoogleService-Info.plist, APNs entitlement, or tracking-purpose string. These checks do not replace physical-device VPN/certificate testing or Apple's review. + +Mudmouth CI now builds for iOS Simulator instead of attempting to compile UIKit for macOS. Lint tool versions are fixed, and only 16 existing force-try findings are recorded in its baseline; new findings remain checked. The retired runner and nonexistent package.json release check were replaced with package metadata validation. The unavailable external AI review is manual; an independent code review was performed for these changes. + +The consent lifecycle UI test passed on a connected iPad Air 13-inch (M3), iPadOS 26.3.1, on 2026-10-04. It checks decline, consent, withdrawal, disabled capture controls, and relaunch. The test supports iPad's top tab buttons and preserves consent screenshots in the test result. + +The physical-device VPN lifecycle test also passed on this device at 13:17 JST (45.539 seconds, zero failures). Initially, the VPN configuration was missing and the app displayed its setup error. After registering it through Set Up Capture, the test started the tunnel, sent an anonymous HTTPS GET with a unique probe path to `api.lp1.av5ja.srv.nintendo.net`, received an HTTP response, and verified that exact path in the captured history. It then stopped and restarted the tunnel, withdrew consent, and independently confirmed iPadOS reported Not Connected. The device is left with consent withdrawn and the VPN stopped; its installed certificate and VPN configuration remain. The existing installed certificate was recognized and trusted; fresh profile installation/removal and Nintendo account/token capture were not exercised. + +The reusable `testPhysicalVPNLifecycle` requires a physical device with the matching certificate installed/trusted and the Interceptor VPN configuration registered. It skips on Simulator. The verified device's system Settings UI was English. The captured public-request screenshot is saved in [evidence/physical-vpn-https-probe.png](evidence/physical-vpn-https-probe.png); it contains no account cookies or authentication tokens. The complete local result bundle is `/tmp/interceptor-ipad-vpn-final.xcresult`. + +Before switching devices, `testPhysicalNintendoCapture` passed on M3 at 13:27 JST (27.504 seconds, zero failures). It enabled capture, launched the logged-in Nintendo Switch App 3.5.0, opened its SplatNet 3 cell, returned to Interceptor, and verified the Splatoon 3 host appeared in Token List without opening live token details. It withdrew consent afterward. This verifies the visible token-list integration; it does not compare a new token against a previously saved token. Nintendo screenshots containing friends/account information are private local diagnostics and are not included in this repository or review attachments. The local result bundle is `/tmp/interceptor-nintendo-splatnet3.xcresult`. + +Published app-specific policy URL: https://qleap.jp/term/interceptor_privacy_policy. Public support: https://qleap.jp/support. Confirm App Store legal developer details, the replacement build selection, review screenshots/video, and the reviewer account/access path before submitting. + +The physical test switch selector now supports iPadOS 18, which exposes the actual UISwitch beside the labeled row. The Nintendo test also requires the `/api/bullet_tokens` request in captured history; both physical tests passed on M2. XCTest teardown blocks revoke consent even if a UI assertion aborts the test, and VPN lifecycle testing first stops an already-active tunnel to establish its initial state. The native VPN-status check supports iPadOS 18. Tests use portrait orientation for readable device evidence. + +After the test selector changes, the simulator build and consent lifecycle test passed at 14:12 JST (zero failures; the two physical-only tests were skipped as intended). Result: `/tmp/interceptor-m2-test-selector-simulator.xcresult`. + +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. diff --git a/docs/app-review/evidence/physical-m2-capture.png b/docs/app-review/evidence/physical-m2-capture.png new file mode 100644 index 0000000..de0bb29 Binary files /dev/null and b/docs/app-review/evidence/physical-m2-capture.png differ diff --git a/docs/app-review/evidence/physical-vpn-https-probe.png b/docs/app-review/evidence/physical-vpn-https-probe.png new file mode 100644 index 0000000..89b2aa2 Binary files /dev/null and b/docs/app-review/evidence/physical-vpn-https-probe.png differ diff --git a/docs/app-review/privacy-policy-draft.md b/docs/app-review/privacy-policy-draft.md new file mode 100644 index 0000000..e8ac4f7 --- /dev/null +++ b/docs/app-review/privacy-policy-draft.md @@ -0,0 +1,68 @@ +# Interceptor Privacy Policy — draft / プライバシーポリシー案 + +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. + +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. +Published effective date: 2026-10-04. + +## English + +### What Interceptor does + +Interceptor inspects selected Nintendo-related HTTPS traffic on your own device through a local proxy using an iOS VPN configuration. It does not provide a remote VPN server or a public Wi-Fi protection service. To inspect HTTPS, you must install and trust a certificate authority certificate generated locally by the app. + +### Data accessed and purpose + +After you explicitly consent to capture, the app can process and save request and response hosts, paths and query parameters, headers, cookies, authentication tokens, account identifiers, and bodies for its configured hosts, including paths beyond the endpoints used for token notifications. Some fields contain sensitive account or session information. The app uses these records to show you your traffic and to display supported service tokens. It also stores settings, consent state, and locally generated certificate/key material needed for inspection. Use the app only for devices, accounts, and traffic you are authorized to inspect. + +### Storage and transmission + +New capture records are stored in local shared storage used by the app and its packet-tunnel extension. Newly saved tokens, certificates, and private keys 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. The revised release has no Firebase or Firebase remote messaging integration and does not request advertising tracking permission. + +This does not mean that every network request remains on the device. The inspected requests still reach their intended Nintendo services, which process them under their own policies. Opening external support or policy links also connects to the selected website. If you copy data or share it outside Interceptor, the destination and its handling are your choice. Clipboard contents and subsequent sharing can expose sensitive information. + +Earlier app versions may have saved synchronizable Keychain data. Disabling new synchronization does not erase earlier iCloud copies or copies on other devices. Apple's Keychain, device migration, and backup/restore behavior also depend on your system settings and the stored item's protection attributes. This policy does not promise that all data is excluded from OS backups or that uninstalling the app erases every Keychain item. Control existing cloud copies and backups through the relevant Apple services and device settings. + +### 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. + +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. + +Notifications are optional and generated locally. Their text and payload do not contain captured cookies, authentication tokens, headers, or bodies. The app reads captured results from its local storage. You can disable notifications in iOS Settings without enabling remote push or sending capture results to a push server. + +### Questions and updates + +Contact the verified privacy contact listed above with questions. A future change to capture scope, storage, or external sharing requires an updated explanation and policy. This draft does not establish that Apple has approved the app or its network inspection design. + +## 日本語 + +### アプリの仕組み + +Interceptorは、iOSのVPN設定と端末内のプロキシを使い、自分の端末で任天堂関連の一部のHTTPS通信を解析します。外部のVPNサーバーや公共Wi-Fiを保護するサービスは提供しません。HTTPS通信の解析には、アプリが端末内で生成した認証局証明書をインストールし、信頼する操作が必要です。 + +### 取得するデータと用途 + +通信の取得に明示的に同意した後、設定したホストへの通信(トークン通知の対象エンドポイント以外のパスも含む)について、リクエストとレスポンスのホスト、パス、クエリ、ヘッダー、Cookie、認証トークン、アカウント識別子、本文を処理・保存することがあります。アカウントやセッションに関する機密情報が含まれる場合があります。これらは、通信内容と対応サービスのトークンを利用者に表示するために使用します。設定、同意の状態、解析に必要な端末内の証明書と鍵も保存します。解析する権限を持つ端末、アカウント、通信だけを対象にしてください。 + +### 保存先と送信 + +新たな通信記録は、アプリとパケットトンネル拡張が使用する端末内の共有領域に保存します。新たに保存するトークン、証明書、秘密鍵には、iCloudキーチェーン同期を無効にしたKeychain項目を使用します。取得データを開発者のサーバーへアップロードしたり、販売したり、広告に利用したり、第三者へ自動共有したりしません。修正版ではFirebaseとFirebaseのリモート通知機能を使用せず、広告追跡の許可を求めません。 + +すべての通信が端末内で完結するという意味ではありません。解析対象の通信は、本来の送信先である任天堂のサービスへ送られ、そのサービスの方針に従って処理されます。サポートやポリシーの外部リンクを開いた場合は、リンク先のウェブサイトへ接続します。利用者がデータをコピーしてアプリ外で使用・共有する場合、その送信先と取り扱いは利用者が選ぶものです。クリップボードやその後の共有により、機密情報が外部へ渡ることがあります。 + +旧版では、同期可能なKeychain項目を保存していた可能性があります。新たな同期を停止しても、以前のiCloud上のコピーや他の端末のコピーは削除されません。AppleのKeychain、端末移行、バックアップと復元の扱いは、システム設定や保存項目の保護属性にも左右されます。すべてのデータがOSのバックアップ対象外になることや、アンインストールですべてのKeychain項目が消えることを保証するものではありません。既存のクラウド上のコピーとバックアップは、Appleの該当サービスや端末の設定で管理してください。 + +### 同意と操作 + +証明書の設定や通信の取得を始める前に、アプリ内で用途を説明して同意を求めます。同意しなくてもアプリ内を閲覧できます。設定から同意を撤回すると取得を停止し、再び同意するまで自動再開しません。撤回だけでは、保存済みの通信記録、トークン、過去のクラウド上のコピー、証明書プロファイルは削除されません。 + +通信履歴はホームの消去操作で削除できます。この操作で、別に保存されたKeychainのトークンや証明書も削除されるとは限りません。該当する削除操作を行うまでデータが残ることがあり、過去のバックアップや自分でコピーしたデータは別に残る場合があります。証明書の信頼を停止してプロファイルを削除する場合や、VPN設定が不要になった場合は、iOSの設定から操作してください。任天堂のセッションやトークンを無効化・更新する場合は、任天堂のアカウントやサービスの操作が必要です。 + +通知は任意で、端末内で生成します。通知の文面とペイロードには、取得したCookie、認証トークン、ヘッダー、本文を含めません。アプリは端末内に保存された取得結果を読み取ります。iOSの設定から通知を無効にできます。通知のためにリモートプッシュを有効にしたり、取得結果をプッシュ通知のサーバーへ送ったりしません。 + +### 問い合わせと更新 + +質問は、上記の確認済みの問い合わせ先へご連絡ください。取得対象、保存先、外部共有の方針を変更する場合は、説明とポリシーを更新します。この案は、Appleがアプリや通信解析の仕組みを承認したことを示すものではありません。 diff --git a/docs/app-review/review-notes-draft.md b/docs/app-review/review-notes-draft.md new file mode 100644 index 0000000..d03603e --- /dev/null +++ b/docs/app-review/review-notes-draft.md @@ -0,0 +1,42 @@ +# App Review Notes — draft + +App: Interceptor (6749347474) +Prepared: 2026-10-04 +Replacement build/version: not yet selected + +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 + +Interceptor is a local network inspection tool for selected Nintendo-related hosts. Its packet tunnel configures an HTTPS proxy on 127.0.0.1:6836 on the same device; it does not connect to a developer-operated remote VPN server. The proxy inspects selected HTTPS traffic using a certificate authority generated locally. The user must explicitly install the certificate profile and enable trust in iOS Settings. + +Captured records may include headers, cookies, authentication tokens, account identifiers, and request/response bodies. These are used to display traffic and supported service tokens to the user. The revised in-app notice explains this before certificate setup or capture and requires explicit consent. The previous statement that the app collects or transmits no data should not be used: the app processes local traffic, and requests continue to their original Nintendo services. + +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. + +## 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. +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. +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. + +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. + +- 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. +- 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/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..0a61d44 --- /dev/null +++ b/fastlane/metadata/en-US/description.txt @@ -0,0 +1,11 @@ +Interceptor helps you inspect selected Nintendo-related HTTPS traffic on your own iPhone or iPad. View saved request and response headers, cookies, and available JSON bodies, and inspect supported service tokens. + +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. + +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. + +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/en-US/keywords.txt b/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 0000000..e980662 --- /dev/null +++ b/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +network,https,request,response,headers,cookies,certificate,proxy,debugging,local,traffic diff --git a/fastlane/metadata/en-US/promotional_text.txt b/fastlane/metadata/en-US/promotional_text.txt new file mode 100644 index 0000000..4aa3b49 --- /dev/null +++ b/fastlane/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Inspect selected Nintendo HTTPS traffic on your device. Review sensitive-data handling and consent before capture; withdraw consent in Settings to stop it. diff --git a/fastlane/metadata/ja/description.txt b/fastlane/metadata/ja/description.txt new file mode 100644 index 0000000..af850b2 --- /dev/null +++ b/fastlane/metadata/ja/description.txt @@ -0,0 +1,11 @@ +Interceptorは、自分のiPhoneやiPadで、任天堂関連の一部のHTTPS通信を確認するためのアプリです。保存したリクエストとレスポンスのヘッダー、Cookie、取得できたJSON本文や、対応サービスのトークンを確認できます。 + +iOSのVPN設定を使い、対象ホストの通信を端末内のプロキシへ送ります。外部のVPNサーバーは提供しません。接続元の公開IPアドレスを変更したり、公共Wi-Fiの通信を保護したりする機能ではありません。HTTPS通信の解析には、端末内で生成した認証局証明書をiOSの設定でインストールし、信頼する操作が必要です。 + +証明書の設定や通信の取得を始める前に、取得するデータと用途を説明し、同意を求めます。通信には、Cookie、認証トークン、アカウント識別子、リクエストやレスポンスの内容など、機密情報が含まれることがあります。自分が解析する権限を持つアカウントと端末で使用してください。取得対象はアプリに設定された任天堂関連のホストに限られます。サービスの仕様変更や証明書の制約により、取得できない場合があります。 + +新たな通信記録は、アプリの端末内の共有領域に保存します。新たな証明書、秘密鍵、保存するトークンには、iCloudキーチェーン同期を無効にしたKeychain項目を使用します。取得データを開発者のサーバーへアップロードしたり、販売したり、広告に利用したり、第三者へ自動共有したりしません。任天堂への本来の通信は、引き続き任天堂のサービスへ送信されます。自分でコピーしたデータ、過去に同期したiCloudキーチェーンのデータ、OSのバックアップは、この端末内の取得処理とは別に扱われます。 + +同意しなくてもアプリ内を閲覧できます。設定から同意を撤回すると通信の取得を停止し、再び同意するまで自動再開しません。同意の撤回だけでは、保存済みデータやインストールした証明書は削除されません。通信履歴はホームから消去できます。任意のローカル通知には、トークンや取得したヘッダー・本文を含めません。 + +任天堂が提供、公認するアプリではありません。任天堂および関連する名称は、各権利者の商標です。 diff --git a/fastlane/metadata/ja/keywords.txt b/fastlane/metadata/ja/keywords.txt new file mode 100644 index 0000000..c55a1f7 --- /dev/null +++ b/fastlane/metadata/ja/keywords.txt @@ -0,0 +1 @@ +通信解析,ネットワーク,HTTPS,リクエスト,レスポンス,ヘッダー,Cookie,証明書,プロキシ,デバッグ diff --git a/fastlane/metadata/ja/promotional_text.txt b/fastlane/metadata/ja/promotional_text.txt new file mode 100644 index 0000000..1a735e8 --- /dev/null +++ b/fastlane/metadata/ja/promotional_text.txt @@ -0,0 +1 @@ +端末内で任天堂関連のHTTPS通信を確認。取得するデータと保存先を確認して同意した後に開始し、設定から同意を撤回すると停止できます。