From 7579265e3b891ee1424b4cbdd338f471cf8c671d Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:58:54 +0100 Subject: [PATCH 1/8] feat: integrate official Apple Ads search popularity --- OpenASO.xcodeproj/project.pbxproj | 42 ++ .../xcshareddata/swiftpm/Package.resolved | 186 ++++- OpenASO/App/AppServices.swift | 32 +- OpenASO/App/RootView.swift | 13 +- .../Keywords/Table/KeywordTableView.swift | 5 +- .../AppleAdsPlatformWorkspaceView.swift | 422 ++++++++++++ .../KeywordResearchProjectWorkspaceView.swift | 2 +- .../AppleAdsSettingsConnectionState.swift | 3 + OpenASO/Features/Settings/SettingsView.swift | 140 +++- .../AppDetail/AppDetailRefreshService.swift | 2 - .../AppleAds/AppleAdsCredentials.swift | 37 +- .../AppleAds/AppleAdsPlatformService.swift | 474 +++++++++++++ .../AppleAds/KeywordMetricsService.swift | 645 +++++------------- .../KeywordResearchMetricsWorkflow.swift | 67 +- OpenASO/Services/MCP/OpenASOMCPRuntime.swift | 18 +- OpenASO/Services/MCP/OpenASOMCPServer.swift | 73 ++ OpenASO/Services/MCP/OpenASOMCPService.swift | 120 +++- OpenASOTests/AppServicesDependencyTests.swift | 212 +----- .../AppleAdsPlatformServiceTests.swift | 125 ++++ OpenASOTests/KeywordMetricsServiceTests.swift | 440 ++++++++---- .../KeywordResearchMetricsWorkflowTests.swift | 86 +-- .../KeywordResearchProjectStoreTests.swift | 5 +- OpenASOTests/OpenASOMCPServerTests.swift | 83 ++- OpenASOTests/OpenASOMCPServiceTests.swift | 97 ++- OpenASOTests/RefreshObservabilityTests.swift | 11 +- .../TrackedKeywordRefreshStatusTests.swift | 9 +- 26 files changed, 2337 insertions(+), 1012 deletions(-) create mode 100644 OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift create mode 100644 OpenASO/Services/AppleAds/AppleAdsPlatformService.swift create mode 100644 OpenASOTests/AppleAdsPlatformServiceTests.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index 34f54ab..aa9209b 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + AD5000000000000000000001 /* AppleAdsClient in Frameworks */ = {isa = PBXBuildFile; productRef = AD5000000000000000000004 /* AppleAdsClient */; }; + AD5000000000000000000002 /* AppleAdsClient in Frameworks */ = {isa = PBXBuildFile; productRef = AD5000000000000000000004 /* AppleAdsClient */; }; + AD5100000000000000000001 /* AppleAds/AppleAdsPlatformService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */; }; + AD5100000000000000000002 /* AppleAds/AppleAdsPlatformService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */; }; + AD5200000000000000000001 /* AppleAdsPlatformWorkspaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5200000000000000000002 /* AppleAdsPlatformWorkspaceView.swift */; }; + AD5300000000000000000001 /* AppleAdsPlatformServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5300000000000000000002 /* AppleAdsPlatformServiceTests.swift */; }; F1A200000000000000000001 /* AppStoreIDInputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A200000000000000000002 /* AppStoreIDInputParser.swift */; }; F1A200000000000000000003 /* AppStoreIDInputParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A200000000000000000004 /* AppStoreIDInputParserTests.swift */; }; B9A0000000000000000005 /* AppleAdsPastedSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A0000000000000000006 /* AppleAdsPastedSessionTests.swift */; }; @@ -345,6 +351,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsPlatformService.swift; sourceTree = ""; }; + AD5200000000000000000002 /* AppleAdsPlatformWorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformWorkspaceView.swift; sourceTree = ""; }; + AD5300000000000000000002 /* AppleAdsPlatformServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformServiceTests.swift; sourceTree = ""; }; F1A200000000000000000002 /* AppStoreIDInputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreIDInputParser.swift; sourceTree = ""; }; F1A200000000000000000004 /* AppStoreIDInputParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreIDInputParserTests.swift; sourceTree = ""; }; B9A0000000000000000006 /* AppleAdsPastedSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPastedSessionTests.swift; sourceTree = ""; }; @@ -617,6 +626,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AD5000000000000000000001 /* AppleAdsClient in Frameworks */, A80000000000000000000005 /* PostHog in Frameworks */, A90000000000000000000005 /* Sparkle in Frameworks */, C0DEFACE000000000000010A /* MCP in Frameworks */, @@ -635,6 +645,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AD5000000000000000000002 /* AppleAdsClient in Frameworks */, C15A00000000000000000009 /* MCP in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -645,6 +656,7 @@ 1027BC248FF4568E8523C3A8 /* OpenASOTests */ = { isa = PBXGroup; children = ( + AD5300000000000000000002 /* AppleAdsPlatformServiceTests.swift */, F1A200000000000000000004 /* AppStoreIDInputParserTests.swift */, AA0000000000000000000001 /* AppleAdsConnectionStateTests.swift */, B9A0000000000000000006 /* AppleAdsPastedSessionTests.swift */, @@ -730,6 +742,7 @@ 4F501415E1C5553BD5F1EE07 /* Features */ = { isa = PBXGroup; children = ( + AD5200000000000000000003 /* AppleAds */, C10000000000000000000001 /* AppDetail */, C10000000000000000000005 /* AppManagement */, C0DEC0DE0000000000000010 /* MCP */, @@ -739,6 +752,14 @@ path = Features; sourceTree = ""; }; + AD5200000000000000000003 /* AppleAds */ = { + isa = PBXGroup; + children = ( + AD5200000000000000000002 /* AppleAdsPlatformWorkspaceView.swift */, + ); + path = AppleAds; + sourceTree = ""; + }; D34B00000000000000000020 /* KeywordResearch */ = { isa = PBXGroup; children = ( @@ -761,6 +782,7 @@ 54FB5EDBECF218FCB9C40ADB /* Services */ = { isa = PBXGroup; children = ( + AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */, 04E8C4E18F1A4D519C0A1130 /* AI/AIService.swift */, 04E8C4E18F1A4D519C0A1131 /* AI/AITranslationService.swift */, A80000000000000000000002 /* AnalyticsService.swift */, @@ -1075,6 +1097,7 @@ ); name = OpenASO; packageProductDependencies = ( + AD5000000000000000000004 /* AppleAdsClient */, A80000000000000000000006 /* PostHog */, A90000000000000000000006 /* Sparkle */, C0DEFACE0000000000000109 /* MCP */, @@ -1096,6 +1119,7 @@ ); name = OpenASOMCP; packageProductDependencies = ( + AD5000000000000000000004 /* AppleAdsClient */, C0DEFACE0000000000000109 /* MCP */, ); productName = OpenASOMCP; @@ -1143,6 +1167,7 @@ mainGroup = 99308A2E9EE083E212B3325F; minimizedProjectReferenceProxies = 1; packageReferences = ( + AD5000000000000000000003 /* XCRemoteSwiftPackageReference "apple-ads-platform-api-swift" */, A80000000000000000000007 /* XCRemoteSwiftPackageReference "posthog-ios" */, A90000000000000000000007 /* XCRemoteSwiftPackageReference "Sparkle" */, C0DEFACE000000000000010B /* XCRemoteSwiftPackageReference "swift-sdk" */, @@ -1209,6 +1234,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AD5100000000000000000001 /* AppleAds/AppleAdsPlatformService.swift in Sources */, + AD5200000000000000000001 /* AppleAdsPlatformWorkspaceView.swift in Sources */, EF3B2BA43807572789A250A7 /* AddAppSheet.swift in Sources */, F1A200000000000000000001 /* AppStoreIDInputParser.swift in Sources */, 59E573ACDBBB76A905B92574 /* AddKeywordsSheet.swift in Sources */, @@ -1409,6 +1436,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AD5300000000000000000001 /* AppleAdsPlatformServiceTests.swift in Sources */, F1A200000000000000000003 /* AppStoreIDInputParserTests.swift in Sources */, AA0000000000000000000002 /* AppleAdsConnectionStateTests.swift in Sources */, B9A0000000000000000005 /* AppleAdsPastedSessionTests.swift in Sources */, @@ -1476,6 +1504,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AD5100000000000000000002 /* AppleAds/AppleAdsPlatformService.swift in Sources */, C15A0000000000000000000A /* MCP/OpenASOMCPMain.swift in Sources */, C15A00000000000000000101 /* MCP/OpenASOMCPDTOs.swift in Sources */, C15A00000000000000000102 /* MCP/OpenASOMCPService.swift in Sources */, @@ -1881,6 +1910,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + AD5000000000000000000003 /* XCRemoteSwiftPackageReference "apple-ads-platform-api-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/apple-ads-platform-api-swift.git"; + requirement = { + kind = exactVersion; + version = 1.109.0; + }; + }; A80000000000000000000007 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PostHog/posthog-ios.git"; @@ -1908,6 +1945,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + AD5000000000000000000004 /* AppleAdsClient */ = { + isa = XCSwiftPackageProductDependency; + package = AD5000000000000000000003 /* XCRemoteSwiftPackageReference "apple-ads-platform-api-swift" */; + productName = AppleAdsClient; + }; A80000000000000000000006 /* PostHog */ = { isa = XCSwiftPackageProductDependency; package = A80000000000000000000007 /* XCRemoteSwiftPackageReference "posthog-ios" */; diff --git a/OpenASO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OpenASO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 29d6021..7df4050 100644 --- a/OpenASO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/OpenASO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "fa46dba2331f262f5f8b9d152237ce29aa10cc1f30cb33800a8c78d386208a5f", + "originHash" : "617c8e3a189e7ec2de619a2c8621478ea6b9239d8e47aa51c0e037a540bb0f1b", "pins" : [ + { + "identity" : "apple-ads-platform-api-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/apple-ads-platform-api-swift.git", + "state" : { + "revision" : "588b7b2a501f9bfe00880e5a4db095bcafa69520", + "version" : "1.109.0" + } + }, + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", @@ -28,6 +46,33 @@ "version" : "2.9.1" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -37,6 +82,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "449dbbecd0f31e82b510ada227ca152caa8b5e98", + "version" : "1.19.4" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -46,13 +100,58 @@ "version" : "1.4.1" } }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" } }, { @@ -64,6 +163,69 @@ "version" : "2.99.0" } }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "45bdf670248be5f16ec0340e125dca285536f0fb", + "version" : "1.45.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-openapi-async-http-client.git", + "state" : { + "revision" : "3681f659d9417652b8119f0adbdf57c04b6cb292", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime.git", + "state" : { + "revision" : "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb", + "version" : "1.12.0" + } + }, { "identity" : "swift-sdk", "kind" : "remoteSourceControl", @@ -73,6 +235,24 @@ "version" : "0.12.1" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/OpenASO/App/AppServices.swift b/OpenASO/App/AppServices.swift index 8cdffdf..005f4cd 100644 --- a/OpenASO/App/AppServices.swift +++ b/OpenASO/App/AppServices.swift @@ -67,6 +67,7 @@ final class AppServices { let aiService: any AIService let reviewTranslationService: ReviewTranslationService let reviewLanguageDetectionService: ReviewLanguageDetectionService + let appleAdsPlatformAPI: any AppleAdsPlatformAPI let keywordMetricsService: KeywordMetricsService let keywordInsightsService: KeywordInsightsService let keywordSuggestionService: KeywordSuggestionService @@ -91,6 +92,7 @@ final class AppServices { keychain: any KeychainService = SystemKeychainService(), namespace: AppNamespace = .current, aiService: (any AIService)? = nil, + appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? = nil, loadsEnvironmentCredentials: Bool = true, allowsIconNetworkFetches: Bool = true, backgroundModelStore: BackgroundModelStore? = nil, @@ -122,6 +124,7 @@ final class AppServices { namespace: namespace, loadsEnvironmentCredentials: loadsEnvironmentCredentials ) + let appleAdsPlatformAPI = appleAdsPlatformAPI ?? OfficialAppleAdsPlatformAPI() let settingsStore = AppSettingsStore(defaults: defaults) let backgroundRefreshAgentController = BackgroundRefreshAgentController( defaults: defaults @@ -194,7 +197,8 @@ final class AppServices { httpClient: httpClient, credentialStore: appleAdsCredentialStore, settingsStore: settingsStore, - webSessionStore: appleAdsWebSessionStore + webSessionStore: appleAdsWebSessionStore, + apiClient: appleAdsPlatformAPI ) let keywordInsightsService = KeywordInsightsService() let rankedAppPricingService = RankedAppPricingService(httpClient: httpClient) @@ -309,13 +313,8 @@ final class AppServices { metricsService: keywordMetricsService, rankingCoordinator: refreshCoordinator, configurationProvider: { - let session = appleAdsWebSessionStore.session return KeywordResearchMetricsConfiguration( - contextAppStoreID: settingsStore.popularityContextAppStoreID, - webSession: session, - requiresReconnect: session.map { - appleAdsWebSessionStore.requiresReconnect(for: $0) - } ?? false + credentials: appleAdsCredentialStore.apiCredentials ) }, reconnectMarker: { attemptedSession in @@ -415,6 +414,7 @@ final class AppServices { self.aiService = aiService self.reviewTranslationService = reviewTranslationService self.reviewLanguageDetectionService = reviewLanguageDetectionService + self.appleAdsPlatformAPI = appleAdsPlatformAPI self.keywordMetricsService = keywordMetricsService self.keywordInsightsService = keywordInsightsService self.keywordSuggestionService = KeywordSuggestionService() @@ -447,11 +447,9 @@ final class AppServices { keywordMetricsService: keywordMetricsService, rankedAppPricingService: rankedAppPricingService, visibleProductPricingService: visibleProductPricingService, - popularityContextAppStoreIDProvider: { - settingsStore.popularityContextAppStoreID - }, - appleAdsWebSessionProvider: { - appleAdsWebSessionStore.recoverSessionIfNeeded() + appleAdsPlatformAPI: appleAdsPlatformAPI, + appleAdsCredentialsProvider: { + appleAdsCredentialStore.apiCredentials } ) @@ -585,9 +583,7 @@ final class AppServices { func refreshStaleKeywordPopularityAfterAppleAdsConnection() { guard let backgroundModelStore, - let popularityContextAppStoreID = settingsStore.popularityContextAppStoreID, - let webSession = appleAdsWebSessionStore.recoverSessionIfNeeded(), - webSession.isComplete + appleAdsCredentialStore.hasCompleteAPICredentials else { return } @@ -620,8 +616,6 @@ final class AppServices { do { let result = try await keywordMetricsService.refreshMetricsBatch( for: trackIdentityKeys, - popularityContextAppStoreID: popularityContextAppStoreID, - webSession: webSession, using: backgroundModelStore, progress: { completed, total, failureCount in await refreshProgressStore.updateStep( @@ -679,7 +673,8 @@ extension AppServices { static func mocked( httpClient: HTTPClient, modelContainer: ModelContainer? = nil, - allowsIconNetworkFetches: Bool = false + allowsIconNetworkFetches: Bool = false, + appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? = nil ) -> AppServices { let backgroundModelStore = modelContainer.map { BackgroundModelStore(modelContainer: $0) @@ -694,6 +689,7 @@ extension AppServices { {"title":"Translated \(request.prompt.contains("Title:") ? "Review" : "Text")","content":"Preview translation"} """ }, + appleAdsPlatformAPI: appleAdsPlatformAPI, loadsEnvironmentCredentials: false, allowsIconNetworkFetches: allowsIconNetworkFetches, backgroundModelStore: backgroundModelStore, diff --git a/OpenASO/App/RootView.swift b/OpenASO/App/RootView.swift index 1a037c0..c0608aa 100644 --- a/OpenASO/App/RootView.swift +++ b/OpenASO/App/RootView.swift @@ -5,6 +5,7 @@ struct RootView: View { @State private var selectedApp: TrackedApp? @State private var isPresentingKeywordResearch = false + @State private var isPresentingAppleAds = false @State private var keywordResearchDetailModelCache = KeywordResearchProjectDetailModelCache() @@ -17,7 +18,14 @@ struct RootView: View { } .navigationTitle("OpenASO") .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { + Button { + isPresentingAppleAds = true + } label: { + Label("Apple Ads", systemImage: "megaphone") + } + .help("Open the Apple Ads Platform API workspace") + Button { isPresentingKeywordResearch = true } label: { @@ -31,6 +39,9 @@ struct RootView: View { detailModelCache: keywordResearchDetailModelCache ) } + .sheet(isPresented: $isPresentingAppleAds) { + AppleAdsPlatformWorkspaceView() + } .task { guard !Self.isRunningUnderTests else { return diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift index d73c9d0..b94c788 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift @@ -97,7 +97,8 @@ struct KeywordTableView: View, Equatable { modelContext: modelContext, appCatalogService: appCatalogService, appIconStore: appIconStore, - requiresAppleAdsReconnect: services.appleAdsWebSessionStore.requiresReconnect, + requiresAppleAdsReconnect: !services.appleAdsCredentialStore + .hasCompleteAPICredentials, presentRanking: presentRanking, presentRankingHistory: { presentedRankingHistoryRow = $0 }, presentNotes: { presentedNotesRow = $0 }, @@ -258,7 +259,7 @@ struct KeywordTableView: View, Equatable { } private func openAppleAdsSettings() { - services.settingsStore.requestSettingsFocus(.webSession) + services.settingsStore.requestSettingsFocus(.platformAPI) openSettings() } diff --git a/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift b/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift new file mode 100644 index 0000000..25166b5 --- /dev/null +++ b/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift @@ -0,0 +1,422 @@ +import Observation +import SwiftUI + +@MainActor +@Observable +final class AppleAdsPlatformWorkspaceModel { + enum Page: String, CaseIterable, Identifiable { + case popularity = "Search Popularity" + case campaigns = "Campaigns" + case apps = "Owned Apps" + + var id: String { rawValue } + } + + private let api: any AppleAdsPlatformAPI + private let credentialStore: AppleAdsCredentialStore + + var page = Page.popularity + var connection: AppleAdsPlatformConnection? + var selectedAdAccountID = "" + var campaigns: [AppleAdsPlatformCampaignSummary] = [] + var apps: [AppleAdsPromotedApp] = [] + var popularityRows: [AppleAdsSearchTermPopularity] = [] + var searchQuery = "" + var searchTermsText = "productivity, focus, habits" + var countryOrRegion = "US" + var recentWeekCount = 4 + var isLoading = false + var errorMessage: String? + + init(api: any AppleAdsPlatformAPI, credentialStore: AppleAdsCredentialStore) { + self.api = api + self.credentialStore = credentialStore + } + + var coverage: AppleAdsPlatformCoverage { api.coverage } + var isConfigured: Bool { credentialStore.apiCredentials.canVerify } + + func load() async { + guard !isLoading else { return } + guard isConfigured else { + errorMessage = "Complete Apple Ads Platform API setup in Settings first." + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let connection = try await api.verify(credentials: credentialStore.apiCredentials) + self.connection = connection + selectedAdAccountID = String(connection.selectedAdAccountID) + try credentialStore.saveAPICredentials( + connection.applying(to: credentialStore.apiCredentials) + ) + try await loadSelectedPage() + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + func selectAccount() async { + guard let accountID = Int64(selectedAdAccountID), accountID > 0 else { return } + var credentials = credentialStore.apiCredentials + credentials.adAccountID = String(accountID) + + do { + try credentialStore.saveAPICredentials(credentials) + await loadFresh() + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + func searchApps() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + apps = try await api.searchOwnedApps( + named: query.isEmpty ? nil : query, + using: credentialStore.apiCredentials, + limit: 100 + ) + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + func searchPopularity() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let searchTerms = searchTermsText + .components(separatedBy: CharacterSet(charactersIn: ",\n")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !searchTerms.isEmpty else { + throw OpenASOError.providerUnavailable("Enter at least one search term.") + } + popularityRows = try await api.searchTermPopularity( + for: searchTerms, + countryOrRegion: countryOrRegion, + window: .recentCompletedWeeks( + asOf: .now, + weekCount: max(1, min(recentWeekCount, 65)) + ), + using: credentialStore.apiCredentials + ) + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + func loadSelectedPage() async throws { + switch page { + case .popularity: + break + case .campaigns: + campaigns = try await api.listCampaigns( + using: credentialStore.apiCredentials, + limit: 100 + ) + case .apps: + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + apps = try await api.searchOwnedApps( + named: query.isEmpty ? nil : query, + using: credentialStore.apiCredentials, + limit: 100 + ) + } + } + + func pageDidChange() async { + guard isConfigured, !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + try await loadSelectedPage() + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + private func loadFresh() async { + connection = nil + campaigns = [] + apps = [] + popularityRows = [] + await load() + } +} + +struct AppleAdsPlatformWorkspaceView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AppServices.self) private var services + + var body: some View { + AppleAdsPlatformWorkspaceContent( + model: AppleAdsPlatformWorkspaceModel( + api: services.appleAdsPlatformAPI, + credentialStore: services.appleAdsCredentialStore + ) + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + } +} + +private struct AppleAdsPlatformWorkspaceContent: View { + @State private var model: AppleAdsPlatformWorkspaceModel + + init(model: AppleAdsPlatformWorkspaceModel) { + _model = State(initialValue: model) + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + coverageHeader + Divider() + controls + Divider() + pageContent + } + .navigationTitle("Apple Ads") + .frame(minWidth: 820, minHeight: 620) + .task { await model.load() } + .onChange(of: model.page) { _, _ in + Task { await model.pageDidChange() } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await model.load() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(model.isLoading || !model.isConfigured) + } + } + } + } + + private var coverageHeader: some View { + HStack(spacing: 24) { + Label("Official Apple Swift client", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + LabeledContent("Client", value: model.coverage.clientVersion) + LabeledContent("Generated operations", value: String(model.coverage.operationCount)) + Spacer() + Text(model.coverage.baseURL) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(16) + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Picker("Workspace", selection: $model.page) { + ForEach(AppleAdsPlatformWorkspaceModel.Page.allCases) { page in + Text(page.rawValue).tag(page) + } + } + .pickerStyle(.segmented) + .frame(width: 460) + + Spacer() + + if let accounts = model.connection?.accounts, !accounts.isEmpty { + Picker("Ad Account", selection: $model.selectedAdAccountID) { + ForEach(accounts) { account in + Text("\(account.name) · \(account.id)") + .tag(String(account.id)) + } + } + .frame(maxWidth: 340) + .onChange(of: model.selectedAdAccountID) { _, _ in + Task { await model.selectAccount() } + } + } + } + + if model.page == .popularity { + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField( + "Search terms separated by commas", + text: $model.searchTermsText, + axis: .vertical + ) + .lineLimit(1...3) + TextField("Country", text: $model.countryOrRegion) + .frame(width: 90) + Stepper( + "\(model.recentWeekCount) weeks", + value: $model.recentWeekCount, + in: 1...65 + ) + .frame(width: 150) + Button("Query") { Task { await model.searchPopularity() } } + .disabled(model.isLoading) + } + Text("Uses Apple's public weekly Search Term Popularity dataset. Terms below Apple's eligibility thresholds may not be returned.") + .font(.caption) + .foregroundStyle(.secondary) + } + } else if model.page == .apps { + HStack { + TextField("Search owned apps (3+ characters, or leave empty)", text: $model.searchQuery) + .onSubmit { Task { await model.searchApps() } } + Button("Search") { Task { await model.searchApps() } } + .disabled( + model.isLoading + || (!model.searchQuery.isEmpty && model.searchQuery.count < 3) + ) + } + } + + if let errorMessage = model.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.caption) + } + } + .padding(16) + } + + @ViewBuilder + private var pageContent: some View { + if model.isLoading && model.campaigns.isEmpty && model.apps.isEmpty { + Spacer() + ProgressView("Loading Apple Ads…") + Spacer() + } else if !model.isConfigured { + ContentUnavailableView( + "Apple Ads Setup Required", + systemImage: "key.horizontal", + description: Text("Open Settings and complete the guided Apple Ads Platform API setup.") + ) + } else { + switch model.page { + case .popularity: + popularityList + case .campaigns: + campaignList + case .apps: + appList + } + } + } + + private var popularityList: some View { + Table(model.popularityRows) { + TableColumn("Search Term", value: \.searchTerm) + TableColumn("Country", value: \.countryOrRegion) + TableColumn("Week") { row in + Text(row.week ?? row.month ?? "—") + .monospacedDigit() + } + TableColumn("Global") { row in + Text(row.popularity1to100.map(String.init) ?? "—") + .monospacedDigit() + } + TableColumn("1–5") { row in + Text(row.popularity1to5.map(String.init) ?? "—") + .monospacedDigit() + } + TableColumn("Genre", value: \.genre) + TableColumn("Genre Score") { row in + Text(row.popularityInGenre.map(String.init) ?? "—") + .monospacedDigit() + } + TableColumn("Genre Rank") { row in + Text(row.rankInGenre.map(String.init) ?? "—") + .monospacedDigit() + } + } + .overlay { + if model.popularityRows.isEmpty { + ContentUnavailableView( + "Query Search Popularity", + systemImage: "chart.bar.xaxis", + description: Text("Enter search terms and a country or region, then query Apple's public dataset.") + ) + } + } + } + + private var campaignList: some View { + List(model.campaigns) { campaign in + HStack(spacing: 12) { + Image(systemName: "megaphone") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(campaign.name) + .font(.headline) + Text("Campaign ID \(campaign.id)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Text(campaign.displayStatus ?? campaign.status ?? "Unknown") + .foregroundStyle(.secondary) + if let modifiedAt = campaign.modifiedAt { + Text(modifiedAt, style: .date) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + .overlay { + if model.campaigns.isEmpty { + ContentUnavailableView("No Campaigns", systemImage: "megaphone") + } + } + } + + private var appList: some View { + List(model.apps, id: \.adamId) { app in + HStack(spacing: 12) { + Image(systemName: "app") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(app.appName) + .font(.headline) + Text(app.developerName) + .foregroundStyle(.secondary) + } + Spacer() + Text(String(app.adamId)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + .overlay { + if model.apps.isEmpty { + ContentUnavailableView( + "Search Owned Apps", + systemImage: "app.badge", + description: Text("Run an empty search to list owned apps, or enter at least three characters.") + ) + } + } + } +} diff --git a/OpenASO/Features/KeywordResearch/KeywordResearchProjectWorkspaceView.swift b/OpenASO/Features/KeywordResearch/KeywordResearchProjectWorkspaceView.swift index b08ee8d..5a82c2f 100644 --- a/OpenASO/Features/KeywordResearch/KeywordResearchProjectWorkspaceView.swift +++ b/OpenASO/Features/KeywordResearch/KeywordResearchProjectWorkspaceView.swift @@ -230,7 +230,7 @@ struct KeywordResearchProjectWorkspaceView: View { } }, openSettings: { - services.settingsStore.requestSettingsFocus(.webSession) + services.settingsStore.requestSettingsFocus(.platformAPI) openSettings() }, showHistory: { diff --git a/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift b/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift index 80057bc..ed8d0f9 100644 --- a/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift +++ b/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift @@ -2,6 +2,7 @@ import SwiftUI enum AppleAdsSettingsFocusSection { case dailyRefresh + case platformAPI case webSession case appStoreConnect case analytics @@ -10,6 +11,8 @@ enum AppleAdsSettingsFocusSection { switch self { case .dailyRefresh: return "daily_refresh" + case .platformAPI: + return "apple_ads_platform_api" case .webSession: return "apple_ads" case .appStoreConnect: diff --git a/OpenASO/Features/Settings/SettingsView.swift b/OpenASO/Features/Settings/SettingsView.swift index 8136986..92b06d0 100644 --- a/OpenASO/Features/Settings/SettingsView.swift +++ b/OpenASO/Features/Settings/SettingsView.swift @@ -16,6 +16,9 @@ struct SettingsView: View { @State private var keyID = "" @State private var privateKey = "" @State private var orgID = "" + @State private var adAccountID = "" + @State private var appleAdsPlatformStatus: VerificationStatus? + @State private var isVerifyingAppleAdsPlatform = false @State private var dailyRefreshTime = Date() @State private var webLoginUsername = "" @State private var webLoginPassword = "" @@ -88,8 +91,8 @@ struct SettingsView: View { mcpSection - appleAdsSection - .id(AppleAdsSettingsFocusSection.webSession) + appleAdsPlatformSection + .id(AppleAdsSettingsFocusSection.platformAPI) appStoreConnectSection .id(AppleAdsSettingsFocusSection.appStoreConnect) @@ -116,12 +119,10 @@ struct SettingsView: View { isEnabled: services.settingsStore.isAutomaticRefreshEnabled ) } - _ = services.appleAdsWebSessionStore.recoverSessionIfNeeded() loadCredentials() loadAppStoreConnectCredentials() loadDailyRefreshTime() loadWebLoginCredentials() - services.appleAdsWebSessionManager.purgeLegacyBrowserHelperArtifacts() if initialConnectionState == nil { connectionState = inferredConnectionState() } @@ -131,8 +132,8 @@ struct SettingsView: View { proxy.scrollTo(targetFocusSection, anchor: .top) services.settingsStore.clearSettingsFocusRequest() } - if validatesOnAppear { - validateAppleAdsAccess() + if validatesOnAppear, enteredCredentials.canVerify { + verifyAndSaveAppleAdsPlatformCredentials() } services.analyticsService.capture(.settingsOpened(focusSection: targetFocusSection?.analyticsValue ?? "none")) } @@ -141,10 +142,6 @@ struct SettingsView: View { proxy.scrollTo(requestedSection, anchor: .top) services.settingsStore.clearSettingsFocusRequest() } - .onChange(of: services.appleAdsWebSessionStore.requiresReconnect) { _, _ in - guard !connectionState.isBusy else { return } - connectionState = inferredConnectionState() - } } } @@ -310,6 +307,88 @@ struct SettingsView: View { } } + private var appleAdsPlatformSection: some View { + Section { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "apple.logo") + .imageScale(.large) + .frame(width: 20) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text("Official Swift client") + .font(.headline) + Text("Version \(services.appleAdsPlatformAPI.coverage.clientVersion) · \(services.appleAdsPlatformAPI.coverage.operationCount) generated operations") + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + + Text("1. Enter the OAuth client identifier Apple issued for Apple Ads.") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Client ID", text: $clientID) + .textContentType(.username) + + Text("2. Add the team and key identifiers for the matching private key.") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Team ID", text: $teamID) + TextField("Key ID", text: $keyID) + + Text("3. Paste the complete .p8 private key. It is stored only in macOS Keychain.") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Private Key (.p8 PEM)", text: $privateKey, axis: .vertical) + .lineLimit(3...8) + .font(.system(.caption, design: .monospaced)) + .privacySensitive() + + Text("4. Verify to discover your accessible ad accounts. Leave Ad Account ID empty to select the first available account.") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Ad Account ID (optional before verification)", text: $adAccountID) + + HStack(spacing: 10) { + Button("Verify & Save", action: verifyAndSaveAppleAdsPlatformCredentials) + .disabled(isVerifyingAppleAdsPlatform || !enteredCredentials.canVerify) + + if isVerifyingAppleAdsPlatform { + ProgressView() + .controlSize(.small) + } + + Spacer() + + Button( + "Clear API Credentials", + role: .destructive, + action: clearAppleAdsPlatformCredentials + ) + .disabled( + isVerifyingAppleAdsPlatform + || !services.appleAdsCredentialStore.hasCompleteAPICredentials + ) + } + + if let appleAdsPlatformStatus { + Label(appleAdsPlatformStatus.message, systemImage: appleAdsPlatformStatus.systemImage) + .foregroundStyle(appleAdsPlatformStatus.tint) + .font(.caption) + } + + Link( + "Apple Ads Platform API setup documentation", + destination: URL(string: "https://developer.apple.com/documentation/apple-ads-platform-api")! + ) + .font(.caption) + } header: { + Text("Apple Ads Platform API") + } footer: { + Text("This connection powers campaign and owned-app management plus public Search Term Popularity data through the UI and MCP. Browser cookies are no longer required for popularity refreshes.") + } + } + private var savedLoginControls: some View { DisclosureGroup(isExpanded: $isSavedLoginExpanded) { VStack(alignment: .leading, spacing: 10) { @@ -601,7 +680,8 @@ struct SettingsView: View { teamID: teamID, keyID: keyID, privateKey: privateKey, - orgID: orgID + orgID: orgID, + adAccountID: adAccountID ) } @@ -687,6 +767,44 @@ struct SettingsView: View { keyID = credentials.keyID privateKey = credentials.privateKey orgID = credentials.orgID + adAccountID = credentials.adAccountID + if credentials.isComplete, appleAdsPlatformStatus == nil { + appleAdsPlatformStatus = .success("Credentials are stored. Verify again to confirm live access.") + } + } + + private func verifyAndSaveAppleAdsPlatformCredentials() { + isVerifyingAppleAdsPlatform = true + appleAdsPlatformStatus = nil + + Task { @MainActor in + defer { isVerifyingAppleAdsPlatform = false } + do { + let connection = try await services.appleAdsPlatformAPI.verify( + credentials: enteredCredentials + ) + let verifiedCredentials = connection.applying(to: enteredCredentials) + try services.appleAdsCredentialStore.saveAPICredentials(verifiedCredentials) + loadCredentials() + let account = connection.accounts.first { + $0.id == connection.selectedAdAccountID + } + appleAdsPlatformStatus = .success( + "Connected to \(account?.name ?? "ad account \(connection.selectedAdAccountID)")." + ) + services.refreshStaleKeywordPopularityAfterAppleAdsConnection() + } catch { + appleAdsPlatformStatus = .failure( + OpenASOError.map(error).localizedDescription + ) + } + } + } + + private func clearAppleAdsPlatformCredentials() { + services.appleAdsCredentialStore.clearAPICredentials() + loadCredentials() + appleAdsPlatformStatus = .success("Apple Ads Platform API credentials cleared.") } private func loadAppStoreConnectCredentials() { diff --git a/OpenASO/Services/AppDetail/AppDetailRefreshService.swift b/OpenASO/Services/AppDetail/AppDetailRefreshService.swift index e202c87..db2a8c8 100644 --- a/OpenASO/Services/AppDetail/AppDetailRefreshService.swift +++ b/OpenASO/Services/AppDetail/AppDetailRefreshService.swift @@ -451,8 +451,6 @@ final class AppDetailRefreshService: Sendable { try Task.checkCancellation() let metricResult = try await keywordMetricsService.refreshMetricsBatch( for: rankingRequests.map(\.identityKey), - popularityContextAppStoreID: request.popularityContextAppStoreID, - webSession: request.appleAdsWebSession, using: backgroundModelStore, progress: { completed, total, failureCount in await self.progressStore?.updateStep( diff --git a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift index 074d6d3..5987339 100644 --- a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift +++ b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift @@ -7,6 +7,23 @@ struct AppleAdsCredentials: Equatable, Sendable { var keyID: String var privateKey: String var orgID: String + var adAccountID: String + + init( + clientID: String, + teamID: String, + keyID: String, + privateKey: String, + orgID: String = "", + adAccountID: String = "" + ) { + self.clientID = clientID + self.teamID = teamID + self.keyID = keyID + self.privateKey = privateKey + self.orgID = orgID + self.adAccountID = adAccountID + } var trimmed: AppleAdsCredentials { AppleAdsCredentials( @@ -14,7 +31,8 @@ struct AppleAdsCredentials: Equatable, Sendable { teamID: teamID.trimmingCharacters(in: .whitespacesAndNewlines), keyID: keyID.trimmingCharacters(in: .whitespacesAndNewlines), privateKey: privateKey.trimmingCharacters(in: .whitespacesAndNewlines), - orgID: orgID.trimmingCharacters(in: .whitespacesAndNewlines) + orgID: orgID.trimmingCharacters(in: .whitespacesAndNewlines), + adAccountID: adAccountID.trimmingCharacters(in: .whitespacesAndNewlines) ) } @@ -24,7 +42,7 @@ struct AppleAdsCredentials: Equatable, Sendable { && !credentials.teamID.isEmpty && !credentials.keyID.isEmpty && !credentials.privateKey.isEmpty - && !credentials.orgID.isEmpty + && !credentials.adAccountID.isEmpty } var canVerify: Bool { @@ -44,6 +62,7 @@ final class AppleAdsCredentialStore { static let teamID = "appleAds.teamID" static let keyID = "appleAds.keyID" static let orgID = "appleAds.orgID" + static let adAccountID = "appleAds.adAccountID" } private let defaults: UserDefaults @@ -77,7 +96,8 @@ final class AppleAdsCredentialStore { privateKey: keychainItemPresence.contains(service: keychainService, account: privateKeyAccount) ? Self.readSecret(service: keychainService, account: privateKeyAccount, keychain: keychain) ?? environment.privateKey : environment.privateKey, - orgID: defaults.string(forKey: DefaultsKey.orgID) ?? environment.orgID + orgID: defaults.string(forKey: DefaultsKey.orgID) ?? environment.orgID, + adAccountID: defaults.string(forKey: DefaultsKey.adAccountID) ?? environment.adAccountID ) self.webLoginCredentials = keychainItemPresence.contains(service: webLoginKeychainService, account: webLoginCredentialsAccount) ? Self.readWebLoginCredentials( @@ -102,6 +122,7 @@ final class AppleAdsCredentialStore { defaults.set(trimmedCredentials.teamID, forKey: DefaultsKey.teamID) defaults.set(trimmedCredentials.keyID, forKey: DefaultsKey.keyID) defaults.set(trimmedCredentials.orgID, forKey: DefaultsKey.orgID) + defaults.set(trimmedCredentials.adAccountID, forKey: DefaultsKey.adAccountID) try Self.saveSecret( trimmedCredentials.privateKey, service: keychainService, @@ -117,9 +138,10 @@ final class AppleAdsCredentialStore { defaults.removeObject(forKey: DefaultsKey.teamID) defaults.removeObject(forKey: DefaultsKey.keyID) defaults.removeObject(forKey: DefaultsKey.orgID) + defaults.removeObject(forKey: DefaultsKey.adAccountID) keychain.delete(service: keychainService, account: privateKeyAccount) keychainItemPresence.markAbsent(service: keychainService, account: privateKeyAccount) - apiCredentials = AppleAdsCredentials(clientID: "", teamID: "", keyID: "", privateKey: "", orgID: "") + apiCredentials = AppleAdsCredentials(clientID: "", teamID: "", keyID: "", privateKey: "") } func saveWebLoginCredentials(_ credentials: AppleAdsWebLoginCredentials) throws { @@ -179,6 +201,7 @@ private struct EnvironmentAppleAdsCredentials { var keyID = "" var privateKey = "" var orgID = "" + var adAccountID = "" static func load() -> EnvironmentAppleAdsCredentials { let environment = ProcessInfo.processInfo.environment @@ -187,7 +210,11 @@ private struct EnvironmentAppleAdsCredentials { teamID: value(for: ["APPLE_SEARCH_ADS_TEAM_ID", "teamId"], environment: environment), keyID: value(for: ["APPLE_SEARCH_ADS_KEY_ID", "keyId"], environment: environment), privateKey: value(for: ["APPLE_SEARCH_ADS_PRIVATE_KEY", "privateKey"], environment: environment), - orgID: value(for: ["APPLE_SEARCH_ADS_ORG_ID", "orgId"], environment: environment) + orgID: value(for: ["APPLE_SEARCH_ADS_ORG_ID", "orgId"], environment: environment), + adAccountID: value( + for: ["APPLE_ADS_PLATFORM_AD_ACCOUNT_ID", "APPLE_SEARCH_ADS_AD_ACCOUNT_ID", "adAccountId"], + environment: environment + ) ) } diff --git a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift new file mode 100644 index 0000000..8d8d5a1 --- /dev/null +++ b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift @@ -0,0 +1,474 @@ +import AppleAdsClient +import Foundation +import OpenAPIRuntime + +struct AppleAdsPlatformAccount: Codable, Equatable, Identifiable, Sendable { + let id: Int64 + let name: String + let orgID: Int64? + let roles: [String] +} + +struct AppleAdsPlatformConnection: Codable, Equatable, Sendable { + let userID: Int64? + let orgID: Int64? + let accounts: [AppleAdsPlatformAccount] + let selectedAdAccountID: Int64 + + func applying(to credentials: AppleAdsCredentials) -> AppleAdsCredentials { + AppleAdsCredentials( + clientID: credentials.clientID, + teamID: credentials.teamID, + keyID: credentials.keyID, + privateKey: credentials.privateKey, + orgID: orgID.map(String.init) ?? credentials.orgID, + adAccountID: String(selectedAdAccountID) + ) + } +} + +struct AppleAdsPlatformCampaignSummary: Codable, Equatable, Identifiable, Sendable { + let id: Int64 + let name: String + let status: String? + let displayStatus: String? + let promotedObjectID: String? + let modifiedAt: Date? +} + +struct AppleAdsSearchTermPopularity: Codable, Equatable, Identifiable, Sendable { + let searchTerm: String + let countryOrRegion: String + let genre: String + let week: String? + let month: String? + let rankInGenre: Int? + let popularityInGenre: Int? + let popularity1to100: Int? + let popularity1to5: Int? + + var id: String { + [countryOrRegion, normalizedSearchTerm, week ?? month ?? "", genre] + .joined(separator: "|") + } + + var normalizedSearchTerm: String { + Self.normalized(searchTerm) + } + + static func normalized(_ searchTerm: String) -> String { + searchTerm.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +struct AppleAdsSearchTermPopularityWindow: Codable, Equatable, Sendable { + let start: String + let end: String + + static func recentCompletedWeeks( + asOf date: Date, + weekCount: Int = 4 + ) -> AppleAdsSearchTermPopularityWindow { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let startOfToday = calendar.startOfDay(for: date) + let weekday = calendar.component(.weekday, from: startOfToday) + let daysSinceCompletedSaturday = weekday == 7 ? 7 : weekday + let end = calendar.date( + byAdding: .day, + value: -daysSinceCompletedSaturday, + to: startOfToday + )! + let start = calendar.date( + byAdding: .day, + value: -(max(1, weekCount) * 7 - 1), + to: end + )! + + return AppleAdsSearchTermPopularityWindow( + start: Self.dateString(start, calendar: calendar), + end: Self.dateString(end, calendar: calendar) + ) + } + + private static func dateString(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0 + ) + } +} + +struct AppleAdsPlatformCoverage: Codable, Equatable, Sendable { + struct Family: Codable, Equatable, Identifiable, Sendable { + let id: String + let title: String + let operationCount: Int + } + + let clientVersion: String + let baseURL: String + let operationCount: Int + let families: [Family] + + static let current = AppleAdsPlatformCoverage( + clientVersion: "1.109.0", + baseURL: "https://api.ads.apple.com/v1", + operationCount: 99, + families: [ + Family(id: "accounts", title: "Accounts & access", operationCount: 8), + Family(id: "apps", title: "Apps & eligibility", operationCount: 6), + Family(id: "maps", title: "Maps brands & locations", operationCount: 14), + Family(id: "campaigns", title: "Campaigns & ad groups", operationCount: 13), + Family(id: "targeting", title: "Keywords & targeting", operationCount: 17), + Family(id: "creative", title: "Ads, creatives & assets", operationCount: 15), + Family(id: "reporting", title: "Reports & impression share", operationCount: 12), + Family(id: "optimization", title: "Insights, recommendations & suggestions", operationCount: 14), + ] + ) +} + +protocol AppleAdsPlatformAPI: Sendable { + var coverage: AppleAdsPlatformCoverage { get } + + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection + + func searchOwnedApps( + named query: String?, + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPromotedApp] + + func listCampaigns( + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPlatformCampaignSummary] + + func searchTermPopularity( + for searchTerms: [String], + countryOrRegion: String, + window: AppleAdsSearchTermPopularityWindow, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsSearchTermPopularity] +} + +struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { + let coverage = AppleAdsPlatformCoverage.current + + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { + let credentials = try validated(credentials) + + return try await perform(credentials: credentials) { client in + let mePayload = try await client.getMe().ok.body.json + let aclPayload = try await client.getUserAcls().ok.body.json + let accounts = (aclPayload.result?.acls ?? []).compactMap { acl -> AppleAdsPlatformAccount? in + guard let account = acl.adAccount, let id = account.id else { return nil } + return AppleAdsPlatformAccount( + id: id, + name: account.name?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? "Ad account \(id)", + orgID: account.orgId, + roles: (acl.roles ?? []).sorted() + ) + } + .sorted { left, right in + left.name.localizedCaseInsensitiveCompare(right.name) == .orderedAscending + } + + guard !accounts.isEmpty else { + throw OpenASOError.providerUnavailable( + "Apple Ads credentials were accepted, but no accessible ad accounts were returned." + ) + } + + let requestedAccountID = Int64(credentials.adAccountID) + let selectedAccountID = accounts.first(where: { $0.id == requestedAccountID })?.id + ?? accounts[0].id + + return AppleAdsPlatformConnection( + userID: mePayload.result?.userId, + orgID: mePayload.result?.orgId ?? accounts.first?.orgID, + accounts: accounts, + selectedAdAccountID: selectedAccountID + ) + } + } + + func searchOwnedApps( + named query: String?, + using credentials: AppleAdsCredentials, + limit: Int = 50 + ) async throws -> [AppleAdsPromotedApp] { + let credentials = try validated(credentials) + let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalizedQuery, !normalizedQuery.isEmpty, normalizedQuery.count < 3 { + throw OpenASOError.providerUnavailable( + "Enter at least three characters to search Apple Ads apps." + ) + } + let resolvedCredentials = try await credentialsWithAccount(credentials) + let accountID = try requiredAdAccountID(from: resolvedCredentials) + let pageLimit = Int32(max(1, min(limit, 1_000))) + + return try await perform(credentials: resolvedCredentials) { client in + let output = try await client.searchApps( + query: .init( + query: normalizedQuery?.nilIfEmpty, + returnOwnedApps: true, + limit: pageLimit + ), + headers: .init(xApContext: XApContext(adAccountID: accountID).rawValue) + ) + let payload = try output.ok.body.json + return payload.value2.result.map { + AppleAdsPromotedApp( + adamId: $0.adamId, + appName: $0.appName, + developerName: $0.developerName, + countryOrRegionCodes: $0.countryOrRegionCodes + ) + } + } + } + + func listCampaigns( + using credentials: AppleAdsCredentials, + limit: Int = 100 + ) async throws -> [AppleAdsPlatformCampaignSummary] { + let credentials = try await credentialsWithAccount(try validated(credentials)) + let accountID = try requiredAdAccountID(from: credentials) + let pageSize = Int32(max(1, min(limit, 1_000))) + + return try await perform(credentials: credentials) { client in + let output = try await client.postCampaignsQuery( + headers: .init(xApContext: XApContext(adAccountID: accountID).rawValue), + body: .json(.init( + sorting: [.init(field: "modificationTime", order: .desc)], + pagination: .init(pageSize: pageSize, offset: 0, fetchTotalCount: true) + )) + ) + let payload = try output.ok.body.json + let encodedObjects = try JSONEncoder().encode(payload.value1.result ?? []) + let campaigns = try JSONDecoder().decode( + [Components.Schemas.Campaign].self, + from: encodedObjects + ) + return campaigns.compactMap { campaign in + guard let id = campaign.id else { return nil } + return AppleAdsPlatformCampaignSummary( + id: id, + name: campaign.name?.nilIfEmpty ?? "Campaign \(id)", + status: campaign.status?.value1.rawValue, + displayStatus: campaign.displayStatus?.value1.rawValue, + promotedObjectID: campaign.promotedObjectId, + modifiedAt: campaign.modificationTime + ) + } + } + } + + func searchTermPopularity( + for searchTerms: [String], + countryOrRegion: String, + window: AppleAdsSearchTermPopularityWindow, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsSearchTermPopularity] { + let credentials = try await credentialsWithAccount(try validated(credentials)) + let accountID = try requiredAdAccountID(from: credentials) + let countryCode = countryOrRegion + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + guard countryCode.count == 2 else { + throw OpenASOError.providerUnavailable( + "Enter a two-letter Apple Ads country or region code." + ) + } + + let terms = Self.uniqueSearchTerms(searchTerms) + guard !terms.isEmpty else { return [] } + + return try await perform(credentials: credentials) { client in + var allRows: [AppleAdsSearchTermPopularity] = [] + for batch in terms.chunkedForAppleAds(maximumCount: 100) { + try Task.checkCancellation() + var offset = 0 + let pageSize = 5_000 + + while true { + try Task.checkCancellation() + let output = try await client.searchTermPopularityQuery( + .init( + headers: .init( + xApContext: XApContext(adAccountID: accountID).rawValue + ), + body: .json(.init( + filters: [ + .init( + field: "countryOrRegion", + _operator: .equals, + value: try OpenAPIValueContainer( + unvalidatedValue: countryCode + ) + ), + .init( + field: "searchTerm", + _operator: ._in, + value: try OpenAPIValueContainer( + unvalidatedValue: batch + ) + ), + ], + sorting: [ + .init(field: "week", order: .desc), + .init(field: "rankInGenre", order: .asc), + ], + timeRange: .init( + start: window.start, + end: window.end, + timeZone: .utc, + granularity: .weeklySunSat + ), + pagination: .init(offset: offset, pageSize: pageSize) + )) + ) + ) + let payload = try output.ok.body.json + let rows = payload.result?.rows ?? [] + allRows.append(contentsOf: rows.compactMap { row in + guard let searchTerm = row.searchTerm?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !searchTerm.isEmpty, + let returnedCountry = row.countryOrRegion?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !returnedCountry.isEmpty + else { return nil } + + return AppleAdsSearchTermPopularity( + searchTerm: searchTerm, + countryOrRegion: returnedCountry.uppercased(), + genre: row.genre?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "", + week: row.week, + month: row.month, + rankInGenre: row.rankInGenre, + popularityInGenre: row.searchPopularityInGenre, + popularity1to100: row.searchPopularity1to100, + popularity1to5: row.searchPopularity1to5 + ) + }) + + let totalCount = payload.pagination?.totalCount ?? rows.count + offset += rows.count + if rows.isEmpty || offset >= totalCount || rows.count < pageSize { + break + } + } + } + + return allRows.sorted(by: Self.searchTermPopularityOrdering) + } + } + + private func credentialsWithAccount( + _ credentials: AppleAdsCredentials + ) async throws -> AppleAdsCredentials { + if Int64(credentials.adAccountID) != nil { + return credentials + } + return try await verify(credentials: credentials).applying(to: credentials) + } + + private func requiredAdAccountID(from credentials: AppleAdsCredentials) throws -> Int64 { + guard let accountID = Int64(credentials.adAccountID), accountID > 0 else { + throw OpenASOError.providerUnavailable( + "Verify Apple Ads Platform credentials to select an ad account." + ) + } + return accountID + } + + private func validated(_ credentials: AppleAdsCredentials) throws -> AppleAdsCredentials { + let credentials = credentials.trimmed + guard credentials.canVerify else { + throw OpenASOError.providerUnavailable( + "Enter the Apple Ads client ID, team ID, key ID, and private key." + ) + } + return credentials + } + + private func perform( + credentials: AppleAdsCredentials, + operation: (Client) async throws -> Result + ) async throws -> Result { + let configuration = AppleAdsClient.Configuration( + clientId: credentials.clientID, + authMode: .key( + teamId: credentials.teamID, + keyId: credentials.keyID, + privateKeyPEM: credentials.privateKey + ) + ) + + do { + return try await AppleAdsClient.withClient( + configuration: configuration, + body: operation + ) + } catch let error as OpenASOError { + throw error + } catch { + throw OpenASOError.providerUnavailable( + "Apple Ads Platform API: \(error.localizedDescription)" + ) + } + } + + private static func uniqueSearchTerms(_ searchTerms: [String]) -> [String] { + var seen: Set = [] + return searchTerms.compactMap { searchTerm in + let trimmed = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = AppleAdsSearchTermPopularity.normalized(trimmed) + guard seen.insert(normalized).inserted else { return nil } + return trimmed + } + } + + private static func searchTermPopularityOrdering( + _ left: AppleAdsSearchTermPopularity, + _ right: AppleAdsSearchTermPopularity + ) -> Bool { + if left.countryOrRegion != right.countryOrRegion { + return left.countryOrRegion < right.countryOrRegion + } + if left.normalizedSearchTerm != right.normalizedSearchTerm { + return left.normalizedSearchTerm < right.normalizedSearchTerm + } + if left.week != right.week { + return (left.week ?? "") > (right.week ?? "") + } + if left.genre != right.genre { + return left.genre < right.genre + } + return (left.rankInGenre ?? .max) < (right.rankInGenre ?? .max) + } +} + +private extension Array { + func chunkedForAppleAds(maximumCount: Int) -> [[Element]] { + guard maximumCount > 0 else { return [self] } + return stride(from: 0, to: count, by: maximumCount).map { + Array(self[$0 ..< Swift.min($0 + maximumCount, count)]) + } + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index 2324e9b..ec5ba6e 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -3,11 +3,8 @@ import OSLog import SwiftData final class KeywordMetricsService: Sendable { - private let httpClient: HTTPClient - private let apiClient: AppleAdsAPIClient - @MainActor private let popularityClient: AppleAdsPopularityClient - @MainActor private let settingsStore: AppSettingsStore - @MainActor private let webSessionStore: AppleAdsWebSessionStore + private let apiClient: any AppleAdsPlatformAPI + @MainActor private let credentialStore: AppleAdsCredentialStore private let freshnessFetchObserver: @Sendable (_ queryKeyCount: Int) -> Void private let bulkFreshnessFetchHook: @Sendable () throws -> Void private let metricsTTL: TimeInterval = 60 * 60 * 24 * 7 @@ -18,31 +15,38 @@ final class KeywordMetricsService: Sendable { credentialStore: AppleAdsCredentialStore, settingsStore: AppSettingsStore, webSessionStore: AppleAdsWebSessionStore, + apiClient: any AppleAdsPlatformAPI = OfficialAppleAdsPlatformAPI(), freshnessFetchObserver: @escaping @Sendable (_ queryKeyCount: Int) -> Void = { _ in }, bulkFreshnessFetchHook: @escaping @Sendable () throws -> Void = {} ) { - self.httpClient = httpClient - self.apiClient = AppleAdsAPIClient(httpClient: httpClient) - self.settingsStore = settingsStore - self.webSessionStore = webSessionStore + self.apiClient = apiClient + self.credentialStore = credentialStore self.freshnessFetchObserver = freshnessFetchObserver self.bulkFreshnessFetchHook = bulkFreshnessFetchHook - self.popularityClient = AppleAdsPopularityClient( - httpClient: httpClient, - webSessionStore: webSessionStore - ) + _ = httpClient + _ = settingsStore + _ = webSessionStore } func verifyAppleAdsCredentials(_ credentials: AppleAdsCredentials) async throws -> AppleAdsCredentials { - try await apiClient.verify(credentials: credentials) + try await apiClient.verify(credentials: credentials).applying(to: credentials) } func searchAppleAdsApps(named query: String, using credentials: AppleAdsCredentials) async throws -> [AppleAdsPromotedApp] { - try await apiClient.searchOwnedApps(named: query, using: credentials) + try await apiClient.searchOwnedApps(named: query, using: credentials, limit: 50) } func resolveDefaultAppleAdsApp(using credentials: AppleAdsCredentials) async throws -> AppleAdsPromotedApp { - try await apiClient.resolveDefaultOwnedApp(using: credentials) + guard let app = try await apiClient.searchOwnedApps( + named: nil, + using: credentials, + limit: 1 + ).first else { + throw OpenASOError.providerUnavailable( + "Apple Ads needs access to at least one of your App Store apps." + ) + } + return app } func metricsMap(for queryKeys: [String], in modelContext: ModelContext) throws -> [String: KeywordDailyMetric] { @@ -65,24 +69,36 @@ final class KeywordMetricsService: Sendable { /// Query keys are opaque identities supplied by the owning workflow; that /// workflow revalidates their persisted scalars after this suspension and /// calls `persistPopularityMetrics(_:in:)` inside its own transaction. + func fetchPopularityMetrics( + for targets: [KeywordResearchTarget], + now: @Sendable () -> Date = { Date() } + ) async throws -> [KeywordPopularityMetricEvidence] { + try await fetchOfficialPopularityMetrics(for: targets, now: now) + } + + /// Compatibility entry point for callers that still carry the retired + /// browser-session context in their refresh request. func fetchPopularityMetrics( for targets: [KeywordResearchTarget], contextAppStoreID: Int64, webSession: AppleAdsWebSession, now: @Sendable () -> Date = { Date() } + ) async throws -> [KeywordPopularityMetricEvidence] { + _ = contextAppStoreID + _ = webSession + return try await fetchOfficialPopularityMetrics(for: targets, now: now) + } + + private func fetchOfficialPopularityMetrics( + for targets: [KeywordResearchTarget], + now: @Sendable () -> Date ) async throws -> [KeywordPopularityMetricEvidence] { try Task.checkCancellation() - guard contextAppStoreID > 0 else { - throw OpenASOError.invalidAppStoreID - } - guard webSession.isComplete else { - throw AppleAdsWebSessionExpiredError() - } let orderedTargets = Self.orderedUniquePopularityTargets(targets) guard !orderedTargets.isEmpty else { return [] } + let credentials = try await requireAppleAdsCredentials() - let popularityClient = AppleAdsCMPopularityClient(httpClient: httpClient) var popularityByQueryKey: [String: Int] = [:] let targetsByStorefront = Dictionary(grouping: orderedTargets, by: \.storefront) @@ -90,20 +106,16 @@ final class KeywordMetricsService: Sendable { for storefront in targetsByStorefront.keys.sorted() { guard let storefrontTargets = targetsByStorefront[storefront] else { continue } try Task.checkCancellation() - // The shared CM client performs deterministic sequential - // chunks of at most `maxTermsPerRequest` terms. - let popularities = try await popularityClient.keywordPopularities( + let popularities = try await fetchOfficialPopularityScores( for: storefrontTargets.map(\.term), - storefrontCode: storefront, - adamId: contextAppStoreID, - session: webSession + countryOrRegion: storefront, + credentials: credentials, + asOf: now() ) try Task.checkCancellation() for target in storefrontTargets { - let normalizedTerm = AppleAdsCMPopularityClient.normalizedKeywordKey( - target.term - ) + let normalizedTerm = AppleAdsSearchTermPopularity.normalized(target.term) guard let popularity = popularities[normalizedTerm] else { continue } popularityByQueryKey[target.queryKey] = min(100, max(1, popularity)) } @@ -125,6 +137,48 @@ final class KeywordMetricsService: Sendable { } } + private func requireAppleAdsCredentials() async throws -> AppleAdsCredentials { + let credentials = await credentialStore.apiCredentials + guard credentials.isComplete else { + throw OpenASOError.providerUnavailable( + "Configure and verify Apple Ads Platform API credentials in Settings." + ) + } + return credentials + } + + private func fetchOfficialPopularityScores( + for searchTerms: [String], + countryOrRegion: String, + credentials: AppleAdsCredentials, + asOf date: Date + ) async throws -> [String: Int] { + let rows = try await apiClient.searchTermPopularity( + for: searchTerms, + countryOrRegion: countryOrRegion, + window: .recentCompletedWeeks(asOf: date), + using: credentials + ) + + var latestRows: [String: AppleAdsSearchTermPopularity] = [:] + for row in rows where row.popularity1to100 != nil { + let key = row.normalizedSearchTerm + guard let existing = latestRows[key] else { + latestRows[key] = row + continue + } + let rowWeek = row.week ?? row.month ?? "" + let existingWeek = existing.week ?? existing.month ?? "" + if rowWeek > existingWeek + || (rowWeek == existingWeek + && (row.popularity1to100 ?? 0) > (existing.popularity1to100 ?? 0)) { + latestRows[key] = row + } + } + + return latestRows.compactMapValues(\.popularity1to100) + } + /// Applies fetched evidence to shared query metrics without touching /// tracked apps, tracks, refresh statuses, or `AppKeywordStats`. The caller /// owns the surrounding transaction and must first revalidate any project @@ -239,103 +293,76 @@ final class KeywordMetricsService: Sendable { continue } - guard settingsStore.popularityContextAppStoreID != nil else { - let payload = Self.makeAppleAdsMetrics(popularityResult: .missingContextApp) - Self.applyMetricsPayloadSafely( - payload, - for: track, - statusTracks: queryTracks, - in: modelContext, - outcomes: &outcomes - ) - continue - } - tracksNeedingPopularity.append(track) } - let webSession = popularityClient.recoverSessionIfNeeded() - if let contextAppStoreID = settingsStore.popularityContextAppStoreID { - let storefrontGroups = Self.orderedTrackGroups(tracksNeedingPopularity) - if webSessionStore.requiresReconnect { - outcomes.append(contentsOf: storefrontGroups.flatMap(\.tracks).map { - KeywordMetricsRefreshOutcome( - trackID: $0.persistentModelID, - errorMessage: nil, - disposition: .skipped - ) - }) - return outcomes - } + let credentials = credentialStore.apiCredentials + guard credentials.isComplete else { + _ = Self.applyPopularityResult( + .missingCredentials, + to: tracksNeedingPopularity, + tracksByQueryKey: tracksByQueryKey, + in: modelContext, + outcomes: &outcomes + ) + return outcomes + } - for (groupIndex, group) in storefrontGroups.enumerated() { - guard !Task.isCancelled else { return outcomes } + let storefrontGroups = Self.orderedTrackGroups(tracksNeedingPopularity) + for group in storefrontGroups { + guard !Task.isCancelled else { return outcomes } + let storefrontTracks = group.tracks + let storefrontCode = storefrontTracks.first?.storefront ?? "US" - let storefrontTracks = group.tracks - let storefrontCode = storefrontTracks.first?.storefront ?? "US" - let popularityResult = await popularityClient.searchPopularities( + do { + let popularities = try await fetchOfficialPopularityScores( for: storefrontTracks.map(\.term), - storefrontCode: storefrontCode, - adamId: contextAppStoreID, - session: webSession + countryOrRegion: storefrontCode, + credentials: credentials, + asOf: Date() ) guard !Task.isCancelled else { return outcomes } - - switch popularityResult { - case .success(let popularities): - for track in storefrontTracks { - guard !Task.isCancelled else { return outcomes } - let result: AppleAdsPopularityResult - if let popularity = popularities[AppleAdsCMPopularityClient.normalizedKeywordKey(track.term)] { - result = .success(popularity) - } else { - result = .notFound - } - let payload = Self.makeAppleAdsMetrics(popularityResult: result) - Self.applyMetricsPayloadSafely( - payload, - for: track, - statusTracks: tracksByQueryKey[track.queryKey] ?? [track], - in: modelContext, - outcomes: &outcomes - ) - } - case .missingCredentials: - guard Self.applyPopularityResult( - .missingCredentials, - to: storefrontTracks, - tracksByQueryKey: tracksByQueryKey, + for track in storefrontTracks { + guard !Task.isCancelled else { return outcomes } + let key = AppleAdsSearchTermPopularity.normalized(track.term) + let result = popularities[key].map(AppleAdsPopularityResult.success) + ?? .notFound + Self.applyMetricsPayloadSafely( + Self.makeAppleAdsMetrics(popularityResult: result), + for: track, + statusTracks: tracksByQueryKey[track.queryKey] ?? [track], in: modelContext, outcomes: &outcomes - ) else { return outcomes } - case .expiredSession(let attemptedSession): - webSessionStore.markReconnectRequired(for: attemptedSession) - let skippedTracks = storefrontGroups[groupIndex...].flatMap(\.tracks) - outcomes.append(contentsOf: skippedTracks.map { - KeywordMetricsRefreshOutcome( - trackID: $0.persistentModelID, - errorMessage: nil, - disposition: .skipped - ) - }) - return outcomes - case .cancelled: - return outcomes - case .failure(let message): - guard Self.applyPopularityResult( - .failure(message), - to: storefrontTracks, - tracksByQueryKey: tracksByQueryKey, - in: modelContext, - outcomes: &outcomes - ) else { return outcomes } + ) } + } catch { + if Self.isCancellation(error) { return outcomes } + guard Self.applyPopularityResult( + .failure(OpenASOError.map(error).localizedDescription), + to: storefrontTracks, + tracksByQueryKey: tracksByQueryKey, + in: modelContext, + outcomes: &outcomes + ) else { return outcomes } } } return outcomes } + func refreshMetrics( + for trackIdentityKeys: [String], + using modelStore: BackgroundModelStore, + progress: (@Sendable (_ completed: Int, _ total: Int, _ failureCount: Int) async -> Void)? = nil + ) async throws -> [KeywordMetricsRefreshOutcome] { + (try await refreshMetricsBatch( + for: trackIdentityKeys, + using: modelStore, + progress: progress + )).outcomes + } + + @available(*, deprecated, message: "Apple Ads Platform credentials are read from the credential store.") func refreshMetrics( for trackIdentityKeys: [String], popularityContextAppStoreID: Int64?, @@ -345,8 +372,6 @@ final class KeywordMetricsService: Sendable { ) async throws -> [KeywordMetricsRefreshOutcome] { (try await refreshMetricsBatch( for: trackIdentityKeys, - popularityContextAppStoreID: popularityContextAppStoreID, - webSession: webSession, using: modelStore, progress: progress )).outcomes @@ -354,8 +379,6 @@ final class KeywordMetricsService: Sendable { func refreshMetricsBatch( for trackIdentityKeys: [String], - popularityContextAppStoreID: Int64?, - webSession: AppleAdsWebSession?, using modelStore: BackgroundModelStore, progress: (@Sendable (_ completed: Int, _ total: Int, _ failureCount: Int) async -> Void)? = nil, didPersist: (@Sendable (KeywordMetricsPersistenceUpdate) async -> Void)? = nil @@ -410,7 +433,7 @@ final class KeywordMetricsService: Sendable { try Task.checkCancellation() var outcomes: [KeywordMetricsRefreshOutcome] = [] - var batchErrors: [KeywordMetricsBatchError] = [] + let batchErrors: [KeywordMetricsBatchError] = [] var tracksNeedingPopularity: [KeywordMetricsRefreshCandidate] = [] let totalCount = candidates.count var completedCount = 0 @@ -436,29 +459,11 @@ final class KeywordMetricsService: Sendable { continue } - guard popularityContextAppStoreID != nil else { - let outcome = try await persistMetricsPayload( - Self.makeAppleAdsMetrics(popularityResult: .missingContextApp), - for: candidate, - using: modelStore - ) - outcomes.append(outcome) - if outcome.errorMessage != nil { failureCount += 1 } - completedCount += 1 - await didPersist?(candidate.persistenceUpdate) - await progress?(completedCount, totalCount, failureCount) - try Task.checkCancellation() - continue - } - tracksNeedingPopularity.append(candidate) } - guard let popularityContextAppStoreID else { - return KeywordMetricsRefreshBatchResult(outcomes: outcomes, batchErrors: batchErrors) - } - - guard let webSession, webSession.isComplete else { + let credentials = await credentialStore.apiCredentials + guard credentials.isComplete else { for candidate in tracksNeedingPopularity { try Task.checkCancellation() let outcome = try await persistMetricsPayload( @@ -476,69 +481,24 @@ final class KeywordMetricsService: Sendable { return KeywordMetricsRefreshBatchResult(outcomes: outcomes, batchErrors: batchErrors) } - let cmPopularityClient = AppleAdsCMPopularityClient(httpClient: httpClient) let storefrontGroups = Self.orderedCandidateGroups(tracksNeedingPopularity) try Task.checkCancellation() - if await webSessionStore.requiresReconnect(for: webSession) { - try Task.checkCancellation() - batchErrors.append(.appleAdsSessionExpired) - failureCount += 1 - if storefrontGroups.isEmpty { - await progress?(completedCount, totalCount, failureCount) - try Task.checkCancellation() - } - for candidate in storefrontGroups.flatMap(\.tracks) { - try Task.checkCancellation() - outcomes.append( - KeywordMetricsRefreshOutcome( - trackID: candidate.trackID, - errorMessage: nil, - disposition: .skipped - ) - ) - completedCount += 1 - await progress?(completedCount, totalCount, failureCount) - try Task.checkCancellation() - } - return KeywordMetricsRefreshBatchResult(outcomes: outcomes, batchErrors: batchErrors) - } - guard !storefrontGroups.isEmpty else { return KeywordMetricsRefreshBatchResult(outcomes: outcomes, batchErrors: batchErrors) } - for (groupIndex, group) in storefrontGroups.enumerated() { + for group in storefrontGroups { try Task.checkCancellation() let storefrontTracks = group.tracks let storefrontCode = storefrontTracks.first?.storefront ?? "US" let popularities: [String: Int] do { - popularities = try await cmPopularityClient.keywordPopularities( + popularities = try await fetchOfficialPopularityScores( for: storefrontTracks.map(\.term), - storefrontCode: storefrontCode, - adamId: popularityContextAppStoreID, - session: webSession + countryOrRegion: storefrontCode, + credentials: credentials, + asOf: Date() ) - } catch is AppleAdsWebSessionExpiredError { - try Task.checkCancellation() - await webSessionStore.markReconnectRequired(for: webSession) - batchErrors.append(.appleAdsSessionExpired) - failureCount += 1 - let skippedCandidates = storefrontGroups[groupIndex...].flatMap(\.tracks) - for candidate in skippedCandidates { - try Task.checkCancellation() - outcomes.append( - KeywordMetricsRefreshOutcome( - trackID: candidate.trackID, - errorMessage: nil, - disposition: .skipped - ) - ) - completedCount += 1 - await progress?(completedCount, totalCount, failureCount) - try Task.checkCancellation() - } - break } catch { try Task.checkCancellation() if Self.isCancellation(error) { @@ -564,7 +524,9 @@ final class KeywordMetricsService: Sendable { for candidate in storefrontTracks { try Task.checkCancellation() let result: AppleAdsPopularityResult - if let popularity = popularities[AppleAdsCMPopularityClient.normalizedKeywordKey(candidate.term)] { + if let popularity = popularities[ + AppleAdsSearchTermPopularity.normalized(candidate.term) + ] { result = .success(popularity) } else { result = .notFound @@ -586,22 +548,51 @@ final class KeywordMetricsService: Sendable { return KeywordMetricsRefreshBatchResult(outcomes: outcomes, batchErrors: batchErrors) } + @available(*, deprecated, message: "Apple Ads Platform credentials are read from the credential store.") + func refreshMetricsBatch( + for trackIdentityKeys: [String], + popularityContextAppStoreID: Int64?, + webSession: AppleAdsWebSession?, + using modelStore: BackgroundModelStore, + progress: (@Sendable (_ completed: Int, _ total: Int, _ failureCount: Int) async -> Void)? = nil, + didPersist: (@Sendable (KeywordMetricsPersistenceUpdate) async -> Void)? = nil + ) async throws -> KeywordMetricsRefreshBatchResult { + _ = popularityContextAppStoreID + _ = webSession + return try await refreshMetricsBatch( + for: trackIdentityKeys, + using: modelStore, + progress: progress, + didPersist: didPersist + ) + } + func refreshStalePopularityMetrics( - popularityContextAppStoreID: Int64, - webSession: AppleAdsWebSession, using modelStore: BackgroundModelStore, progress: (@Sendable (_ completed: Int, _ total: Int, _ failureCount: Int) async -> Void)? = nil ) async throws -> [KeywordMetricsRefreshOutcome] { - guard webSession.isComplete else { return [] } - let trackIdentityKeys = try await prepareStalePopularityRefresh(using: modelStore).trackIdentityKeys guard !trackIdentityKeys.isEmpty else { return [] } return try await refreshMetrics( for: trackIdentityKeys, - popularityContextAppStoreID: popularityContextAppStoreID, - webSession: webSession, + using: modelStore, + progress: progress + ) + } + + @available(*, deprecated, message: "Apple Ads Platform credentials are read from the credential store.") + func refreshStalePopularityMetrics( + popularityContextAppStoreID: Int64, + webSession: AppleAdsWebSession, + using modelStore: BackgroundModelStore, + progress: (@Sendable (_ completed: Int, _ total: Int, _ failureCount: Int) async -> Void)? = nil + ) async throws -> [KeywordMetricsRefreshOutcome] { + _ = popularityContextAppStoreID + _ = webSession + + return try await refreshStalePopularityMetrics( using: modelStore, progress: progress ) @@ -970,21 +961,21 @@ final class KeywordMetricsService: Sendable { popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity failed to fetch. Connect an Apple Ads web session in Settings." + statusMessage: "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." ) case .missingContextApp: return KeywordMetricsPayload( popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity failed to fetch. Reconnect Apple Ads in Settings so OpenASO can detect a linked app." + statusMessage: "Popularity failed to fetch. Configure Apple Ads Platform API access in Settings." ) case .notFound: return KeywordMetricsPayload( popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity failed to fetch. Apple Ads returned no popularity for this keyword using the configured popularity app." + statusMessage: "Popularity unavailable. Apple Ads returned no eligible search-term popularity row for this keyword and country or region." ) case .failure(let message): if isUnsupportedAppleAdsStorefrontMessage(message) { @@ -1188,223 +1179,6 @@ private struct KeywordMetricsPayload: Sendable { var updatedAt: Date = .now } -private struct AppleAdsAPIClient: Sendable { - private let httpClient: HTTPClient - - init(httpClient: HTTPClient) { - self.httpClient = httpClient - } - - func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsCredentials { - let credentials = credentials.trimmed - guard credentials.canVerify else { - throw OpenASOError.providerUnavailable("Enter the Apple Ads client ID, team ID, key ID, and private key.") - } - - let accessToken = try await requestAccessToken(using: credentials) - let orgID = try await requestOrgID(accessToken: accessToken) - return AppleAdsCredentials( - clientID: credentials.clientID, - teamID: credentials.teamID, - keyID: credentials.keyID, - privateKey: credentials.privateKey, - orgID: orgID - ) - } - - func searchOwnedApps(named query: String, using credentials: AppleAdsCredentials) async throws -> [AppleAdsPromotedApp] { - let credentials = credentials.trimmed - let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalizedQuery.count >= 3 else { - throw OpenASOError.providerUnavailable("Enter at least three characters to search Apple Ads apps.") - } - guard credentials.canVerify else { - throw OpenASOError.providerUnavailable("Enter the Apple Ads client ID, team ID, key ID, and private key.") - } - - let accessToken = try await requestAccessToken(using: credentials) - let orgID = credentials.orgID.isEmpty ? try await requestOrgID(accessToken: accessToken) : credentials.orgID - return try await searchOwnedApps( - named: normalizedQuery, - accessToken: accessToken, - orgID: orgID - ) - } - - func resolveDefaultOwnedApp(using credentials: AppleAdsCredentials) async throws -> AppleAdsPromotedApp { - let credentials = credentials.trimmed - guard credentials.canVerify else { - throw OpenASOError.providerUnavailable("Enter and verify Apple Ads API credentials to find a linked app.") - } - - let accessToken = try await requestAccessToken(using: credentials) - let orgID = credentials.orgID.isEmpty ? try await requestOrgID(accessToken: accessToken) : credentials.orgID - let campaignApps = try await fetchCampaignApps(accessToken: accessToken, orgID: orgID) - if let app = campaignApps.first { - return app - } - - throw OpenASOError.providerUnavailable("Apple Ads needs at least one app with an Apple Ads campaign linked to this account to fetch popularity and difficulty data.") - } - - private func searchOwnedApps(named query: String, accessToken: String, orgID: String) async throws -> [AppleAdsPromotedApp] { - var components = URLComponents(string: "https://api.searchads.apple.com/api/v5/search/apps")! - components.queryItems = [ - URLQueryItem(name: "query", value: query), - URLQueryItem(name: "returnOwnedApps", value: "true") - ] - - guard let url = components.url else { - throw OpenASOError.unexpectedResponse - } - - var request = URLRequest(url: url) - request.timeoutInterval = 20 - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("orgId=\(orgID)", forHTTPHeaderField: "X-AP-Context") - - let data = try await validatedData(for: request, using: httpClient) - let response = try JSONDecoder().decode(AppleAdsAppSearchEnvelope.self, from: data) - return response.data - } - - private func fetchCampaignApps(accessToken: String, orgID: String) async throws -> [AppleAdsPromotedApp] { - var request = URLRequest(url: URL(string: "https://api.searchads.apple.com/api/v5/campaigns")!) - request.timeoutInterval = 20 - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("orgId=\(orgID)", forHTTPHeaderField: "X-AP-Context") - - let data = try await validatedData(for: request, using: httpClient) - let response = try JSONDecoder().decode(AppleAdsCampaignEnvelope.self, from: data) - var seenAppIDs: Set = [] - return response.data.compactMap { campaign in - guard !campaign.deleted, seenAppIDs.insert(campaign.adamId).inserted else { - return nil - } - - return AppleAdsPromotedApp( - adamId: campaign.adamId, - appName: campaign.appName ?? "App ID \(campaign.adamId)", - developerName: "", - countryOrRegionCodes: campaign.countriesOrRegions - ) - } - } - - private func requestAccessToken(using credentials: AppleAdsCredentials) async throws -> String { - let clientSecret = try AppleSearchAdsJWT( - clientID: credentials.clientID, - teamID: credentials.teamID, - keyID: credentials.keyID, - privateKey: credentials.privateKey - ).signed() - - var request = URLRequest(url: URL(string: "https://appleid.apple.com/auth/oauth2/token")!) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.timeoutInterval = 20 - - var components = URLComponents() - components.queryItems = [ - URLQueryItem(name: "grant_type", value: "client_credentials"), - URLQueryItem(name: "scope", value: "searchadsorg"), - URLQueryItem(name: "client_id", value: credentials.clientID), - URLQueryItem(name: "client_secret", value: clientSecret) - ] - request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - - let data = try await validatedData(for: request, using: httpClient) - let response = try JSONDecoder().decode(AccessTokenResponse.self, from: data) - return response.accessToken - } - - private func requestOrgID(accessToken: String) async throws -> String { - var request = URLRequest(url: URL(string: "https://api.searchads.apple.com/api/v5/acls")!) - request.timeoutInterval = 20 - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - - let data = try await validatedData(for: request, using: httpClient) - let response = try JSONDecoder().decode(UserACLEnvelope.self, from: data) - guard let orgID = response.data.first?.orgID else { - throw OpenASOError.providerUnavailable("Apple Ads credentials verified, but no org ID was returned.") - } - return String(orgID) - } -} - -@MainActor -private final class AppleAdsPopularityClient { - private let webSessionStore: AppleAdsWebSessionStore - private let cmPopularityClient: AppleAdsCMPopularityClient - - init( - httpClient: HTTPClient, - webSessionStore: AppleAdsWebSessionStore - ) { - self.webSessionStore = webSessionStore - self.cmPopularityClient = AppleAdsCMPopularityClient(httpClient: httpClient) - } - - func recoverSessionIfNeeded() -> AppleAdsWebSession? { - webSessionStore.recoverSessionIfNeeded() - } - - func searchPopularity(for keyword: String, storefrontCode: String, adamId: Int64) async -> AppleAdsPopularityResult { - guard let session = webSessionStore.recoverSessionIfNeeded(), session.isComplete else { - return .missingCredentials - } - - do { - if let popularity = try await cmPopularityClient.keywordPopularity( - for: keyword, - storefrontCode: storefrontCode, - adamId: adamId, - session: session - ) { - return .success(popularity) - } - - return .notFound - } catch { - return .failure(OpenASOError.map(error).localizedDescription) - } - } - - func searchPopularities( - for keywords: [String], - storefrontCode: String, - adamId: Int64, - session: AppleAdsWebSession? - ) async -> AppleAdsPopularityBatchResult { - guard let session, session.isComplete else { - return .missingCredentials - } - - do { - try Task.checkCancellation() - let popularities = try await cmPopularityClient.keywordPopularities( - for: keywords, - storefrontCode: storefrontCode, - adamId: adamId, - session: session - ) - try Task.checkCancellation() - return .success(popularities) - } catch is AppleAdsWebSessionExpiredError { - return .expiredSession(session) - } catch is CancellationError { - return .cancelled - } catch let error as URLError where error.code == .cancelled { - return .cancelled - } catch { - if Task.isCancelled { - return .cancelled - } - return .failure(OpenASOError.map(error).localizedDescription) - } - } -} - struct AppleAdsPromotedApp: Codable, Equatable, Identifiable, Sendable { let adamId: Int64 let appName: String @@ -1421,46 +1195,3 @@ private enum AppleAdsPopularityResult { case notFound case failure(String) } - -private enum AppleAdsPopularityBatchResult { - case success([String: Int]) - case missingCredentials - case expiredSession(AppleAdsWebSession) - case cancelled - case failure(String) -} - -private struct AccessTokenResponse: Decodable { - let accessToken: String - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - } -} - -private struct UserACLEnvelope: Decodable { - let data: [UserACL] -} - -private struct UserACL: Decodable { - let orgID: Int - - private enum CodingKeys: String, CodingKey { - case orgID = "orgId" - } -} - -private struct AppleAdsAppSearchEnvelope: Decodable { - let data: [AppleAdsPromotedApp] -} - -private struct AppleAdsCampaignEnvelope: Decodable { - let data: [AppleAdsCampaign] -} - -private struct AppleAdsCampaign: Decodable { - let adamId: Int64 - let appName: String? - let countriesOrRegions: [String] - let deleted: Bool -} diff --git a/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift b/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift index 69c4bb8..5e83ca1 100644 --- a/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift +++ b/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift @@ -78,6 +78,19 @@ struct KeywordResearchMetricsConfiguration: Equatable, Sendable { let contextAppStoreID: Int64? let webSession: AppleAdsWebSession? let requiresReconnect: Bool + let credentials: AppleAdsCredentials? + + init( + contextAppStoreID: Int64? = nil, + webSession: AppleAdsWebSession? = nil, + requiresReconnect: Bool = false, + credentials: AppleAdsCredentials? = nil + ) { + self.contextAppStoreID = contextAppStoreID + self.webSession = webSession + self.requiresReconnect = requiresReconnect + self.credentials = credentials + } } /// App-only popularity workflow for pre-live research memberships. @@ -213,11 +226,9 @@ actor KeywordResearchMetricsWorkflow { ) return Self.orderedResult(selections: selections, outcomesByKeywordID: outcomesByKeywordID) } - guard let contextAppStoreID = configuration.contextAppStoreID, - let webSession = configuration.webSession - else { - throw OpenASOError.unexpectedResponse - } + let adAccountID = Int64(configuration.credentials?.adAccountID ?? "") + ?? configuration.contextAppStoreID + ?? 0 var fetchedEvidence: [KeywordPopularityMetricEvidence] = [] var failedIssuesByQueryKey: [String: KeywordResearchMetricsIssue] = [:] @@ -234,31 +245,8 @@ actor KeywordResearchMetricsWorkflow { do { fetchedEvidence.append(contentsOf: try await metricsService.fetchPopularityMetrics( for: storefrontSelections.map(\.target), - contextAppStoreID: contextAppStoreID, - webSession: webSession, now: now )) - } catch is AppleAdsWebSessionExpiredError { - // Recording exact-session expiry is a security-state - // commit point. Once marked, return a truthful outcome - // instead of reporting cancellation after the durable UI - // state already changed. - await reconnectMarker(webSession) - let issue = Self.issue(.sessionExpired) - let currentMetrics = try await currentMetricsAfterRevalidating( - networkSelections, - honorCancellation: false - ) - Self.applyFallback( - issue: issue, - to: networkSelections, - currentMetrics: currentMetrics, - outcomesByKeywordID: &outcomesByKeywordID - ) - return Self.orderedResult( - selections: selections, - outcomesByKeywordID: outcomesByKeywordID - ) } catch { if Self.isCancellation(error) { throw CancellationError() @@ -276,9 +264,7 @@ actor KeywordResearchMetricsWorkflow { let currentConfiguration = await configurationProvider() try Task.checkCancellation() - guard currentConfiguration == configuration, - !currentConfiguration.requiresReconnect - else { + guard currentConfiguration == configuration else { let currentMetrics = try await currentMetricsAfterRevalidating( networkSelections, honorCancellation: true @@ -365,7 +351,7 @@ actor KeywordResearchMetricsWorkflow { persistence: persistence, currentMetric: currentMetric ), - contextAppStoreID: contextAppStoreID + adAccountID: adAccountID ) } else { outcomesByKeywordID[selection.keywordGeneration.id] = selection.fallbackOutcome( @@ -450,7 +436,7 @@ private extension KeywordResearchMetricsWorkflow { func outcome( commitOutcome: CommitOutcome, - contextAppStoreID: Int64 + adAccountID: Int64 ) -> KeywordResearchMetricsOutcome { switch commitOutcome.persistence.disposition { case .inserted, .updated: @@ -463,7 +449,7 @@ private extension KeywordResearchMetricsWorkflow { platform: target.platform, popularityScore: commitOutcome.persistence.popularityScore, observedAt: commitOutcome.persistence.observedAt, - provenance: .requestedContext(appStoreID: contextAppStoreID), + provenance: .requestedContext(appStoreID: adAccountID), disposition: .refreshed, issue: nil ) @@ -608,6 +594,13 @@ private extension KeywordResearchMetricsWorkflow { static func configurationIssue( _ configuration: KeywordResearchMetricsConfiguration ) -> KeywordResearchMetricsIssue? { + if let credentials = configuration.credentials { + guard credentials.isComplete else { + return issue(.missingSession) + } + return nil + } + guard let contextAppStoreID = configuration.contextAppStoreID, contextAppStoreID > 0 else { @@ -643,11 +636,11 @@ private extension KeywordResearchMetricsWorkflow { case .missingContextApp: message = "Connect Apple Ads and choose a linked context app before refreshing popularity." case .missingSession: - message = "Connect an Apple Ads web session before refreshing popularity." + message = "Configure and verify Apple Ads Platform API credentials before refreshing popularity." case .reconnectRequired: - message = "Reconnect the Apple Ads web session before refreshing popularity." + message = "Verify the current Apple Ads Platform API credentials before refreshing popularity." case .sessionExpired: - message = AppleAdsWebSessionExpiredError.message + message = "Apple Ads Platform API authorization expired. Verify the credentials in Settings." case .configurationChanged: message = "Apple Ads settings changed during refresh. Run it again with the current configuration." case .unsupportedStorefront: diff --git a/OpenASO/Services/MCP/OpenASOMCPRuntime.swift b/OpenASO/Services/MCP/OpenASOMCPRuntime.swift index ebc37e2..b85ae78 100644 --- a/OpenASO/Services/MCP/OpenASOMCPRuntime.swift +++ b/OpenASO/Services/MCP/OpenASOMCPRuntime.swift @@ -79,7 +79,8 @@ enum OpenASOMCPRuntime { /// open the user's workspace. static func makeService( modelContainer: ModelContainer, - httpClient: any HTTPClient = URLSessionHTTPClient() + httpClient: any HTTPClient = URLSessionHTTPClient(), + namespace: AppNamespace = .current ) async -> OpenASOMCPService { let backgroundModelStore = BackgroundModelStore(modelContainer: modelContainer) let keywordResearchProjectStore = KeywordResearchProjectStore( @@ -93,6 +94,15 @@ enum OpenASOMCPRuntime { appCatalogService: appCatalogService ) let reviewService = AppStorefrontReviewService(httpClient: httpClient) + let appleAdsCredentialStore = await MainActor.run { + let defaults = UserDefaults(suiteName: namespace.userDefaultsSuiteName) ?? .standard + return AppleAdsCredentialStore( + defaults: defaults, + keychain: SystemKeychainService(), + namespace: namespace + ) + } + let appleAdsPlatformAPI = OfficialAppleAdsPlatformAPI() let mcpService = OpenASOMCPService( backgroundModelStore: backgroundModelStore, keywordResearchProjectStore: keywordResearchProjectStore, @@ -102,7 +112,11 @@ enum OpenASOMCPRuntime { screenshotDownloadService: ScreenshotDownloadService(), rankingProvider: rankingProvider, rankingRefreshCoordinator: rankingRefreshCoordinator, - reviewService: reviewService + reviewService: reviewService, + appleAdsPlatformAPI: appleAdsPlatformAPI, + appleAdsCredentialsProvider: { + appleAdsCredentialStore.apiCredentials + } ) return mcpService } diff --git a/OpenASO/Services/MCP/OpenASOMCPServer.swift b/OpenASO/Services/MCP/OpenASOMCPServer.swift index c3e6daa..8ad8cce 100644 --- a/OpenASO/Services/MCP/OpenASOMCPServer.swift +++ b/OpenASO/Services/MCP/OpenASOMCPServer.swift @@ -21,6 +21,8 @@ struct OpenASOMCPServerFactory: Sendable { let service: OpenASOMCPService let configuration: OpenASOMCPServerConfiguration + static var availableTools: [Tool] { tools } + init( service: OpenASOMCPService, configuration: OpenASOMCPServerConfiguration = OpenASOMCPServerConfiguration() @@ -137,6 +139,36 @@ struct OpenASOMCPServerFactory: Sendable { private func callTool(_ parameters: CallTool.Parameters) async throws -> CallTool.Result { let arguments = parameters.arguments ?? [:] switch parameters.name { + case "apple_ads_platform_capabilities": + return try Self.toolResult(try service.appleAdsPlatformCapabilities()) + + case "apple_ads_platform_status": + return try Self.toolResult(try await service.appleAdsPlatformStatus()) + + case "apple_ads_platform_search_apps": + return try Self.toolResult( + try await service.searchAppleAdsPlatformApps( + query: try arguments.optionalString("query"), + limit: try arguments.optionalInt("limit") ?? 50 + ) + ) + + case "apple_ads_platform_list_campaigns": + return try Self.toolResult( + try await service.listAppleAdsPlatformCampaigns( + limit: try arguments.optionalInt("limit") ?? 100 + ) + ) + + case "apple_ads_search_term_popularity": + return try Self.toolResult( + try await service.searchAppleAdsTermPopularity( + searchTerms: try arguments.requiredStringArray("search_terms"), + countryOrRegion: try arguments.requiredString("country_or_region"), + recentWeekCount: try arguments.optionalInt("recent_week_count") ?? 4 + ) + ) + case "list_apps": let result = try await service.listApps( includeUntrackedCatalogApps: arguments.bool("include_untracked_catalog_apps") ?? false, @@ -460,6 +492,47 @@ private extension OpenASOMCPServerFactory { static var tools: [Tool] { [ + tool( + "apple_ads_platform_capabilities", + "Report the official pinned Apple Ads Swift client version, API base URL, generated operation count, and resource-family coverage.", + schema(optional: [:]), + readOnly: true + ), + tool( + "apple_ads_platform_status", + "Validate locally stored Apple Ads Platform API credentials and list accessible ad accounts. Secrets are never accepted or returned through MCP.", + schema(optional: [:]), + readOnly: true, + openWorld: true + ), + tool( + "apple_ads_platform_search_apps", + "Search apps owned by the configured Apple Ads account using Apple's official Swift client. Omit query to list owned apps.", + schema(optional: ["query": .string, "limit": .integer]), + readOnly: true, + openWorld: true + ), + tool( + "apple_ads_platform_list_campaigns", + "List a bounded campaign summary for the configured Apple Ads ad account using Apple's official Swift client.", + schema(optional: ["limit": .integer]), + readOnly: true, + openWorld: true + ), + tool( + "apple_ads_search_term_popularity", + "Query Apple's public Search Term Popularity API. Returns eligible weekly rows with global 1-100 and 1-5 popularity, genre popularity, and genre rank for a country or region.", + schema( + required: ["search_terms", "country_or_region"], + optional: [ + "search_terms": .stringArray, + "country_or_region": .string, + "recent_week_count": .integer, + ] + ), + readOnly: true, + openWorld: true + ), tool("list_apps", "List tracked apps and optionally untracked catalog apps.", schema( optional: [ "include_untracked_catalog_apps": .boolean, diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 5de5968..c8851fa 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -402,6 +402,12 @@ actor OpenASOMCPRankingRefreshScheduler { } } +struct OpenASOMCPAppleAdsPlatformStatus: Codable, Equatable, Sendable { + let isConfigured: Bool + let connection: AppleAdsPlatformConnection? + let nextStep: String +} + final class OpenASOMCPService: Sendable { typealias RankingRefreshAttemptsPersistence = @Sendable ( _ requests: [RankingRefreshRequest], @@ -482,8 +488,9 @@ final class OpenASOMCPService: Sendable { private let keywordMetricsService: KeywordMetricsService? private let rankedAppPricingService: RankedAppPricingService private let visibleProductPricingService: VisibleProductPricingService - private let popularityContextAppStoreIDProvider: @MainActor @Sendable () -> Int64? - private let appleAdsWebSessionProvider: @MainActor @Sendable () -> AppleAdsWebSession? + private let appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? + private let appleAdsCredentialsProvider: + @MainActor @Sendable () -> AppleAdsCredentials? private let now: @Sendable () -> Date init( @@ -501,8 +508,9 @@ final class OpenASOMCPService: Sendable { keywordMetricsService: KeywordMetricsService? = nil, rankedAppPricingService: RankedAppPricingService? = nil, visibleProductPricingService: VisibleProductPricingService? = nil, - popularityContextAppStoreIDProvider: @escaping @MainActor @Sendable () -> Int64? = { nil }, - appleAdsWebSessionProvider: @escaping @MainActor @Sendable () -> AppleAdsWebSession? = { nil }, + appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? = nil, + appleAdsCredentialsProvider: + @escaping @MainActor @Sendable () -> AppleAdsCredentials? = { nil }, now: @escaping @Sendable () -> Date = { Date() } ) { self.backgroundModelStore = backgroundModelStore @@ -533,11 +541,103 @@ final class OpenASOMCPService: Sendable { ?? RankedAppPricingService(httpClient: httpClient, now: now) self.visibleProductPricingService = visibleProductPricingService ?? VisibleProductPricingService(httpClient: httpClient, now: now) - self.popularityContextAppStoreIDProvider = popularityContextAppStoreIDProvider - self.appleAdsWebSessionProvider = appleAdsWebSessionProvider + self.appleAdsPlatformAPI = appleAdsPlatformAPI + self.appleAdsCredentialsProvider = appleAdsCredentialsProvider self.now = now } + func appleAdsPlatformCapabilities() throws -> AppleAdsPlatformCoverage { + try requireAppleAdsPlatformAPI().coverage + } + + func appleAdsPlatformStatus() async throws -> OpenASOMCPAppleAdsPlatformStatus { + guard let credentials = await appleAdsCredentialsProvider() else { + return OpenASOMCPAppleAdsPlatformStatus( + isConfigured: false, + connection: nil, + nextStep: "Add Apple Ads Platform API credentials in OpenASO Settings." + ) + } + guard credentials.canVerify else { + return OpenASOMCPAppleAdsPlatformStatus( + isConfigured: false, + connection: nil, + nextStep: "Complete the client ID, team ID, key ID, and private key in OpenASO Settings." + ) + } + + let connection = try await requireAppleAdsPlatformAPI().verify(credentials: credentials) + return OpenASOMCPAppleAdsPlatformStatus( + isConfigured: true, + connection: connection, + nextStep: "Apple Ads Platform API is ready." + ) + } + + func searchAppleAdsPlatformApps( + query: String?, + limit: Int + ) async throws -> [AppleAdsPromotedApp] { + try await requireAppleAdsPlatformAPI().searchOwnedApps( + named: query, + using: try await requireAppleAdsCredentials(), + limit: max(1, min(limit, 1_000)) + ) + } + + func listAppleAdsPlatformCampaigns( + limit: Int + ) async throws -> [AppleAdsPlatformCampaignSummary] { + try await requireAppleAdsPlatformAPI().listCampaigns( + using: try await requireAppleAdsCredentials(), + limit: max(1, min(limit, 1_000)) + ) + } + + func searchAppleAdsTermPopularity( + searchTerms: [String], + countryOrRegion: String, + recentWeekCount: Int + ) async throws -> [AppleAdsSearchTermPopularity] { + let searchTerms = searchTerms + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !searchTerms.isEmpty else { + throw OpenASOError.providerUnavailable("Provide at least one search term.") + } + guard searchTerms.count <= 1_000 else { + throw OpenASOError.providerUnavailable("Search at most 1,000 terms per request.") + } + + return try await requireAppleAdsPlatformAPI().searchTermPopularity( + for: searchTerms, + countryOrRegion: countryOrRegion, + window: .recentCompletedWeeks( + asOf: now(), + weekCount: max(1, min(recentWeekCount, 65)) + ), + using: try await requireAppleAdsCredentials() + ) + } + + private func requireAppleAdsPlatformAPI() throws -> any AppleAdsPlatformAPI { + guard let appleAdsPlatformAPI else { + throw OpenASOError.providerUnavailable( + "Apple Ads Platform API is unavailable in this runtime." + ) + } + return appleAdsPlatformAPI + } + + private func requireAppleAdsCredentials() async throws -> AppleAdsCredentials { + guard let credentials = await appleAdsCredentialsProvider(), credentials.isComplete else { + throw OpenASOError.providerUnavailable( + "Configure and verify Apple Ads Platform API credentials in OpenASO Settings first." + ) + } + return credentials + } + private static func historyPageFromLookahead( rowsWithLookahead: [Row], kind: OpenASOMCPHistoryKind, @@ -2285,12 +2385,8 @@ final class OpenASOMCPService: Sendable { var skippedIdentityKeys: Set = [] var batchSummary: OpenASOMCPKeywordRefreshBatchSummary? if let keywordMetricsService { - let popularityContextAppStoreID = await popularityContextAppStoreIDProvider() - let webSession = await appleAdsWebSessionProvider() let refreshResult = try await keywordMetricsService.refreshMetricsBatch( for: trackIdentityKeys, - popularityContextAppStoreID: popularityContextAppStoreID, - webSession: webSession, using: backgroundModelStore ) let refreshState = try await backgroundModelStore.read { modelContext in @@ -2320,7 +2416,7 @@ final class OpenASOMCPService: Sendable { ) } } else { - let message = "Popularity failed to fetch. Connect an Apple Ads web session in Settings." + let message = "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." try await backgroundModelStore.write { modelContext in for identityKey in trackIdentityKeys { guard let track = try Self.fetchTrackedKeyword(identityKey: identityKey, in: modelContext) @@ -2369,7 +2465,7 @@ final class OpenASOMCPService: Sendable { rankingProvenance: nil, error: error.map { OpenASOMCPErrorDTO( - code: $0.localizedCaseInsensitiveContains("Connect an Apple Ads") + code: $0.localizedCaseInsensitiveContains("Apple Ads Platform API credentials") ? "apple_ads_not_configured" : "keyword_popularity_unavailable", message: $0 diff --git a/OpenASOTests/AppServicesDependencyTests.swift b/OpenASOTests/AppServicesDependencyTests.swift index 9aec4e6..5013fa0 100644 --- a/OpenASOTests/AppServicesDependencyTests.swift +++ b/OpenASOTests/AppServicesDependencyTests.swift @@ -184,42 +184,13 @@ struct AppServicesDependencyTests { } @Test - func automaticResearchMetricsWorkflowUsesCurrentAppleAdsStateAndExactSessionMarker() async throws { + func automaticResearchMetricsWorkflowUsesCurrentAppleAdsPlatformCredentials() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) - let contextAppStoreID: Int64 = 6_608_976_383 - let initialSession = AppleAdsWebSession( - cookieHeader: "cookie=initial; XSRF-TOKEN-CM=initial-token", - xsrfToken: "initial-token", - updatedAt: Date(timeIntervalSince1970: 100) - ) - let replacementSession = AppleAdsWebSession( - cookieHeader: "cookie=replacement; XSRF-TOKEN-CM=replacement-token", - xsrfToken: "replacement-token", - updatedAt: Date(timeIntervalSince1970: 200) - ) - var sessionStore: AppleAdsWebSessionStore? - var expiresAndReplacesSession = false + let adAccountID: Int64 = 6_608_976_383 var requestCount = 0 let client = MockHTTPClient { request in requestCount += 1 let url = try #require(request.url) - #expect( - URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems?.first(where: { $0.name == "adamId" })?.value - == String(contextAppStoreID) - ) - #expect(request.value(forHTTPHeaderField: "Cookie") == initialSession.cookieHeader) - #expect( - request.value(forHTTPHeaderField: "X-XSRF-TOKEN-CM") - == initialSession.xsrfToken - ) - if expiresAndReplacesSession { - try #require(sessionStore).save(replacementSession) - return ( - Data(), - makeHTTPURLResponse(url: url, statusCode: 401) - ) - } return ( Data( #"{"status":"success","data":[{"name":"app services wiring","popularity":64}]}"#.utf8 @@ -227,8 +198,11 @@ struct AppServicesDependencyTests { makeHTTPURLResponse(url: url, statusCode: 200) ) } - let services = AppServices.mocked(httpClient: client, modelContainer: container) - sessionStore = services.appleAdsWebSessionStore + let services = AppServices.mocked( + httpClient: client, + modelContainer: container, + appleAdsPlatformAPI: HTTPBackedSearchPopularityAPI(httpClient: client) + ) let projectStore = try #require(services.keywordResearchProjectStore) let workflow = try #require(services.keywordResearchMetricsWorkflow) let project = try await projectStore.createProject( @@ -243,47 +217,49 @@ struct AppServicesDependencyTests { platform: .iphone ) - let missingContext = try await workflow.refresh( + let missingCredentials = try await workflow.refresh( projectGeneration: addition.project.generation, keywordGeneration: addition.keyword.generation, policy: .requireNetwork ) - #expect(missingContext.issue?.code == .missingContextApp) + #expect(missingCredentials.issue?.code == .missingSession) #expect(requestCount == 0) - services.settingsStore.savePopularityContextAppStoreID(contextAppStoreID) - try services.appleAdsWebSessionStore.save(initialSession) - services.appleAdsWebSessionStore.markReconnectRequired(for: initialSession) - let reconnectRequired = try await workflow.refresh( - projectGeneration: addition.project.generation, - keywordGeneration: addition.keyword.generation, - policy: .requireNetwork + let credentials = AppleAdsCredentials( + clientID: "SEARCHADS.app-services-fixture", + teamID: "fixture-team", + keyID: "fixture-key", + privateKey: "fixture-private-key", + orgID: "fixture-org", + adAccountID: String(adAccountID) ) - #expect(reconnectRequired.issue?.code == .reconnectRequired) - #expect(requestCount == 0) + try services.appleAdsCredentialStore.saveAPICredentials(credentials) - services.appleAdsWebSessionStore.clearReconnectRequirement(for: initialSession) let refreshed = try await workflow.refresh( projectGeneration: addition.project.generation, keywordGeneration: addition.keyword.generation, policy: .requireNetwork ) #expect(refreshed.popularityScore == 64) - #expect(refreshed.provenance == .requestedContext(appStoreID: contextAppStoreID)) + #expect(refreshed.provenance == .requestedContext(appStoreID: adAccountID)) #expect(refreshed.disposition == .refreshed) #expect(refreshed.issue == nil) #expect(requestCount == 1) - expiresAndReplacesSession = true - let expired = try await workflow.refresh( + var replacementCredentials = credentials + replacementCredentials.adAccountID = String(adAccountID + 1) + try services.appleAdsCredentialStore.saveAPICredentials(replacementCredentials) + let refreshedWithReplacementAccount = try await workflow.refresh( projectGeneration: addition.project.generation, keywordGeneration: addition.keyword.generation, policy: .requireNetwork ) - #expect(expired.issue?.code == .sessionExpired) + #expect( + refreshedWithReplacementAccount.provenance + == .requestedContext(appStoreID: adAccountID + 1) + ) + #expect(refreshedWithReplacementAccount.popularityScore == 64) #expect(requestCount == 2) - #expect(services.appleAdsWebSessionStore.session == replacementSession) - #expect(!services.appleAdsWebSessionStore.requiresReconnect) } @Test @@ -952,72 +928,6 @@ struct AppServicesDependencyTests { #expect(keychain.deleteCallCount == 0) } - @Test - func keywordMetricsRefreshAttemptsTransientRecoveryOnceAcrossStorefronts() async throws { - let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) - let modelContext = ModelContext(container) - let defaults = Self.makeDefaults() - let keychain = ScriptedKeychainService() - let session = AppleAdsWebSession(cookieHeader: "cookie=value", xsrfToken: "token", updatedAt: .now) - try AppleAdsWebSessionStore(defaults: defaults, keychain: keychain).save(session) - keychain.enqueueReadResults([ - .failure(.status(errSecInteractionNotAllowed)), - .failure(.status(errSecNotAvailable)), - ]) - let services = AppServices( - httpClient: MockHTTPClient { request in - throw OpenASOError.providerUnavailable( - "Unexpected request to \(request.url?.absoluteString ?? "unknown URL")" - ) - }, - defaults: defaults, - keychain: keychain, - loadsEnvironmentCredentials: false, - allowsIconNetworkFetches: false - ) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - - let trackedApp = TrackedApp( - appStoreID: 1, - bundleID: nil, - name: "App", - sellerName: nil, - defaultPlatform: .iphone - ) - var tracks: [TrackedAppKeyword] = [] - for storefront in ["us", "gb"] { - let query = try KeywordQuery.fetchOrInsert( - term: "focus app \(storefront)", - storefront: storefront, - platform: .iphone, - in: modelContext - ) - let track = TrackedAppKeyword( - term: query.term, - storefront: storefront, - platform: .iphone, - trackedApp: trackedApp, - query: query - ) - trackedApp.keywordTracks.append(track) - modelContext.insert(track) - tracks.append(track) - } - modelContext.insert(trackedApp) - try modelContext.save() - - let outcomes = await services.keywordMetricsService.refreshMetrics( - for: trackedApp, - tracks: tracks, - in: modelContext - ) - - #expect(outcomes.count == 2) - #expect(outcomes.allSatisfy { $0.errorMessage != nil }) - #expect(keychain.readCallCount == 2) - #expect(keychain.deleteCallCount == 0) - } - @Test func webSessionStoreDoesNotRetryPermanentReadFailure() throws { let defaults = Self.makeDefaults() @@ -1064,74 +974,6 @@ struct AppServicesDependencyTests { #expect(malformedKeychain.deleteCallCount == 0) } - @Test - func keywordMetricsRefreshRecoversAfterTransientStartupRead() async throws { - let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) - let modelContext = ModelContext(container) - let defaults = Self.makeDefaults() - let keychain = ScriptedKeychainService() - let session = AppleAdsWebSession( - cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", - xsrfToken: "token", - updatedAt: .now - ) - try AppleAdsWebSessionStore(defaults: defaults, keychain: keychain).save(session) - keychain.enqueueReadResults([.failure(.status(errSecNotAvailable))]) - let services = AppServices( - httpClient: MockHTTPClient { request in - let payload = #"{"status":"success","data":[{"name":"focus app","popularity":41}]}"# - return ( - Data(payload.utf8), - makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) - ) - }, - defaults: defaults, - keychain: keychain, - loadsEnvironmentCredentials: false, - allowsIconNetworkFetches: false - ) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - - let trackedApp = TrackedApp( - appStoreID: 1, - bundleID: nil, - name: "App", - sellerName: nil, - defaultPlatform: .iphone - ) - let query = try KeywordQuery.fetchOrInsert( - term: "focus app", - storefront: "us", - platform: .iphone, - in: modelContext - ) - let track = TrackedAppKeyword( - term: "focus app", - storefront: "us", - platform: .iphone, - trackedApp: trackedApp, - query: query - ) - trackedApp.keywordTracks.append(track) - modelContext.insert(trackedApp) - modelContext.insert(track) - try modelContext.save() - - let outcomes = await services.keywordMetricsService.refreshMetrics( - for: trackedApp, - tracks: [track], - in: modelContext - ) - let metrics = try #require(try modelContext.fetch(FetchDescriptor()).first) - - #expect(outcomes.count == 1) - #expect(outcomes.first?.errorMessage == nil) - #expect(metrics.popularityScore == 41) - #expect(track.statusMessage == nil) - #expect(keychain.readCallCount == 2) - #expect(keychain.deleteCallCount == 0) - } - @Test func cmPopularityClientBatchesTermsAtOneHundred() async throws { struct RequestBody: Decodable { diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift new file mode 100644 index 0000000..68d9eb7 --- /dev/null +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import OpenASO + +struct AppleAdsPlatformServiceTests { + @Test + func coverageAccountsForEveryGeneratedOperation() { + let coverage = AppleAdsPlatformCoverage.current + + #expect(coverage.clientVersion == "1.109.0") + #expect(coverage.baseURL == "https://api.ads.apple.com/v1") + #expect(coverage.families.reduce(0) { $0 + $1.operationCount } == coverage.operationCount) + #expect(coverage.operationCount == 99) + } + + @Test + func verifiedConnectionAppliesSelectedAdAccountWithoutLosingCredentials() { + let credentials = AppleAdsCredentials( + clientID: "client", + teamID: "team", + keyID: "key", + privateKey: "private" + ) + let connection = AppleAdsPlatformConnection( + userID: 10, + orgID: 20, + accounts: [ + AppleAdsPlatformAccount( + id: 30, + name: "Primary", + orgID: 20, + roles: ["API Account Manager"] + ) + ], + selectedAdAccountID: 30 + ) + + let applied = connection.applying(to: credentials) + + #expect(applied.clientID == credentials.clientID) + #expect(applied.privateKey == credentials.privateKey) + #expect(applied.orgID == "20") + #expect(applied.adAccountID == "30") + #expect(applied.isComplete) + } + + @Test + func completedWeeklyWindowUsesPreviousSaturdayAndFourFullWeeks() throws { + let date = try #require( + ISO8601DateFormatter().date(from: "2026-08-15T12:00:00Z") + ) + + let window = AppleAdsSearchTermPopularityWindow.recentCompletedWeeks( + asOf: date, + weekCount: 4 + ) + + #expect(window.start == "2026-07-12") + #expect(window.end == "2026-08-08") + } + + @Test + func searchTermPopularityIdentityAndNormalizationAreStable() { + let row = AppleAdsSearchTermPopularity( + searchTerm: " Focus ", + countryOrRegion: "US", + genre: "Productivity", + week: "2026-08-02", + month: nil, + rankInGenre: 3, + popularityInGenre: 87, + popularity1to100: 72, + popularity1to5: 4 + ) + + #expect(row.normalizedSearchTerm == "focus") + #expect(row.id == "US|focus|2026-08-02|Productivity") + } + + @MainActor + @Test + func credentialStorePersistsAdAccountAndKeepsPrivateKeyInKeychain() throws { + let suiteName = "com.thirdtech.openaso.tests.apple-ads-platform.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + let keychain = InMemoryKeychainService() + let namespace = AppNamespace(bundleIdentifier: suiteName) + let credentials = AppleAdsCredentials( + clientID: "client", + teamID: "team", + keyID: "key", + privateKey: "private", + orgID: "20", + adAccountID: "30" + ) + + let store = AppleAdsCredentialStore( + defaults: defaults, + keychain: keychain, + namespace: namespace, + loadsEnvironmentCredentials: false + ) + try store.saveAPICredentials(credentials) + let reloaded = AppleAdsCredentialStore( + defaults: defaults, + keychain: keychain, + namespace: namespace, + loadsEnvironmentCredentials: false + ) + + #expect(reloaded.apiCredentials == credentials) + #expect(defaults.string(forKey: "appleAds.adAccountID") == "30") + #expect(defaults.string(forKey: "appleAds.privateKey") == nil) + } + + @Test + func mcpRegistryExposesOfficialClientReadTools() { + let names = Set(OpenASOMCPServerFactory.availableTools.map(\.name)) + + #expect(names.contains("apple_ads_platform_capabilities")) + #expect(names.contains("apple_ads_platform_status")) + #expect(names.contains("apple_ads_platform_search_apps")) + #expect(names.contains("apple_ads_platform_list_campaigns")) + #expect(names.contains("apple_ads_search_term_popularity")) + } +} diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index c0e93af..15da45d 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -22,12 +22,21 @@ struct KeywordMetricsServiceTests { makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) ) }, - modelContainer: container - ) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - try services.appleAdsWebSessionStore.save( - AppleAdsWebSession(cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", xsrfToken: "token", updatedAt: .now) + modelContainer: container, + appleAdsPlatformAPI: HTTPBackedSearchPopularityAPI( + httpClient: MockHTTPClient { request in + requestCount += 1 + let payload = """ + Sign in + """ + return ( + Data(payload.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + } + ) ) + try saveTestCredentials(in: services.appleAdsCredentialStore) let trackedApp = TrackedApp(appStoreID: 1, bundleID: nil, name: "App", sellerName: nil, defaultPlatform: .iphone) let query = try KeywordQuery.fetchOrInsert(term: "focus app", storefront: "us", platform: .iphone, in: modelContext) @@ -66,10 +75,9 @@ struct KeywordMetricsServiceTests { in: modelContext ) #expect(refreshStatus.rankingMessage == rankingStatus) - #expect(refreshStatus.popularityMessage == nil) + #expect(refreshStatus.popularityMessage?.contains("Popularity failed to fetch") == true) #expect(track.statusMessage == nil) - #expect(services.appleAdsWebSessionStore.requiresReconnect) - #expect(services.appleAdsWebSessionStore.hasSession) + #expect(!services.appleAdsWebSessionStore.requiresReconnect) #expect(requestCount == 1) } @@ -100,51 +108,24 @@ struct KeywordMetricsServiceTests { #expect(try TrackedKeywordRefreshStatusStore.snapshot( for: track, in: modelContext - ).popularityMessage == "Popularity failed to fetch. Reconnect Apple Ads in Settings so OpenASO can detect a linked app.") + ).popularityMessage == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings.") } @Test func resolveDefaultAppleAdsAppReturnsFirstCampaignLinkedApp() async throws { + let expectedApp = AppleAdsPromotedApp( + adamId: 6_448_311_069, + appName: "Atten", + developerName: "Third Tech", + countryOrRegionCodes: ["US"] + ) let services = AppServices.mocked( httpClient: MockHTTPClient { request in - let url = try #require(request.url) - if url.host == "appleid.apple.com" { - return ( - Data(#"{"access_token":"token"}"#.utf8), - makeHTTPURLResponse(url: url, statusCode: 200) - ) - } - - if url.path == "/api/v5/acls" { - return ( - Data(#"{"data":[{"orgId":12345}]}"#.utf8), - makeHTTPURLResponse(url: url, statusCode: 200) - ) - } - - if url.path == "/api/v5/campaigns" { - #expect(request.value(forHTTPHeaderField: "X-AP-Context") == "orgId=12345") - let payload = """ - { - "data": [ - { - "adamId": 6448311069, - "appName": "Atten", - "countriesOrRegions": ["US"], - "deleted": false - } - ] - } - """ - return ( - Data(payload.utf8), - makeHTTPURLResponse(url: url, statusCode: 200) - ) - } - - Issue.record("Unexpected request to \(url.absoluteString)") - throw OpenASOError.providerUnavailable("Unexpected request") - } + throw OpenASOError.providerUnavailable( + "Unexpected request to \(request.url?.absoluteString ?? "unknown URL")" + ) + }, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [expectedApp]) ) let app = try await services.keywordMetricsService.resolveDefaultAppleAdsApp( @@ -153,12 +134,11 @@ struct KeywordMetricsServiceTests { teamID: "team", keyID: "key", privateKey: Self.privateKey, - orgID: "" + adAccountID: "12345" ) ) - #expect(app.adamId == 6_448_311_069) - #expect(app.appName == "Atten") + #expect(app == expectedApp) } @Test @@ -378,12 +358,25 @@ struct KeywordMetricsServiceTests { makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) ) }, - modelContainer: container - ) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - try services.appleAdsWebSessionStore.save( - AppleAdsWebSession(cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", xsrfToken: "token", updatedAt: .now) + modelContainer: container, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI( + apps: [], + popularityRows: [ + AppleAdsSearchTermPopularity( + searchTerm: "focus app", + countryOrRegion: "US", + genre: "Productivity", + week: "2026-08-08", + month: nil, + rankInGenre: 1, + popularityInGenre: 88, + popularity1to100: 88, + popularity1to5: 5 + ) + ] + ) ) + try saveTestCredentials(in: services.appleAdsCredentialStore) let trackedApp = TrackedApp(appStoreID: 1, bundleID: nil, name: "App", sellerName: nil, defaultPlatform: .iphone) let query = try KeywordQuery.fetchOrInsert(term: "focus app", storefront: "us", platform: .iphone, in: modelContext) @@ -406,18 +399,17 @@ struct KeywordMetricsServiceTests { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) let services = AppServices.mocked( - httpClient: MockHTTPClient { request in - ( - Data(#"{"error":{"errors":[{"message":"Bad request"}]}}"#.utf8), - makeHTTPURLResponse(url: try #require(request.url), statusCode: 400) - ) + httpClient: MockHTTPClient { _ in + throw OpenASOError.providerUnavailable("Unexpected request") }, - modelContainer: container - ) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - try services.appleAdsWebSessionStore.save( - AppleAdsWebSession(cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", xsrfToken: "token", updatedAt: .now) + modelContainer: container, + appleAdsPlatformAPI: FailingSearchPopularityAPI( + error: .providerUnavailable( + "Apple Ads does not support keyword popularity in Angola." + ) + ) ) + try saveTestCredentials(in: services.appleAdsCredentialStore) let trackedApp = TrackedApp(appStoreID: 1, bundleID: nil, name: "App", sellerName: nil, defaultPlatform: .iphone) let query = try KeywordQuery.fetchOrInsert(term: "focus app", storefront: "ao", platform: .iphone, in: modelContext) @@ -510,8 +502,6 @@ struct KeywordMetricsServiceTests { let backgroundModelStore = try #require(services.backgroundModelStore) let outcomes = try await service.refreshStalePopularityMetrics( - popularityContextAppStoreID: 123_456_789, - webSession: AppleAdsWebSession(cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", xsrfToken: "token", updatedAt: .now), using: backgroundModelStore ) let storedScores = try await backgroundModelStore.read { modelContext in @@ -533,13 +523,13 @@ struct KeywordMetricsServiceTests { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) let requestBodies = KeywordPopularityRequestRecorder() - let contextAppStoreID: Int64 = 6_608_976_383 + let legacyContextAppStoreID: Int64 = 6_608_976_383 let session = completeWebSession let client = MockHTTPClient { request in #expect( URLComponents(url: try #require(request.url), resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "adamId" })?.value - == String(contextAppStoreID) + == "123456789" ) #expect(request.value(forHTTPHeaderField: "Cookie") == session.cookieHeader) #expect(request.value(forHTTPHeaderField: "X-XSRF-TOKEN-CM") == session.xsrfToken) @@ -614,12 +604,9 @@ struct KeywordMetricsServiceTests { let evidence = try await service.fetchPopularityMetrics( for: targets, - contextAppStoreID: contextAppStoreID, + contextAppStoreID: legacyContextAppStoreID, webSession: session, - now: { - #expect(requestBodies.snapshot().count == 3) - return observedAt - } + now: { observedAt } ) let outcomes = try await backgroundModelStore.write { context in try service.persistPopularityMetrics(evidence, in: context) @@ -954,8 +941,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: identityKeys, - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record(completed: completed, total: total, failureCount: failureCount) @@ -1040,8 +1025,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: [track.identityKey], - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore ) let stored = try await backgroundModelStore.read { context in @@ -1135,9 +1118,12 @@ struct KeywordMetricsServiceTests { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) let client = SuspendedKeywordMetricsHTTPClient() - let services = AppServices.mocked(httpClient: client, modelContainer: container) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - try services.appleAdsWebSessionStore.save(completeWebSession) + let services = AppServices.mocked( + httpClient: client, + modelContainer: container, + appleAdsPlatformAPI: HTTPBackedSearchPopularityAPI(httpClient: client) + ) + try saveTestCredentials(in: services.appleAdsCredentialStore) let backgroundModelStore = try #require(services.backgroundModelStore) let trackedApp = TrackedApp( appStoreID: 1, @@ -1247,8 +1233,6 @@ struct KeywordMetricsServiceTests { try modelContext.save() let outcomes = try await service.refreshStalePopularityMetrics( - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore ) let newerTrackIdentityKey = newerTrack.identityKey @@ -1343,8 +1327,6 @@ struct KeywordMetricsServiceTests { let preparation = try await service.prepareStalePopularityRefresh(using: backgroundModelStore) let outcomes = try await service.refreshMetrics( for: preparation.trackIdentityKeys, - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore ) let persisted = try await backgroundModelStore.read { context in @@ -1369,7 +1351,7 @@ struct KeywordMetricsServiceTests { } @Test - func expiredBackgroundSessionStopsNinetyTwoKeywordBatchAndPreservesCachedMetrics() async throws { + func providerAuthenticationFailurePreservesNinetyTwoCachedMetrics() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) let requestBodies = KeywordPopularityRequestRecorder() @@ -1393,15 +1375,18 @@ struct KeywordMetricsServiceTests { ) let session = completeWebSession try webSessionStore.save(session) + let credentialStore = AppleAdsCredentialStore( + defaults: defaults, + keychain: keychain, + namespace: namespace + ) + try saveTestCredentials(in: credentialStore) let service = KeywordMetricsService( httpClient: client, - credentialStore: AppleAdsCredentialStore( - defaults: defaults, - keychain: keychain, - namespace: namespace - ), + credentialStore: credentialStore, settingsStore: AppSettingsStore(defaults: defaults), - webSessionStore: webSessionStore + webSessionStore: webSessionStore, + apiClient: HTTPBackedSearchPopularityAPI(httpClient: client) ) let backgroundModelStore = BackgroundModelStore(modelContainer: container) let progressRecorder = KeywordMetricsProgressRecorder() @@ -1457,8 +1442,6 @@ struct KeywordMetricsServiceTests { let result = try await service.refreshMetricsBatch( for: identityKeys, - popularityContextAppStoreID: 123_456_789, - webSession: session, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record( @@ -1470,8 +1453,6 @@ struct KeywordMetricsServiceTests { ) let repeatedResult = try await service.refreshMetricsBatch( for: identityKeys, - popularityContextAppStoreID: 123_456_789, - webSession: session, using: backgroundModelStore ) let storedState = try await backgroundModelStore.read { context in @@ -1483,34 +1464,37 @@ struct KeywordMetricsServiceTests { notes: Dictionary(uniqueKeysWithValues: metrics.compactMap { metric in metric.notes.map { (metric.queryKey, $0) } }), - statuses: Dictionary(uniqueKeysWithValues: tracks.compactMap { track in - track.statusMessage.map { (track.identityKey, $0) } + statuses: Dictionary(uniqueKeysWithValues: try tracks.compactMap { track in + try TrackedKeywordRefreshStatusStore.snapshot( + for: track, + in: context + ).rankingMessage.map { (track.identityKey, $0) } }) ) } let progressUpdates = await progressRecorder.snapshot() let requests = requestBodies.snapshot() - #expect(requests.count == 1) + #expect(requests.count == 4) #expect(requests.first?.storefronts == ["CA"]) #expect( requests.first?.terms == expectedFirstRequestTerms.sorted { $0.identityKey < $1.identityKey }.map(\.term) ) #expect(result.outcomes.count == 92) - #expect(result.outcomes.allSatisfy { $0.errorMessage == nil && $0.isSkipped }) - #expect(result.skippedCount == 92) - #expect(result.batchErrors == [.appleAdsSessionExpired]) - #expect(result.failureCount == 1) - #expect(repeatedResult.skippedCount == 92) - #expect(repeatedResult.failureCount == 1) + #expect(result.outcomes.allSatisfy { $0.errorMessage != nil && !$0.isSkipped }) + #expect(result.skippedCount == 0) + #expect(result.batchErrors.isEmpty) + #expect(result.failureCount == 92) + #expect(repeatedResult.skippedCount == 0) + #expect(repeatedResult.failureCount == 92) #expect(progressUpdates.first == .init(completed: 0, total: 92, failureCount: 0)) - #expect(progressUpdates.last == .init(completed: 92, total: 92, failureCount: 1)) + #expect(progressUpdates.last == .init(completed: 92, total: 92, failureCount: 92)) #expect(storedState.scores == expectedScores.mapValues(Optional.some)) #expect(storedState.updatedAt == [staleUpdatedAt]) #expect(storedState.notes == expectedNotes) #expect(storedState.statuses == expectedStatuses) - #expect(webSessionStore.requiresReconnect) + #expect(!webSessionStore.requiresReconnect) #expect(webSessionStore.session == session) } @@ -1563,8 +1547,6 @@ struct KeywordMetricsServiceTests { let refreshTask = Task { try await service.refreshMetricsBatch( for: [track.identityKey], - popularityContextAppStoreID: 123_456_789, - webSession: session, using: backgroundModelStore ) } @@ -1583,15 +1565,16 @@ struct KeywordMetricsServiceTests { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) var requestCount = 0 + let client = MockHTTPClient { _ in + requestCount += 1 + throw CancellationError() + } let services = AppServices.mocked( - httpClient: MockHTTPClient { _ in - requestCount += 1 - throw CancellationError() - }, - modelContainer: container + httpClient: client, + modelContainer: container, + appleAdsPlatformAPI: HTTPBackedSearchPopularityAPI(httpClient: client) ) - services.settingsStore.savePopularityContextAppStoreID(123_456_789) - try services.appleAdsWebSessionStore.save(completeWebSession) + try saveTestCredentials(in: services.appleAdsCredentialStore) let trackedApp = TrackedApp( appStoreID: 1, bundleID: nil, @@ -1679,8 +1662,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: [freshTrack.identityKey, staleTrack.identityKey, missingTrack.identityKey], - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record(completed: completed, total: total, failureCount: failureCount) @@ -1710,12 +1691,19 @@ struct KeywordMetricsServiceTests { } @Test - func backgroundRefreshPreservesMissingContextOutcomesWithoutProviderRequests() async throws { + func backgroundRefreshDoesNotRequireLegacyContext() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) let client = MockHTTPClient { request in - Issue.record("Unexpected request to \(request.url?.absoluteString ?? "unknown URL")") - throw OpenASOError.providerUnavailable("Unexpected request") + let body = try #require(request.httpBody) + let requestBody = try JSONDecoder().decode(KeywordPopularityRequestBody.self, from: body) + let entries = requestBody.terms.map { term in + #"{"name":"\#(term)","popularity":64}"# + }.joined(separator: ",") + return ( + Data(#"{"status":"success","data":[\#(entries)]}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) } let freshnessFetchRecorder = KeywordMetricsFreshnessFetchRecorder() let service = makeKeywordMetricsService(httpClient: client, freshnessFetchRecorder: freshnessFetchRecorder) @@ -1730,8 +1718,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: tracks.map(\.identityKey), - popularityContextAppStoreID: nil, - webSession: completeWebSession, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record(completed: completed, total: total, failureCount: failureCount) @@ -1753,12 +1739,12 @@ struct KeywordMetricsServiceTests { } #expect(outcomes.count == tracks.count) - #expect(outcomes.allSatisfy { $0.errorMessage?.contains("Reconnect Apple Ads") == true }) - #expect(storedState.statuses.count == tracks.count) + #expect(outcomes.allSatisfy { $0.errorMessage == nil }) + #expect(storedState.statuses.isEmpty) #expect(storedState.metricCount == tracks.count) - #expect(storedState.populatedMetricCount == 0) + #expect(storedState.populatedMetricCount == tracks.count) #expect(progressUpdates.count == tracks.count + 1) - #expect(progressUpdates.last == .init(completed: tracks.count, total: tracks.count, failureCount: tracks.count)) + #expect(progressUpdates.last == .init(completed: tracks.count, total: tracks.count, failureCount: 0)) #expect(freshnessFetchRecorder.snapshot() == [tracks.count]) } @@ -1771,7 +1757,11 @@ struct KeywordMetricsServiceTests { throw OpenASOError.providerUnavailable("Unexpected request") } let freshnessFetchRecorder = KeywordMetricsFreshnessFetchRecorder() - let service = makeKeywordMetricsService(httpClient: client, freshnessFetchRecorder: freshnessFetchRecorder) + let service = makeKeywordMetricsService( + httpClient: client, + freshnessFetchRecorder: freshnessFetchRecorder, + configuresCredentials: false + ) let backgroundModelStore = BackgroundModelStore(modelContainer: container) let progressRecorder = KeywordMetricsProgressRecorder() let trackedApp = TrackedApp(appStoreID: 1, bundleID: nil, name: "App", sellerName: nil, defaultPlatform: .iphone) @@ -1783,8 +1773,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: tracks.map(\.identityKey), - popularityContextAppStoreID: 123_456_789, - webSession: nil, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record(completed: completed, total: total, failureCount: failureCount) @@ -1793,7 +1781,9 @@ struct KeywordMetricsServiceTests { let progressUpdates = await progressRecorder.snapshot() #expect(outcomes.count == tracks.count) - #expect(outcomes.allSatisfy { $0.errorMessage?.contains("Connect an Apple Ads web session") == true }) + #expect(outcomes.allSatisfy { + $0.errorMessage?.contains("Configure and verify Apple Ads Platform API credentials") == true + }) #expect(progressUpdates.count == tracks.count + 1) #expect(progressUpdates.last == .init(completed: tracks.count, total: tracks.count, failureCount: tracks.count)) #expect(freshnessFetchRecorder.snapshot() == [tracks.count]) @@ -1829,8 +1819,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: tracks.map(\.identityKey), - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record(completed: completed, total: total, failureCount: failureCount) @@ -1867,8 +1855,6 @@ struct KeywordMetricsServiceTests { let outcomes = try await service.refreshMetrics( for: [removedTrack.identityKey, retainedTrack.identityKey], - popularityContextAppStoreID: 123_456_789, - webSession: completeWebSession, using: backgroundModelStore, progress: { completed, total, failureCount in await progressRecorder.record(completed: completed, total: total, failureCount: failureCount) @@ -1983,19 +1969,44 @@ struct KeywordMetricsServiceTests { private func makeKeywordMetricsService( httpClient: HTTPClient, freshnessFetchRecorder: KeywordMetricsFreshnessFetchRecorder, + configuresCredentials: Bool = true, bulkFreshnessFetchHook: @escaping @Sendable () throws -> Void = {} ) -> KeywordMetricsService { - let dependencies = AppServices.mocked(httpClient: httpClient) + let api = HTTPBackedSearchPopularityAPI(httpClient: httpClient) + let dependencies = AppServices.mocked( + httpClient: httpClient, + appleAdsPlatformAPI: api + ) + if configuresCredentials { + do { + try saveTestCredentials(in: dependencies.appleAdsCredentialStore) + } catch { + preconditionFailure("Could not configure the in-memory Apple Ads test keychain: \(error)") + } + } return KeywordMetricsService( httpClient: httpClient, credentialStore: dependencies.appleAdsCredentialStore, settingsStore: dependencies.settingsStore, webSessionStore: dependencies.appleAdsWebSessionStore, + apiClient: api, freshnessFetchObserver: freshnessFetchRecorder.record, bulkFreshnessFetchHook: bulkFreshnessFetchHook ) } + private func saveTestCredentials(in store: AppleAdsCredentialStore) throws { + try store.saveAPICredentials( + AppleAdsCredentials( + clientID: "test-client", + teamID: "test-team", + keyID: "test-key", + privateKey: Self.privateKey, + adAccountID: "123456789" + ) + ) + } + private var completeWebSession: AppleAdsWebSession { AppleAdsWebSession( cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", @@ -2269,3 +2280,160 @@ private actor GatedKeywordPopularityHTTPClient: HTTPClient { recordedRequestCount } } + +struct StaticAppleAdsPlatformAPI: AppleAdsPlatformAPI { + let coverage = AppleAdsPlatformCoverage.current + let apps: [AppleAdsPromotedApp] + var popularityRows: [AppleAdsSearchTermPopularity] = [] + + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { + AppleAdsPlatformConnection( + userID: 1, + orgID: 2, + accounts: [ + AppleAdsPlatformAccount( + id: Int64(credentials.adAccountID) ?? 12_345, + name: "Test account", + orgID: 2, + roles: ["API Account Manager"] + ) + ], + selectedAdAccountID: Int64(credentials.adAccountID) ?? 12_345 + ) + } + + func searchOwnedApps( + named query: String?, + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPromotedApp] { + Array(apps.prefix(limit)) + } + + func listCampaigns( + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPlatformCampaignSummary] { + [] + } + + func searchTermPopularity( + for searchTerms: [String], + countryOrRegion: String, + window: AppleAdsSearchTermPopularityWindow, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsSearchTermPopularity] { + popularityRows.filter { + $0.countryOrRegion.caseInsensitiveCompare(countryOrRegion) == .orderedSame + && searchTerms.map(AppleAdsSearchTermPopularity.normalized) + .contains($0.normalizedSearchTerm) + } + } +} + +struct FailingSearchPopularityAPI: AppleAdsPlatformAPI { + let coverage = AppleAdsPlatformCoverage.current + let error: OpenASOError + + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { + throw error + } + + func searchOwnedApps( + named query: String?, + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPromotedApp] { + throw error + } + + func listCampaigns( + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPlatformCampaignSummary] { + throw error + } + + func searchTermPopularity( + for searchTerms: [String], + countryOrRegion: String, + window: AppleAdsSearchTermPopularityWindow, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsSearchTermPopularity] { + throw error + } +} + +/// Keeps the pre-existing persistence/concurrency fixtures useful while the +/// production service uses only Apple's official client. This adapter exists +/// solely in the test target and translates the fixture's historical HTTP +/// response into the new public Search Term Popularity model. +struct HTTPBackedSearchPopularityAPI: AppleAdsPlatformAPI { + let coverage = AppleAdsPlatformCoverage.current + let httpClient: any HTTPClient + + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { + AppleAdsPlatformConnection( + userID: 1, + orgID: 2, + accounts: [ + AppleAdsPlatformAccount( + id: Int64(credentials.adAccountID) ?? 123_456_789, + name: "Fixture account", + orgID: 2, + roles: ["API Account Manager"] + ) + ], + selectedAdAccountID: Int64(credentials.adAccountID) ?? 123_456_789 + ) + } + + func searchOwnedApps( + named query: String?, + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPromotedApp] { + [] + } + + func listCampaigns( + using credentials: AppleAdsCredentials, + limit: Int + ) async throws -> [AppleAdsPlatformCampaignSummary] { + [] + } + + func searchTermPopularity( + for searchTerms: [String], + countryOrRegion: String, + window: AppleAdsSearchTermPopularityWindow, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsSearchTermPopularity] { + let scores = try await AppleAdsCMPopularityClient(httpClient: httpClient) + .keywordPopularities( + for: searchTerms, + storefrontCode: countryOrRegion, + adamId: Int64(credentials.adAccountID) ?? 123_456_789, + session: AppleAdsWebSession( + cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", + xsrfToken: "token", + updatedAt: .now + ) + ) + return scores.map { normalizedTerm, score in + AppleAdsSearchTermPopularity( + searchTerm: searchTerms.first { + AppleAdsSearchTermPopularity.normalized($0) == normalizedTerm + } ?? normalizedTerm, + countryOrRegion: countryOrRegion.uppercased(), + genre: "Fixture", + week: window.end, + month: nil, + rankInGenre: nil, + popularityInGenre: score, + popularity1to100: score, + popularity1to5: nil + ) + } + } +} diff --git a/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift b/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift index 499a783..7323b19 100644 --- a/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift +++ b/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift @@ -43,8 +43,6 @@ struct KeywordResearchMetricsWorkflowTests { #expect(requests.first?.storefronts == ["GB"]) #expect(requests.first?.terms == [keyword.term]) #expect(requests.first?.contextAppStoreID == metricsContextAppStoreID) - #expect(requests.first?.cookieHeader == metricsSession.cookieHeader) - #expect(requests.first?.xsrfToken == metricsSession.xsrfToken) } @Test @@ -174,7 +172,7 @@ struct KeywordResearchMetricsWorkflowTests { } @Test - func missingSessionAndReconnectRequirementAreExplicitUnavailableOutcomes() async throws { + func incompletePlatformCredentialsAreAnExplicitUnavailableOutcome() async throws { let missingSessionClient = ScriptedMetricsHTTPClient(defaultReply: .scores(defaultScore: 90)) let missingSession = try await makeFixture( httpClient: missingSessionClient, @@ -193,23 +191,6 @@ struct KeywordResearchMetricsWorkflowTests { #expect(missingSessionOutcome.issue?.code == .missingSession) #expect(await missingSessionClient.recordedRequests().isEmpty) - let reconnectClient = ScriptedMetricsHTTPClient(defaultReply: .scores(defaultScore: 90)) - let reconnect = try await makeFixture( - httpClient: reconnectClient, - now: metricsTestDate, - configuration: .reconnectRequired - ) - let reconnectKeyword = try #require(reconnect.keywords.first) - - let reconnectOutcome = try await reconnect.workflow.refresh( - projectGeneration: reconnect.project.generation, - keywordGeneration: reconnectKeyword.generation - ) - - #expect(reconnectOutcome.disposition == .unavailable) - #expect(reconnectOutcome.issue?.code == .reconnectRequired) - #expect(await reconnectClient.recordedRequests().isEmpty) - #expect(reconnect.services.appleAdsWebSessionStore.requiresReconnect(for: metricsSession)) } @Test @@ -274,7 +255,7 @@ struct KeywordResearchMetricsWorkflowTests { } @Test - func expiredCurrentSessionMarksReconnectAndPersistsNothing() async throws { + func providerAuthorizationFailurePersistsNothing() async throws { let client = ScriptedMetricsHTTPClient(defaultReply: .status(403)) let fixture = try await makeFixture(httpClient: client, now: metricsTestDate) let keyword = try #require(fixture.keywords.first) @@ -286,10 +267,8 @@ struct KeywordResearchMetricsWorkflowTests { ) #expect(outcome.disposition == .unavailable) - #expect(outcome.issue?.code == .sessionExpired) + #expect(outcome.issue?.code == .providerFailure) #expect(outcome.popularityScore == nil) - #expect(fixture.services.appleAdsWebSessionStore.requiresReconnect(for: metricsSession)) - #expect(fixture.services.appleAdsWebSessionStore.hasSession) #expect(try await metricCount(in: fixture.backgroundStore) == 0) } @@ -429,10 +408,9 @@ struct KeywordResearchMetricsWorkflowTests { } await client.waitUntilStarted() - fixture.services.settingsStore.savePopularityContextAppStoreID( - metricsContextAppStoreID + 1 - ) - try fixture.services.appleAdsWebSessionStore.save(replacementMetricsSession) + var replacementCredentials = metricsAPICredentials + replacementCredentials.adAccountID = String(metricsContextAppStoreID + 1) + try fixture.services.appleAdsCredentialStore.saveAPICredentials(replacementCredentials) await client.release() let outcome = try await task.value @@ -440,7 +418,6 @@ struct KeywordResearchMetricsWorkflowTests { #expect(outcome.issue?.code == .configurationChanged) #expect(outcome.popularityScore == nil) #expect(try await metricCount(in: fixture.backgroundStore) == 0) - #expect(!fixture.services.appleAdsWebSessionStore.requiresReconnect(for: replacementMetricsSession)) } @Test @@ -776,21 +753,27 @@ private extension KeywordResearchMetricsWorkflowTests { keywords.append(addition.keyword) } - let services = AppServices.mocked(httpClient: httpClient) + let platformAPI = HTTPBackedSearchPopularityAPI(httpClient: httpClient) + let services = AppServices.mocked( + httpClient: httpClient, + appleAdsPlatformAPI: platformAPI + ) switch configuration { case .connected: - services.settingsStore.savePopularityContextAppStoreID(metricsContextAppStoreID) - try services.appleAdsWebSessionStore.save(metricsSession) + try services.appleAdsCredentialStore.saveAPICredentials(metricsAPICredentials) case .missingContext: - try services.appleAdsWebSessionStore.save(metricsSession) + break case .missingSession: - services.settingsStore.savePopularityContextAppStoreID(metricsContextAppStoreID) + try services.appleAdsCredentialStore.saveAPICredentials( + AppleAdsCredentials( + clientID: metricsAPICredentials.clientID, + teamID: metricsAPICredentials.teamID, + keyID: metricsAPICredentials.keyID, + privateKey: metricsAPICredentials.privateKey + ) + ) case .missingContextAndSession: break - case .reconnectRequired: - services.settingsStore.savePopularityContextAppStoreID(metricsContextAppStoreID) - try services.appleAdsWebSessionStore.save(metricsSession) - services.appleAdsWebSessionStore.markReconnectRequired(for: metricsSession) } let workflow = KeywordResearchMetricsWorkflow( @@ -798,13 +781,11 @@ private extension KeywordResearchMetricsWorkflowTests { metricsService: services.keywordMetricsService, rankingCoordinator: services.refreshCoordinator, configurationProvider: { [services] in - let session = services.appleAdsWebSessionStore.session return KeywordResearchMetricsConfiguration( - contextAppStoreID: services.settingsStore.popularityContextAppStoreID, - webSession: session, - requiresReconnect: session.map { - services.appleAdsWebSessionStore.requiresReconnect(for: $0) - } ?? services.appleAdsWebSessionStore.requiresReconnect + credentials: configuration == .missingContext + || configuration == .missingContextAndSession + ? nil + : services.appleAdsCredentialStore.apiCredentials ) }, reconnectMarker: { [services] session in @@ -835,12 +816,11 @@ private struct MetricsWorkflowFixture { let keywords: [KeywordResearchKeywordSnapshot] } -private enum MetricsTestConfiguration { +private enum MetricsTestConfiguration: Equatable, Sendable { case connected case missingContext case missingSession case missingContextAndSession - case reconnectRequired } private struct KeywordSpec: Sendable { @@ -1317,15 +1297,17 @@ private let metricsDay: TimeInterval = 60 * 60 * 24 private let metricsTestDate = Date(timeIntervalSince1970: 1_800_000_000) private let metricsProjectCreatedAt = Date(timeIntervalSince1970: 1_790_000_000) private let metricsContextAppStoreID: Int64 = 6_608_976_383 +private let metricsAPICredentials = AppleAdsCredentials( + clientID: "SEARCHADS.fixture-client", + teamID: "fixture-team", + keyID: "fixture-key", + privateKey: "fixture-private-key", + orgID: "fixture-org", + adAccountID: String(metricsContextAppStoreID) +) private let metricsSession = AppleAdsWebSession( cookieHeader: "metrics-cookie=private-value; XSRF-TOKEN-CM=private-token", xsrfToken: "private-token", updatedAt: Date(timeIntervalSince1970: 1_780_000_000), accountName: "Private test account" ) -private let replacementMetricsSession = AppleAdsWebSession( - cookieHeader: "replacement-cookie=private-value; XSRF-TOKEN-CM=replacement-token", - xsrfToken: "replacement-token", - updatedAt: Date(timeIntervalSince1970: 1_780_000_001), - accountName: "Replacement test account" -) diff --git a/OpenASOTests/KeywordResearchProjectStoreTests.swift b/OpenASOTests/KeywordResearchProjectStoreTests.swift index b8d5d73..d8f133e 100644 --- a/OpenASOTests/KeywordResearchProjectStoreTests.swift +++ b/OpenASOTests/KeywordResearchProjectStoreTests.swift @@ -963,7 +963,10 @@ struct KeywordResearchProjectStoreTests { try runtimeContext.save() let runtimeService = await OpenASOMCPRuntime.makeService( modelContainer: runtimeContainer, - httpClient: httpClient + httpClient: httpClient, + namespace: AppNamespace( + bundleIdentifier: "com.thirdtech.openaso.tests.runtime.\(UUID().uuidString)" + ) ) let runtimeReader = try #require(runtimeService.keywordResearchProjectStore) #expect(try await runtimeReader.listProjects(offset: 0, limit: 50).map(\.name) == [ diff --git a/OpenASOTests/OpenASOMCPServerTests.swift b/OpenASOTests/OpenASOMCPServerTests.swift index 54956dc..7899ecc 100644 --- a/OpenASOTests/OpenASOMCPServerTests.swift +++ b/OpenASOTests/OpenASOMCPServerTests.swift @@ -541,15 +541,29 @@ struct OpenASOMCPServerTests { @Test func appServicesMCPProviderUsesConfiguredAppleAdsPopularity() async throws { var requestCount = 0 + let platformAPI = StaticAppleAdsPlatformAPI( + apps: [], + popularityRows: [ + AppleAdsSearchTermPopularity( + searchTerm: "focus timer", + countryOrRegion: "US", + genre: "Productivity", + week: "2026-08-08", + month: nil, + rankInGenre: 12, + popularityInGenre: 73, + popularity1to100: 73, + popularity1to5: 4 + ) + ] + ) let context = try AppServicesMCPTestContext( httpClient: MockHTTPClient { request in requestCount += 1 - #expect(request.url?.host == "app-ads.apple.com") - return ( - appleAdsPopularityPayload(keyword: "focus timer", popularity: 73), - makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) - ) - } + Issue.record("Official Apple Ads API should not use the legacy HTTP client: \(request)") + throw OpenASOError.providerUnavailable("Unexpected legacy request") + }, + appleAdsPlatformAPI: platformAPI ) try context.insertTrackedKeyword(appStoreID: 123, keyword: "focus timer") try context.configureAppleAds(contextAppStoreID: 987) @@ -559,7 +573,7 @@ struct OpenASOMCPServerTests { appStoreID: 123 ) - #expect(requestCount == 1) + #expect(requestCount == 0) #expect(result.summary.refreshed == 1) #expect(result.summary.failed == 0) #expect(result.outcomes.first?.track.popularityScore == 73) @@ -588,11 +602,11 @@ struct OpenASOMCPServerTests { #expect(result.summary.refreshed == 0) #expect(result.summary.failed == 1) #expect(result.outcomes.first?.track.popularityScore == nil) - #expect(result.outcomes.first?.error?.code == "keyword_popularity_unavailable") + #expect(result.outcomes.first?.error?.code == "apple_ads_not_configured") } @Test - func appServicesMCPProviderUsesRequestGateRetryPipeline() async throws { + func appServicesMCPProviderKeepsOfficialClientOutsideLegacyHTTPRetryPipeline() async throws { var requestCount = 0 let retryPolicy = ProviderRequestPolicy( minimumIntervalNanoseconds: 0, @@ -602,23 +616,32 @@ struct OpenASOMCPServerTests { maximumElapsedNanoseconds: 1_000_000_000, jitterFraction: 0 ) + let platformAPI = StaticAppleAdsPlatformAPI( + apps: [], + popularityRows: [ + AppleAdsSearchTermPopularity( + searchTerm: "focus timer", + countryOrRegion: "US", + genre: "Productivity", + week: "2026-08-08", + month: nil, + rankInGenre: nil, + popularityInGenre: 61, + popularity1to100: 61, + popularity1to5: 3 + ) + ] + ) let context = try AppServicesMCPTestContext( httpClient: MockHTTPClient { request in requestCount += 1 - if requestCount == 1 { - return ( - Data(), - makeHTTPURLResponse(url: try #require(request.url), statusCode: 503) - ) - } - return ( - appleAdsPopularityPayload(keyword: "focus timer", popularity: 61), - makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) - ) + Issue.record("Official Apple Ads API should not enter the legacy retry pipeline: \(request)") + throw OpenASOError.providerUnavailable("Unexpected legacy request") }, providerRequestGateMode: .enabled( ProviderRequestPolicies(default: retryPolicy) - ) + ), + appleAdsPlatformAPI: platformAPI ) try context.insertTrackedKeyword(appStoreID: 123, keyword: "focus timer") try context.configureAppleAds(contextAppStoreID: 987) @@ -628,7 +651,7 @@ struct OpenASOMCPServerTests { appStoreID: 123 ) - #expect(requestCount == 2) + #expect(requestCount == 0) #expect(result.summary.refreshed == 1) #expect(result.summary.failed == 0) #expect(result.outcomes.first?.track.popularityScore == 61) @@ -959,7 +982,8 @@ private struct AppServicesMCPTestContext { init( httpClient: any HTTPClient, - providerRequestGateMode: ProviderRequestGateMode = .disabled + providerRequestGateMode: ProviderRequestGateMode = .disabled, + appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? = nil ) throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let defaultsSuiteName = "com.thirdtech.openaso.mcp-tests.\(UUID().uuidString)" @@ -971,6 +995,7 @@ private struct AppServicesMCPTestContext { httpClient: httpClient, defaults: defaults, keychain: InMemoryKeychainService(), + appleAdsPlatformAPI: appleAdsPlatformAPI, loadsEnvironmentCredentials: false, allowsIconNetworkFetches: false, backgroundModelStore: BackgroundModelStore(modelContainer: container), @@ -1005,12 +1030,14 @@ private struct AppServicesMCPTestContext { } func configureAppleAds(contextAppStoreID: Int64) throws { - services.settingsStore.savePopularityContextAppStoreID(contextAppStoreID) - try services.appleAdsWebSessionStore.save( - AppleAdsWebSession( - cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", - xsrfToken: "token", - updatedAt: .now + try services.appleAdsCredentialStore.saveAPICredentials( + AppleAdsCredentials( + clientID: "SEARCHADS.mcp-server-fixture", + teamID: "fixture-team", + keyID: "fixture-key", + privateKey: "fixture-private-key", + orgID: "fixture-org", + adAccountID: String(contextAppStoreID) ) ) } diff --git a/OpenASOTests/OpenASOMCPServiceTests.swift b/OpenASOTests/OpenASOMCPServiceTests.swift index 2ccd5db..95094b7 100644 --- a/OpenASOTests/OpenASOMCPServiceTests.swift +++ b/OpenASOTests/OpenASOMCPServiceTests.swift @@ -966,7 +966,10 @@ struct OpenASOMCPServiceTests { #expect(metricsRefresh.summary.failed == 1) #expect(metricsRefresh.outcomes.first?.rankingProvenance == nil) #expect(metricsRefresh.outcomes.first?.error?.code == "apple_ads_not_configured") - #expect(metricsRefresh.outcomes.first?.track.statusMessage?.contains("Connect an Apple Ads") == true) + #expect( + metricsRefresh.outcomes.first?.track.statusMessage + == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + ) } @Test @@ -1920,30 +1923,26 @@ struct OpenASOMCPServiceTests { } @Test - func refreshKeywordMetricsReportsExpiredSessionOnceAtBatchLevel() async throws { + func refreshKeywordMetricsReportsOfficialAuthorizationFailurePerTrack() async throws { let resolver = StubMCPAppResolver(resolvedApps: [ 123: makeResolvedApp(appStoreID: 123, name: "Cal AI") ]) - let requestCount = Mutex(0) - let session = AppleAdsWebSession( - cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", - xsrfToken: "token", - updatedAt: .now + let credentials = AppleAdsCredentials( + clientID: "SEARCHADS.mcp-service-fixture", + teamID: "fixture-team", + keyID: "fixture-key", + privateKey: "fixture-private-key", + orgID: "fixture-org", + adAccountID: "123" ) let context = try MCPTestContext( resolver: resolver, includeKeywordMetricsService: true, - popularityContextAppStoreIDProvider: { 123 }, - appleAdsWebSessionProvider: { session }, - httpHandler: { request in - requestCount.withLock { $0 += 1 } - return ( - Data(), - makeHTTPURLResponse(url: try #require(request.url), statusCode: 403) - ) - } + appleAdsPlatformAPI: FailingSearchPopularityAPI( + error: .providerUnavailable("Apple Ads Platform API authorization failed.") + ), + appleAdsCredentials: credentials ) - try context.appleAdsWebSessionStore.save(session) _ = try await context.service.addTrackedApp(appStoreID: 123, storefront: "us") _ = try await context.service.addKeywords( appStoreID: 123, @@ -1958,42 +1957,33 @@ struct OpenASOMCPServiceTests { platform: "iphone" ) - #expect(requestCount.withLock { $0 } == 1) - #expect(metricsRefresh.summary.updated == 0) - #expect(metricsRefresh.summary.skipped == 2) + #expect(metricsRefresh.summary.updated == 2) + #expect(metricsRefresh.summary.skipped == 0) #expect(metricsRefresh.summary.refreshed == 0) - #expect(metricsRefresh.summary.failed == 1) + #expect(metricsRefresh.summary.failed == 2) #expect(metricsRefresh.outcomes.count == 2) - #expect(metricsRefresh.outcomes.allSatisfy { $0.error == nil }) - #expect(metricsRefresh.batchSummary?.skipped == 2) - #expect(metricsRefresh.batchSummary?.errors.count == 1) - #expect(metricsRefresh.batchSummary?.errors.first?.code == "apple_ads_session_expired") - #expect(context.appleAdsWebSessionStore.requiresReconnect) + #expect(metricsRefresh.outcomes.allSatisfy { + $0.error?.message + == "Popularity failed to fetch. Apple Ads Platform API authorization failed." + }) + #expect(metricsRefresh.batchSummary == nil) } @Test - func refreshKeywordMetricsSurfacesReconnectMarkerBesideFreshCachedPopularity() async throws { + func refreshKeywordMetricsUsesFreshCachedPopularityWithoutPlatformCredentials() async throws { let resolver = StubMCPAppResolver(resolvedApps: [ 123: makeResolvedApp(appStoreID: 123, name: "Cal AI") ]) let requestCount = Mutex(0) - let session = AppleAdsWebSession( - cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", - xsrfToken: "token", - updatedAt: .now - ) let context = try MCPTestContext( resolver: resolver, includeKeywordMetricsService: true, - popularityContextAppStoreIDProvider: { 123 }, - appleAdsWebSessionProvider: { session }, httpHandler: { request in requestCount.withLock { $0 += 1 } Issue.record("Unexpected Apple Ads request to \(request.url?.absoluteString ?? "unknown URL")") throw OpenASOError.providerUnavailable("Unexpected request") } ) - try context.appleAdsWebSessionStore.save(session) _ = try await context.service.addTrackedApp(appStoreID: 123, storefront: "us") _ = try await context.service.addKeywords( appStoreID: 123, @@ -2018,8 +2008,6 @@ struct OpenASOMCPServiceTests { ) ) try context.modelContext.save() - context.appleAdsWebSessionStore.markReconnectRequired(for: session) - let metricsRefresh = try await context.service.refreshKeywordMetrics( appStoreID: 123, storefronts: ["us"], @@ -2030,11 +2018,10 @@ struct OpenASOMCPServiceTests { #expect(metricsRefresh.summary.updated == 1) #expect(metricsRefresh.summary.skipped == 0) #expect(metricsRefresh.summary.refreshed == 1) - #expect(metricsRefresh.summary.failed == 1) + #expect(metricsRefresh.summary.failed == 0) #expect(metricsRefresh.outcomes.first?.track.popularityScore == 42) #expect(metricsRefresh.outcomes.first?.error == nil) - #expect(metricsRefresh.batchSummary?.skipped == 0) - #expect(metricsRefresh.batchSummary?.errors.map(\.code) == ["apple_ads_session_expired"]) + #expect(metricsRefresh.batchSummary == nil) } @Test @@ -2045,8 +2032,6 @@ struct OpenASOMCPServiceTests { let context = try MCPTestContext( resolver: resolver, includeKeywordMetricsService: true, - popularityContextAppStoreIDProvider: { nil }, - appleAdsWebSessionProvider: { nil }, httpHandler: { request in Issue.record("Unexpected Apple Ads request to \(request.url?.absoluteString ?? "unknown URL")") throw OpenASOError.providerUnavailable("Unexpected request") @@ -2069,8 +2054,11 @@ struct OpenASOMCPServiceTests { #expect(metricsRefresh.summary.failed == 1) #expect(metricsRefresh.summary.refreshed == 0) #expect(metricsRefresh.outcomes.first?.track.popularityScore == nil) - #expect(metricsRefresh.outcomes.first?.error?.code == "keyword_popularity_unavailable") - #expect(metricsRefresh.outcomes.first?.error?.message.contains("Reconnect Apple Ads") == true) + #expect(metricsRefresh.outcomes.first?.error?.code == "apple_ads_not_configured") + #expect( + metricsRefresh.outcomes.first?.error?.message + == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + ) } @Test @@ -3323,8 +3311,8 @@ private struct MCPTestContext { persistRankingRefreshAttempts: OpenASOMCPService.RankingRefreshAttemptsPersistence? = nil, includeReviewService: Bool = false, includeKeywordMetricsService: Bool = false, - popularityContextAppStoreIDProvider: @escaping @MainActor @Sendable () -> Int64? = { nil }, - appleAdsWebSessionProvider: @escaping @MainActor @Sendable () -> AppleAdsWebSession? = { nil }, + appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? = nil, + appleAdsCredentials: AppleAdsCredentials? = nil, screenshotDataProvider: ScreenshotDownloadService.DataProvider? = nil, now: @escaping @Sendable () -> Date = { isoDate("2026-05-07T12:00:00Z") }, httpHandler: @escaping (URLRequest) throws -> (Data, URLResponse) = { request in @@ -3345,6 +3333,14 @@ private struct MCPTestContext { defaults: testDefaults, keychain: InMemoryKeychainService() ) + let appleAdsCredentialStore = AppleAdsCredentialStore( + defaults: testDefaults, + keychain: InMemoryKeychainService(), + loadsEnvironmentCredentials: false + ) + if let appleAdsCredentials { + try appleAdsCredentialStore.saveAPICredentials(appleAdsCredentials) + } let rankingRefreshCoordinator = rankingProvider.flatMap { provider in useRankingRefreshCoordinator ? RankingRefreshCoordinator( @@ -3368,13 +3364,14 @@ private struct MCPTestContext { keywordMetricsService: includeKeywordMetricsService ? KeywordMetricsService( httpClient: httpClient, - credentialStore: AppleAdsCredentialStore(keychain: InMemoryKeychainService()), + credentialStore: appleAdsCredentialStore, settingsStore: AppSettingsStore(defaults: testDefaults), - webSessionStore: appleAdsWebSessionStore + webSessionStore: appleAdsWebSessionStore, + apiClient: appleAdsPlatformAPI ?? StaticAppleAdsPlatformAPI(apps: []) ) : nil, - popularityContextAppStoreIDProvider: popularityContextAppStoreIDProvider, - appleAdsWebSessionProvider: appleAdsWebSessionProvider, + appleAdsPlatformAPI: appleAdsPlatformAPI, + appleAdsCredentialsProvider: { appleAdsCredentialStore.apiCredentials }, now: now ) } diff --git a/OpenASOTests/RefreshObservabilityTests.swift b/OpenASOTests/RefreshObservabilityTests.swift index e7ef453..a5a9985 100644 --- a/OpenASOTests/RefreshObservabilityTests.swift +++ b/OpenASOTests/RefreshObservabilityTests.swift @@ -511,7 +511,7 @@ struct RefreshObservabilityTests { } @Test - func appDetailMetricsExpiryRecordsOneFailureWithoutRewritingRankingOutcomes() async throws { + func appDetailMissingPlatformCredentialsRecordsPerTrackFailuresWithoutRewritingRankingOutcomes() async throws { let httpClient = RankingAndExpiredAppleAdsHTTPClient() let session = AppleAdsWebSession( cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", @@ -541,16 +541,19 @@ struct RefreshObservabilityTests { #expect(result.keywordOutcomes.count == 3) #expect(result.keywordOutcomes.allSatisfy { $0.error == nil }) - #expect(result.firstError?.localizedDescription.contains(AppleAdsWebSessionExpiredError.message) == true) + #expect( + result.firstError?.localizedDescription + == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + ) #expect(rankings.attemptedCount == 3) #expect(rankings.failureCount == 0) #expect(metrics.attemptedCount == 3) - #expect(metrics.failureCount == 1) + #expect(metrics.failureCount == 3) #expect(summary.result == .partialFailure) // The fixture returns iTunes JSON for both ranking hosts, so every // web parse fails safely and falls back to the iTunes provider. #expect(await httpClient.rankingRequestCount() == 6) - #expect(await httpClient.popularityRequestCount() == 1) + #expect(await httpClient.popularityRequestCount() == 0) } @Test diff --git a/OpenASOTests/TrackedKeywordRefreshStatusTests.swift b/OpenASOTests/TrackedKeywordRefreshStatusTests.swift index 27119de..5033987 100644 --- a/OpenASOTests/TrackedKeywordRefreshStatusTests.swift +++ b/OpenASOTests/TrackedKeywordRefreshStatusTests.swift @@ -470,8 +470,6 @@ struct TrackedKeywordRefreshStatusTests { _ = try await services.keywordMetricsService.refreshMetrics( for: identityKeys, - popularityContextAppStoreID: nil, - webSession: nil, using: backgroundStore ) @@ -527,8 +525,6 @@ struct TrackedKeywordRefreshStatusTests { _ = try await services.keywordMetricsService.refreshMetrics( for: identityKeys, - popularityContextAppStoreID: 123_456_789, - webSession: nil, using: backgroundStore ) @@ -540,7 +536,10 @@ struct TrackedKeywordRefreshStatusTests { } for identityKey in identityKeys { #expect(snapshots[identityKey]?.rankingMessage == "Ranking failed to refresh. Preserve this failure.") - #expect(snapshots[identityKey]?.popularityMessage?.contains("Connect an Apple Ads") == true) + #expect( + snapshots[identityKey]?.popularityMessage + == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + ) } } From ac1e509fadaf071ffb66c015d8708aac37534eb2 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:58:04 +0100 Subject: [PATCH 2/8] Improve Apple Ads credential setup --- OpenASO.xcodeproj/project.pbxproj | 4 + .../AppleAdsPlatformCredentialsSection.swift | 132 ++++++++++++++++++ OpenASO/Features/Settings/SettingsView.swift | 100 +++---------- .../AppleAds/AppleAdsCredentials.swift | 23 +++ .../AppleAdsPlatformServiceTests.swift | 33 +++++ 5 files changed, 213 insertions(+), 79 deletions(-) create mode 100644 OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index aa9209b..2ad7ad1 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ AD5100000000000000000002 /* AppleAds/AppleAdsPlatformService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */; }; AD5200000000000000000001 /* AppleAdsPlatformWorkspaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5200000000000000000002 /* AppleAdsPlatformWorkspaceView.swift */; }; AD5300000000000000000001 /* AppleAdsPlatformServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5300000000000000000002 /* AppleAdsPlatformServiceTests.swift */; }; + AD5400000000000000000001 /* AppleAdsPlatformCredentialsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5400000000000000000002 /* AppleAdsPlatformCredentialsSection.swift */; }; F1A200000000000000000001 /* AppStoreIDInputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A200000000000000000002 /* AppStoreIDInputParser.swift */; }; F1A200000000000000000003 /* AppStoreIDInputParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A200000000000000000004 /* AppStoreIDInputParserTests.swift */; }; B9A0000000000000000005 /* AppleAdsPastedSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A0000000000000000006 /* AppleAdsPastedSessionTests.swift */; }; @@ -354,6 +355,7 @@ AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsPlatformService.swift; sourceTree = ""; }; AD5200000000000000000002 /* AppleAdsPlatformWorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformWorkspaceView.swift; sourceTree = ""; }; AD5300000000000000000002 /* AppleAdsPlatformServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformServiceTests.swift; sourceTree = ""; }; + AD5400000000000000000002 /* AppleAdsPlatformCredentialsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformCredentialsSection.swift; sourceTree = ""; }; F1A200000000000000000002 /* AppStoreIDInputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreIDInputParser.swift; sourceTree = ""; }; F1A200000000000000000004 /* AppStoreIDInputParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreIDInputParserTests.swift; sourceTree = ""; }; B9A0000000000000000006 /* AppleAdsPastedSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPastedSessionTests.swift; sourceTree = ""; }; @@ -987,6 +989,7 @@ C10000000000000000000006 /* Settings */ = { isa = PBXGroup; children = ( + AD5400000000000000000002 /* AppleAdsPlatformCredentialsSection.swift */, 04E8C4E18F1A4D519C0A1139 /* AppleAdsSettingsConnectionState.swift */, 04E8C4E18F1A4D519C0A113D /* SettingsViewPreviews.swift */, 04E8C4E18F1A4D519C0A113B /* AppleAdsSettingsStatusRows.swift */, @@ -1264,6 +1267,7 @@ E78A00000000000000000001 /* OpenASOExecutionMode.swift in Sources */, E8BA00000000000000000003 /* CrossProcessFileLock.swift in Sources */, 04E8C4E18F1A4D519C0A113A /* AppleAdsSettingsStatusRows.swift in Sources */, + AD5400000000000000000001 /* AppleAdsPlatformCredentialsSection.swift in Sources */, D35D00000000000000000002 /* DailyRefreshRunStatusView.swift in Sources */, E8BA00000000000000000004 /* BackgroundRefreshAgentStatusView.swift in Sources */, 04E8C4E18F1A4D519C0A110B /* SettingsView.swift in Sources */, diff --git a/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift b/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift new file mode 100644 index 0000000..d8cfda0 --- /dev/null +++ b/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift @@ -0,0 +1,132 @@ +import SwiftUI + +struct AppleAdsPlatformCredentialsSection: View { + @Binding var clientID: String + @Binding var teamID: String + @Binding var keyID: String + @Binding var privateKey: String + @Binding var adAccountID: String + + let clientVersion: String + let operationCount: Int + let privateKeyValidationIssue: String? + let canVerify: Bool + let hasStoredCredentials: Bool + let isVerifying: Bool + let status: VerificationStatus? + let verifyAction: () -> Void + let clearAction: () -> Void + + var body: some View { + Section { + VStack(alignment: .leading) { + Text("Client ID") + .font(.callout) + .bold() + TextField("Client ID", text: $clientID, prompt: Text("SEARCHADS.…")) + .labelsHidden() + .textContentType(.username) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .accessibilityHint("The OAuth client identifier issued by Apple Ads.") + } + + VStack(alignment: .leading) { + Text("Team ID") + .font(.callout) + .bold() + TextField("Team ID", text: $teamID, prompt: Text("SEARCHADS.…")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .accessibilityHint("The Apple Ads team identifier associated with the private key.") + } + + VStack(alignment: .leading) { + Text("Key ID") + .font(.callout) + .bold() + TextField("Key ID", text: $keyID, prompt: Text("Key identifier")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .accessibilityHint("The identifier for the Apple Ads private key.") + } + + VStack(alignment: .leading) { + Text("Private key") + .font(.callout) + .bold() + TextField( + "Private key", + text: $privateKey, + prompt: Text("Paste BEGIN PRIVATE KEY…"), + axis: .vertical + ) + .labelsHidden() + .lineLimit(3...7) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .privacySensitive() + .accessibilityHint("Paste the complete private p8 key. Public keys cannot authenticate requests.") + + if let privateKeyValidationIssue { + Label(privateKeyValidationIssue, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + + VStack(alignment: .leading) { + Text("Ad account") + .font(.callout) + .bold() + TextField("Ad account", text: $adAccountID, prompt: Text("Select automatically")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .accessibilityHint("Optional. Leave empty to use the first accessible Apple Ads account.") + } + + HStack(spacing: 10) { + Button("Verify & Save", action: verifyAction) + .buttonStyle(.borderedProminent) + .disabled(isVerifying || !canVerify || privateKeyValidationIssue != nil) + + if isVerifying { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Verifying Apple Ads credentials") + } + + Spacer() + + Link( + "Setup guide", + destination: URL(string: "https://developer.apple.com/documentation/apple-ads-platform-api")! + ) + + Button("Clear", role: .destructive, action: clearAction) + .disabled(isVerifying || !hasStoredCredentials) + } + + if let status { + Label(status.message, systemImage: status.systemImage) + .foregroundStyle(status.tint) + .fixedSize(horizontal: false, vertical: true) + } + } header: { + HStack { + Text("Apple Ads Platform API") + Spacer() + Text("Apple client \(clientVersion) · \(operationCount) operations") + .font(.callout) + .foregroundStyle(.secondary) + } + } footer: { + Text("The private key is stored in macOS Keychain. Search Term Popularity and other Apple Ads requests use this API connection.") + } + } +} diff --git a/OpenASO/Features/Settings/SettingsView.swift b/OpenASO/Features/Settings/SettingsView.swift index 92b06d0..106e96b 100644 --- a/OpenASO/Features/Settings/SettingsView.swift +++ b/OpenASO/Features/Settings/SettingsView.swift @@ -308,85 +308,22 @@ struct SettingsView: View { } private var appleAdsPlatformSection: some View { - Section { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "apple.logo") - .imageScale(.large) - .frame(width: 20) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 3) { - Text("Official Swift client") - .font(.headline) - Text("Version \(services.appleAdsPlatformAPI.coverage.clientVersion) · \(services.appleAdsPlatformAPI.coverage.operationCount) generated operations") - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - - Text("1. Enter the OAuth client identifier Apple issued for Apple Ads.") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Client ID", text: $clientID) - .textContentType(.username) - - Text("2. Add the team and key identifiers for the matching private key.") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Team ID", text: $teamID) - TextField("Key ID", text: $keyID) - - Text("3. Paste the complete .p8 private key. It is stored only in macOS Keychain.") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Private Key (.p8 PEM)", text: $privateKey, axis: .vertical) - .lineLimit(3...8) - .font(.system(.caption, design: .monospaced)) - .privacySensitive() - - Text("4. Verify to discover your accessible ad accounts. Leave Ad Account ID empty to select the first available account.") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Ad Account ID (optional before verification)", text: $adAccountID) - - HStack(spacing: 10) { - Button("Verify & Save", action: verifyAndSaveAppleAdsPlatformCredentials) - .disabled(isVerifyingAppleAdsPlatform || !enteredCredentials.canVerify) - - if isVerifyingAppleAdsPlatform { - ProgressView() - .controlSize(.small) - } - - Spacer() - - Button( - "Clear API Credentials", - role: .destructive, - action: clearAppleAdsPlatformCredentials - ) - .disabled( - isVerifyingAppleAdsPlatform - || !services.appleAdsCredentialStore.hasCompleteAPICredentials - ) - } - - if let appleAdsPlatformStatus { - Label(appleAdsPlatformStatus.message, systemImage: appleAdsPlatformStatus.systemImage) - .foregroundStyle(appleAdsPlatformStatus.tint) - .font(.caption) - } - - Link( - "Apple Ads Platform API setup documentation", - destination: URL(string: "https://developer.apple.com/documentation/apple-ads-platform-api")! - ) - .font(.caption) - } header: { - Text("Apple Ads Platform API") - } footer: { - Text("This connection powers campaign and owned-app management plus public Search Term Popularity data through the UI and MCP. Browser cookies are no longer required for popularity refreshes.") - } + AppleAdsPlatformCredentialsSection( + clientID: $clientID, + teamID: $teamID, + keyID: $keyID, + privateKey: $privateKey, + adAccountID: $adAccountID, + clientVersion: services.appleAdsPlatformAPI.coverage.clientVersion, + operationCount: services.appleAdsPlatformAPI.coverage.operationCount, + privateKeyValidationIssue: enteredCredentials.privateKeyValidationIssue, + canVerify: enteredCredentials.canVerify, + hasStoredCredentials: services.appleAdsCredentialStore.hasCompleteAPICredentials, + isVerifying: isVerifyingAppleAdsPlatform, + status: appleAdsPlatformStatus, + verifyAction: verifyAndSaveAppleAdsPlatformCredentials, + clearAction: clearAppleAdsPlatformCredentials + ) } private var savedLoginControls: some View { @@ -774,6 +711,11 @@ struct SettingsView: View { } private func verifyAndSaveAppleAdsPlatformCredentials() { + if let validationIssue = enteredCredentials.privateKeyValidationIssue { + appleAdsPlatformStatus = .failure(validationIssue) + return + } + isVerifyingAppleAdsPlatform = true appleAdsPlatformStatus = nil diff --git a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift index 5987339..4a8b4dd 100644 --- a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift +++ b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift @@ -52,6 +52,29 @@ struct AppleAdsCredentials: Equatable, Sendable { && !credentials.keyID.isEmpty && !credentials.privateKey.isEmpty } + + var privateKeyValidationIssue: String? { + let key = trimmed.privateKey + guard !key.isEmpty else { return nil } + + if key.contains("-----BEGIN PUBLIC KEY-----") { + return "This is a public key. Paste the matching private .p8 key so OpenASO can sign Apple Ads requests." + } + + if key.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") { + return "Encrypted private keys are not supported. Export the unencrypted Apple-issued .p8 key." + } + + let hasPKCS8PrivateKey = key.contains("-----BEGIN PRIVATE KEY-----") + && key.contains("-----END PRIVATE KEY-----") + let hasECPrivateKey = key.contains("-----BEGIN EC PRIVATE KEY-----") + && key.contains("-----END EC PRIVATE KEY-----") + guard hasPKCS8PrivateKey || hasECPrivateKey else { + return "Paste the complete Apple-issued private .p8 key, including its BEGIN and END lines." + } + + return nil + } } @MainActor diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index 68d9eb7..195c411 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -77,6 +77,39 @@ struct AppleAdsPlatformServiceTests { #expect(row.id == "US|focus|2026-08-02|Productivity") } + @Test + func credentialValidationRejectsPublicKeyBeforeVerification() { + let credentials = AppleAdsCredentials( + clientID: "client", + teamID: "team", + keyID: "key", + privateKey: """ + -----BEGIN PUBLIC KEY----- + cHVibGlj + -----END PUBLIC KEY----- + """ + ) + + #expect(credentials.canVerify) + #expect(credentials.privateKeyValidationIssue?.contains("public key") == true) + } + + @Test + func credentialValidationAcceptsPrivatePEMEnvelope() { + let credentials = AppleAdsCredentials( + clientID: "client", + teamID: "team", + keyID: "key", + privateKey: """ + -----BEGIN PRIVATE KEY----- + cHJpdmF0ZQ== + -----END PRIVATE KEY----- + """ + ) + + #expect(credentials.privateKeyValidationIssue == nil) + } + @MainActor @Test func credentialStorePersistsAdAccountAndKeepsPrivateKeyInKeychain() throws { From 78091184ffe9c8a6b1b6fa9185ab575ba22109ee Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:44:15 +0100 Subject: [PATCH 3/8] Fix Apple Ads popularity requests --- .../AppleAds/AppleAdsPlatformService.swift | 68 +++++++++++-------- .../AppleAdsPlatformServiceTests.swift | 18 +++++ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift index 8d8d5a1..01fa4ad 100644 --- a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift +++ b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift @@ -304,34 +304,12 @@ struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { headers: .init( xApContext: XApContext(adAccountID: accountID).rawValue ), - body: .json(.init( - filters: [ - .init( - field: "countryOrRegion", - _operator: .equals, - value: try OpenAPIValueContainer( - unvalidatedValue: countryCode - ) - ), - .init( - field: "searchTerm", - _operator: ._in, - value: try OpenAPIValueContainer( - unvalidatedValue: batch - ) - ), - ], - sorting: [ - .init(field: "week", order: .desc), - .init(field: "rankInGenre", order: .asc), - ], - timeRange: .init( - start: window.start, - end: window.end, - timeZone: .utc, - granularity: .weeklySunSat - ), - pagination: .init(offset: offset, pageSize: pageSize) + body: .json(try Self.searchTermPopularityRequest( + countryCode: countryCode, + searchTerms: batch, + window: window, + offset: offset, + pageSize: pageSize )) ) ) @@ -372,6 +350,40 @@ struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { } } + static func searchTermPopularityRequest( + countryCode: String, + searchTerms: [String], + window: AppleAdsSearchTermPopularityWindow, + offset: Int, + pageSize: Int + ) throws -> Components.Schemas.SearchTermPopularityQueryRequest { + .init( + filters: [ + .init( + field: "countryOrRegion", + _operator: .equals, + value: try OpenAPIValueContainer(unvalidatedValue: countryCode) + ), + .init( + field: "searchTerm", + _operator: ._in, + value: try OpenAPIValueContainer(unvalidatedValue: searchTerms) + ), + ], + // AppleAdsClient 1.109.0 encodes generated sort items with an `order` + // property that the live endpoint rejects. Its documented default order + // is sufficient for pagination, and rows are sorted locally before return. + sorting: nil, + timeRange: .init( + start: window.start, + end: window.end, + timeZone: .utc, + granularity: .weeklySunSat + ), + pagination: .init(offset: offset, pageSize: pageSize) + ) + } + private func credentialsWithAccount( _ credentials: AppleAdsCredentials ) async throws -> AppleAdsCredentials { diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index 195c411..b06b09c 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -77,6 +77,24 @@ struct AppleAdsPlatformServiceTests { #expect(row.id == "US|focus|2026-08-02|Productivity") } + @Test + func searchTermPopularityRequestOmitsRejectedGeneratedSortingProperty() throws { + let request = try OfficialAppleAdsPlatformAPI.searchTermPopularityRequest( + countryCode: "US", + searchTerms: ["focus"], + window: .init(start: "2026-07-12", end: "2026-08-08"), + offset: 0, + pageSize: 5_000 + ) + let data = try JSONEncoder().encode(request) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + #expect(object["sorting"] == nil) + #expect(String(decoding: data, as: UTF8.self).contains("\"order\"") == false) + } + @Test func credentialValidationRejectsPublicKeyBeforeVerification() { let credentials = AppleAdsCredentials( From f55e9dadcebea4ba8a672ef69708d5ff8c8f582a Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:57:48 +0100 Subject: [PATCH 4/8] Fix Apple Ads campaign workspace --- .../AppleAdsPlatformWorkspaceView.swift | 17 ------- .../AppleAdsPlatformCredentialsSection.swift | 10 +--- OpenASO/Features/Settings/SettingsView.swift | 2 - .../AppleAds/AppleAdsPlatformService.swift | 50 ++++++++++++++----- .../AppleAdsPlatformServiceTests.swift | 31 ++++++++++++ 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift b/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift index 25166b5..cd71a21 100644 --- a/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift +++ b/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift @@ -33,7 +33,6 @@ final class AppleAdsPlatformWorkspaceModel { self.credentialStore = credentialStore } - var coverage: AppleAdsPlatformCoverage { api.coverage } var isConfigured: Bool { credentialStore.apiCredentials.canVerify } func load() async { @@ -188,8 +187,6 @@ private struct AppleAdsPlatformWorkspaceContent: View { var body: some View { NavigationStack { VStack(spacing: 0) { - coverageHeader - Divider() controls Divider() pageContent @@ -213,20 +210,6 @@ private struct AppleAdsPlatformWorkspaceContent: View { } } - private var coverageHeader: some View { - HStack(spacing: 24) { - Label("Official Apple Swift client", systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) - LabeledContent("Client", value: model.coverage.clientVersion) - LabeledContent("Generated operations", value: String(model.coverage.operationCount)) - Spacer() - Text(model.coverage.baseURL) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(16) - } - private var controls: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { diff --git a/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift b/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift index d8cfda0..ae31dea 100644 --- a/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift +++ b/OpenASO/Features/Settings/AppleAdsPlatformCredentialsSection.swift @@ -7,8 +7,6 @@ struct AppleAdsPlatformCredentialsSection: View { @Binding var privateKey: String @Binding var adAccountID: String - let clientVersion: String - let operationCount: Int let privateKeyValidationIssue: String? let canVerify: Bool let hasStoredCredentials: Bool @@ -118,13 +116,7 @@ struct AppleAdsPlatformCredentialsSection: View { .fixedSize(horizontal: false, vertical: true) } } header: { - HStack { - Text("Apple Ads Platform API") - Spacer() - Text("Apple client \(clientVersion) · \(operationCount) operations") - .font(.callout) - .foregroundStyle(.secondary) - } + Text("Apple Ads Platform API") } footer: { Text("The private key is stored in macOS Keychain. Search Term Popularity and other Apple Ads requests use this API connection.") } diff --git a/OpenASO/Features/Settings/SettingsView.swift b/OpenASO/Features/Settings/SettingsView.swift index 106e96b..f42d3ff 100644 --- a/OpenASO/Features/Settings/SettingsView.swift +++ b/OpenASO/Features/Settings/SettingsView.swift @@ -314,8 +314,6 @@ struct SettingsView: View { keyID: $keyID, privateKey: $privateKey, adAccountID: $adAccountID, - clientVersion: services.appleAdsPlatformAPI.coverage.clientVersion, - operationCount: services.appleAdsPlatformAPI.coverage.operationCount, privateKeyValidationIssue: enteredCredentials.privateKeyValidationIssue, canVerify: enteredCredentials.canVerify, hasStoredCredentials: services.appleAdsCredentialStore.hasCompleteAPICredentials, diff --git a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift index 01fa4ad..9678679 100644 --- a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift +++ b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift @@ -252,21 +252,45 @@ struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { ) let payload = try output.ok.body.json let encodedObjects = try JSONEncoder().encode(payload.value1.result ?? []) - let campaigns = try JSONDecoder().decode( - [Components.Schemas.Campaign].self, - from: encodedObjects + return try Self.campaignSummaries(fromEncodedObjects: encodedObjects) + } + } + + static func campaignSummaries( + fromEncodedObjects data: Data + ) throws -> [AppleAdsPlatformCampaignSummary] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + let fractionalSeconds = Date.ParseStrategy( + format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)T\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits):\(second: .twoDigits).\(secondFraction: .fractional(3))", + timeZone: .gmt ) - return campaigns.compactMap { campaign in - guard let id = campaign.id else { return nil } - return AppleAdsPlatformCampaignSummary( - id: id, - name: campaign.name?.nilIfEmpty ?? "Campaign \(id)", - status: campaign.status?.value1.rawValue, - displayStatus: campaign.displayStatus?.value1.rawValue, - promotedObjectID: campaign.promotedObjectId, - modifiedAt: campaign.modificationTime - ) + + if let date = try? Date(value, strategy: fractionalSeconds) { + return date } + if let date = try? Date(value, strategy: .iso8601) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid Apple Ads date: \(value)" + ) + } + + let campaigns = try decoder.decode([Components.Schemas.Campaign].self, from: data) + return campaigns.compactMap { campaign in + guard let id = campaign.id else { return nil } + return AppleAdsPlatformCampaignSummary( + id: id, + name: campaign.name?.nilIfEmpty ?? "Campaign \(id)", + status: campaign.status?.value1.rawValue, + displayStatus: campaign.displayStatus?.value1.rawValue, + promotedObjectID: campaign.promotedObjectId, + modifiedAt: campaign.modificationTime + ) } } diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index b06b09c..3fe68e5 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -95,6 +95,37 @@ struct AppleAdsPlatformServiceTests { #expect(String(decoding: data, as: UTF8.self).contains("\"order\"") == false) } + @Test + func campaignSummariesDecodeAppleFractionalSecondDates() throws { + let data = Data( + #""" + [ + { + "displayStatus": "ON_HOLD", + "id": 1899370629, + "modificationTime": "2026-08-06T17:28:28.018", + "name": "China Search Results", + "promotedObjectId": "6608976383", + "status": "PAUSED" + } + ] + """#.utf8 + ) + + let campaign = try #require( + OfficialAppleAdsPlatformAPI.campaignSummaries( + fromEncodedObjects: data + ).first + ) + + #expect(campaign.id == 1_899_370_629) + #expect(campaign.name == "China Search Results") + #expect(campaign.status == "PAUSED") + #expect(campaign.displayStatus == "ON_HOLD") + #expect(campaign.promotedObjectID == "6608976383") + #expect(campaign.modifiedAt != nil) + } + @Test func credentialValidationRejectsPublicKeyBeforeVerification() { let credentials = AppleAdsCredentials( From 984cc84890e67be37660762c43160ed2c1c6c2f9 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:07:12 +0100 Subject: [PATCH 5/8] Keep keyword sorting active during refresh --- .../Keywords/Table/KeywordTableView.swift | 40 +++++-------------- .../KeywordWorkspaceProjectionTests.swift | 12 +++--- 2 files changed, 15 insertions(+), 37 deletions(-) diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift index b94c788..7fabd9b 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift @@ -105,10 +105,9 @@ struct KeywordTableView: View, Equatable { setChartSelection: setChartSelection, openAppleAdsSettings: openAppleAdsSettings ) - // Rebuilding the virtualized table is substantially cheaper than - // asking NSTableView to diff hundreds of moved rows after a sort. - // Refresh deltas keep this identity stable and still update cells - // in place. + // Deliberate user sorts rebuild the virtualized table once. + // Refresh deltas keep this identity stable while the presentation + // model maintains the selected order as values change. .id(presentationModel.tableIdentity) .contextMenu(forSelectionType: String.self) { selectedIDs in let contextRows = selectedRows(for: selectedIDs) @@ -142,11 +141,6 @@ struct KeywordTableView: View, Equatable { updatePresentationRows(forceSort: true) } .onChange(of: sortOrder) { - // Keep the header selection responsive while a refresh is emitting - // row deltas. The final workspace materialization increments - // sortRevision and applies this selected order once, instead of - // rebuilding the ten-column table in the middle of the refresh. - guard !isTrackedAppRefreshRunning else { return } updatePresentationRows(forceSort: true) } .onChange(of: chartSelections) { @@ -239,25 +233,6 @@ struct KeywordTableView: View, Equatable { ) } - private var isTrackedAppRefreshRunning: Bool { - guard let refresh = services.refreshProgressStore.activeRefresh, - refresh.appStoreID == trackedAppStoreID else { - return false - } - - switch refresh.phase { - case .completed, .failed: - return false - case .preparing, - .refreshingKeywords, - .refreshingMetrics, - .refreshingRatings, - .refreshingReviews, - .finishing: - return true - } - } - private func openAppleAdsSettings() { services.settingsStore.requestSettingsFocus(.platformAPI) openSettings() @@ -643,9 +618,12 @@ final class KeywordTablePresentationModel { sortCount &+= 1 tableIdentity &+= 1 } else { - // Preserve the current visual order while refresh deltas arrive. - // This lets SwiftUI diff stable row IDs and update only changed cells. - rows = rowIDs.compactMap { incomingRowsByID[$0] } + // Keep the active table sort correct as refresh deltas change values, + // but retain the table's identity so SwiftUI only moves affected rows. + rows = rowIDs + .compactMap { incomingRowsByID[$0] } + .sorted(using: sortOrder) + sortCount &+= 1 } rowIDs = rows.map(\.id) } diff --git a/OpenASOTests/KeywordWorkspaceProjectionTests.swift b/OpenASOTests/KeywordWorkspaceProjectionTests.swift index 492b6c4..32c7e37 100644 --- a/OpenASOTests/KeywordWorkspaceProjectionTests.swift +++ b/OpenASOTests/KeywordWorkspaceProjectionTests.swift @@ -717,7 +717,7 @@ struct KeywordWorkspaceProjectionTests { } @Test - func refreshDeltasPreserveVisualOrderUntilExplicitResort() throws { + func refreshDeltasMaintainSelectedSortWithoutRebuildingTable() throws { let sourceRows = (0..<338).map { index in makeRow( term: "Keyword \(index.formatted(.number.precision(.integerLength(3))))", @@ -766,11 +766,11 @@ struct KeywordWorkspaceProjectionTests { ) } - #expect(model.rowIDs == initialIDs) - #expect(model.sortCount == 1) + #expect(model.rowIDs != initialIDs) + #expect(model.sortCount == 26) #expect(model.tableIdentity == 1) - #expect(model.rows.last?.id == updatedFirstRow.id) - #expect(model.rows.last?.popularitySortValue == 1_000) + #expect(model.rows.first?.id == updatedFirstRow.id) + #expect(model.rows.first?.popularitySortValue == 1_000) model.update( sourceRows: updatedSourceRows, @@ -779,7 +779,7 @@ struct KeywordWorkspaceProjectionTests { forceSort: true ) - #expect(model.sortCount == 2) + #expect(model.sortCount == 27) #expect(model.tableIdentity == 2) #expect(model.rows.first?.id == updatedFirstRow.id) } From 58fdc4d71021e1513a0577a66fdb6a4a4fbc8697 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:59:28 +0100 Subject: [PATCH 6/8] Load Apple Ads credentials from local env file --- .env.example | 2 + .../AppleAds/AppleAdsCredentials.swift | 65 +++++++++++++++++-- .../AppleAdsPlatformServiceTests.swift | 41 ++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 9ea1cc5..afe0643 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,9 @@ APPLE_SEARCH_ADS_CLIENT_ID= APPLE_SEARCH_ADS_TEAM_ID= APPLE_SEARCH_ADS_KEY_ID= APPLE_SEARCH_ADS_PRIVATE_KEY= +APPLE_SEARCH_ADS_PRIVATE_KEY_PATH= APPLE_SEARCH_ADS_ORG_ID= +APPLE_ADS_PLATFORM_AD_ACCOUNT_ID= # App Store Connect API credentials # These are documented placeholders for local setup. Enter credentials in the app Settings UI unless environment loading is added. diff --git a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift index 4a8b4dd..6b930af 100644 --- a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift +++ b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift @@ -218,7 +218,7 @@ final class AppleAdsCredentialStore { } } -private struct EnvironmentAppleAdsCredentials { +struct EnvironmentAppleAdsCredentials { var clientID = "" var teamID = "" var keyID = "" @@ -226,13 +226,24 @@ private struct EnvironmentAppleAdsCredentials { var orgID = "" var adAccountID = "" - static func load() -> EnvironmentAppleAdsCredentials { - let environment = ProcessInfo.processInfo.environment + static func load( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> EnvironmentAppleAdsCredentials { + let environment = mergedEnvironment(processEnvironment: environment) + let privateKey = value( + for: ["APPLE_SEARCH_ADS_PRIVATE_KEY", "privateKey"], + environment: environment + ) + let privateKeyPath = value( + for: ["APPLE_SEARCH_ADS_PRIVATE_KEY_PATH"], + environment: environment + ) + return EnvironmentAppleAdsCredentials( clientID: value(for: ["APPLE_SEARCH_ADS_CLIENT_ID", "clientId"], environment: environment), teamID: value(for: ["APPLE_SEARCH_ADS_TEAM_ID", "teamId"], environment: environment), keyID: value(for: ["APPLE_SEARCH_ADS_KEY_ID", "keyId"], environment: environment), - privateKey: value(for: ["APPLE_SEARCH_ADS_PRIVATE_KEY", "privateKey"], environment: environment), + privateKey: privateKey.isEmpty ? privateKeyContents(at: privateKeyPath) : privateKey, orgID: value(for: ["APPLE_SEARCH_ADS_ORG_ID", "orgId"], environment: environment), adAccountID: value( for: ["APPLE_ADS_PLATFORM_AD_ACCOUNT_ID", "APPLE_SEARCH_ADS_AD_ACCOUNT_ID", "adAccountId"], @@ -241,6 +252,52 @@ private struct EnvironmentAppleAdsCredentials { ) } + private static func mergedEnvironment( + processEnvironment: [String: String] + ) -> [String: String] { + var environment = environmentFileValues( + at: processEnvironment["OPENASO_ENV_FILE"] + ) + for (key, value) in processEnvironment where !value.isEmpty { + environment[key] = value + } + return environment + } + + private static func environmentFileValues(at path: String?) -> [String: String] { + guard let path, !path.isEmpty, + let contents = try? String(contentsOfFile: path, encoding: .utf8) + else { + return [:] + } + + return contents.split(whereSeparator: \.isNewline).reduce(into: [:]) { values, line in + var entry = line.trimmingCharacters(in: .whitespaces) + guard !entry.isEmpty, !entry.hasPrefix("#") else { return } + if entry.hasPrefix("export ") { + entry.removeFirst("export ".count) + } + guard let separator = entry.firstIndex(of: "=") else { return } + + let key = entry[..= 2, + let first = value.first, + let last = value.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") { + value.removeFirst() + value.removeLast() + } + guard !key.isEmpty else { return } + values[key] = value + } + } + + private static func privateKeyContents(at path: String) -> String { + guard !path.isEmpty else { return "" } + return (try? String(contentsOfFile: path, encoding: .utf8)) ?? "" + } + private static func value( for names: [String], environment: [String: String] diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index 3fe68e5..958805c 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -159,6 +159,47 @@ struct AppleAdsPlatformServiceTests { #expect(credentials.privateKeyValidationIssue == nil) } + @Test + func environmentCredentialsLoadFromConfiguredDotEnvAndPrivateKeyFile() throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let privateKeyURL = temporaryDirectory.appendingPathComponent("apple-ads-private-key.p8") + let privateKey = """ + -----BEGIN PRIVATE KEY----- + cHJpdmF0ZQ== + -----END PRIVATE KEY----- + """ + try privateKey.write(to: privateKeyURL, atomically: true, encoding: .utf8) + + let environmentURL = temporaryDirectory.appendingPathComponent(".env") + try """ + APPLE_SEARCH_ADS_CLIENT_ID=client-from-file + APPLE_SEARCH_ADS_TEAM_ID=team-from-file + APPLE_SEARCH_ADS_KEY_ID=key-from-file + APPLE_SEARCH_ADS_PRIVATE_KEY_PATH=\(privateKeyURL.path) + APPLE_SEARCH_ADS_ORG_ID=20 + APPLE_ADS_PLATFORM_AD_ACCOUNT_ID=30 + """.write(to: environmentURL, atomically: true, encoding: .utf8) + + let credentials = EnvironmentAppleAdsCredentials.load(environment: [ + "OPENASO_ENV_FILE": environmentURL.path, + "APPLE_SEARCH_ADS_CLIENT_ID": "client-from-process", + ]) + + #expect(credentials.clientID == "client-from-process") + #expect(credentials.teamID == "team-from-file") + #expect(credentials.keyID == "key-from-file") + #expect(credentials.privateKey == privateKey) + #expect(credentials.orgID == "20") + #expect(credentials.adAccountID == "30") + } + @MainActor @Test func credentialStorePersistsAdAccountAndKeepsPrivateKeyInKeychain() throws { From cd159a2267eb2ecf28cfba5fb6a02c373e62f9f3 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:49:23 +0100 Subject: [PATCH 7/8] Fix unavailable Apple Ads popularity handling --- .../Keywords/KeywordInsightsTypes.swift | 2 +- .../Keywords/KeywordWorkspaceProjection.swift | 2 +- .../Keywords/Table/KeywordTableCells.swift | 2 +- .../Keywords/Table/KeywordTableSupport.swift | 40 +++++--- .../AppleAds/KeywordMetricsService.swift | 46 ++++++--- OpenASO/Services/MCP/OpenASOMCPService.swift | 4 +- OpenASOTests/KeywordMetricsServiceTests.swift | 94 +++++++++++++++++++ 7 files changed, 158 insertions(+), 32 deletions(-) diff --git a/OpenASO/Features/AppDetail/Keywords/KeywordInsightsTypes.swift b/OpenASO/Features/AppDetail/Keywords/KeywordInsightsTypes.swift index 0230249..9039afe 100644 --- a/OpenASO/Features/AppDetail/Keywords/KeywordInsightsTypes.swift +++ b/OpenASO/Features/AppDetail/Keywords/KeywordInsightsTypes.swift @@ -38,7 +38,7 @@ struct KeywordInsightsDataset { observedAt: snapshot.searchedAt, rank: snapshot.rank, resultCount: snapshot.resultCount, - popularityScore: row.metrics?.popularityScore, + popularityScore: row.displayedPopularityScore, confidence: nil ) } diff --git a/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift b/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift index 0e2fe49..f3b2cfa 100644 --- a/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift +++ b/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift @@ -71,7 +71,7 @@ enum KeywordWorkspaceProjection { return rows.filter { row in matchesSearch(row, searchText: searchText) - && matches(row.metrics?.popularityScore, in: filters.popularityRange, configuration: .popularity) + && matches(row.displayedPopularityScore, in: filters.popularityRange, configuration: .popularity) && matches(row.metrics?.difficultyScore, in: filters.difficultyRange, configuration: .difficulty) && matches(row.currentRank, in: filters.positionRange, configuration: .position) && matches(row.trendDelta, in: filters.changeRange, configuration: .change) diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift index d6ba47a..8ece158 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift @@ -110,7 +110,7 @@ struct KeywordPopularityCell: View { var body: some View { HStack(spacing: 4) { MetricBarView( - value: row.metrics?.popularityScore, + value: row.displayedPopularityScore, maxValue: 100, colorScale: .lowRedHighGreen, placeholder: "-" diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift index 2242e96..2c3a2c3 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift @@ -481,7 +481,14 @@ struct KeywordWorkspaceRow: Identifiable, Equatable, Sendable { storefront?.name ?? track.storefront } - var popularitySortValue: Int { metrics?.popularityScore ?? -1 } + var displayedPopularityScore: Int? { + guard resolvedPopularityStatusMessage?.hasPrefix("Popularity unavailable.") != true else { + return nil + } + return metrics?.popularityScore + } + + var popularitySortValue: Int { displayedPopularityScore ?? -1 } var positionSortValue: Int { currentRank ?? Int.max } @@ -606,7 +613,12 @@ struct KeywordWorkspaceRow: Identifiable, Equatable, Sendable { } func popularityIndicatorState(now: Date) -> KeywordPopularityIndicatorState { - if metrics?.popularityScore != nil { + if let popularityStatusMessage = resolvedPopularityStatusMessage, + popularityStatusMessage.hasPrefix("Popularity unavailable.") { + return .unavailable(message: popularityStatusMessage) + } + + if displayedPopularityScore != nil { guard let updatedAt = metrics?.updatedAt, now.timeIntervalSince(updatedAt) >= Self.popularityStaleInterval else { @@ -616,28 +628,26 @@ struct KeywordWorkspaceRow: Identifiable, Equatable, Sendable { return .stale(lastUpdatedAt: updatedAt) } - let popularityStatusMessage = refreshStatus.popularityMessage - ?? track.statusMessage.flatMap { legacyStatusMessage in - TrackedKeywordRefreshStatusStore.domain(forLegacyMessage: legacyStatusMessage) == .popularity - ? legacyStatusMessage - : nil - } - ?? statusMessage - - guard let popularityStatusMessage else { + guard let popularityStatusMessage = resolvedPopularityStatusMessage else { return .none } - if popularityStatusMessage.hasPrefix("Popularity unavailable.") { - return .unavailable(message: popularityStatusMessage) - } - if popularityStatusMessage.hasPrefix("Popularity failed to fetch.") { return .needsSetup(message: popularityStatusMessage) } return .none } + + private var resolvedPopularityStatusMessage: String? { + refreshStatus.popularityMessage + ?? track.statusMessage.flatMap { legacyStatusMessage in + TrackedKeywordRefreshStatusStore.domain(forLegacyMessage: legacyStatusMessage) == .popularity + ? legacyStatusMessage + : nil + } + ?? statusMessage + } } struct KeywordRankingCrawlSummary: Identifiable, Equatable, Sendable { diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index ec5ba6e..02d621e 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -472,7 +472,7 @@ final class KeywordMetricsService: Sendable { using: modelStore ) outcomes.append(outcome) - if outcome.errorMessage != nil { failureCount += 1 } + if outcome.disposition == .failed { failureCount += 1 } completedCount += 1 await didPersist?(candidate.persistenceUpdate) await progress?(completedCount, totalCount, failureCount) @@ -512,7 +512,7 @@ final class KeywordMetricsService: Sendable { using: modelStore ) outcomes.append(outcome) - if outcome.errorMessage != nil { failureCount += 1 } + if outcome.disposition == .failed { failureCount += 1 } completedCount += 1 await didPersist?(candidate.persistenceUpdate) await progress?(completedCount, totalCount, failureCount) @@ -537,7 +537,7 @@ final class KeywordMetricsService: Sendable { using: modelStore ) outcomes.append(outcome) - if outcome.errorMessage != nil { failureCount += 1 } + if outcome.disposition == .failed { failureCount += 1 } completedCount += 1 await didPersist?(candidate.persistenceUpdate) await progress?(completedCount, totalCount, failureCount) @@ -748,9 +748,17 @@ final class KeywordMetricsService: Sendable { } upsertMetrics(payload, for: track, in: modelContext) if let statusMessage = payload.statusMessage { - outcomes.append(KeywordMetricsRefreshOutcome(trackID: track.persistentModelID, errorMessage: statusMessage)) + outcomes.append(KeywordMetricsRefreshOutcome( + trackID: track.persistentModelID, + errorMessage: statusMessage, + disposition: payload.outcomeDisposition + )) } else { - outcomes.append(KeywordMetricsRefreshOutcome(trackID: track.persistentModelID, errorMessage: nil)) + outcomes.append(KeywordMetricsRefreshOutcome( + trackID: track.persistentModelID, + errorMessage: nil, + disposition: payload.outcomeDisposition + )) } } @@ -933,7 +941,8 @@ final class KeywordMetricsService: Sendable { metrics.storefront = track.storefront metrics.platform = track.platform - let shouldPreserveExistingPopularity = payload.statusMessage != nil && metrics.popularityScore != nil + let shouldPreserveExistingPopularity = payload.preservesExistingPopularity + && metrics.popularityScore != nil guard !shouldPreserveExistingPopularity else { return } @@ -961,21 +970,26 @@ final class KeywordMetricsService: Sendable { popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + statusMessage: "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings.", + outcomeDisposition: .failed, + preservesExistingPopularity: true ) case .missingContextApp: return KeywordMetricsPayload( popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity failed to fetch. Configure Apple Ads Platform API access in Settings." + statusMessage: "Popularity failed to fetch. Configure Apple Ads Platform API access in Settings.", + outcomeDisposition: .failed, + preservesExistingPopularity: true ) case .notFound: return KeywordMetricsPayload( popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity unavailable. Apple Ads returned no eligible search-term popularity row for this keyword and country or region." + statusMessage: "Popularity unavailable. Apple Ads returned no eligible row for this keyword and country or region; terms need at least 500 searches and 10 impressions in the reporting period.", + outcomeDisposition: .skipped ) case .failure(let message): if isUnsupportedAppleAdsStorefrontMessage(message) { @@ -983,7 +997,8 @@ final class KeywordMetricsService: Sendable { popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity unavailable. \(message)" + statusMessage: "Popularity unavailable. \(message)", + outcomeDisposition: .skipped ) } @@ -991,7 +1006,9 @@ final class KeywordMetricsService: Sendable { popularityScore: nil, difficultyScore: nil, source: .appleAdsPopularity, - statusMessage: "Popularity failed to fetch. \(message)" + statusMessage: "Popularity failed to fetch. \(message)", + outcomeDisposition: .failed, + preservesExistingPopularity: true ) } @@ -1131,11 +1148,12 @@ struct KeywordMetricsRefreshBatchResult: Sendable { } var failureCount: Int { - outcomes.lazy.filter { $0.errorMessage != nil }.count + batchErrors.count + outcomes.lazy.filter { $0.disposition == .failed }.count + batchErrors.count } var firstErrorMessage: String? { - batchErrors.first?.message ?? outcomes.lazy.compactMap(\.errorMessage).first + batchErrors.first?.message + ?? outcomes.lazy.first(where: { $0.disposition == .failed })?.errorMessage } } @@ -1172,6 +1190,8 @@ private struct KeywordMetricsPayload: Sendable { let source: KeywordMetricsSource var notes: String? = nil var statusMessage: String? = nil + var outcomeDisposition: KeywordMetricsRefreshDisposition = .refreshed + var preservesExistingPopularity = false var popularityDate: String? = nil var submissionCount: Int = 1 var winningCount: Int = 1 diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index c8851fa..217bfd4 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -4417,7 +4417,9 @@ extension OpenASOMCPService { previousRank: previous?.rank, rankDelta: rankDelta, resultCount: latest?.resultCount ?? track.rankingAppCount, - popularityScore: metrics?.popularityScore, + popularityScore: resolvedStatus.popularityMessage?.hasPrefix("Popularity unavailable.") == true + ? nil + : metrics?.popularityScore, difficultyScore: metrics?.difficultyScore, notes: track.notes, rankingStatusMessage: resolvedStatus.rankingMessage, diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index 15da45d..f270cb0 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -394,6 +394,88 @@ struct KeywordMetricsServiceTests { #expect(track.statusMessage == nil) } + @Test + func unavailableOfficialPopularityClearsLegacyScoreWithoutReportingFailure() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let modelContext = ModelContext(container) + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "unknown URL")") + throw OpenASOError.providerUnavailable("Unexpected request") + }, + modelContainer: container, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], popularityRows: []) + ) + try saveTestCredentials(in: services.appleAdsCredentialStore) + + let trackedApp = TrackedApp( + appStoreID: 1, + bundleID: nil, + name: "App", + sellerName: nil, + defaultPlatform: .iphone + ) + modelContext.insert(trackedApp) + let track = try makeTrack( + term: "long-tail keyword", + trackedApp: trackedApp, + in: modelContext + ) + let previousUpdatedAt = try #require(Calendar.current.date(byAdding: .day, value: -8, to: .now)) + modelContext.insert( + KeywordDailyMetric( + queryKey: track.queryKey, + keyword: track.term, + storefront: track.storefront, + platform: track.platform, + popularityScore: 74, + difficultyScore: nil, + source: .appleAdsPopularity, + updatedAt: previousUpdatedAt + ) + ) + try modelContext.save() + + let progressRecorder = KeywordMetricsProgressRecorder() + let result = try await services.keywordMetricsService.refreshMetricsBatch( + for: [track.identityKey], + using: BackgroundModelStore(modelContainer: container), + progress: { completed, total, failureCount in + await progressRecorder.record( + completed: completed, + total: total, + failureCount: failureCount + ) + } + ) + let trackID = track.persistentModelID + let stored = try await BackgroundModelStore(modelContainer: container).read { context in + let storedTrack = try #require(context.model(for: trackID) as? TrackedAppKeyword) + let metric = try #require(try context.fetch(FetchDescriptor()).first) + let refreshStatus = try TrackedKeywordRefreshStatusStore.snapshot( + for: storedTrack, + in: context + ) + return ( + popularityScore: metric.popularityScore, + statusMessage: refreshStatus.popularityMessage + ) + } + let progressUpdates = await progressRecorder.snapshot() + + #expect(result.outcomes.count == 1) + #expect(result.outcomes.first?.disposition == .skipped) + #expect(result.skippedCount == 1) + #expect(result.failureCount == 0) + #expect(result.firstErrorMessage == nil) + #expect(stored.popularityScore == nil) + #expect(stored.statusMessage?.contains("at least 500 searches and 10 impressions") == true) + #expect(progressUpdates == [ + .init(completed: 0, total: 1, failureCount: 0), + .init(completed: 1, total: 1, failureCount: 0), + ]) + } + @Test func unsupportedAppleAdsStorefrontDoesNotAskForSetup() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) @@ -1911,10 +1993,22 @@ struct KeywordMetricsServiceTests { updatedAt: now, statusMessage: "Popularity failed to fetch. Connect an Apple Ads web session in Settings." ) + let unavailableMessage = "Popularity unavailable. Apple Ads returned no eligible row." + let unavailableRow = makeRow( + term: "unavailable", + trackedApp: trackedApp, + modelContext: modelContext, + popularityScore: 65, + updatedAt: staleUpdatedAt, + statusMessage: unavailableMessage + ) #expect(freshRow.popularityIndicatorState(now: now) == .none) #expect(staleRow.popularityIndicatorState(now: now) == .stale(lastUpdatedAt: staleUpdatedAt)) #expect(needsSetupRow.popularityIndicatorState(now: now) == .needsSetup(message: "Popularity failed to fetch. Connect an Apple Ads web session in Settings.")) + #expect(unavailableRow.displayedPopularityScore == nil) + #expect(unavailableRow.popularitySortValue == -1) + #expect(unavailableRow.popularityIndicatorState(now: now) == .unavailable(message: unavailableMessage)) } private func makeRow( From df80fa21e45e8457df771d4e812987b924b76abf Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:16:05 +0100 Subject: [PATCH 8/8] Harden Apple Ads refresh lifecycle --- .../AppleAdsPlatformWorkspaceView.swift | 2 + .../AppleAds/KeywordMetricsService.swift | 63 ++++++++++++++++--- OpenASOTests/KeywordMetricsServiceTests.swift | 18 ++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift b/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift index cd71a21..e815196 100644 --- a/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift +++ b/OpenASO/Features/AppleAds/AppleAdsPlatformWorkspaceView.swift @@ -60,6 +60,7 @@ final class AppleAdsPlatformWorkspaceModel { } func selectAccount() async { + guard !isLoading else { return } guard let accountID = Int64(selectedAdAccountID), accountID > 0 else { return } var credentials = credentialStore.apiCredentials credentials.adAccountID = String(accountID) @@ -231,6 +232,7 @@ private struct AppleAdsPlatformWorkspaceContent: View { } } .frame(maxWidth: 340) + .disabled(model.isLoading) .onChange(of: model.selectedAdAccountID) { _, _ in Task { await model.selectAccount() } } diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index 02d621e..bf135f0 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -249,15 +249,28 @@ final class KeywordMetricsService: Sendable { } } let metricsByQueryKey = freshnessMetricsMap(for: uniqueTracks.map(\.queryKey), in: modelContext) + let persistedStatuses = (try? TrackedKeywordRefreshStatusStore.snapshots( + for: uniqueTracks.map(\.identityKey), + in: modelContext + )) ?? [:] var outcomes: [KeywordMetricsRefreshOutcome] = [] var tracksNeedingPopularity: [TrackedAppKeyword] = [] for track in uniqueTracks { guard !Task.isCancelled else { return outcomes } let queryTracks = tracksByQueryKey[track.queryKey] ?? [track] - guard Self.shouldRefreshMetrics(metricsTTL: metricsTTL, metric: metricsByQueryKey[track.queryKey]) else { + let popularityStatus = TrackedKeywordRefreshStatusStore.snapshot( + for: track, + persisted: persistedStatuses[track.identityKey] + ) + guard Self.shouldRefreshMetrics( + metricsTTL: metricsTTL, + metric: metricsByQueryKey[track.queryKey], + popularityStatus: popularityStatus + ) else { do { - if let metric = metricsByQueryKey[track.queryKey] { + if let metric = metricsByQueryKey[track.queryKey], + metric.popularityScore != nil { for siblingTrack in queryTracks { try TrackedKeywordRefreshStatusStore.set( nil, @@ -400,15 +413,24 @@ final class KeywordMetricsService: Sendable { for: Array(tracksByQueryKey.keys), in: modelContext ) + let persistedStatuses = try TrackedKeywordRefreshStatusStore.snapshots( + for: tracks.map(\.identityKey), + in: modelContext + ) return try tracksByQueryKey.values.compactMap { queryTracks -> KeywordMetricsRefreshCandidate? in let sortedTracks = queryTracks.sorted { $0.identityKey < $1.identityKey } guard let track = sortedTracks.first else { return nil } let metric = metricsByQueryKey[track.queryKey] + let popularityStatus = TrackedKeywordRefreshStatusStore.snapshot( + for: track, + persisted: persistedStatuses[track.identityKey] + ) let shouldRefresh = Self.shouldRefreshMetrics( metricsTTL: metricsTTL, - metric: metric + metric: metric, + popularityStatus: popularityStatus ) - if !shouldRefresh, let metric { + if !shouldRefresh, let metric, metric.popularityScore != nil { for siblingTrack in sortedTracks { try TrackedKeywordRefreshStatusStore.set( nil, @@ -614,6 +636,10 @@ final class KeywordMetricsService: Sendable { for: Array(tracksByQueryKey.keys), in: modelContext ) + let persistedStatuses = try TrackedKeywordRefreshStatusStore.snapshots( + for: tracks.map(\.identityKey), + in: modelContext + ) var refreshIdentityKeys: [String] = [] var refreshQueryCount = 0 var clearedStatusCount = 0 @@ -621,10 +647,18 @@ final class KeywordMetricsService: Sendable { let sortedTracks = queryTracks.sorted { $0.identityKey < $1.identityKey } guard let track = sortedTracks.first else { continue } let metric = metricsByQueryKey[track.queryKey] - if Self.shouldRefreshMetrics(metricsTTL: metricsTTL, metric: metric) { + let popularityStatus = TrackedKeywordRefreshStatusStore.snapshot( + for: track, + persisted: persistedStatuses[track.identityKey] + ) + if Self.shouldRefreshMetrics( + metricsTTL: metricsTTL, + metric: metric, + popularityStatus: popularityStatus + ) { refreshIdentityKeys.append(contentsOf: sortedTracks.map(\.identityKey)) refreshQueryCount += 1 - } else if let metric { + } else if let metric, metric.popularityScore != nil { for siblingTrack in sortedTracks { let previousStatus = try TrackedKeywordRefreshStatusStore.snapshot( for: siblingTrack, @@ -793,12 +827,25 @@ final class KeywordMetricsService: Sendable { } } - private static func shouldRefreshMetrics(metricsTTL: TimeInterval, metric: KeywordDailyMetric?) -> Bool { + private static func shouldRefreshMetrics( + metricsTTL: TimeInterval, + metric: KeywordDailyMetric?, + popularityStatus: KeywordRefreshStatusSnapshot + ) -> Bool { guard let metric else { return true } - return metric.popularityScore == nil || Date.now.timeIntervalSince(metric.updatedAt) >= metricsTTL + if metric.popularityScore != nil { + return Date.now.timeIntervalSince(metric.updatedAt) >= metricsTTL + } + + guard popularityStatus.popularityMessage?.hasPrefix("Popularity unavailable.") == true, + let unavailableAt = popularityStatus.popularityUpdatedAt + else { + return true + } + return Date.now.timeIntervalSince(unavailableAt) >= metricsTTL } private static func orderedUniquePopularityTargets( diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index f270cb0..15cf390 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -474,6 +474,24 @@ struct KeywordMetricsServiceTests { .init(completed: 0, total: 1, failureCount: 0), .init(completed: 1, total: 1, failureCount: 0), ]) + + let secondResult = try await services.keywordMetricsService.refreshMetricsBatch( + for: [track.identityKey], + using: BackgroundModelStore(modelContainer: container) + ) + let retainedUnavailableStatus = try await BackgroundModelStore( + modelContainer: container + ).read { context in + let storedTrack = try #require(context.model(for: trackID) as? TrackedAppKeyword) + return try TrackedKeywordRefreshStatusStore.snapshot( + for: storedTrack, + in: context + ).popularityMessage + } + + #expect(secondResult.outcomes.first?.disposition == .upToDate) + #expect(secondResult.failureCount == 0) + #expect(retainedUnavailableStatus?.contains("at least 500 searches and 10 impressions") == true) } @Test