diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt index 1e72f984..e3b722f6 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.window.ComposeUIViewController import com.yushosei.newpipe.extractor.NewPipe import com.yushosei.newpipe.util.DefaultDownloaderImpl import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.webview.SafariUserAgent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -35,6 +36,7 @@ import org.koin.core.component.inject fun MainViewController() = ComposeUIViewController(configure = { initKoin() + SafariUserAgent.prime() }) { InitNewPipe { App() } } @Composable diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt index 9b1ed717..c465732c 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -1,41 +1,43 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.spotube.spotube.core.webview - -import dev.nucleusframework.webview.web.WebViewState -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSDate -import platform.Foundation.NSHTTPCookieStorage -import platform.Foundation.distantPast -import platform.WebKit.WKWebsiteDataStore -import platform.WebKit.WKWebsiteDataTypeCookies -import platform.WebKit.WKWebsiteDataTypeLocalStorage - -actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { -} - -@OptIn(ExperimentalForeignApi::class) -actual suspend fun platformClearWebviewData(pluginId: String?) { - if (pluginId == null) return - - val dataStore = WKWebsiteDataStore.defaultDataStore() - val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage) - dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {} - - NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast) +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import dev.nucleusframework.webview.web.WebViewState +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.NSHTTPCookieStorage +import platform.Foundation.distantPast +import platform.WebKit.WKWebsiteDataStore +import platform.WebKit.WKWebsiteDataTypeCookies +import platform.WebKit.WKWebsiteDataTypeLocalStorage + +actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { + // Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request. + SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun platformClearWebviewData(pluginId: String?) { + if (pluginId == null) return + + val dataStore = WKWebsiteDataStore.defaultDataStore() + val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage) + dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {} + + NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast) } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt new file mode 100644 index 00000000..4607265d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIDevice +import platform.UIKit.UIUserInterfaceIdiomPad +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration + +/** + * Mobile Safari's exact User-Agent on this device. + * + * WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`, + * which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went + * wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26 + * reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application + * name and reports `navigator.userAgent`, which is cached for every web view. + */ +internal object SafariUserAgent { + var value: String? = null + private set + + // Kept alive until its async evaluation calls back. + private var probe: WKWebView? = null + + /** Must run on the main thread (WebKit); call once at startup. */ + @OptIn(ExperimentalForeignApi::class) + fun prime() { + if (value != null || probe != null) return + val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() } + probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView -> + webView.evaluateJavaScript("navigator.userAgent") { result, _ -> + value = result as? String + probe = null + Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" } + } + } + } + + // What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form. + private fun safariApplicationName(): String { + val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".") + return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) { + "Version/$version Safari/605.1.15" + } else { + "Version/$version Mobile/15E148 Safari/604.1" + } + } +}