From 09df4b494c81fa8b6741ea14fc41435b6b3c2320 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 11:54:29 +0600 Subject: [PATCH 1/7] build: upgrade to Kotlin 2.4.21 and Compose Multiplatform 1.12.2 Several dependencies (coil 3.5.0, millennium, mqtt/ditchoom, composeunstyled, markdown-renderer, composewebview, kmp-zip, filekit) now ship klibs built with Kotlin 2.4, which Kotlin/Native 2.3 refuses to read, breaking the iOS build. - Bump zipline (1.28.0), mokkery (3.5.1) and ksp (2.3.12) for the new compiler - Type TOP_LEVEL_ROUTES as Set (Kotlin 2.4 inferred Set) - Import ComposeApp in iOSApp.swift, which uses ExternalUriHandler --- .../spotube/spotube/core/navigation/NavigationState.kt | 2 +- gradle/libs.versions.toml | 10 +++++----- iosApp/iosApp/iOSApp.swift | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index f92c91d5..3dc7461e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -40,7 +40,7 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import org.koin.core.component.KoinComponent -val TOP_LEVEL_ROUTES = setOf( +val TOP_LEVEL_ROUTES: Set = setOf( Routes.Home, Routes.Search, Routes.Library, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30e39bf7..30876263 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" composeHotReload = "1.2.0" -composeMultiplatform = "1.11.1" +composeMultiplatform = "1.12.2" coil = "3.5.0" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" @@ -37,7 +37,7 @@ jna = "5.18.1" junit = "4.13.2" kermit = "2.1.0" kmpZip = "1.0.0" -kotlin = "2.3.21" +kotlin = "2.4.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" @@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" -ziplineVersion = "1.27.0" +ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" runner = "1.7.0" @@ -68,7 +68,7 @@ feather-icons = "1.1.1" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.5" +ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" @@ -76,7 +76,7 @@ vlcjBundler = "0.2.0" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" -mokkery = "3.3.0" +mokkery = "3.5.1" [libraries] appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index f0058387..349b4ba1 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -14,6 +14,7 @@ // along with this program. If not, see . import SwiftUI +import ComposeApp @main struct iOSApp: App { From ff7a1714ff7dd8538ada6400fc330aae506f3f07 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 11:54:29 +0600 Subject: [PATCH 2/7] refactor(network): central HttpClientFactory; drop Ktor CIO client On iOS the CIO client engine competed with Darwin as the default engine and has no TLS on Kotlin/Native, so HTTPS requests (e.g. plugin discovery) failed. Remove ktor-client-cio and build every client through a Koin-provided HttpClientFactory that owns one client on the platform default engine (Darwin on iOS, OkHttp on Android/desktop). Consumers use the shared default/json clients or derive their own via create { }, sharing the engine. --- AGENTS.md | 6 +- composeApp/build.gradle.kts | 1 - .../org/spotube/spotube/core/di/Modules.kt | 7 ++- .../spotube/core/network/HttpClientFactory.kt | 60 +++++++++++++++++++ .../core/remote/RemoteControlClient.kt | 6 +- .../spotube/core/server/LocalServer.kt | 10 ++-- .../core/tools/user_agents/UserAgents.kt | 19 +++--- .../zipline/host_apis/RealHttpClientAPI.kt | 6 +- .../plugin_apis/lrclib/RealLRCLibLyricsAPI.kt | 35 ++++++----- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 15 ++--- .../modules/downloads/DownloadManager.kt | 7 ++- .../modules/plugin/GitHubPluginRepo.kt | 15 +---- .../spotube/modules/plugin/PluginManager.kt | 7 ++- .../spotube/modules/plugin/PluginViewModel.kt | 10 ++-- .../modules/update/AppUpdateService.kt | 18 ++---- 15 files changed, 133 insertions(+), 89 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt diff --git a/AGENTS.md b/AGENTS.md index e61c7d52..6fdadf2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,13 @@ ## Dependencies - `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. -- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). +- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). - **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). +## Networking +- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). +- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. + ## GraalVM native image (desktop) - Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. - Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b717523d..70c93430 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -199,7 +199,6 @@ kotlin { implementation(libs.ktor.client.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) - implementation(libs.ktor.client.cio) implementation(libs.ktor.client.websockets) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index 05ec4253..d7668946 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -17,6 +17,8 @@ package org.spotube.spotube.core.di +import org.koin.core.module.dsl.onClose +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository @@ -108,6 +110,7 @@ expect val platformModules: Module val sharedModules = module { includes(navigationModule) singleOf(::Database) + single { HttpClientFactory() } withOptions { onClose { it?.close() } } singleOf(::WebViewController) @@ -164,7 +167,7 @@ val sharedModules = module { viewModelOf(::WelcomeViewModel) // Updates - single { AppUpdateService(get()) } withOptions { createdAtStart() } + single { AppUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } // Downloads @@ -273,7 +276,7 @@ val sharedModules = module { createdAtStart() } single { RemoteControlHandler(get(), get(), get(), get()) } - single { RemoteControlClient() } + single { RemoteControlClient(get()) } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt new file mode 100644 index 00000000..1157bec2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt @@ -0,0 +1,60 @@ +/* + * 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.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * The single place the app builds Ktor [HttpClient]s. + * + * It owns one client on the platform's default engine (Darwin on iOS, OkHttp on + * Android/desktop), so every client shares that engine's connection pool and + * no code path can accidentally pick an engine that doesn't work on a platform. + * + * - [default] and [json] are shared: use them directly and never close them. + * - [create] derives a client with extra configuration (timeouts, WebSockets, + * default request, …). It reuses the shared engine; the caller owns the + * returned client and may close it without affecting anyone else. + */ +class HttpClientFactory : AutoCloseable { + private val root = HttpClient() + + /** Plain client with no plugins installed. */ + val default: HttpClient get() = root + + /** Client that (de)serializes JSON bodies via [HttpJson]. */ + val json: HttpClient by lazy { + create { install(ContentNegotiation) { json(HttpJson) } } + } + + fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block) + + override fun close() { + root.close() + } +} + +/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */ +val HttpJson = Json { + ignoreUnknownKeys = true + isLenient = true +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt index ed7dce2e..f6f474c3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -17,8 +17,8 @@ package org.spotube.spotube.core.remote +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.header import io.ktor.client.request.url @@ -49,7 +49,7 @@ import spotube.composeapp.generated.resources.devices_error_send_failed * WebSocket client for controlling a remote Spotube instance. * Connects to the remote device's `/control` endpoint and sends commands. */ -class RemoteControlClient { +class RemoteControlClient(httpClientFactory: HttpClientFactory) { private val logger = Logger.withTag("RemoteControlClient") private val json = Json { ignoreUnknownKeys = true @@ -59,7 +59,7 @@ class RemoteControlClient { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(WebSockets) install(HttpTimeout) { connectTimeoutMillis = 10_000 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index d0a700e9..14e4837c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -17,16 +17,15 @@ package org.spotube.spotube.core.server +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.remote.RemoteControlHandler import org.spotube.spotube.modules.settings.SettingsViewModel -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application import io.ktor.server.application.install -import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.response.respondText @@ -60,11 +59,12 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, - private val remoteControlHandler: RemoteControlHandler) : KoinComponent { + private val remoteControlHandler: RemoteControlHandler, + httpClientFactory: HttpClientFactory) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient { - // A stalled upstream connection must not wedge the CIO dispatcher thread + private val httpClient = httpClientFactory.create { + // A stalled upstream connection must not wedge an engine thread // forever. Only the connect phase is bounded — the proxy streams long // audio bodies, so request/socket timeouts would cut playback short. install(HttpTimeout) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt index 28a0674b..de319170 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt @@ -17,12 +17,13 @@ package org.spotube.spotube.core.tools.user_agents +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.PlatformType import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.getPlatform -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess @@ -53,7 +54,8 @@ import kotlin.time.Clock * [random] is intentionally synchronous and never blocks on the network: it serves * whatever is cached (even stale) and refreshes in the background when needed. */ -object UserAgents { +object UserAgents : KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() private const val SOURCE_URL = "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" private const val CACHE_FILE_NAME = "user_agents_cache.json" @@ -111,14 +113,9 @@ object UserAgents { private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") private suspend fun fetchRemote(): List { - val client = HttpClient(CIO) - try { - val res = client.get(SOURCE_URL) - if (!res.status.isSuccess()) error("Unexpected status ${res.status}") - return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) - } finally { - client.close() - } + val res = httpClientFactory.default.get(SOURCE_URL) + if (!res.status.isSuccess()) error("Unexpected status ${res.status}") + return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) } private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index a396aced..553966b1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -17,11 +17,12 @@ package org.spotube.spotube.core.zipline.host_apis +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.host_apis.HttpClientAPI import org.spotube.plugin_interfaces.host_apis.HttpMethod import org.spotube.plugin_interfaces.host_apis.HttpResponse import org.spotube.spotube.core.di.injectLogger -import io.ktor.client.HttpClient import io.ktor.client.request.request import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent class RealHttpClientAPI : HttpClientAPI, KoinComponent { val logger by injectLogger() - val httpClient = HttpClient() + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient get() = httpClientFactory.default override suspend fun request( method: HttpMethod, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index b46bc9e0..59a931b8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -17,11 +17,14 @@ package org.spotube.spotube.core.zipline.plugin_apis.lrclib +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpJson +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class LRCLibResponse( @@ -47,19 +49,22 @@ data class LRCLibResponse( val plainLyrics: String?, val syncedLyrics: String?) -class RealLRCLibLyricsAPI : LyricsAPI { - private val httpClient = HttpClient { - install(Logging) { - level = LogLevel.ALL - logger = Logger.SIMPLE - } - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - defaultRequest { - url("https://lrclib.net/api/get") - // TODO: Add the actual version of Spotube instead of hardcoding it - userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") +class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient by lazy { + httpClientFactory.create { + install(Logging) { + level = LogLevel.ALL + logger = Logger.SIMPLE + } + install(ContentNegotiation) { + json(HttpJson) + } + defaultRequest { + url("https://lrclib.net/api/get") + // TODO: Add the actual version of Spotube instead of hardcoding it + userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index a1c80553..52b2eaa7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -17,15 +17,15 @@ package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.tools.user_agents.UserAgents -import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post @@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent { .lowercase() } - val client = HttpClient() { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } + private val httpClientFactory: HttpClientFactory by inject() + private val client get() = httpClientFactory.json private fun MetadataTrack.describe() = "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 9b1824a0..2cbd6775 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.downloads +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.paths.Paths @@ -26,7 +27,6 @@ import org.spotube.spotube.core.server.StreamingUrlRepository import org.spotube.spotube.core.server.urlSummary import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import io.ktor.client.request.head @@ -88,7 +88,8 @@ sealed interface DownloadStatus { class DownloadManager( private val settingsRepository: SettingsRepository, private val paths: Paths, - private val streamingUrlRepository: StreamingUrlRepository + private val streamingUrlRepository: StreamingUrlRepository, + private val httpClientFactory: HttpClientFactory ) { companion object { const val MAX_CONCURRENT_DOWNLOADS = 4 @@ -101,7 +102,7 @@ class DownloadManager( private val fileSystem = FileSystem.SYSTEM private val queueMutex = Mutex() - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(HttpTimeout) { requestTimeoutMillis = 300_000 connectTimeoutMillis = 30_000 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 34d1fd92..86a8f30b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -17,9 +17,9 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.parameter @@ -27,7 +27,6 @@ import io.ktor.http.append import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class GitHubRepoSearchResponse( @@ -64,12 +63,8 @@ data class GitHubAsset( val name: String, @SerialName("browser_download_url") val browserDownloadUrl: String) -class GitHubPluginRepository { - private val httpClient = HttpClient { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } +/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */ +class GitHubPluginRepository(private val httpClient: HttpClient) { suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { return httpClient.get("https://api.github.com/search/repositories") { @@ -113,8 +108,4 @@ class GitHubPluginRepository { emptyList() } } - - fun close() { - httpClient.close() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 71871b23..6b2ed87a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.db.Database import org.spotube.spotube.core.db.DatabaseKeys import org.spotube.spotube.core.di.injectLogger @@ -24,7 +25,6 @@ import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.zipline.BuiltInPluginService import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.ZiplinePluginService -import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel import io.ktor.utils.io.readRemaining @@ -113,7 +113,8 @@ interface ScrobblePluginSource { class PluginManager( val database: Database, - val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { + val paths: Paths, + private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -121,7 +122,7 @@ class PluginManager( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() val pluginsDirPath: Path get() = pluginsDir - private val httpClient = HttpClient() + private val httpClient get() = httpClientFactory.default enum class InstallPromptKind { INSTALL, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index e3d5ff5c..7d685cfd 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.spotube.spotube.core.di.injectLogger @@ -141,10 +142,11 @@ private data class PluginScreenState( @OptIn(ExperimentalCoroutinesApi::class) class PluginViewModel( private val pluginManager: PluginManager, - private val webViewController: WebViewController) : ViewModel(), KoinComponent { + private val webViewController: WebViewController, + httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent { private val logger by injectLogger() - private val gitHubRepo = GitHubPluginRepository() + private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json) private val _screenState = MutableStateFlow(PluginScreenState()) private val _allRepos = mutableListOf() @@ -658,8 +660,4 @@ class PluginViewModel( } } - override fun onCleared() { - gitHubRepo.close() - super.onCleared() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt index 234a0338..0bbc8552 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt @@ -17,13 +17,11 @@ package org.spotube.spotube.modules.update +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.AppBuildInfo import org.spotube.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.http.HttpHeaders @@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import net.swiftzer.semver.SemVer import org.koin.core.component.KoinComponent @@ -52,14 +49,11 @@ data class AvailableAppUpdate( * process, independent of any screen's lifecycle. */ class AppUpdateService( - private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { + private val settingsRepository: SettingsRepository, + httpClientFactory: HttpClientFactory) : KoinComponent { private val logger = Logger.withTag("AppUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val httpClient = HttpClient(CIO) { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } + private val httpClient = httpClientFactory.json private val _availableUpdate = MutableStateFlow(null) val availableUpdate: StateFlow = _availableUpdate.asStateFlow() @@ -117,10 +111,6 @@ class AppUpdateService( private fun parseVersion(version: String): SemVer? = runCatching { SemVer.parse(version) }.getOrNull() - - override fun close() { - httpClient.close() - } } @Serializable From 7eb56c85931d4c0ad7cca09f948401baebdfc472 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 23 Oct 2026 11:54:29 +0600 Subject: [PATCH 3/7] fix(ios): run Zipline on an 8 MiB stack thread newSingleThreadContext threads get only 512 KiB of stack on iOS, while QuickJS may recurse ~6 MiB deep when compiling a plugin, so the app crashed with SIGBUS at startup once a plugin was installed. Host the dispatcher on an NSThread with an 8 MiB stack, matching JVM/Android. --- .../spotube/core/zipline/ZiplineDispatcher.kt | 3 +- .../core/zipline/ZiplineDispatcher.ios.kt | 35 +++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt index 8ee39b8f..99496217 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt @@ -40,7 +40,8 @@ class ZiplineDispatcher( * size is ~1 MiB, which causes native stack overflow when compiling large * JS modules like kotlin-stdlib (~491 KB). * - * This expect/actual ensures the backing thread has a sufficiently large stack. + * iOS secondary threads get only 512 KiB, so every platform must create its own + * large-stack thread. This expect/actual ensures the backing thread has one. */ expect fun createZiplineDispatcher(): ZiplineDispatcher diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt index 8249ae1e..c4a1559a 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt @@ -17,17 +17,32 @@ package org.spotube.spotube.core.zipline -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import platform.Foundation.NSThread +import kotlin.coroutines.ContinuationInterceptor + +// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but +// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading +// a plugin overflowed into the guard page and crashed the app with SIGBUS. +private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB -@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) actual fun createZiplineDispatcher(): ZiplineDispatcher { - // On iOS/native, thread stack sizes are typically 8 MiB by default, - // so newSingleThreadContext is sufficient. - val ctx = newSingleThreadContext("Zipline") - return ZiplineDispatcher(ctx) { - ctx.close() + val dispatcher = CompletableDeferred() + val stopped = CompletableDeferred() + // NSThread is the only way to choose a thread's stack size here; it hosts an event + // loop (runBlocking) whose dispatcher accepts work from any thread. + val thread = NSThread { + runBlocking { + dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher) + stopped.await() + } + } + thread.name = "Zipline" + thread.stackSize = ZIPLINE_THREAD_STACK_SIZE + thread.start() + return ZiplineDispatcher(runBlocking { dispatcher.await() }) { + stopped.complete(Unit) } } - From 99ba08501bf656ba80f639c3dfd31cc14f08d18e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 13:40:00 +0600 Subject: [PATCH 4/7] fix(server): don't crash when the proxy port is still in use CIO reports a failed bind from its accept coroutine; unhandled, that exception terminates the process on Kotlin/Native before the port fallback can run, so relaunching iOS shortly after playback crashed (EADDRINUSE). Contain it with a parent CoroutineExceptionHandler and enable reuseAddress so the preferred port can be rebound while old connections are in TIME_WAIT. --- .../spotube/core/server/ServerBinder.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt index 756cc73e..43d44309 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt @@ -21,7 +21,10 @@ package org.spotube.spotube.core.server import io.ktor.server.application.Application import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer +import io.ktor.server.application.serverConfig +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withTimeout @@ -37,6 +40,9 @@ internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val * preferred port can't be bound, retry on an OS-assigned free port (port 0). * * The caller must read [BoundServer.port] instead of assuming [preferredPort]. + * + * `reuseAddress` lets a restarted app rebind its old port while connections from the previous + * run (e.g. the player's) are still in TIME_WAIT. */ internal suspend fun startServerWithPortFallback( host: String, @@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback( val candidates = listOf(preferredPort, 0).distinct() var lastFailure: Throwable? = null + // CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the + // process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still + // reaches us through resolvedConnectors(), so it only needs to be contained here. + val swallowBindFailure = CoroutineExceptionHandler { _, _ -> } + for (candidate in candidates) { - val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module) + val engine = embeddedServer( + factory = CIO, + rootConfig = serverConfig { + parentCoroutineContext = swallowBindFailure + module { module() } + }, + configure = { + connector { + this.host = host + this.port = candidate + } + reuseAddress = true + }) try { engine.start(wait = false) val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } From cb8316d1417fe0f3f536dfadea055e05e39baa7a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 13:40:00 +0600 Subject: [PATCH 5/7] fix(ios): stream formats AVPlayer can play AVPlayer can't open WebM/Ogg or DASH, so the default opus/webm stream failed silently on iOS. - Rank streams the platform's player can play above format/quality prefs - Default iOS streaming to aac/mp4, migrate saved WebM/Ogg prefs on iOS and hide unplayable formats in the picker (desktop/Android keep opus/webm) - Normalize codec/container names on iOS like androidJvm (mp4a -> aac, m4a -> mp4) - Wait up to 30s for the audio plugin to load when resolving, so the queue restored at startup isn't failed permanently before plugins are ready --- .../core/newpipe/NewPipeService.androidJvm.kt | 8 --- .../spotube/core/newpipe/NewPipeModels.kt | 12 ++++- .../core/server/StreamingUrlRepository.kt | 52 ++++++++++++++++-- .../modules/settings/SettingsModels.kt | 34 ++++++++---- .../modules/settings/SettingsRepository.kt | 4 +- .../modules/settings/sections/utils.kt | 6 ++- .../StreamingUrlRepositoryBehaviorTest.kt | 18 +++++++ .../core/server/StreamingUrlRepositoryTest.kt | 35 +++++++++++- .../settings/StreamingFormatDefaultsTest.kt | 53 +++++++++++++++++++ .../core/newpipe/NewPipeService.ios.kt | 4 +- 10 files changed, 198 insertions(+), 28 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index a79f5a03..1500c6f9 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -162,14 +162,6 @@ actual class NewPipeService { * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded * aac/mp4 DASH/HLS manifests always won instead. */ -private fun normalizeCodec(codec: String?): String { - val value = codec.orEmpty() - return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value -} - -private fun normalizeContainer(suffix: String): String = - if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix - private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { DeliveryMethod.DASH -> StreamProtocol.DASH DeliveryMethod.HLS -> StreamProtocol.HLS diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt index 8ca9471f..91fe54a2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt @@ -49,4 +49,14 @@ data class MediaStream( val container: String, val bitrate: Int, val isAudio: Boolean, - val isVideo: Boolean) \ No newline at end of file + val isVideo: Boolean) + +/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */ +internal fun normalizeCodec(codec: String?): String { + val value = codec.orEmpty() + return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value +} + +/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */ +internal fun normalizeContainer(suffix: String): String = + if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 3ee50fa0..19692bf6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -23,13 +23,18 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.PlatformType import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.getPlatform import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.component.KoinComponent import kotlin.math.abs import kotlin.time.Clock @@ -111,7 +116,14 @@ class StreamingUrlRepository( // read it, nor replace it with a stream playback didn't ask for. val useUrlCache = request == null + // A built-in audio plugin is always selected, so null means plugins are still loading. + // The player requests the restored queue's track right at startup; failing that request + // leaves the item permanently failed (AVPlayer), so wait for the plugin instead. val audioPlugin = pluginManager.selectedAudioPlugin.value + ?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) { + logger.i { "[Resolve $trackId] waiting for the audio plugin to load" } + pluginManager.selectedAudioPlugin.filterNotNull().first() + } if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null @@ -243,10 +255,12 @@ class StreamingUrlRepository( logger.i { "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + - "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" + + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + + "lowest wins):\n" + streams.joinToString("\n") { stream -> val marker = if (stream === selected) " -> " else " " - "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " + + "${formatMismatchCount(stream, preferredFormat)}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" @@ -254,6 +268,10 @@ class StreamingUrlRepository( } when { selected == null -> logger.w { "[Resolve $trackId] no stream selected" } + !selected.isPlayableOn(currentPlatform) -> logger.w { + "[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " + + "${selected.describeForLog()}, which the player will likely reject" + } formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "got ${selected.codec}/${selected.container} (${selected.protocol})" @@ -371,6 +389,8 @@ class StreamingUrlRepository( * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are * resolved against the YouTube origin; progressive URLs are returned unchanged. */ +private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds + internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { if (url.startsWith("http")) return url return when (protocol) { @@ -379,12 +399,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String } } -/** Selects the available stream closest to the user's preferred format and quality. */ +internal val currentPlatform: PlatformType by lazy { getPlatform().type } + +/** + * Selects the available stream closest to the user's preferred format and quality. + * Streams the [platform]'s player can decode always win over ones it can't, whatever the + * preferences say: e.g. opus/webm is unplayable on iOS. + */ internal fun selectPreferredAudioStream( streams: List, preferredFormat: AudioFormat, - preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( + preferredQuality: AudioQuality, + platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull( compareBy { stream -> + if (stream.isPlayableOn(platform)) 0 else 1 + }.thenBy { stream -> formatMismatchCount(stream, preferredFormat) }.thenBy { stream -> if (stream.matchesQualityType(preferredQuality)) 0 else 1 @@ -395,6 +424,21 @@ internal fun selectPreferredAudioStream( } ) +// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load. +private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska") + +/** + * Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android) + * handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. + */ +internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = + isContainerPlayableOn(container, platform) && + !(platform == PlatformType.IOS && protocol == StreamProtocol.DASH) + +/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ +internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = + platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS + private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index d6da4e16..8d0fa86d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -17,6 +17,9 @@ package org.spotube.spotube.modules.settings +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import kotlinx.serialization.SerialName @@ -42,15 +45,7 @@ data class UserSettings( val palette: MillenniumPalette = MillenniumPalette.Blue, // Playback - val streamingMusicFormat: AudioFormat = AudioFormat( - codec = "opus", - container = "webm", - qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000)) - ), + val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val enableMusicCaching: Boolean = true, val cacheFolder: String? = null, @@ -111,3 +106,24 @@ data class JamBroker( val clientIdPrefix: String = "spotube", val keepAliveSeconds: Int = 30, val connectionTimeoutSeconds: Int = 10) + +/** + * opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC. + * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). + */ +internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { + val ios = platform == PlatformType.IOS + return AudioFormat( + codec = if (ios) "aac" else "opus", + container = if (ios) "mp4" else "webm", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000))) +} + +/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */ +internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings = + if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this + else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt index a69fd159..b8b3772c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -54,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { - settingsJson.decodeFromString(json as String) + settingsJson.decodeFromString(json as String).withPlayableStreamingFormat() } else { UserSettings() // Default value } @@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { suspend fun getCurrentSettings(): UserSettings { val preferences = database.settingsDataStore.data.first() val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() - return settingsJson.decodeFromString(storedJson) + return settingsJson.decodeFromString(storedJson).withPlayableStreamingFormat() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt index ab59ce60..34849447 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -18,6 +18,8 @@ package org.spotube.spotube.modules.settings.sections +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.theme.MillenniumTheme @@ -56,6 +58,7 @@ private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 256_000)) +// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg). internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", @@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf( AudioFormat( codec = "vorbis", container = "ogg", - qualities = standardLossyQualities)) + qualities = standardLossyQualities) +).filter { isContainerPlayableOn(it.container, currentPlatform) } internal val downloadFormatPresets = listOf( AudioFormat( diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index d7bda94a..f297b17a 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -8,6 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.settings.UserSettings import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -130,6 +132,22 @@ class StreamingUrlRepositoryBehaviorTest { assertNull(info) } + @Test + fun `waits for an audio plugin that is still loading`() = runTest { + val h = Harness() + val loader = launch { + delay(5_000) + h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + } + + val info = h.repository.resolveStreamInfo(track("t1")) + loader.join() + + assertEquals("https://example.com/a.opus", info?.url) + } + @Test fun `returns null when the plugin yields no sources`() = runTest { val h = Harness() diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt index e950747a..b5f68eb9 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -1,5 +1,6 @@ package org.spotube.spotube.core.server +import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream @@ -13,6 +14,7 @@ import kotlin.test.assertSame * Tests for the stream preference selection used by [StreamingUrlRepository]. * * Ordering contract (best first): + * 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH). * 1. Fewest codec/container mismatches against the preferred format. * 2. Matching quality family (lossy vs lossless). * 3. Closest bitrate / sample rate. @@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest { private fun select( streams: List, format: AudioFormat = opusWebm(), - quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) + quality: AudioQuality = AudioQuality.Lossy(256_000), + platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform) // ---------- empty / single ---------- @@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest { assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) } + + // ---------- device playability ---------- + + @Test + fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS dash manifests are skipped in favour of progressive streams`() { + val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) + val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS an unplayable stream is still returned when nothing else exists`() { + val opus = lossy("opus-160", 160_000) + assertSame(opus, select(listOf(opus), platform = PlatformType.IOS)) + } + + @Test + fun `off iOS the preferred opus webm stream still wins`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android)) + } + } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt new file mode 100644 index 00000000..cc35c849 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -0,0 +1,53 @@ +/* + * 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.modules.settings + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.spotube.PlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class StreamingFormatDefaultsTest { + + private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + @Test + fun `ios defaults to aac in mp4`() { + val format = defaultStreamingMusicFormat(PlatformType.IOS) + assertEquals("aac" to "mp4", format.codec to format.container) + } + + @Test + fun `other platforms keep opus in webm`() { + val format = defaultStreamingMusicFormat(PlatformType.Android) + assertEquals("opus" to "webm", format.codec to format.container) + } + + @Test + fun `saved opus webm is moved to aac on ios`() { + val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS) + assertEquals("mp4", migrated.streamingMusicFormat.container) + } + + @Test + fun `saved opus webm is kept off ios`() { + val settings = UserSettings(streamingMusicFormat = opusWebm) + assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux)) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index f70b3a39..95eb42c1 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -77,9 +77,9 @@ actual class NewPipeService { .map { stream -> org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( url = stream.content, - codec = stream.codec ?: "unknown", + codec = normalizeCodec(stream.codec ?: "unknown"), bitrate = stream.bitrate, - container = stream.format?.suffix ?: "unknown") + container = normalizeContainer(stream.format?.suffix ?: "unknown")) }, videoStreams = listOf(), // Not yet supported id = id From cc21568457c44dd02bfafeb39adb76f5568726d4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 15:04:26 +0600 Subject: [PATCH 6/7] fix(build): restore version catalog lost in dev merge The merge of dev resolved the libs.versions.toml conflict by taking the wrong catalog: it reverted the Kotlin 2.4.21 / Compose 1.12.2 / Zipline 1.28.0 upgrade and dropped dev's jmustache entries, so buildSrc failed with "Unresolved reference 'jmustache'". Keep both sides: mokkery 3.5.1 plus jmustache. --- gradle/libs.versions.toml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30e39bf7..838df455 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" composeHotReload = "1.2.0" -composeMultiplatform = "1.11.1" +composeMultiplatform = "1.12.2" coil = "3.5.0" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" @@ -37,7 +37,7 @@ jna = "5.18.1" junit = "4.13.2" kermit = "2.1.0" kmpZip = "1.0.0" -kotlin = "2.3.21" +kotlin = "2.4.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" @@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" -ziplineVersion = "1.27.0" +ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" runner = "1.7.0" @@ -68,7 +68,7 @@ feather-icons = "1.1.1" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.5" +ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" @@ -76,9 +76,12 @@ vlcjBundler = "0.2.0" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" -mokkery = "3.3.0" +mokkery = "3.5.1" +jmustache = "1.16" [libraries] +# buildSrc: renders the package manager manifests in packaging/ +jmustache = { module = "com.samskivert:jmustache", version.ref = "jmustache" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } From bdf3655a23c6da8bb79ce471b4ca7add7cee7a3a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 15:04:26 +0600 Subject: [PATCH 7/7] 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. --- .../org/spotube/spotube/MainViewController.kt | 2 + .../core/webview/PlatformWebviewConfig.ios.kt | 82 ++++++++++--------- .../spotube/core/webview/SafariUserAgent.kt | 68 +++++++++++++++ 3 files changed, 112 insertions(+), 40 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt 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" + } + } +}