From 84932352a2f4a8f172c35508c2c0099f7fba95ee Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:43:43 +0100 Subject: [PATCH 1/6] Add campaign management popularity fallback --- .../AppleAds/KeywordMetricsService.swift | 162 ++++++++++-- OpenASOTests/KeywordMetricsServiceTests.swift | 250 ++++++++++++++++++ 2 files changed, 391 insertions(+), 21 deletions(-) diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index bf135f0..d671460 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -4,7 +4,10 @@ import SwiftData final class KeywordMetricsService: Sendable { private let apiClient: any AppleAdsPlatformAPI + private let campaignManagementClient: AppleAdsCMPopularityClient @MainActor private let credentialStore: AppleAdsCredentialStore + @MainActor private let settingsStore: AppSettingsStore + @MainActor private let webSessionStore: AppleAdsWebSessionStore private let freshnessFetchObserver: @Sendable (_ queryKeyCount: Int) -> Void private let bulkFreshnessFetchHook: @Sendable () throws -> Void private let metricsTTL: TimeInterval = 60 * 60 * 24 * 7 @@ -20,12 +23,12 @@ final class KeywordMetricsService: Sendable { bulkFreshnessFetchHook: @escaping @Sendable () throws -> Void = {} ) { self.apiClient = apiClient + self.campaignManagementClient = AppleAdsCMPopularityClient(httpClient: httpClient) self.credentialStore = credentialStore + self.settingsStore = settingsStore + self.webSessionStore = webSessionStore self.freshnessFetchObserver = freshnessFetchObserver self.bulkFreshnessFetchHook = bulkFreshnessFetchHook - _ = httpClient - _ = settingsStore - _ = webSessionStore } func verifyAppleAdsCredentials(_ credentials: AppleAdsCredentials) async throws -> AppleAdsCredentials { @@ -73,7 +76,7 @@ final class KeywordMetricsService: Sendable { for targets: [KeywordResearchTarget], now: @Sendable () -> Date = { Date() } ) async throws -> [KeywordPopularityMetricEvidence] { - try await fetchOfficialPopularityMetrics(for: targets, now: now) + try await fetchResolvedPopularityMetrics(for: targets, now: now) } /// Compatibility entry point for callers that still carry the retired @@ -86,10 +89,10 @@ final class KeywordMetricsService: Sendable { ) async throws -> [KeywordPopularityMetricEvidence] { _ = contextAppStoreID _ = webSession - return try await fetchOfficialPopularityMetrics(for: targets, now: now) + return try await fetchResolvedPopularityMetrics(for: targets, now: now) } - private func fetchOfficialPopularityMetrics( + private func fetchResolvedPopularityMetrics( for targets: [KeywordResearchTarget], now: @Sendable () -> Date ) async throws -> [KeywordPopularityMetricEvidence] { @@ -106,7 +109,7 @@ final class KeywordMetricsService: Sendable { for storefront in targetsByStorefront.keys.sorted() { guard let storefrontTargets = targetsByStorefront[storefront] else { continue } try Task.checkCancellation() - let popularities = try await fetchOfficialPopularityScores( + let resolution = try await fetchPopularityScoresWithFallback( for: storefrontTargets.map(\.term), countryOrRegion: storefront, credentials: credentials, @@ -116,7 +119,7 @@ final class KeywordMetricsService: Sendable { for target in storefrontTargets { let normalizedTerm = AppleAdsSearchTermPopularity.normalized(target.term) - guard let popularity = popularities[normalizedTerm] else { continue } + guard let popularity = resolution.scores[normalizedTerm] else { continue } popularityByQueryKey[target.queryKey] = min(100, max(1, popularity)) } } @@ -179,6 +182,89 @@ final class KeywordMetricsService: Sendable { return latestRows.compactMapValues(\.popularity1to100) } + private func fetchPopularityScoresWithFallback( + for searchTerms: [String], + countryOrRegion: String, + credentials: AppleAdsCredentials, + asOf date: Date + ) async throws -> AppleAdsPopularityResolution { + let officialScores = try await fetchOfficialPopularityScores( + for: searchTerms, + countryOrRegion: countryOrRegion, + credentials: credentials, + asOf: date + ) + try Task.checkCancellation() + + let missingTerms = Self.orderedUniqueTerms(searchTerms).filter { term in + officialScores[AppleAdsSearchTermPopularity.normalized(term)] == nil + } + guard !missingTerms.isEmpty else { + return AppleAdsPopularityResolution(scores: officialScores) + } + + let fallbackAccess = await campaignManagementFallbackAccess() + let context: CampaignManagementPopularityContext + switch fallbackAccess { + case .available(let availableContext): + context = availableContext + case .requiresReconnect: + return AppleAdsPopularityResolution( + scores: officialScores, + missingTermErrorMessage: AppleAdsWebSessionExpiredError.message + ) + case .unconfigured: + return AppleAdsPopularityResolution(scores: officialScores) + } + + do { + let fallbackScores = try await campaignManagementClient.keywordPopularities( + for: missingTerms, + storefrontCode: countryOrRegion, + adamId: context.appStoreID, + session: context.webSession + ) + try Task.checkCancellation() + await webSessionStore.clearReconnectRequirement(for: context.webSession) + + var scores = officialScores + for (term, score) in fallbackScores where scores[term] == nil { + scores[term] = score + } + return AppleAdsPopularityResolution(scores: scores) + } catch { + if Self.isCancellation(error) { + throw error + } + if error is AppleAdsWebSessionExpiredError { + await webSessionStore.markReconnectRequired(for: context.webSession) + } + return AppleAdsPopularityResolution( + scores: officialScores, + missingTermErrorMessage: OpenASOError.map(error).localizedDescription + ) + } + } + + @MainActor + private func campaignManagementFallbackAccess() -> CampaignManagementFallbackAccess { + guard let appStoreID = settingsStore.popularityContextAppStoreID, + let webSession = webSessionStore.recoverSessionIfNeeded(), + webSession.isComplete + else { + return .unconfigured + } + guard !webSessionStore.requiresReconnect(for: webSession) else { + return .requiresReconnect + } + return .available( + CampaignManagementPopularityContext( + appStoreID: appStoreID, + webSession: webSession + ) + ) + } + /// 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 @@ -328,7 +414,7 @@ final class KeywordMetricsService: Sendable { let storefrontCode = storefrontTracks.first?.storefront ?? "US" do { - let popularities = try await fetchOfficialPopularityScores( + let resolution = try await fetchPopularityScoresWithFallback( for: storefrontTracks.map(\.term), countryOrRegion: storefrontCode, credentials: credentials, @@ -338,8 +424,10 @@ final class KeywordMetricsService: Sendable { for track in storefrontTracks { guard !Task.isCancelled else { return outcomes } let key = AppleAdsSearchTermPopularity.normalized(track.term) - let result = popularities[key].map(AppleAdsPopularityResult.success) - ?? .notFound + let result = Self.popularityResult( + forNormalizedTerm: key, + resolution: resolution + ) Self.applyMetricsPayloadSafely( Self.makeAppleAdsMetrics(popularityResult: result), for: track, @@ -513,9 +601,9 @@ final class KeywordMetricsService: Sendable { try Task.checkCancellation() let storefrontTracks = group.tracks let storefrontCode = storefrontTracks.first?.storefront ?? "US" - let popularities: [String: Int] + let resolution: AppleAdsPopularityResolution do { - popularities = try await fetchOfficialPopularityScores( + resolution = try await fetchPopularityScoresWithFallback( for: storefrontTracks.map(\.term), countryOrRegion: storefrontCode, credentials: credentials, @@ -545,14 +633,10 @@ final class KeywordMetricsService: Sendable { for candidate in storefrontTracks { try Task.checkCancellation() - let result: AppleAdsPopularityResult - if let popularity = popularities[ - AppleAdsSearchTermPopularity.normalized(candidate.term) - ] { - result = .success(popularity) - } else { - result = .notFound - } + let result = Self.popularityResult( + forNormalizedTerm: AppleAdsSearchTermPopularity.normalized(candidate.term), + resolution: resolution + ) let outcome = try await persistMetricsPayload( Self.makeAppleAdsMetrics(popularityResult: result), for: candidate, @@ -864,6 +948,26 @@ final class KeywordMetricsService: Sendable { return ordered.filter { seenQueryKeys.insert($0.queryKey).inserted } } + private static func orderedUniqueTerms(_ terms: [String]) -> [String] { + var seenKeys: Set = [] + return terms.filter { term in + seenKeys.insert(AppleAdsSearchTermPopularity.normalized(term)).inserted + } + } + + private static func popularityResult( + forNormalizedTerm term: String, + resolution: AppleAdsPopularityResolution + ) -> AppleAdsPopularityResult { + if let score = resolution.scores[term] { + return .success(score) + } + if let message = resolution.missingTermErrorMessage { + return .failure(message) + } + return .notFound + } + private static func upsertPopularityMetrics( evidence: [KeywordPopularityMetricEvidence], in modelContext: ModelContext @@ -1231,6 +1335,22 @@ private struct KeywordMetricsCandidateGroup: Sendable { let tracks: [KeywordMetricsRefreshCandidate] } +private struct CampaignManagementPopularityContext: Sendable { + let appStoreID: Int64 + let webSession: AppleAdsWebSession +} + +private enum CampaignManagementFallbackAccess: Sendable { + case available(CampaignManagementPopularityContext) + case requiresReconnect + case unconfigured +} + +private struct AppleAdsPopularityResolution: Sendable { + let scores: [String: Int] + var missingTermErrorMessage: String? = nil +} + private struct KeywordMetricsPayload: Sendable { let popularityScore: Int? let difficultyScore: Int? diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index 15cf390..dc4201f 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -394,6 +394,224 @@ struct KeywordMetricsServiceTests { #expect(track.statusMessage == nil) } + @Test + func foregroundOfficialPopularityFallsBackPerMissingKeywordWithoutChangingPresentedSource() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let modelContext = ModelContext(container) + var campaignManagementRequests: [KeywordPopularityRequestBody] = [] + let fallbackAppStoreID: Int64 = 6_608_976_383 + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + let url = try #require(request.url) + #expect(url.host == "app-ads.apple.com") + #expect(url.path == "/cm/api/v2/keywords/popularities") + #expect( + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "adamId" })?.value + == String(fallbackAppStoreID) + ) + let requestBody = try JSONDecoder().decode( + KeywordPopularityRequestBody.self, + from: try #require(request.httpBody) + ) + campaignManagementRequests.append(requestBody) + return ( + Data( + #"{"status":"success","data":[{"name":"voice to text","popularity":67}]}"#.utf8 + ), + makeHTTPURLResponse(url: url, statusCode: 200) + ) + }, + modelContainer: container, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI( + apps: [], + popularityRows: [officialFlightTrackerPopularity] + ) + ) + try configureCampaignManagementFallback( + in: services, + appStoreID: fallbackAppStoreID + ) + + let trackedApp = TrackedApp( + appStoreID: 1, + bundleID: nil, + name: "App", + sellerName: nil, + defaultPlatform: .iphone + ) + modelContext.insert(trackedApp) + let officialTrack = try makeTrack( + term: "flight tracker", + trackedApp: trackedApp, + in: modelContext + ) + let fallbackTrack = try makeTrack( + term: "voice to text", + trackedApp: trackedApp, + in: modelContext + ) + try modelContext.save() + + let result = await services.keywordMetricsService.refreshMetrics( + for: trackedApp, + tracks: [officialTrack, fallbackTrack], + in: modelContext + ) + let stored = Dictionary(uniqueKeysWithValues: try modelContext.fetch( + FetchDescriptor() + ).map { metric in + ( + metric.keyword, + PopularityMetricStoredState( + score: metric.popularityScore, + sourceRaw: metric.sourceRaw, + notes: metric.notes + ) + ) + }) + + #expect(campaignManagementRequests.count == 1) + #expect(campaignManagementRequests.first?.storefronts == ["US"]) + #expect(campaignManagementRequests.first?.terms == ["voice to text"]) + #expect(result.count == 2) + #expect(result.allSatisfy { $0.disposition == .refreshed }) + #expect(stored["flight tracker"]?.score == 60) + #expect(stored["flight tracker"]?.sourceRaw == "appleAdsPopularity") + #expect(stored["flight tracker"]?.notes == nil) + #expect(stored["voice to text"]?.score == 67) + #expect(stored["voice to text"]?.sourceRaw == "appleAdsPopularity") + #expect(stored["voice to text"]?.notes == nil) + } + + @Test + func appIndependentPopularityUsesCampaignManagementFallbackForOfficialOmission() async throws { + var requestedTerms: [String] = [] + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + let requestBody = try JSONDecoder().decode( + KeywordPopularityRequestBody.self, + from: try #require(request.httpBody) + ) + requestedTerms = requestBody.terms + return ( + Data( + #"{"status":"success","data":[{"name":"speech notes","popularity":54}]}"#.utf8 + ), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + }, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], popularityRows: []) + ) + try configureCampaignManagementFallback(in: services) + let target = try makePopularityTarget(term: "speech notes", storefront: "us") + + let evidence = try await services.keywordMetricsService.fetchPopularityMetrics( + for: [target], + now: { Date(timeIntervalSince1970: 500) } + ) + + #expect(requestedTerms == ["speech notes"]) + #expect(evidence.count == 1) + #expect(evidence.first?.target == target) + #expect(evidence.first?.popularityScore == 54) + } + + @Test + func expiredFallbackPreservesOfficialHitAndCachedMissingTerm() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let modelContext = ModelContext(container) + var requestCount = 0 + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + requestCount += 1 + return ( + Data(#"{"error":"unauthorized"}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 401) + ) + }, + modelContainer: container, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI( + apps: [], + popularityRows: [officialFlightTrackerPopularity] + ) + ) + try configureCampaignManagementFallback(in: services) + + let trackedApp = TrackedApp( + appStoreID: 1, + bundleID: nil, + name: "App", + sellerName: nil, + defaultPlatform: .iphone + ) + modelContext.insert(trackedApp) + let officialTrack = try makeTrack( + term: "flight tracker", + trackedApp: trackedApp, + in: modelContext + ) + let cachedTrack = try makeTrack( + term: "voice to text", + trackedApp: trackedApp, + in: modelContext + ) + let secondStorefrontCachedTrack = try makeTrack( + term: "meeting notes", + storefront: "gb", + trackedApp: trackedApp, + in: modelContext + ) + let cachedUpdatedAt = try #require( + Calendar.current.date(byAdding: .day, value: -8, to: .now) + ) + modelContext.insert(KeywordDailyMetric( + queryKey: cachedTrack.queryKey, + keyword: cachedTrack.term, + storefront: cachedTrack.storefront, + platform: cachedTrack.platform, + popularityScore: 72, + difficultyScore: nil, + source: .appleAdsPopularity, + updatedAt: cachedUpdatedAt + )) + modelContext.insert(KeywordDailyMetric( + queryKey: secondStorefrontCachedTrack.queryKey, + keyword: secondStorefrontCachedTrack.term, + storefront: secondStorefrontCachedTrack.storefront, + platform: secondStorefrontCachedTrack.platform, + popularityScore: 61, + difficultyScore: nil, + source: .appleAdsPopularity, + updatedAt: cachedUpdatedAt + )) + try modelContext.save() + + let backgroundStore = BackgroundModelStore(modelContainer: container) + let result = try await services.keywordMetricsService.refreshMetricsBatch( + for: [ + officialTrack.identityKey, + cachedTrack.identityKey, + secondStorefrontCachedTrack.identityKey, + ], + using: backgroundStore + ) + let storedScores = try await backgroundStore.read { context in + Dictionary(uniqueKeysWithValues: try context.fetch( + FetchDescriptor() + ).map { ($0.keyword, $0.popularityScore) }) + } + + #expect(requestCount == 1) + #expect(result.outcomes.count == 3) + #expect(result.outcomes.filter { $0.disposition == .refreshed }.count == 1) + #expect(result.outcomes.filter { $0.disposition == .failed }.count == 2) + #expect(storedScores["flight tracker"] == 60) + #expect(storedScores["voice to text"] == 72) + #expect(storedScores["meeting notes"] == 61) + #expect(services.appleAdsWebSessionStore.requiresReconnect) + } + @Test func unavailableOfficialPopularityClearsLegacyScoreWithoutReportingFailure() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) @@ -2119,6 +2337,18 @@ struct KeywordMetricsServiceTests { ) } + private func configureCampaignManagementFallback( + in services: AppServices, + appStoreID: Int64 = 6_608_976_383 + ) throws { + try saveTestCredentials(in: services.appleAdsCredentialStore) + try services.appleAdsWebSessionStore.save(completeWebSession) + services.settingsStore.savePopularityContext( + appStoreID: appStoreID, + storefrontCode: "US" + ) + } + private var completeWebSession: AppleAdsWebSession { AppleAdsWebSession( cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", @@ -2127,6 +2357,20 @@ struct KeywordMetricsServiceTests { ) } + private var officialFlightTrackerPopularity: AppleAdsSearchTermPopularity { + AppleAdsSearchTermPopularity( + searchTerm: "flight tracker", + countryOrRegion: "US", + genre: "Travel", + week: "2026-08-08", + month: nil, + rankInGenre: 81, + popularityInGenre: 70, + popularity1to100: 60, + popularity1to5: 3 + ) + } + private func makePopularityTarget( term: String, storefront: String, @@ -2188,6 +2432,12 @@ private struct KeywordPopularityRequestBody: Decodable, Sendable { let terms: [String] } +private struct PopularityMetricStoredState: Equatable, Sendable { + let score: Int? + let sourceRaw: String + let notes: String? +} + private struct ForcedBulkFreshnessFetchError: Error {} private struct AppIndependentPopularityStoredState: Equatable, Sendable { From 7ddb3704f0d3f1728b9ac4c7044ffc4ddb1d2e18 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:23:38 +0100 Subject: [PATCH 2/6] Guide users through Apple Ads popularity fallback --- OpenASO.xcodeproj/project.pbxproj | 4 ++ .../Keywords/Table/KeywordTableCells.swift | 18 ++++-- .../Keywords/Table/KeywordTableView.swift | 2 +- .../AppleAdsSettingsConnectionState.swift | 2 +- OpenASO/Features/Settings/SettingsView.swift | 7 +- .../AppleAds/AppleAdsCredentials.swift | 4 +- .../AppleAdsPopularityFallbackMessaging.swift | 5 ++ .../AppleAds/KeywordMetricsService.swift | 11 ++-- .../KeywordResearchMetricsWorkflow.swift | 2 +- .../AppleAdsPlatformServiceTests.swift | 25 ++++++++ OpenASOTests/KeywordMetricsServiceTests.swift | 64 +++++++++++++++---- .../KeywordResearchMetricsWorkflowTests.swift | 2 +- 12 files changed, 117 insertions(+), 29 deletions(-) create mode 100644 OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index a4abff8..0b569e9 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; + ADF000000000000000000001 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF000000000000000000002 /* AppleAds/AppleAdsPopularityFallbackMessaging.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 */; }; @@ -353,6 +354,7 @@ /* Begin PBXFileReference section */ AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsPlatformService.swift; sourceTree = ""; }; + ADF000000000000000000002 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsPopularityFallbackMessaging.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 = ""; }; @@ -801,6 +803,7 @@ FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */, 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */, 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */, + ADF000000000000000000002 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift */, 04E8C4E18F1A4D519C0A1124 /* AppleAds/AppleAdsWebSession.swift */, B9A0000000000000000004 /* AppleAds/AppleAdsPastedSession.swift */, B9A0000000000000000002 /* AppleAds/AppleAdsWebLoginController.swift */, @@ -1272,6 +1275,7 @@ E8BA00000000000000000004 /* BackgroundRefreshAgentStatusView.swift in Sources */, 04E8C4E18F1A4D519C0A110B /* SettingsView.swift in Sources */, 04E8C4E18F1A4D519C0A1123 /* AppleAds/AppleAdsWebSession.swift in Sources */, + ADF000000000000000000001 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift in Sources */, B9A0000000000000000003 /* AppleAds/AppleAdsPastedSession.swift in Sources */, B9A0000000000000000001 /* AppleAds/AppleAdsWebLoginController.swift in Sources */, 04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */, diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift index 8ece158..047e79d 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift @@ -421,9 +421,11 @@ private struct KeywordPopularityIndicatorPopover: View { } if showsSettingsButton { - Button(action: openAppleAdsSettings) { - Label("Open Apple Ads Settings", systemImage: "gearshape") - } + Button( + settingsButtonTitle, + systemImage: "gearshape", + action: openAppleAdsSettings + ) .controlSize(.regular) } } @@ -449,6 +451,14 @@ private struct KeywordPopularityIndicatorPopover: View { } } + private var settingsButtonTitle: String { + if case .needsSetup = state { + "Connect Apple Ads" + } else { + "Open Apple Ads Settings" + } + } + private var title: String { switch state { case .none: @@ -473,7 +483,7 @@ private struct KeywordPopularityIndicatorPopover: View { case .reconnectRequired: return "Refresh your Apple Ads session before requesting new popularity data. Existing cached values remain available." case .needsSetup: - return "Popularity could not be fetched for this keyword. Connect or refresh Apple Ads so OpenASO can detect a linked app automatically." + return "Apple's primary service did not return a score for this keyword. Connect your Apple Ads account so OpenASO can check Apple's web service too." case .unavailable: return "Apple Ads keyword popularity is not available for this keyword's storefront." } diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift index 7fabd9b..f0a7768 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift @@ -234,7 +234,7 @@ struct KeywordTableView: View, Equatable { } private func openAppleAdsSettings() { - services.settingsStore.requestSettingsFocus(.platformAPI) + services.settingsStore.requestSettingsFocus(.webSession) openSettings() } diff --git a/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift b/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift index ed8d0f9..d825da4 100644 --- a/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift +++ b/OpenASO/Features/Settings/AppleAdsSettingsConnectionState.swift @@ -63,7 +63,7 @@ enum AppleAdsConnectionState: Equatable { var message: String { switch self { case .notConnected: - return "Connect Apple Ads to fetch keyword popularity." + return "Connect your Apple Ads account in the browser to fetch popularity scores missing from Apple's primary service." case .accountSelectionRequired: return AppleAdsWebLoginError.explicitAccountRequired.localizedDescription case .openingBrowser: diff --git a/OpenASO/Features/Settings/SettingsView.swift b/OpenASO/Features/Settings/SettingsView.swift index f42d3ff..9ddeccf 100644 --- a/OpenASO/Features/Settings/SettingsView.swift +++ b/OpenASO/Features/Settings/SettingsView.swift @@ -94,6 +94,9 @@ struct SettingsView: View { appleAdsPlatformSection .id(AppleAdsSettingsFocusSection.platformAPI) + appleAdsSection + .id(AppleAdsSettingsFocusSection.webSession) + appStoreConnectSection .id(AppleAdsSettingsFocusSection.appStoreConnect) @@ -301,9 +304,9 @@ struct SettingsView: View { pastedSessionFallback } header: { - Text("Apple Ads") + Text("Apple Ads Web Access") } footer: { - Text("Connect Apple Ads to show keyword popularity in OpenASO. OpenASO requires a specific Apple Account and does not reuse the Mac's default account. Optional saved login details are filled automatically and stay in your macOS Keychain. Your Apple Ads account needs access to at least one of your App Store apps.") + Text("OpenASO uses this web connection only when Apple's primary service does not return a keyword popularity score. Connect the Apple Ads account you want OpenASO to use; optional saved login details stay in your macOS Keychain. The account needs access to at least one of your App Store apps.") } } diff --git a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift index 6b930af..4153c63 100644 --- a/OpenASO/Services/AppleAds/AppleAdsCredentials.swift +++ b/OpenASO/Services/AppleAds/AppleAdsCredentials.swift @@ -132,7 +132,7 @@ final class AppleAdsCredentialStore { } var hasCompleteAPICredentials: Bool { - apiCredentials.isComplete + apiCredentials.canVerify } var hasWebLoginCredentials: Bool { @@ -234,6 +234,8 @@ struct EnvironmentAppleAdsCredentials { for: ["APPLE_SEARCH_ADS_PRIVATE_KEY", "privateKey"], environment: environment ) + .replacingOccurrences(of: "\\r\\n", with: "\n") + .replacingOccurrences(of: "\\n", with: "\n") let privateKeyPath = value( for: ["APPLE_SEARCH_ADS_PRIVATE_KEY_PATH"], environment: environment diff --git a/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift b/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift new file mode 100644 index 0000000..b36f48b --- /dev/null +++ b/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift @@ -0,0 +1,5 @@ +enum AppleAdsPopularityFallbackMessaging { + static let connectionRequired = + "Apple's primary service didn't return a score for this keyword. " + + "Connect your Apple Ads account in Settings so OpenASO can check Apple's web service too." +} diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index d671460..547f2d5 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -142,7 +142,7 @@ final class KeywordMetricsService: Sendable { private func requireAppleAdsCredentials() async throws -> AppleAdsCredentials { let credentials = await credentialStore.apiCredentials - guard credentials.isComplete else { + guard credentials.canVerify else { throw OpenASOError.providerUnavailable( "Configure and verify Apple Ads Platform API credentials in Settings." ) @@ -214,7 +214,10 @@ final class KeywordMetricsService: Sendable { missingTermErrorMessage: AppleAdsWebSessionExpiredError.message ) case .unconfigured: - return AppleAdsPopularityResolution(scores: officialScores) + return AppleAdsPopularityResolution( + scores: officialScores, + missingTermErrorMessage: AppleAdsPopularityFallbackMessaging.connectionRequired + ) } do { @@ -396,7 +399,7 @@ final class KeywordMetricsService: Sendable { } let credentials = credentialStore.apiCredentials - guard credentials.isComplete else { + guard credentials.canVerify else { _ = Self.applyPopularityResult( .missingCredentials, to: tracksNeedingPopularity, @@ -573,7 +576,7 @@ final class KeywordMetricsService: Sendable { } let credentials = await credentialStore.apiCredentials - guard credentials.isComplete else { + guard credentials.canVerify else { for candidate in tracksNeedingPopularity { try Task.checkCancellation() let outcome = try await persistMetricsPayload( diff --git a/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift b/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift index 5e83ca1..35800d2 100644 --- a/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift +++ b/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift @@ -595,7 +595,7 @@ private extension KeywordResearchMetricsWorkflow { _ configuration: KeywordResearchMetricsConfiguration ) -> KeywordResearchMetricsIssue? { if let credentials = configuration.credentials { - guard credentials.isComplete else { + guard credentials.canVerify else { return issue(.missingSession) } return nil diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index 958805c..25e1e6e 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -200,6 +200,31 @@ struct AppleAdsPlatformServiceTests { #expect(credentials.adAccountID == "30") } + @Test + func environmentCredentialsDecodeEscapedPrivateKeyNewlines() 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 environmentURL = temporaryDirectory.appendingPathComponent(".env") + try #"APPLE_SEARCH_ADS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\ncHJpdmF0ZQ==\n-----END PRIVATE KEY-----""# + .write(to: environmentURL, atomically: true, encoding: .utf8) + + let credentials = EnvironmentAppleAdsCredentials.load(environment: [ + "OPENASO_ENV_FILE": environmentURL.path, + ]) + + #expect(credentials.privateKey == """ + -----BEGIN PRIVATE KEY----- + cHJpdmF0ZQ== + -----END PRIVATE KEY----- + """) + } + @MainActor @Test func credentialStorePersistsAdAccountAndKeepsPrivateKeyInKeychain() throws { diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index dc4201f..ba5bb2f 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -394,6 +394,38 @@ struct KeywordMetricsServiceTests { #expect(track.statusMessage == nil) } + @Test + func apiCredentialsWithoutSelectedAdAccountCanRefreshPopularity() async throws { + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "unknown URL")") + throw OpenASOError.providerUnavailable("Unexpected request") + }, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI( + apps: [], + popularityRows: [officialFlightTrackerPopularity] + ) + ) + try services.appleAdsCredentialStore.saveAPICredentials( + AppleAdsCredentials( + clientID: "test-client", + teamID: "test-team", + keyID: "test-key", + privateKey: Self.privateKey + ) + ) + let target = try makePopularityTarget(term: "flight tracker", storefront: "us") + + let evidence = try await services.keywordMetricsService.fetchPopularityMetrics( + for: [target], + now: { Date(timeIntervalSince1970: 500) } + ) + + #expect(services.appleAdsCredentialStore.apiCredentials.adAccountID.isEmpty) + #expect(services.appleAdsCredentialStore.hasCompleteAPICredentials) + #expect(evidence.first?.popularityScore == 60) + } + @Test func foregroundOfficialPopularityFallsBackPerMissingKeywordWithoutChangingPresentedSource() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) @@ -613,7 +645,7 @@ struct KeywordMetricsServiceTests { } @Test - func unavailableOfficialPopularityClearsLegacyScoreWithoutReportingFailure() async throws { + func unavailableOfficialPopularityPromptsForWebFallbackAndPreservesLegacyScore() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) let modelContext = ModelContext(container) let services = AppServices.mocked( @@ -682,22 +714,22 @@ struct KeywordMetricsServiceTests { 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(result.outcomes.first?.disposition == .failed) + #expect(result.skippedCount == 0) + #expect(result.failureCount == 1) + #expect(result.firstErrorMessage?.contains("Connect your Apple Ads account") == true) + #expect(stored.popularityScore == 74) + #expect(stored.statusMessage == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.connectionRequired)") #expect(progressUpdates == [ .init(completed: 0, total: 1, failureCount: 0), - .init(completed: 1, total: 1, failureCount: 0), + .init(completed: 1, total: 1, failureCount: 1), ]) let secondResult = try await services.keywordMetricsService.refreshMetricsBatch( for: [track.identityKey], using: BackgroundModelStore(modelContainer: container) ) - let retainedUnavailableStatus = try await BackgroundModelStore( + let retainedFallbackPrompt = try await BackgroundModelStore( modelContainer: container ).read { context in let storedTrack = try #require(context.model(for: trackID) as? TrackedAppKeyword) @@ -707,9 +739,9 @@ struct KeywordMetricsServiceTests { ).popularityMessage } - #expect(secondResult.outcomes.first?.disposition == .upToDate) - #expect(secondResult.failureCount == 0) - #expect(retainedUnavailableStatus?.contains("at least 500 searches and 10 impressions") == true) + #expect(secondResult.outcomes.first?.disposition == .failed) + #expect(secondResult.failureCount == 1) + #expect(retainedFallbackPrompt?.contains("Connect your Apple Ads account") == true) } @Test @@ -2227,7 +2259,7 @@ struct KeywordMetricsServiceTests { modelContext: modelContext, popularityScore: nil, updatedAt: now, - statusMessage: "Popularity failed to fetch. Connect an Apple Ads web session in Settings." + statusMessage: "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.connectionRequired)" ) let unavailableMessage = "Popularity unavailable. Apple Ads returned no eligible row." let unavailableRow = makeRow( @@ -2241,7 +2273,11 @@ struct KeywordMetricsServiceTests { #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( + needsSetupRow.popularityIndicatorState(now: now) == .needsSetup( + message: "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.connectionRequired)" + ) + ) #expect(unavailableRow.displayedPopularityScore == nil) #expect(unavailableRow.popularitySortValue == -1) #expect(unavailableRow.popularityIndicatorState(now: now) == .unavailable(message: unavailableMessage)) diff --git a/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift b/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift index 7323b19..5a866d3 100644 --- a/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift +++ b/OpenASOTests/KeywordResearchMetricsWorkflowTests.swift @@ -769,7 +769,7 @@ private extension KeywordResearchMetricsWorkflowTests { clientID: metricsAPICredentials.clientID, teamID: metricsAPICredentials.teamID, keyID: metricsAPICredentials.keyID, - privateKey: metricsAPICredentials.privateKey + privateKey: "" ) ) case .missingContextAndSession: From 4bf33531b347a922728381cc391739e73fc46cdd Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:56:38 +0100 Subject: [PATCH 3/6] Persist Apple Ads web login sessions --- .../AppleAds/AppleAdsWebLoginController.swift | 63 +++++++++++++--- OpenASOTests/AppleAdsPastedSessionTests.swift | 71 +++++++++++++++++++ 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift b/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift index cfa1671..1806cba 100644 --- a/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift +++ b/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift @@ -69,7 +69,13 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { credentials: AppleAdsWebLoginCredentials? = nil, timeout: Duration = .seconds(300) ) async throws -> AppleAdsWebLoginCapture { - let webView = presentWindow(credentials: credentials) + let websiteDataStore = Self.makeWebsiteDataStore() + let reusesExplicitAccount = await Self.prepareForSignIn(using: websiteDataStore) + let webView = presentWindow( + credentials: credentials, + websiteDataStore: websiteDataStore, + reusesExplicitAccount: reusesExplicitAccount + ) defer { dismissWindow() } webView.load(URLRequest(url: Self.signInURL)) @@ -84,8 +90,7 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { throw AppleAdsWebLoginError.closedBeforeCapture } - if Self.isAuthenticatedAppleAdsPage(webView.url), - let capture = await capturedSession(from: webView) { + if let capture = await capturedSession(from: webView) { guard didUseExplicitAccount else { throw AppleAdsWebLoginError.explicitAccountRequired } @@ -103,7 +108,8 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() let appleAdsCookies = cookies.filter(Self.appliesToAppleAds) - guard let xsrfCookie = appleAdsCookies.first(where: { $0.name == AppleAdsSessionCookies.xsrfToken }), + guard Self.isCaptureReady(url: webView.url, cookies: appleAdsCookies), + let xsrfCookie = appleAdsCookies.first(where: { $0.name == AppleAdsSessionCookies.xsrfToken }), appleAdsCookies.contains(where: { $0.name == AppleAdsSessionCookies.session }) else { return nil @@ -144,6 +150,35 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { .joined(separator: "; ") } + nonisolated static func isCaptureReady(url: URL?, cookies: [HTTPCookie]) -> Bool { + url != nil + && cookies.contains { $0.name == AppleAdsSessionCookies.xsrfToken } + && cookies.contains { $0.name == AppleAdsSessionCookies.session } + } + + static func prepareForSignIn(using dataStore: WKWebsiteDataStore) async -> Bool { + let cookieStore = dataStore.httpCookieStore + let cookies = await cookieStore.allCookies() + let reusesExplicitAccount = cookies.contains(where: Self.isAppleIdentityCookie) + + for cookie in cookies where Self.isCapturedAppleAdsSessionCookie(cookie) { + await cookieStore.deleteCookie(cookie) + } + + return reusesExplicitAccount + } + + nonisolated static func isAppleIdentityCookie(_ cookie: HTTPCookie) -> Bool { + let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return domain != AppleAdsSessionCookies.host + && (domain == "apple.com" || domain.hasSuffix(".apple.com")) + } + + nonisolated static func isCapturedAppleAdsSessionCookie(_ cookie: HTTPCookie) -> Bool { + appliesToAppleAds(cookie) + && [AppleAdsSessionCookies.xsrfToken, AppleAdsSessionCookies.session].contains(cookie.name) + } + nonisolated static func isAuthenticatedAppleAdsPage(_ url: URL?) -> Bool { guard let url, url.host?.lowercased() == AppleAdsSessionCookies.host else { return false @@ -192,15 +227,17 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { return trimmed.isEmpty ? nil : trimmed } - private func presentWindow(credentials: AppleAdsWebLoginCredentials?) -> WKWebView { + private func presentWindow( + credentials: AppleAdsWebLoginCredentials?, + websiteDataStore: WKWebsiteDataStore, + reusesExplicitAccount: Bool + ) -> WKWebView { if let webView, window != nil { return webView } let configuration = WKWebViewConfiguration() - // A fresh cookie jar prevents an earlier account's cookies from satisfying capture before - // the current sign-in finishes. The captured session itself is persisted in Keychain. - configuration.websiteDataStore = .nonPersistent() + configuration.websiteDataStore = websiteDataStore configuration.userContentController.add( self, name: Self.explicitAccountMessageHandler @@ -250,10 +287,16 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { self.webView = webView self.window = window didCloseWindow = false - didUseExplicitAccount = false + // The persistent WebKit store is private to OpenASO. Apple identity cookies in it can only + // come from an account the user previously selected in this sign-in window. + didUseExplicitAccount = reusesExplicitAccount return webView } + static func makeWebsiteDataStore() -> WKWebsiteDataStore { + .default() + } + private func dismissWindow() { webView?.configuration.userContentController.removeScriptMessageHandler( forName: Self.explicitAccountMessageHandler @@ -295,7 +338,7 @@ enum AppleAdsWebLoginWindowLayout { } /// Generates a document-local helper that fills the optional saved login on Apple-owned sign-in -/// pages. The script is installed only in the ephemeral WebKit view used for this capture attempt. +/// pages. The script is installed only in the OpenASO WebKit view used for this capture attempt. enum AppleAdsWebLoginAutomation { /// Prevents Apple-owned sign-in pages from silently requesting the Mac's platform credential. /// The Apple ID field also marks that a specific account was explicitly selected without diff --git a/OpenASOTests/AppleAdsPastedSessionTests.swift b/OpenASOTests/AppleAdsPastedSessionTests.swift index ddcc2a0..d40d040 100644 --- a/OpenASOTests/AppleAdsPastedSessionTests.swift +++ b/OpenASOTests/AppleAdsPastedSessionTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import WebKit @testable import OpenASO struct AppleAdsPastedSessionTests { @@ -83,6 +84,76 @@ struct AppleAdsPastedSessionTests { #expect(!AppleAdsWebLoginController.isAuthenticatedAppleAdsPage(nil)) } + @MainActor + @Test + func webLoginUsesPersistentBrowserStorage() { + #expect(AppleAdsWebLoginController.makeWebsiteDataStore().isPersistent) + } + + @Test + func webLoginCapturesUsableCookiesDuringAppleAdsAuthRedirect() throws { + let sessionCookie = try #require(HTTPCookie(properties: [ + .domain: ".app-ads.apple.com", + .path: "/", + .name: AppleAdsSessionCookies.session, + .value: "session", + .secure: "TRUE" + ])) + let xsrfCookie = try #require(HTTPCookie(properties: [ + .domain: "app-ads.apple.com", + .path: "/", + .name: AppleAdsSessionCookies.xsrfToken, + .value: "token", + .secure: "TRUE" + ])) + + #expect(AppleAdsWebLoginController.isCaptureReady( + url: URL(string: "https://app-ads.apple.com/auth/callback"), + cookies: [sessionCookie, xsrfCookie] + )) + } + + @MainActor + @Test + func webLoginPreparationPreservesTrustedBrowserCookiesAndRemovesStaleSession() async throws { + let dataStore = WKWebsiteDataStore.nonPersistent() + let cookieStore = dataStore.httpCookieStore + let identityCookie = try #require(HTTPCookie(properties: [ + .domain: "idmsa.apple.com", + .path: "/", + .name: "trusted-browser", + .value: "trusted", + .secure: "TRUE" + ])) + let sessionCookie = try #require(HTTPCookie(properties: [ + .domain: ".app-ads.apple.com", + .path: "/", + .name: AppleAdsSessionCookies.session, + .value: "stale-session", + .secure: "TRUE" + ])) + let xsrfCookie = try #require(HTTPCookie(properties: [ + .domain: "app-ads.apple.com", + .path: "/", + .name: AppleAdsSessionCookies.xsrfToken, + .value: "stale-token", + .secure: "TRUE" + ])) + await cookieStore.setCookie(identityCookie) + await cookieStore.setCookie(sessionCookie) + await cookieStore.setCookie(xsrfCookie) + + let reusesExplicitAccount = await AppleAdsWebLoginController.prepareForSignIn( + using: dataStore + ) + let retainedCookies = await cookieStore.allCookies() + + #expect(reusesExplicitAccount) + #expect(retainedCookies.contains { $0.name == identityCookie.name }) + #expect(!retainedCookies.contains { $0.name == sessionCookie.name }) + #expect(!retainedCookies.contains { $0.name == xsrfCookie.name }) + } + @Test func webLoginCookieHeaderIsDeterministicAndAppleAdsScoped() throws { let sessionCookie = try #require(HTTPCookie(properties: [ From 5d47888f9e52eeabdd3fa9d487cba6c5f6497602 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:15:52 +0100 Subject: [PATCH 4/6] fix(apple-ads): Recognize modern web sessions Apple Ads does not always issue the legacy XSRF cookie after sign-in. Accept its signed session cookie and omit the obsolete header when absent. --- .../AppleAds/AppleAdsWebLoginController.swift | 28 ++++++++---- .../AppleAds/AppleAdsWebSession.swift | 23 +++++++--- OpenASOTests/AppServicesDependencyTests.swift | 29 +++++++++++++ OpenASOTests/AppleAdsPastedSessionTests.swift | 43 +++++++++++++++++++ 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift b/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift index 1806cba..5fc2edd 100644 --- a/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift +++ b/OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift @@ -8,6 +8,7 @@ enum AppleAdsSessionCookies { static let host = "app-ads.apple.com" static let xsrfToken = "XSRF-TOKEN-CM" static let session = "searchads.soid" + static let authenticatedSession = "app-ads.sid" } struct AppleAdsWebLoginCapture: Equatable, Sendable { @@ -108,16 +109,15 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() let appleAdsCookies = cookies.filter(Self.appliesToAppleAds) - guard Self.isCaptureReady(url: webView.url, cookies: appleAdsCookies), - let xsrfCookie = appleAdsCookies.first(where: { $0.name == AppleAdsSessionCookies.xsrfToken }), - appleAdsCookies.contains(where: { $0.name == AppleAdsSessionCookies.session }) - else { + guard Self.isCaptureReady(url: webView.url, cookies: appleAdsCookies) else { return nil } return AppleAdsWebLoginCapture( cookieHeader: Self.cookieHeader(from: appleAdsCookies), - xsrfToken: xsrfCookie.value, + xsrfToken: appleAdsCookies.first { + $0.name == AppleAdsSessionCookies.xsrfToken + }?.value ?? "", accountName: await accountName(from: webView) ) } @@ -151,9 +151,15 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { } nonisolated static func isCaptureReady(url: URL?, cookies: [HTTPCookie]) -> Bool { - url != nil - && cookies.contains { $0.name == AppleAdsSessionCookies.xsrfToken } - && cookies.contains { $0.name == AppleAdsSessionCookies.session } + guard url != nil, + cookies.contains(where: { $0.name == AppleAdsSessionCookies.session }) else { + return false + } + + return cookies.contains { cookie in + [AppleAdsSessionCookies.xsrfToken, AppleAdsSessionCookies.authenticatedSession] + .contains(cookie.name) + } } static func prepareForSignIn(using dataStore: WKWebsiteDataStore) async -> Bool { @@ -176,7 +182,11 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing { nonisolated static func isCapturedAppleAdsSessionCookie(_ cookie: HTTPCookie) -> Bool { appliesToAppleAds(cookie) - && [AppleAdsSessionCookies.xsrfToken, AppleAdsSessionCookies.session].contains(cookie.name) + && [ + AppleAdsSessionCookies.xsrfToken, + AppleAdsSessionCookies.session, + AppleAdsSessionCookies.authenticatedSession + ].contains(cookie.name) } nonisolated static func isAuthenticatedAppleAdsPage(_ url: URL?) -> Bool { diff --git a/OpenASO/Services/AppleAds/AppleAdsWebSession.swift b/OpenASO/Services/AppleAds/AppleAdsWebSession.swift index e073d6c..72d92e9 100644 --- a/OpenASO/Services/AppleAds/AppleAdsWebSession.swift +++ b/OpenASO/Services/AppleAds/AppleAdsWebSession.swift @@ -24,8 +24,15 @@ struct AppleAdsWebSession: Codable, Equatable, Sendable { } var isComplete: Bool { - !cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !xsrfToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasCookies = !cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasLegacyAuthentication = !xsrfToken + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + let cookieNames = Set(AppleAdsPastedSession.cookiePairs(in: cookieHeader).map(\.name)) + let hasModernAuthentication = cookieNames.contains(AppleAdsSessionCookies.session) + && cookieNames.contains(AppleAdsSessionCookies.authenticatedSession) + + return hasCookies && (hasLegacyAuthentication || hasModernAuthentication) } } @@ -447,7 +454,9 @@ final class AppleAdsWebSessionManager { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie") - request.setValue(session.xsrfToken, forHTTPHeaderField: "X-XSRF-TOKEN-CM") + if !session.xsrfToken.isEmpty { + request.setValue(session.xsrfToken, forHTTPHeaderField: "X-XSRF-TOKEN-CM") + } request.setValue("https://app-ads.apple.com", forHTTPHeaderField: "Origin") request.setValue("https://app-ads.apple.com/cm/app", forHTTPHeaderField: "Referer") request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") @@ -470,7 +479,9 @@ final class AppleAdsWebSessionManager { request.timeoutInterval = 20 request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie") - request.setValue(session.xsrfToken, forHTTPHeaderField: "X-XSRF-TOKEN-CM") + if !session.xsrfToken.isEmpty { + request.setValue(session.xsrfToken, forHTTPHeaderField: "X-XSRF-TOKEN-CM") + } request.setValue("https://app-ads.apple.com", forHTTPHeaderField: "Origin") request.setValue("https://app-ads.apple.com/", forHTTPHeaderField: "Referer") request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") @@ -849,7 +860,9 @@ struct AppleAdsCMPopularityClient { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie") - request.setValue(session.xsrfToken, forHTTPHeaderField: "X-XSRF-TOKEN-CM") + if !session.xsrfToken.isEmpty { + request.setValue(session.xsrfToken, forHTTPHeaderField: "X-XSRF-TOKEN-CM") + } request.setValue("https://app-ads.apple.com", forHTTPHeaderField: "Origin") request.setValue("https://app-ads.apple.com/", forHTTPHeaderField: "Referer") request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") diff --git a/OpenASOTests/AppServicesDependencyTests.swift b/OpenASOTests/AppServicesDependencyTests.swift index 2479324..a0318fb 100644 --- a/OpenASOTests/AppServicesDependencyTests.swift +++ b/OpenASOTests/AppServicesDependencyTests.swift @@ -1092,6 +1092,35 @@ struct AppServicesDependencyTests { #expect(popularities["term 101"] == 1) } + @Test + func cmPopularityClientSupportsModernSessionWithoutLegacyXSRFHeader() async throws { + var xsrfHeader: String? + let client = MockHTTPClient { request in + xsrfHeader = request.value(forHTTPHeaderField: "X-XSRF-TOKEN-CM") + return ( + Data(#"{"status":"success","data":[{"name":"focus","popularity":74}]}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + } + let session = AppleAdsWebSession( + cookieHeader: "app-ads.sid=authenticated-session; searchads.soid=account", + xsrfToken: "", + updatedAt: .now + ) + + let popularities = try await AppleAdsCMPopularityClient(httpClient: client) + .keywordPopularities( + for: ["focus"], + storefrontCode: "us", + adamId: 123_456_789, + session: session + ) + + #expect(session.isComplete) + #expect(xsrfHeader == nil) + #expect(popularities["focus"] == 74) + } + @Test func cmPopularityClientClassifiesUnauthorizedAsExpiredSession() async { await #expect(throws: AppleAdsWebSessionExpiredError()) { diff --git a/OpenASOTests/AppleAdsPastedSessionTests.swift b/OpenASOTests/AppleAdsPastedSessionTests.swift index d40d040..ca51968 100644 --- a/OpenASOTests/AppleAdsPastedSessionTests.swift +++ b/OpenASOTests/AppleAdsPastedSessionTests.swift @@ -113,6 +113,40 @@ struct AppleAdsPastedSessionTests { )) } + @Test + func webLoginCapturesModernSessionWithoutLegacyXSRFCookie() throws { + let authenticatedSessionCookie = try #require(HTTPCookie(properties: [ + .domain: ".apple.com", + .path: "/", + .name: AppleAdsSessionCookies.authenticatedSession, + .value: "authenticated-session", + .secure: "TRUE" + ])) + let accountCookie = try #require(HTTPCookie(properties: [ + .domain: ".apple.com", + .path: "/", + .name: AppleAdsSessionCookies.session, + .value: "account", + .secure: "TRUE" + ])) + + #expect(AppleAdsWebLoginController.isCaptureReady( + url: URL(string: "https://app-ads.apple.com/cm/app/report"), + cookies: [authenticatedSessionCookie, accountCookie] + )) + } + + @Test + func modernCookieSessionIsCompleteWithoutLegacyXSRFToken() { + let session = AppleAdsWebSession( + cookieHeader: "app-ads.sid=authenticated-session; searchads.soid=account", + xsrfToken: "", + updatedAt: updatedAt + ) + + #expect(session.isComplete) + } + @MainActor @Test func webLoginPreparationPreservesTrustedBrowserCookiesAndRemovesStaleSession() async throws { @@ -139,9 +173,17 @@ struct AppleAdsPastedSessionTests { .value: "stale-token", .secure: "TRUE" ])) + let authenticatedSessionCookie = try #require(HTTPCookie(properties: [ + .domain: ".apple.com", + .path: "/", + .name: AppleAdsSessionCookies.authenticatedSession, + .value: "stale-authenticated-session", + .secure: "TRUE" + ])) await cookieStore.setCookie(identityCookie) await cookieStore.setCookie(sessionCookie) await cookieStore.setCookie(xsrfCookie) + await cookieStore.setCookie(authenticatedSessionCookie) let reusesExplicitAccount = await AppleAdsWebLoginController.prepareForSignIn( using: dataStore @@ -152,6 +194,7 @@ struct AppleAdsPastedSessionTests { #expect(retainedCookies.contains { $0.name == identityCookie.name }) #expect(!retainedCookies.contains { $0.name == sessionCookie.name }) #expect(!retainedCookies.contains { $0.name == xsrfCookie.name }) + #expect(!retainedCookies.contains { $0.name == authenticatedSessionCookie.name }) } @Test From 066a3b4893ac553e7123094b2d1018d5fdb83780 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:21:40 +0100 Subject: [PATCH 5/6] fix(apple-ads): Allow web-only popularity refresh Use Campaign Management directly when Platform API credentials are absent. Keep the Platform API preferred when both Apple Ads connections are available. --- OpenASO/App/AppServices.swift | 16 +- .../Keywords/Table/KeywordTableView.swift | 3 +- .../KeywordResearchPresentation.swift | 4 +- OpenASO/Features/Settings/SettingsView.swift | 2 +- .../AppleAdsPopularityFallbackMessaging.swift | 5 +- .../AppleAds/KeywordMetricsService.swift | 139 ++++++++------- .../KeywordResearchMetricsWorkflow.swift | 6 +- OpenASO/Services/MCP/OpenASOMCPService.swift | 4 +- OpenASOTests/AppServicesDependencyTests.swift | 71 +++++++- OpenASOTests/KeywordMetricsServiceTests.swift | 158 +++++++++++++++++- OpenASOTests/OpenASOMCPServiceTests.swift | 4 +- OpenASOTests/RefreshObservabilityTests.swift | 2 +- .../TrackedKeywordRefreshStatusTests.swift | 2 +- 13 files changed, 328 insertions(+), 88 deletions(-) diff --git a/OpenASO/App/AppServices.swift b/OpenASO/App/AppServices.swift index 005f4cd..9f42824 100644 --- a/OpenASO/App/AppServices.swift +++ b/OpenASO/App/AppServices.swift @@ -313,8 +313,15 @@ final class AppServices { metricsService: keywordMetricsService, rankingCoordinator: refreshCoordinator, configurationProvider: { + let storedCredentials = appleAdsCredentialStore.apiCredentials + let webSession = appleAdsWebSessionStore.recoverSessionIfNeeded() return KeywordResearchMetricsConfiguration( - credentials: appleAdsCredentialStore.apiCredentials + contextAppStoreID: settingsStore.popularityContextAppStoreID, + webSession: webSession, + requiresReconnect: webSession.map { + appleAdsWebSessionStore.requiresReconnect(for: $0) + } ?? false, + credentials: storedCredentials.canVerify ? storedCredentials : nil ) }, reconnectMarker: { attemptedSession in @@ -583,7 +590,7 @@ final class AppServices { func refreshStaleKeywordPopularityAfterAppleAdsConnection() { guard let backgroundModelStore, - appleAdsCredentialStore.hasCompleteAPICredentials + hasAppleAdsPopularityConnection else { return } @@ -652,6 +659,11 @@ final class AppServices { } } + var hasAppleAdsPopularityConnection: Bool { + appleAdsCredentialStore.hasCompleteAPICredentials + || (appleAdsWebSessionStore.hasSession && !appleAdsWebSessionStore.requiresReconnect) + } + static func preview(httpClient: HTTPClient, modelContainer: ModelContainer? = nil) -> AppServices { mocked(httpClient: httpClient, modelContainer: modelContainer) } diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift index f0a7768..e36bee1 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift @@ -97,8 +97,7 @@ struct KeywordTableView: View, Equatable { modelContext: modelContext, appCatalogService: appCatalogService, appIconStore: appIconStore, - requiresAppleAdsReconnect: !services.appleAdsCredentialStore - .hasCompleteAPICredentials, + requiresAppleAdsReconnect: !services.hasAppleAdsPopularityConnection, presentRanking: presentRanking, presentRankingHistory: { presentedRankingHistoryRow = $0 }, presentNotes: { presentedNotesRow = $0 }, diff --git a/OpenASO/Features/KeywordResearch/KeywordResearchPresentation.swift b/OpenASO/Features/KeywordResearch/KeywordResearchPresentation.swift index b05ae6b..7f4add4 100644 --- a/OpenASO/Features/KeywordResearch/KeywordResearchPresentation.swift +++ b/OpenASO/Features/KeywordResearch/KeywordResearchPresentation.swift @@ -192,11 +192,11 @@ struct KeywordResearchErrorPresentation: Equatable, Identifiable, Sendable { ) case .missingSession: return authentication( - message: "Connect an Apple Ads web session before refreshing popularity." + message: "Connect Apple Ads using either the Platform API or Web Access before refreshing popularity." ) case .reconnectRequired, .sessionExpired: return authentication( - message: "Reconnect the Apple Ads web session before refreshing popularity." + message: "Refresh Apple Ads Web Access or connect the Platform API before refreshing popularity." ) case .configurationChanged: return Self( diff --git a/OpenASO/Features/Settings/SettingsView.swift b/OpenASO/Features/Settings/SettingsView.swift index 9ddeccf..525604a 100644 --- a/OpenASO/Features/Settings/SettingsView.swift +++ b/OpenASO/Features/Settings/SettingsView.swift @@ -306,7 +306,7 @@ struct SettingsView: View { } header: { Text("Apple Ads Web Access") } footer: { - Text("OpenASO uses this web connection only when Apple's primary service does not return a keyword popularity score. Connect the Apple Ads account you want OpenASO to use; optional saved login details stay in your macOS Keychain. The account needs access to at least one of your App Store apps.") + Text("OpenASO prefers the Platform API when it is connected. Web Access supplies missing scores, or all popularity scores when the Platform API is not connected. Optional saved login details stay in your macOS Keychain. The account needs access to at least one of your App Store apps.") } } diff --git a/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift b/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift index b36f48b..cf04e4c 100644 --- a/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift +++ b/OpenASO/Services/AppleAds/AppleAdsPopularityFallbackMessaging.swift @@ -1,5 +1,8 @@ enum AppleAdsPopularityFallbackMessaging { - static let connectionRequired = + static let webConnectionRequired = "Apple's primary service didn't return a score for this keyword. " + "Connect your Apple Ads account in Settings so OpenASO can check Apple's web service too." + + static let anyConnectionRequired = + "Connect Apple Ads using either the Platform API or Web Access in Settings." } diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index 547f2d5..c9d6662 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -100,7 +100,7 @@ final class KeywordMetricsService: Sendable { let orderedTargets = Self.orderedUniquePopularityTargets(targets) guard !orderedTargets.isEmpty else { return [] } - let credentials = try await requireAppleAdsCredentials() + let credentials = await preferredAppleAdsCredentials() var popularityByQueryKey: [String: Int] = [:] let targetsByStorefront = Dictionary(grouping: orderedTargets, by: \.storefront) @@ -109,7 +109,7 @@ final class KeywordMetricsService: Sendable { for storefront in targetsByStorefront.keys.sorted() { guard let storefrontTargets = targetsByStorefront[storefront] else { continue } try Task.checkCancellation() - let resolution = try await fetchPopularityScoresWithFallback( + let resolution = try await fetchPopularityScores( for: storefrontTargets.map(\.term), countryOrRegion: storefront, credentials: credentials, @@ -140,14 +140,9 @@ final class KeywordMetricsService: Sendable { } } - private func requireAppleAdsCredentials() async throws -> AppleAdsCredentials { + private func preferredAppleAdsCredentials() async -> AppleAdsCredentials? { let credentials = await credentialStore.apiCredentials - guard credentials.canVerify else { - throw OpenASOError.providerUnavailable( - "Configure and verify Apple Ads Platform API credentials in Settings." - ) - } - return credentials + return credentials.canVerify ? credentials : nil } private func fetchOfficialPopularityScores( @@ -203,7 +198,7 @@ final class KeywordMetricsService: Sendable { return AppleAdsPopularityResolution(scores: officialScores) } - let fallbackAccess = await campaignManagementFallbackAccess() + let fallbackAccess = await campaignManagementAccess() let context: CampaignManagementPopularityContext switch fallbackAccess { case .available(let availableContext): @@ -216,19 +211,16 @@ final class KeywordMetricsService: Sendable { case .unconfigured: return AppleAdsPopularityResolution( scores: officialScores, - missingTermErrorMessage: AppleAdsPopularityFallbackMessaging.connectionRequired + missingTermErrorMessage: AppleAdsPopularityFallbackMessaging.webConnectionRequired ) } do { - let fallbackScores = try await campaignManagementClient.keywordPopularities( + let fallbackScores = try await fetchCampaignManagementPopularityScores( for: missingTerms, - storefrontCode: countryOrRegion, - adamId: context.appStoreID, - session: context.webSession + countryOrRegion: countryOrRegion, + context: context ) - try Task.checkCancellation() - await webSessionStore.clearReconnectRequirement(for: context.webSession) var scores = officialScores for (term, score) in fallbackScores where scores[term] == nil { @@ -239,9 +231,6 @@ final class KeywordMetricsService: Sendable { if Self.isCancellation(error) { throw error } - if error is AppleAdsWebSessionExpiredError { - await webSessionStore.markReconnectRequired(for: context.webSession) - } return AppleAdsPopularityResolution( scores: officialScores, missingTermErrorMessage: OpenASOError.map(error).localizedDescription @@ -249,8 +238,67 @@ final class KeywordMetricsService: Sendable { } } + private func fetchPopularityScores( + for searchTerms: [String], + countryOrRegion: String, + credentials: AppleAdsCredentials?, + asOf date: Date + ) async throws -> AppleAdsPopularityResolution { + if let credentials { + return try await fetchPopularityScoresWithFallback( + for: searchTerms, + countryOrRegion: countryOrRegion, + credentials: credentials, + asOf: date + ) + } + + let access = await campaignManagementAccess() + let context: CampaignManagementPopularityContext + switch access { + case .available(let availableContext): + context = availableContext + case .requiresReconnect: + throw AppleAdsWebSessionExpiredError() + case .unconfigured: + throw OpenASOError.providerUnavailable( + AppleAdsPopularityFallbackMessaging.anyConnectionRequired + ) + } + + let scores = try await fetchCampaignManagementPopularityScores( + for: searchTerms, + countryOrRegion: countryOrRegion, + context: context + ) + return AppleAdsPopularityResolution(scores: scores) + } + + private func fetchCampaignManagementPopularityScores( + for searchTerms: [String], + countryOrRegion: String, + context: CampaignManagementPopularityContext + ) async throws -> [String: Int] { + do { + let scores = try await campaignManagementClient.keywordPopularities( + for: searchTerms, + storefrontCode: countryOrRegion, + adamId: context.appStoreID, + session: context.webSession + ) + try Task.checkCancellation() + await webSessionStore.clearReconnectRequirement(for: context.webSession) + return scores + } catch { + if error is AppleAdsWebSessionExpiredError { + await webSessionStore.markReconnectRequired(for: context.webSession) + } + throw error + } + } + @MainActor - private func campaignManagementFallbackAccess() -> CampaignManagementFallbackAccess { + private func campaignManagementAccess() -> CampaignManagementAccess { guard let appStoreID = settingsStore.popularityContextAppStoreID, let webSession = webSessionStore.recoverSessionIfNeeded(), webSession.isComplete @@ -398,17 +446,8 @@ final class KeywordMetricsService: Sendable { tracksNeedingPopularity.append(track) } - let credentials = credentialStore.apiCredentials - guard credentials.canVerify else { - _ = Self.applyPopularityResult( - .missingCredentials, - to: tracksNeedingPopularity, - tracksByQueryKey: tracksByQueryKey, - in: modelContext, - outcomes: &outcomes - ) - return outcomes - } + let storedCredentials = credentialStore.apiCredentials + let credentials = storedCredentials.canVerify ? storedCredentials : nil let storefrontGroups = Self.orderedTrackGroups(tracksNeedingPopularity) for group in storefrontGroups { @@ -417,7 +456,7 @@ final class KeywordMetricsService: Sendable { let storefrontCode = storefrontTracks.first?.storefront ?? "US" do { - let resolution = try await fetchPopularityScoresWithFallback( + let resolution = try await fetchPopularityScores( for: storefrontTracks.map(\.term), countryOrRegion: storefrontCode, credentials: credentials, @@ -575,24 +614,8 @@ final class KeywordMetricsService: Sendable { tracksNeedingPopularity.append(candidate) } - let credentials = await credentialStore.apiCredentials - guard credentials.canVerify else { - for candidate in tracksNeedingPopularity { - try Task.checkCancellation() - let outcome = try await persistMetricsPayload( - Self.makeAppleAdsMetrics(popularityResult: .missingCredentials), - for: candidate, - using: modelStore - ) - outcomes.append(outcome) - if outcome.disposition == .failed { failureCount += 1 } - completedCount += 1 - await didPersist?(candidate.persistenceUpdate) - await progress?(completedCount, totalCount, failureCount) - try Task.checkCancellation() - } - return KeywordMetricsRefreshBatchResult(outcomes: outcomes, batchErrors: batchErrors) - } + let storedCredentials = await credentialStore.apiCredentials + let credentials = storedCredentials.canVerify ? storedCredentials : nil let storefrontGroups = Self.orderedCandidateGroups(tracksNeedingPopularity) try Task.checkCancellation() @@ -606,7 +629,7 @@ final class KeywordMetricsService: Sendable { let storefrontCode = storefrontTracks.first?.storefront ?? "US" let resolution: AppleAdsPopularityResolution do { - resolution = try await fetchPopularityScoresWithFallback( + resolution = try await fetchPopularityScores( for: storefrontTracks.map(\.term), countryOrRegion: storefrontCode, credentials: credentials, @@ -1119,15 +1142,6 @@ final class KeywordMetricsService: Sendable { switch popularityResult { case .success(let score): popularityScore = min(100, max(1, score)) - case .missingCredentials: - return KeywordMetricsPayload( - popularityScore: nil, - difficultyScore: nil, - source: .appleAdsPopularity, - 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, @@ -1343,7 +1357,7 @@ private struct CampaignManagementPopularityContext: Sendable { let webSession: AppleAdsWebSession } -private enum CampaignManagementFallbackAccess: Sendable { +private enum CampaignManagementAccess: Sendable { case available(CampaignManagementPopularityContext) case requiresReconnect case unconfigured @@ -1380,7 +1394,6 @@ struct AppleAdsPromotedApp: Codable, Equatable, Identifiable, Sendable { private enum AppleAdsPopularityResult { case success(Int) - case missingCredentials case missingContextApp case notFound case failure(String) diff --git a/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift b/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift index 35800d2..f090f52 100644 --- a/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift +++ b/OpenASO/Services/AppleAds/KeywordResearchMetricsWorkflow.swift @@ -636,11 +636,11 @@ private extension KeywordResearchMetricsWorkflow { case .missingContextApp: message = "Connect Apple Ads and choose a linked context app before refreshing popularity." case .missingSession: - message = "Configure and verify Apple Ads Platform API credentials before refreshing popularity." + message = "Connect Apple Ads using either the Platform API or Web Access before refreshing popularity." case .reconnectRequired: - message = "Verify the current Apple Ads Platform API credentials before refreshing popularity." + message = "Refresh Apple Ads Web Access or connect the Platform API before refreshing popularity." case .sessionExpired: - message = "Apple Ads Platform API authorization expired. Verify the credentials in Settings." + message = "Apple Ads authorization expired. Refresh Web Access or verify the Platform API 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/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 217bfd4..5bc41ab 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -2416,7 +2416,7 @@ final class OpenASOMCPService: Sendable { ) } } else { - let message = "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + let message = "Popularity failed to fetch. Connect Apple Ads using either the Platform API or Web Access in Settings." try await backgroundModelStore.write { modelContext in for identityKey in trackIdentityKeys { guard let track = try Self.fetchTrackedKeyword(identityKey: identityKey, in: modelContext) @@ -2465,7 +2465,7 @@ final class OpenASOMCPService: Sendable { rankingProvenance: nil, error: error.map { OpenASOMCPErrorDTO( - code: $0.localizedCaseInsensitiveContains("Apple Ads Platform API credentials") + code: $0.localizedCaseInsensitiveContains("Connect Apple Ads") ? "apple_ads_not_configured" : "keyword_popularity_unavailable", message: $0 diff --git a/OpenASOTests/AppServicesDependencyTests.swift b/OpenASOTests/AppServicesDependencyTests.swift index a0318fb..0a085f4 100644 --- a/OpenASOTests/AppServicesDependencyTests.swift +++ b/OpenASOTests/AppServicesDependencyTests.swift @@ -222,7 +222,7 @@ struct AppServicesDependencyTests { keywordGeneration: addition.keyword.generation, policy: .requireNetwork ) - #expect(missingCredentials.issue?.code == .missingSession) + #expect(missingCredentials.issue?.code == .missingContextApp) #expect(requestCount == 0) let credentials = AppleAdsCredentials( @@ -262,6 +262,75 @@ struct AppServicesDependencyTests { #expect(requestCount == 2) } + @Test + func automaticResearchMetricsWorkflowUsesWebAccessWithoutPlatformCredentials() async throws { + struct RequestBody: Decodable { + let terms: [String] + } + + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let contextAppStoreID: Int64 = 6_608_976_383 + var requestedTerms: [String] = [] + let client = MockHTTPClient { request in + #expect(request.url?.path == "/cm/api/v2/keywords/popularities") + let body = try JSONDecoder().decode( + RequestBody.self, + from: try #require(request.httpBody) + ) + requestedTerms = body.terms + return ( + Data( + #"{"status":"success","data":[{"name":"web workflow","popularity":65}]}"#.utf8 + ), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + } + let services = AppServices.mocked( + httpClient: client, + modelContainer: container, + appleAdsPlatformAPI: FailingSearchPopularityAPI( + error: .providerUnavailable("Platform API should not run") + ) + ) + try services.appleAdsWebSessionStore.save( + AppleAdsWebSession( + cookieHeader: "cookie=value; XSRF-TOKEN-CM=token", + xsrfToken: "token", + updatedAt: .now + ) + ) + services.settingsStore.savePopularityContext( + appStoreID: contextAppStoreID, + storefrontCode: "US" + ) + let projectStore = try #require(services.keywordResearchProjectStore) + let workflow = try #require(services.keywordResearchMetricsWorkflow) + let project = try await projectStore.createProject( + name: "Web metrics wiring", + defaultStorefront: "us", + defaultPlatform: .iphone + ) + let addition = try await projectStore.addKeyword( + to: project.revision, + term: "web workflow", + storefront: "us", + platform: .iphone + ) + + let refreshed = try await workflow.refresh( + projectGeneration: addition.project.generation, + keywordGeneration: addition.keyword.generation, + policy: .requireNetwork + ) + + #expect(!services.appleAdsCredentialStore.hasCompleteAPICredentials) + #expect(requestedTerms == ["web workflow"]) + #expect(refreshed.popularityScore == 65) + #expect(refreshed.provenance == .requestedContext(appStoreID: contextAppStoreID)) + #expect(refreshed.disposition == .refreshed) + #expect(refreshed.issue == nil) + } + @Test func freshServicesDoNotReadKeychainWithoutPresenceFlags() { let defaults = Self.makeDefaults() diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index ba5bb2f..7c9b6b6 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -108,7 +108,7 @@ struct KeywordMetricsServiceTests { #expect(try TrackedKeywordRefreshStatusStore.snapshot( for: track, in: modelContext - ).popularityMessage == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings.") + ).popularityMessage == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.anyConnectionRequired)") } @Test @@ -516,6 +516,57 @@ struct KeywordMetricsServiceTests { #expect(stored["voice to text"]?.notes == nil) } + @Test + func foregroundPopularityRefreshUsesWebAccessWithoutPlatformCredentials() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let modelContext = ModelContext(container) + var requestedTerms: [String] = [] + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + let body = try JSONDecoder().decode( + KeywordPopularityRequestBody.self, + from: try #require(request.httpBody) + ) + requestedTerms = body.terms + return ( + Data(#"{"status":"success","data":[{"name":"focus timer","popularity":72}]}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + }, + modelContainer: container, + appleAdsPlatformAPI: FailingSearchPopularityAPI( + error: .providerUnavailable("Platform API should not run") + ) + ) + try configureCampaignManagementFallback( + in: services, + configuresPlatformCredentials: false + ) + let trackedApp = TrackedApp( + appStoreID: 1, + bundleID: nil, + name: "App", + sellerName: nil, + defaultPlatform: .iphone + ) + modelContext.insert(trackedApp) + let track = try makeTrack(term: "focus timer", trackedApp: trackedApp, in: modelContext) + try modelContext.save() + + let outcomes = await services.keywordMetricsService.refreshMetrics( + for: trackedApp, + tracks: [track], + in: modelContext + ) + let metric = try #require(modelContext.fetch(FetchDescriptor()).first) + + #expect(!services.appleAdsCredentialStore.hasCompleteAPICredentials) + #expect(requestedTerms == ["focus timer"]) + #expect(outcomes.first?.disposition == .refreshed) + #expect(metric.popularityScore == 72) + #expect(metric.source == .appleAdsPopularity) + } + @Test func appIndependentPopularityUsesCampaignManagementFallbackForOfficialOmission() async throws { var requestedTerms: [String] = [] @@ -549,6 +600,96 @@ struct KeywordMetricsServiceTests { #expect(evidence.first?.popularityScore == 54) } + @Test + func appIndependentPopularityUsesWebAccessWithoutPlatformCredentials() async throws { + var requestedTerms: [String] = [] + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + let body = try JSONDecoder().decode( + KeywordPopularityRequestBody.self, + from: try #require(request.httpBody) + ) + requestedTerms = body.terms + return ( + Data(#"{"status":"success","data":[{"name":"speech notes","popularity":55}]}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + }, + appleAdsPlatformAPI: FailingSearchPopularityAPI( + error: .providerUnavailable("Platform API should not run") + ) + ) + try configureCampaignManagementFallback( + in: services, + configuresPlatformCredentials: false + ) + let target = try makePopularityTarget(term: "speech notes", storefront: "us") + + let evidence = try await services.keywordMetricsService.fetchPopularityMetrics( + for: [target], + now: { Date(timeIntervalSince1970: 500) } + ) + + #expect(!services.appleAdsCredentialStore.hasCompleteAPICredentials) + #expect(requestedTerms == ["speech notes"]) + #expect(evidence.first?.popularityScore == 55) + } + + @Test + func backgroundPopularityRefreshUsesWebAccessWithoutPlatformCredentials() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let modelContext = ModelContext(container) + var requestedTerms: [String] = [] + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + let body = try JSONDecoder().decode( + KeywordPopularityRequestBody.self, + from: try #require(request.httpBody) + ) + requestedTerms.append(contentsOf: body.terms) + let rows = body.terms.map { term in + #"{"name":"\#(term)","popularity":73}"# + }.joined(separator: ",") + return ( + Data(#"{"status":"success","data":[\#(rows)]}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + }, + modelContainer: container, + appleAdsPlatformAPI: FailingSearchPopularityAPI( + error: .providerUnavailable("Platform API should not run") + ) + ) + try configureCampaignManagementFallback( + in: services, + configuresPlatformCredentials: false + ) + let trackedApp = TrackedApp( + appStoreID: 1, + bundleID: nil, + name: "App", + sellerName: nil, + defaultPlatform: .iphone + ) + modelContext.insert(trackedApp) + let tracks = try ["focus timer", "deep work"].map { + try makeTrack(term: $0, trackedApp: trackedApp, in: modelContext) + } + try modelContext.save() + + let outcomes = try await services.keywordMetricsService.refreshMetrics( + for: tracks.map(\.identityKey), + using: BackgroundModelStore(modelContainer: container) + ) + let storedScores = try modelContext.fetch(FetchDescriptor()) + .compactMap(\.popularityScore) + + #expect(!services.appleAdsCredentialStore.hasCompleteAPICredentials) + #expect(Set(requestedTerms) == Set(tracks.map(\.term))) + #expect(outcomes.allSatisfy { $0.disposition == .refreshed }) + #expect(storedScores == [73, 73]) + } + @Test func expiredFallbackPreservesOfficialHitAndCachedMissingTerm() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) @@ -719,7 +860,7 @@ struct KeywordMetricsServiceTests { #expect(result.failureCount == 1) #expect(result.firstErrorMessage?.contains("Connect your Apple Ads account") == true) #expect(stored.popularityScore == 74) - #expect(stored.statusMessage == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.connectionRequired)") + #expect(stored.statusMessage == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.webConnectionRequired)") #expect(progressUpdates == [ .init(completed: 0, total: 1, failureCount: 0), .init(completed: 1, total: 1, failureCount: 1), @@ -2132,7 +2273,7 @@ struct KeywordMetricsServiceTests { #expect(outcomes.count == tracks.count) #expect(outcomes.allSatisfy { - $0.errorMessage?.contains("Configure and verify Apple Ads Platform API credentials") == true + $0.errorMessage?.contains("Connect Apple Ads") == true }) #expect(progressUpdates.count == tracks.count + 1) #expect(progressUpdates.last == .init(completed: tracks.count, total: tracks.count, failureCount: tracks.count)) @@ -2259,7 +2400,7 @@ struct KeywordMetricsServiceTests { modelContext: modelContext, popularityScore: nil, updatedAt: now, - statusMessage: "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.connectionRequired)" + statusMessage: "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.webConnectionRequired)" ) let unavailableMessage = "Popularity unavailable. Apple Ads returned no eligible row." let unavailableRow = makeRow( @@ -2275,7 +2416,7 @@ struct KeywordMetricsServiceTests { #expect(staleRow.popularityIndicatorState(now: now) == .stale(lastUpdatedAt: staleUpdatedAt)) #expect( needsSetupRow.popularityIndicatorState(now: now) == .needsSetup( - message: "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.connectionRequired)" + message: "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.webConnectionRequired)" ) ) #expect(unavailableRow.displayedPopularityScore == nil) @@ -2375,9 +2516,12 @@ struct KeywordMetricsServiceTests { private func configureCampaignManagementFallback( in services: AppServices, - appStoreID: Int64 = 6_608_976_383 + appStoreID: Int64 = 6_608_976_383, + configuresPlatformCredentials: Bool = true ) throws { - try saveTestCredentials(in: services.appleAdsCredentialStore) + if configuresPlatformCredentials { + try saveTestCredentials(in: services.appleAdsCredentialStore) + } try services.appleAdsWebSessionStore.save(completeWebSession) services.settingsStore.savePopularityContext( appStoreID: appStoreID, diff --git a/OpenASOTests/OpenASOMCPServiceTests.swift b/OpenASOTests/OpenASOMCPServiceTests.swift index 95094b7..28d2450 100644 --- a/OpenASOTests/OpenASOMCPServiceTests.swift +++ b/OpenASOTests/OpenASOMCPServiceTests.swift @@ -968,7 +968,7 @@ struct OpenASOMCPServiceTests { #expect(metricsRefresh.outcomes.first?.error?.code == "apple_ads_not_configured") #expect( metricsRefresh.outcomes.first?.track.statusMessage - == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.anyConnectionRequired)" ) } @@ -2057,7 +2057,7 @@ struct OpenASOMCPServiceTests { #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." + == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.anyConnectionRequired)" ) } diff --git a/OpenASOTests/RefreshObservabilityTests.swift b/OpenASOTests/RefreshObservabilityTests.swift index a5a9985..e19bb4a 100644 --- a/OpenASOTests/RefreshObservabilityTests.swift +++ b/OpenASOTests/RefreshObservabilityTests.swift @@ -543,7 +543,7 @@ struct RefreshObservabilityTests { #expect(result.keywordOutcomes.allSatisfy { $0.error == nil }) #expect( result.firstError?.localizedDescription - == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.anyConnectionRequired)" ) #expect(rankings.attemptedCount == 3) #expect(rankings.failureCount == 0) diff --git a/OpenASOTests/TrackedKeywordRefreshStatusTests.swift b/OpenASOTests/TrackedKeywordRefreshStatusTests.swift index 5033987..c822891 100644 --- a/OpenASOTests/TrackedKeywordRefreshStatusTests.swift +++ b/OpenASOTests/TrackedKeywordRefreshStatusTests.swift @@ -538,7 +538,7 @@ struct TrackedKeywordRefreshStatusTests { #expect(snapshots[identityKey]?.rankingMessage == "Ranking failed to refresh. Preserve this failure.") #expect( snapshots[identityKey]?.popularityMessage - == "Popularity failed to fetch. Configure and verify Apple Ads Platform API credentials in Settings." + == "Popularity failed to fetch. \(AppleAdsPopularityFallbackMessaging.anyConnectionRequired)" ) } } From f69ad6df212ad5272a5cbecbb33fad873ddd9b46 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:31:54 +0100 Subject: [PATCH 6/6] chore(release): Prepare OpenASO 0.4.5 --- OpenASO.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index 0b569e9..f25e0f1 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -1615,7 +1615,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 85BF4D5D6B; GENERATE_INFOPLIST_FILE = NO; @@ -1634,7 +1634,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.4.4; + MARKETING_VERSION = 0.4.5; POSTHOG_HOST = ""; POSTHOG_PROJECT_TOKEN = ""; PRODUCT_BUNDLE_IDENTIFIER = com.thirdtech.openaso.dev; @@ -1844,7 +1844,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 85BF4D5D6B; ENABLE_HARDENED_RUNTIME = YES; @@ -1864,7 +1864,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.4.4; + MARKETING_VERSION = 0.4.5; POSTHOG_HOST = ""; POSTHOG_PROJECT_TOKEN = ""; PRODUCT_BUNDLE_IDENTIFIER = com.thirdtech.openaso;