fix(ios): make the login WebView identify as Mobile Safari

accounts.spotify.com rejected iOS logins ("Oops, something went wrong") because
the WebView sent WKWebView's default User-Agent, which lacks Safari's
"Version/x ... Safari/604.1" and reads as an embedded browser. The random
User-Agent header never reached any request: the screen replaces the content
before it loads.

Have WebKit build Safari's exact UA (with its frozen OS version) via a probe
WKWebView using Safari's applicationNameForUserAgent, prime it at startup and
apply it through webSettings.customUserAgentString so every request uses it.
This commit is contained in:
Kingkor Roy Tirtho 2026-10-26 15:04:26 +06:00
parent cc21568457
commit bdf3655a23
3 changed files with 112 additions and 40 deletions

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.window.ComposeUIViewController
import com.yushosei.newpipe.extractor.NewPipe import com.yushosei.newpipe.extractor.NewPipe
import com.yushosei.newpipe.util.DefaultDownloaderImpl import com.yushosei.newpipe.util.DefaultDownloaderImpl
import org.spotube.spotube.core.di.initKoin import org.spotube.spotube.core.di.initKoin
import org.spotube.spotube.core.webview.SafariUserAgent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -35,6 +36,7 @@ import org.koin.core.component.inject
fun MainViewController() = ComposeUIViewController(configure = { fun MainViewController() = ComposeUIViewController(configure = {
initKoin() initKoin()
SafariUserAgent.prime()
}) { InitNewPipe { App() } } }) { InitNewPipe { App() } }
@Composable @Composable

View File

@ -1,41 +1,43 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.spotube.spotube.core.webview package org.spotube.spotube.core.webview
import dev.nucleusframework.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage import platform.Foundation.NSHTTPCookieStorage
import platform.Foundation.distantPast import platform.Foundation.distantPast
import platform.WebKit.WKWebsiteDataStore import platform.WebKit.WKWebsiteDataStore
import platform.WebKit.WKWebsiteDataTypeCookies import platform.WebKit.WKWebsiteDataTypeCookies
import platform.WebKit.WKWebsiteDataTypeLocalStorage import platform.WebKit.WKWebsiteDataTypeLocalStorage
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { 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 @OptIn(ExperimentalForeignApi::class)
actual suspend fun platformClearWebviewData(pluginId: String?) {
val dataStore = WKWebsiteDataStore.defaultDataStore() if (pluginId == null) return
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {} val dataStore = WKWebsiteDataStore.defaultDataStore()
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast) dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}
}