Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions OpenASO.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -353,6 +354,7 @@

/* Begin PBXFileReference section */
AD5100000000000000000003 /* AppleAds/AppleAdsPlatformService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsPlatformService.swift; sourceTree = "<group>"; };
ADF000000000000000000002 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsPopularityFallbackMessaging.swift; sourceTree = "<group>"; };
AD5200000000000000000002 /* AppleAdsPlatformWorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformWorkspaceView.swift; sourceTree = "<group>"; };
AD5300000000000000000002 /* AppleAdsPlatformServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformServiceTests.swift; sourceTree = "<group>"; };
AD5400000000000000000002 /* AppleAdsPlatformCredentialsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsPlatformCredentialsSection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -1611,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;
Expand All @@ -1630,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;
Expand Down Expand Up @@ -1840,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;
Expand All @@ -1860,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;
Expand Down
16 changes: 14 additions & 2 deletions OpenASO/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -583,7 +590,7 @@ final class AppServices {

func refreshStaleKeywordPopularityAfterAppleAdsConnection() {
guard let backgroundModelStore,
appleAdsCredentialStore.hasCompleteAPICredentials
hasAppleAdsPopularityConnection
else {
return
}
Expand Down Expand Up @@ -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)
}
Expand Down
18 changes: 14 additions & 4 deletions OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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:
Expand All @@ -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."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -234,7 +233,7 @@ struct KeywordTableView: View, Equatable {
}

private func openAppleAdsSettings() {
services.settingsStore.requestSettingsFocus(.platformAPI)
services.settingsStore.requestSettingsFocus(.webSession)
openSettings()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions OpenASO/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ struct SettingsView: View {
appleAdsPlatformSection
.id(AppleAdsSettingsFocusSection.platformAPI)

appleAdsSection
.id(AppleAdsSettingsFocusSection.webSession)

appStoreConnectSection
.id(AppleAdsSettingsFocusSection.appStoreConnect)

Expand Down Expand Up @@ -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 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.")
}
}

Expand Down
4 changes: 3 additions & 1 deletion OpenASO/Services/AppleAds/AppleAdsCredentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ final class AppleAdsCredentialStore {
}

var hasCompleteAPICredentials: Bool {
apiCredentials.isComplete
apiCredentials.canVerify
}

var hasWebLoginCredentials: Bool {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
enum AppleAdsPopularityFallbackMessaging {
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."
}
79 changes: 66 additions & 13 deletions OpenASO/Services/AppleAds/AppleAdsWebLoginController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -69,7 +70,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))
Expand All @@ -84,8 +91,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
}
Expand All @@ -103,15 +109,15 @@ 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 }),
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)
)
}
Expand Down Expand Up @@ -144,6 +150,45 @@ final class AppleAdsWebLoginController: NSObject, AppleAdsWebLoginCapturing {
.joined(separator: "; ")
}

nonisolated static func isCaptureReady(url: URL?, cookies: [HTTPCookie]) -> Bool {
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 {
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,
AppleAdsSessionCookies.authenticatedSession
].contains(cookie.name)
}

nonisolated static func isAuthenticatedAppleAdsPage(_ url: URL?) -> Bool {
guard let url, url.host?.lowercased() == AppleAdsSessionCookies.host else {
return false
Expand Down Expand Up @@ -192,15 +237,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
Expand Down Expand Up @@ -250,10 +297,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
Expand Down Expand Up @@ -295,7 +348,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
Expand Down
Loading
Loading