From 4a883a1addcb4585ea04213c171160572bd3d3a6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:29:00 +0530 Subject: [PATCH 1/3] Add runtime sync backend switch --- composeApp/build.gradle.kts | 9 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../network/SyncBackendStorage.android.kt | 58 ++ .../composeResources/values/strings.xml | 1 + .../commonMain/kotlin/com/nuvio/app/App.kt | 36 + .../com/nuvio/app/core/auth/AuthRepository.kt | 85 ++- .../core/network/NetworkStatusRepository.kt | 5 +- .../app/core/network/SupabaseProvider.kt | 33 +- .../app/core/network/SyncBackendConfig.kt | 150 +++++ .../app/core/network/SyncBackendRepository.kt | 121 ++++ .../app/core/network/SyncBackendStorage.kt | 8 + .../app/features/profiles/ProfileModels.kt | 3 +- .../features/settings/AccountSettingsPage.kt | 94 +-- .../core/network/SyncBackendStorage.ios.kt | 43 ++ server/sync-switch/.gitignore | 2 + server/sync-switch/index.js | 613 ++++++++++++++++++ server/sync-switch/package.json | 14 + 17 files changed, 1205 insertions(+), 72 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt create mode 100644 server/sync-switch/.gitignore create mode 100644 server/sync-switch/index.js create mode 100644 server/sync-switch/package.json diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index aef2d5479..8dadc1c01 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -44,6 +44,15 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |} """.trimMargin() ) + resolve("SyncBackendBootstrapConfig.kt").writeText( + """ + |package com.nuvio.app.core.network + | + |object SyncBackendBootstrapConfig { + | const val SWITCH_MANIFEST_URL = "${props.getProperty("SYNC_BACKEND_MANIFEST_URL", "")}" + |} + """.trimMargin() + ) } outDir.resolve("com/nuvio/app/features/tmdb/TmdbConfig.kt").delete() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index ad83b451b..745c91d55 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.nuvio.app.core.auth.AuthStorage import com.nuvio.app.core.deeplink.handleAppUrl +import com.nuvio.app.core.network.SyncBackendStorage import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage @@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() { window.setBackgroundDrawableResource(R.color.nuvio_background) AddonStorage.initialize(applicationContext) AuthStorage.initialize(applicationContext) + SyncBackendStorage.initialize(applicationContext) LibraryStorage.initialize(applicationContext) WatchedStorage.initialize(applicationContext) MetaScreenSettingsStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt new file mode 100644 index 000000000..e87613d29 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.core.network + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.Proxy +import java.util.concurrent.TimeUnit + +internal actual object SyncBackendStorage { + private const val PREFS_NAME = "nuvio_sync_backend" + private const val KEY_SELECTION_PAYLOAD = "selection_payload_v1" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + actual fun loadSelectionPayload(): String? = + preferences?.getString(KEY_SELECTION_PAYLOAD, null) + + actual fun saveSelectionPayload(payload: String) { + preferences + ?.edit() + ?.putString(KEY_SELECTION_PAYLOAD, payload) + ?.apply() + } +} + +private val syncBackendHttpClient = OkHttpClient.Builder() + .dns(IPv4FirstDns()) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .proxy(Proxy.NO_PROXY) + .build() + +internal actual suspend fun fetchSyncBackendManifestText(url: String): String = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(url) + .get() + .header("Accept", "application/json") + .build() + + syncBackendHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + error("Sync backend manifest request failed with HTTP ${response.code}") + } + response.body?.string()?.takeIf { it.isNotBlank() } + ?: error("Sync backend manifest response was empty") + } + } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 313d53338..be123bdf7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -532,6 +532,7 @@ Status Anonymous Signed in + Sync backend AMOLED Black Use pure black backgrounds for OLED screens. App Language diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 929ae5a8a..0dadb2ddf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -81,6 +81,9 @@ import com.nuvio.app.core.deeplink.AppDeepLink import com.nuvio.app.core.deeplink.AppDeepLinkRepository import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.network.SyncBackendRefreshResult +import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.sync.AppForegroundMonitor import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.SyncManager @@ -408,6 +411,32 @@ private enum class AppGateScreen { Main, } +private suspend fun refreshSyncBackendSelection() { + SyncBackendRepository.ensureLoaded() + + when (val result = SyncBackendRepository.refreshFromManifest()) { + SyncBackendRefreshResult.NotConfigured, + is SyncBackendRefreshResult.Failed, + SyncBackendRefreshResult.Unchanged, + -> Unit + is SyncBackendRefreshResult.Applied -> { + SupabaseProvider.rebuildClient() + NetworkStatusRepository.requestRefresh(force = true) + } + is SyncBackendRefreshResult.RequiresLogout -> { + AuthRepository.resetForSyncBackendChange() + .onSuccess { + SyncBackendRepository.applyBackendAfterLogout( + backend = result.targetBackend, + revision = result.revision, + ) + SupabaseProvider.rebuildClient() + NetworkStatusRepository.requestRefresh(force = true) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview @@ -430,9 +459,16 @@ fun App() { val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { LaunchedEffect(Unit) { + refreshSyncBackendSelection() AuthRepository.initialize() } + LaunchedEffect(Unit) { + AppForegroundMonitor.events().collect { + refreshSyncBackendSelection() + } + } + LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index 7e5f9d85d..11c318fc6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.core.auth import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.storage.LocalAccountDataCleaner import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.providers.builtin.Email @@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -35,35 +37,42 @@ object AuthRepository { if (initialized) return initialized = true - val savedAnonId = AuthStorage.loadAnonymousUserId() - if (savedAnonId != null) { - _state.value = AuthState.Authenticated( - userId = savedAnonId, - email = null, - isAnonymous = true, - ) - } - scope.launch { - SupabaseProvider.client.auth.sessionStatus.collect { status -> - if (AuthStorage.loadAnonymousUserId() != null) return@collect - when (status) { - is SessionStatus.Authenticated -> { - val user = status.session.user - _state.value = AuthState.Authenticated( - userId = user?.id ?: "", - email = user?.email, - isAnonymous = false, - ) - } - is SessionStatus.NotAuthenticated -> { - _state.value = AuthState.Unauthenticated - } - is SessionStatus.Initializing -> { - if (savedAnonId == null) _state.value = AuthState.Loading - } - is SessionStatus.RefreshFailure -> { - _state.value = AuthState.Unauthenticated + SyncBackendRepository.state.collectLatest { backendState -> + if (!backendState.isLoaded) return@collectLatest + + AuthStorage.loadAnonymousUserId()?.let { savedAnonId -> + _state.value = AuthState.Authenticated( + userId = savedAnonId, + email = null, + isAnonymous = true, + ) + } ?: run { + _state.value = AuthState.Loading + } + + SupabaseProvider.client.auth.sessionStatus.collect { status -> + if (AuthStorage.loadAnonymousUserId() != null) return@collect + when (status) { + is SessionStatus.Authenticated -> { + val user = status.session.user + _state.value = AuthState.Authenticated( + userId = user?.id ?: "", + email = user?.email, + isAnonymous = false, + ) + } + is SessionStatus.NotAuthenticated -> { + _state.value = AuthState.Unauthenticated + } + is SessionStatus.Initializing -> { + if (AuthStorage.loadAnonymousUserId() == null) { + _state.value = AuthState.Loading + } + } + is SessionStatus.RefreshFailure -> { + _state.value = AuthState.Unauthenticated + } } } } @@ -119,6 +128,26 @@ object AuthRepository { _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) } + suspend fun resetForSyncBackendChange(): Result = runCatching { + _error.value = null + val wasAnonymous = AuthStorage.loadAnonymousUserId() != null + AuthStorage.clearAnonymousUserId() + + if (!wasAnonymous) { + runCatching { + SupabaseProvider.client.auth.signOut() + }.onFailure { e -> + log.w(e) { "Supabase sign-out failed during sync backend reset; continuing local reset" } + } + } + + _state.value = AuthState.Unauthenticated + LocalAccountDataCleaner.wipe() + }.onFailure { e -> + log.e(e) { "Sync backend auth reset failed" } + _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) + } + suspend fun deleteAccount(): Result = runCatching { _error.value = null SupabaseProvider.client.functions.invoke("delete-account") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt index 4288603fc..626732718 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt @@ -136,9 +136,10 @@ object NetworkStatusRepository { return NetworkCondition.NoInternet } + val backendConfig = SyncBackendRepository.selectedBackend val supabaseReachable = probeReachable( - url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/", - headers = mapOf("apikey" to SupabaseConfig.ANON_KEY), + url = "${backendConfig.normalizedSupabaseUrl}/rest/v1/", + headers = mapOf("apikey" to backendConfig.anonKey), ) if (!supabaseReachable) { return NetworkCondition.ServersUnreachable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt index 77d7090c8..0e317985e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.network import com.nuvio.app.core.build.AppVersionConfig +import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.createSupabaseClient @@ -10,12 +11,34 @@ import io.ktor.client.plugins.defaultRequest import io.ktor.http.HttpHeaders object SupabaseProvider { + private data class ClientHolder( + val backend: SyncBackendConfig, + val client: SupabaseClient, + ) + + private var holder: ClientHolder? = null + + val selectedBackend: SyncBackendConfig + get() = SyncBackendRepository.selectedBackend + @OptIn(SupabaseInternal::class) - val client by lazy { + val client: SupabaseClient + get() = clientFor(selectedBackend) + + fun rebuildClient() { + holder = null + } + + @OptIn(SupabaseInternal::class) + private fun clientFor(config: SyncBackendConfig): SupabaseClient { + holder + ?.takeIf { it.backend.hasSameConnectionIdentity(config) } + ?.let { return it.client } + val userAgent = "NuvioMobile/${AppVersionConfig.VERSION_NAME.ifBlank { "dev" }}" - createSupabaseClient( - supabaseUrl = SupabaseConfig.URL, - supabaseKey = SupabaseConfig.ANON_KEY, + val nextClient = createSupabaseClient( + supabaseUrl = config.normalizedSupabaseUrl, + supabaseKey = config.anonKey, ) { httpConfig { defaultRequest { @@ -26,5 +49,7 @@ object SupabaseProvider { install(Postgrest) install(Functions) } + holder = ClientHolder(backend = config, client = nextClient) + return nextClient } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt new file mode 100644 index 000000000..0ff7db977 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt @@ -0,0 +1,150 @@ +package com.nuvio.app.core.network + +import kotlinx.serialization.Serializable + +internal const val SYNC_BACKEND_HOSTED_ID = "hosted" +internal const val SYNC_BACKEND_NUVIO_ID = "nuvio" + +@Serializable +data class SyncBackendConfig( + val id: String, + val displayName: String, + val supabaseUrl: String, + val anonKey: String, + val avatarPublicBaseUrl: String, + val schemaVersion: Int = 1, +) { + val normalizedSupabaseUrl: String + get() = supabaseUrl.trim().trimEnd('/') + + val normalizedAvatarPublicBaseUrl: String + get() = avatarPublicBaseUrl.trim().trimEnd('/') + + fun avatarStorageUrl(storagePath: String): String = + "${normalizedAvatarPublicBaseUrl}/${storagePath.trim().trimStart('/')}" + + fun normalized(): SyncBackendConfig = + copy( + id = id.trim().lowercase(), + supabaseUrl = normalizedSupabaseUrl, + anonKey = anonKey.trim(), + avatarPublicBaseUrl = normalizedAvatarPublicBaseUrl, + ) +} + +@Serializable +data class SyncBackendManifest( + val version: Int = 1, + val activeBackend: String, + val revision: String = "", + val forceLogoutOnChange: Boolean = true, + val backends: Map = emptyMap(), +) + +@Serializable +data class SyncBackendManifestBackend( + val displayName: String = "", + val supabaseUrl: String = "", + val anonKey: String = "", + val avatarPublicBaseUrl: String = "", + val schemaVersion: Int = 1, +) + +data class SyncBackendState( + val selectedBackend: SyncBackendConfig = SyncBackendDefaults.hosted(), + val appliedRevision: String = "", + val isLoaded: Boolean = false, + val lastManifestError: String? = null, +) + +sealed interface SyncBackendRefreshResult { + data object NotConfigured : SyncBackendRefreshResult + data object Unchanged : SyncBackendRefreshResult + data class Applied( + val backend: SyncBackendConfig, + val revision: String, + ) : SyncBackendRefreshResult + data class RequiresLogout( + val currentBackend: SyncBackendConfig, + val targetBackend: SyncBackendConfig, + val revision: String, + val forceLogout: Boolean, + ) : SyncBackendRefreshResult + data class Failed(val message: String) : SyncBackendRefreshResult +} + +@Serializable +internal data class StoredSyncBackendSelection( + val backend: SyncBackendConfig, + val appliedRevision: String = "", +) + +object SyncBackendDefaults { + fun hosted(): SyncBackendConfig = + SyncBackendConfig( + id = SYNC_BACKEND_HOSTED_ID, + displayName = "Hosted", + supabaseUrl = SupabaseConfig.URL, + anonKey = SupabaseConfig.ANON_KEY, + avatarPublicBaseUrl = "${SupabaseConfig.URL.trim().trimEnd('/')}/storage/v1/object/public/avatars", + schemaVersion = 1, + ).normalized() + + fun nuvio(): SyncBackendConfig = + SyncBackendConfig( + id = SYNC_BACKEND_NUVIO_ID, + displayName = "Nuvio", + supabaseUrl = "https://api.nuvio.tv", + anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI", + avatarPublicBaseUrl = "https://api.nuvio.tv/storage/v1/object/public/avatars", + schemaVersion = 1, + ).normalized() + + fun byId(id: String): SyncBackendConfig? = + when (id.trim().lowercase()) { + SYNC_BACKEND_HOSTED_ID -> hosted() + SYNC_BACKEND_NUVIO_ID -> nuvio() + else -> null + } +} + +internal fun SyncBackendConfig.hasSameConnectionIdentity(other: SyncBackendConfig): Boolean = + id == other.id && + normalizedSupabaseUrl == other.normalizedSupabaseUrl && + anonKey.trim() == other.anonKey.trim() && + schemaVersion == other.schemaVersion + +internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendConfig? { + if (version != 1) return null + + val activeId = activeBackend.trim().lowercase() + val manifestBackend = backends.entries + .firstOrNull { (key, _) -> key.trim().lowercase() == activeId } + ?.value + ?: return null + val fallback = SyncBackendDefaults.byId(activeId) ?: return null + + val supabaseUrl = manifestBackend.supabaseUrl.trim().ifBlank { fallback.supabaseUrl } + val anonKey = manifestBackend.anonKey.trim().ifBlank { fallback.anonKey } + val avatarPublicBaseUrl = manifestBackend.avatarPublicBaseUrl.trim().ifBlank { + "$supabaseUrl/storage/v1/object/public/avatars" + } + + return SyncBackendConfig( + id = activeId, + displayName = manifestBackend.displayName.trim().ifBlank { fallback.displayName }, + supabaseUrl = supabaseUrl, + anonKey = anonKey, + avatarPublicBaseUrl = avatarPublicBaseUrl, + schemaVersion = manifestBackend.schemaVersion.takeIf { it > 0 } ?: fallback.schemaVersion, + ) + .normalized() + .takeIf { it.isUsableClientConfig() } +} + +private fun SyncBackendConfig.isUsableClientConfig(): Boolean = + id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) && + normalizedSupabaseUrl.startsWith("https://") && + anonKey.isNotBlank() && + !anonKey.startsWith("<") && + normalizedAvatarPublicBaseUrl.startsWith("https://") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt new file mode 100644 index 000000000..d38b49410 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt @@ -0,0 +1,121 @@ +package com.nuvio.app.core.network + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object SyncBackendRepository { + private val log = Logger.withTag("SyncBackendRepository") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _state = MutableStateFlow(SyncBackendState()) + val state: StateFlow = _state.asStateFlow() + + val selectedBackend: SyncBackendConfig + get() { + ensureLoaded() + return _state.value.selectedBackend + } + + fun ensureLoaded() { + if (_state.value.isLoaded) return + + val storedSelection = SyncBackendStorage.loadSelectionPayload() + ?.takeIf { it.isNotBlank() } + ?.let { payload -> + runCatching { json.decodeFromString(payload) } + .onFailure { error -> log.w(error) { "Failed to parse stored sync backend selection" } } + .getOrNull() + } + + val backend = storedSelection + ?.backend + ?.normalized() + ?.takeIf { storedBackend -> SyncBackendDefaults.byId(storedBackend.id) != null } + ?: SyncBackendDefaults.hosted() + + _state.value = SyncBackendState( + selectedBackend = backend, + appliedRevision = storedSelection?.appliedRevision.orEmpty(), + isLoaded = true, + ) + } + + suspend fun refreshFromManifest(): SyncBackendRefreshResult { + ensureLoaded() + + val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim() + if (manifestUrl.isBlank()) { + return SyncBackendRefreshResult.NotConfigured + } + + val manifest = runCatching { + json.decodeFromString( + fetchSyncBackendManifestText(manifestUrl), + ) + }.onFailure { error -> + val message = error.message ?: "Failed to fetch sync backend manifest" + log.w(error) { message } + _state.value = _state.value.copy(lastManifestError = message) + }.getOrNull() ?: return SyncBackendRefreshResult.Failed( + _state.value.lastManifestError ?: "Failed to fetch sync backend manifest", + ) + + val targetBackend = manifest.backendConfigForActiveBackend() + ?: return SyncBackendRefreshResult.Failed("Sync backend manifest is invalid") + val revision = manifest.revision.trim() + val currentBackend = _state.value.selectedBackend + + if (currentBackend.hasSameConnectionIdentity(targetBackend)) { + saveSelection(targetBackend, revision) + return SyncBackendRefreshResult.Unchanged + } + + if (!manifest.forceLogoutOnChange) { + saveSelection(targetBackend, revision) + return SyncBackendRefreshResult.Applied(targetBackend, revision) + } + + return SyncBackendRefreshResult.RequiresLogout( + currentBackend = currentBackend, + targetBackend = targetBackend, + revision = revision, + forceLogout = true, + ) + } + + fun applyBackendAfterLogout( + backend: SyncBackendConfig, + revision: String, + ): SyncBackendConfig { + val normalizedBackend = backend.normalized() + saveSelection(normalizedBackend, revision) + return normalizedBackend + } + + private fun saveSelection( + backend: SyncBackendConfig, + revision: String, + ) { + val normalizedBackend = backend.normalized() + val payload = json.encodeToString( + StoredSyncBackendSelection( + backend = normalizedBackend, + appliedRevision = revision, + ), + ) + SyncBackendStorage.saveSelectionPayload(payload) + _state.value = SyncBackendState( + selectedBackend = normalizedBackend, + appliedRevision = revision, + isLoaded = true, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt new file mode 100644 index 000000000..748a8c18b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.core.network + +internal expect object SyncBackendStorage { + fun loadSelectionPayload(): String? + fun saveSelectionPayload(payload: String) +} + +internal expect suspend fun fetchSyncBackendManifestText(url: String): String diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index ccca24b30..32f0dc00a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.profiles import androidx.compose.ui.graphics.Color +import com.nuvio.app.core.network.SyncBackendRepository import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -77,7 +78,7 @@ val PROFILE_COLORS = listOf( ) fun avatarStorageUrl(storagePath: String): String = - "${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath" + SyncBackendRepository.selectedBackend.avatarStorageUrl(storagePath) fun normalizedAvatarUrl(url: String?): String? = url?.trim()?.takeIf { it.isValidAvatarUrl() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index e80c08225..205140e42 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -15,12 +15,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard @@ -36,6 +41,7 @@ import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_ti import nuvio.composeapp.generated.resources.settings_account_status import nuvio.composeapp.generated.resources.settings_account_status_anonymous import nuvio.composeapp.generated.resources.settings_account_status_signed_in +import nuvio.composeapp.generated.resources.settings_account_sync_backend import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.accountSettingsContent( @@ -51,8 +57,10 @@ private fun AccountSettingsBody( isTablet: Boolean, ) { val authState by AuthRepository.state.collectAsStateWithLifecycle() + val syncBackendState by SyncBackendRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showSignOutConfirm by remember { mutableStateOf(false) } + val syncBackendLabel = syncBackendState.selectedBackend.displayName Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { NuvioSurfaceCard { @@ -66,44 +74,21 @@ private fun AccountSettingsBody( when (val state = authState) { is AuthState.Authenticated -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(Res.string.settings_account_status), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = if (state.isAnonymous) { - stringResource(Res.string.settings_account_status_anonymous) - } else { - stringResource(Res.string.settings_account_status_signed_in) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } - if (!state.isAnonymous && state.email != null) { + AccountInfoRow( + label = stringResource(Res.string.settings_account_status), + value = if (state.isAnonymous) { + stringResource(Res.string.settings_account_status_anonymous) + } else { + stringResource(Res.string.settings_account_status_signed_in) + }, + valueColor = MaterialTheme.colorScheme.primary, + ) + state.email?.takeUnless { state.isAnonymous }?.let { email -> Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(Res.string.settings_account_email), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = state.email, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - } + AccountInfoRow( + label = stringResource(Res.string.settings_account_email), + value = email, + ) } } else -> { @@ -114,6 +99,12 @@ private fun AccountSettingsBody( ) } } + + Spacer(modifier = Modifier.height(8.dp)) + AccountInfoRow( + label = stringResource(Res.string.settings_account_sync_backend), + value = syncBackendLabel, + ) } NuvioPrimaryButton( @@ -135,3 +126,32 @@ private fun AccountSettingsBody( onDismiss = { showSignOutConfirm = false }, ) } + +@Composable +private fun AccountInfoRow( + label: String, + value: String, + valueColor: Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + modifier = Modifier.weight(1f), + text = value, + style = MaterialTheme.typography.bodyLarge, + color = valueColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt new file mode 100644 index 000000000..f9fde4450 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.isSuccess +import platform.Foundation.NSUserDefaults + +internal actual object SyncBackendStorage { + private const val KEY_SELECTION_PAYLOAD = "nuvio_sync_backend_selection_payload_v1" + + actual fun loadSelectionPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(KEY_SELECTION_PAYLOAD) + + actual fun saveSelectionPayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = KEY_SELECTION_PAYLOAD) + } +} + +private val syncBackendHttpClient = HttpClient(Darwin) { + install(HttpTimeout) { + requestTimeoutMillis = 10_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 10_000 + } + expectSuccess = false +} + +internal actual suspend fun fetchSyncBackendManifestText(url: String): String { + val response = syncBackendHttpClient.get(url) { + accept(ContentType.Application.Json) + } + val payload = response.bodyAsText() + if (!response.status.isSuccess()) { + error("Sync backend manifest request failed with HTTP ${response.status.value}") + } + return payload.takeIf { it.isNotBlank() } + ?: error("Sync backend manifest response was empty") +} diff --git a/server/sync-switch/.gitignore b/server/sync-switch/.gitignore new file mode 100644 index 000000000..6b71ee4d3 --- /dev/null +++ b/server/sync-switch/.gitignore @@ -0,0 +1,2 @@ +data/*.json +*.log diff --git a/server/sync-switch/index.js b/server/sync-switch/index.js new file mode 100644 index 000000000..88a7fdb2f --- /dev/null +++ b/server/sync-switch/index.js @@ -0,0 +1,613 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs/promises'); +const http = require('http'); +const path = require('path'); + +const BACKEND_HOSTED = 'hosted'; +const BACKEND_NUVIO = 'nuvio'; +const BACKENDS = new Set([BACKEND_HOSTED, BACKEND_NUVIO]); +const DEFAULT_STATE_FILE = path.join(__dirname, 'data', 'state.json'); + +const env = process.env; + +const serverConfig = { + host: env.NUVIO_SWITCH_HOST || env.HOST || '0.0.0.0', + port: parsePort(env.NUVIO_SWITCH_PORT || env.PORT || '3000'), + stateFile: resolveStateFile(env.NUVIO_SWITCH_STATE_FILE || env.STATE_FILE), + manifestVersion: parsePositiveInteger(env.NUVIO_SWITCH_VERSION || '1', 'NUVIO_SWITCH_VERSION'), + defaultBackend: parseBackend(env.NUVIO_SWITCH_DEFAULT_BACKEND || 'hosted'), + forceLogoutOnChange: parseBoolean(env.NUVIO_SWITCH_FORCE_LOGOUT_ON_CHANGE, true), + adminUsername: env.NUVIO_SWITCH_ADMIN_USERNAME || env.ADMIN_USERNAME || 'admin', + adminPassword: env.NUVIO_SWITCH_ADMIN_PASSWORD || env.ADMIN_PASSWORD || '', + hosted: { + displayName: env.NUVIO_SWITCH_HOSTED_DISPLAY_NAME || 'Hosted', + supabaseUrl: + env.NUVIO_SWITCH_HOSTED_SUPABASE_URL || + env.NUVIO_SWITCH_HOSTED_URL || + 'https://dpyhjjcoabcglfmgecug.supabase.co', + anonKey: + env.NUVIO_SWITCH_HOSTED_SUPABASE_ANON_KEY || + env.NUVIO_SWITCH_HOSTED_ANON_KEY || + '', + avatarPublicBaseUrl: + env.NUVIO_SWITCH_HOSTED_AVATAR_PUBLIC_BASE_URL || + 'https://dpyhjjcoabcglfmgecug.supabase.co/storage/v1/object/public/avatars', + schemaVersion: parsePositiveInteger( + env.NUVIO_SWITCH_HOSTED_SCHEMA_VERSION || '1', + 'NUVIO_SWITCH_HOSTED_SCHEMA_VERSION' + ), + }, + nuvio: { + displayName: env.NUVIO_SWITCH_NUVIO_DISPLAY_NAME || 'Nuvio', + supabaseUrl: + env.NUVIO_SWITCH_NUVIO_SUPABASE_URL || + env.NUVIO_SWITCH_NUVIO_URL || + 'https://api.nuvio.tv', + anonKey: + env.NUVIO_SWITCH_NUVIO_SUPABASE_ANON_KEY || + env.NUVIO_SWITCH_NUVIO_ANON_KEY || + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI', + avatarPublicBaseUrl: + env.NUVIO_SWITCH_NUVIO_AVATAR_PUBLIC_BASE_URL || + 'https://api.nuvio.tv/storage/v1/object/public/avatars', + schemaVersion: parsePositiveInteger( + env.NUVIO_SWITCH_NUVIO_SCHEMA_VERSION || '1', + 'NUVIO_SWITCH_NUVIO_SCHEMA_VERSION' + ), + }, +}; + +const server = http.createServer((req, res) => { + handleRequest(req, res).catch((error) => { + console.error(error); + if (error.statusCode === 413) { + sendJson(res, 413, { error: 'request_body_too_large' }); + return; + } + + sendJson(res, 500, { error: 'internal_server_error' }); + }); +}); + +server.listen(serverConfig.port, serverConfig.host, () => { + console.log( + `Nuvio sync switch listening on http://${serverConfig.host}:${serverConfig.port}` + ); + console.log(`State file: ${serverConfig.stateFile}`); +}); + +async function handleRequest(req, res) { + const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const pathname = normalizePath(requestUrl.pathname); + + if (req.method === 'GET' && pathname === '/health') { + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === 'GET' && pathname === '/config.json') { + const state = await readState(); + sendJson(res, 200, buildManifest(state)); + return; + } + + if (req.method === 'GET' && pathname === '/admin') { + if (!requireAdmin(req, res)) return; + + const state = await readState(); + sendHtml(res, 200, renderAdminPage(state)); + return; + } + + if (req.method === 'POST' && pathname === '/admin/backend') { + if (!requireAdmin(req, res)) return; + + const body = await readRequestBody(req); + const backend = parseBackendFromRequest(req, body); + if (!backend) { + sendJson(res, 400, { + error: 'invalid_backend', + message: 'Expected activeBackend or backend to be hosted or nuvio.', + }); + return; + } + + const state = await writeBackend(backend); + if (prefersHtml(req)) { + sendRedirect(res, '/admin'); + return; + } + + sendJson(res, 200, { + ok: true, + activeBackend: state.activeBackend, + revision: state.revision, + }); + return; + } + + if (pathname === '/admin' || pathname === '/admin/backend') { + sendJson(res, 405, { error: 'method_not_allowed' }, { Allow: 'GET, POST' }); + return; + } + + sendJson(res, 404, { error: 'not_found' }); +} + +function normalizePath(pathname) { + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1); + } + + return pathname; +} + +function buildManifest(state) { + return { + version: serverConfig.manifestVersion, + activeBackend: state.activeBackend, + revision: String(state.revision), + forceLogoutOnChange: serverConfig.forceLogoutOnChange, + backends: { + hosted: publicBackendConfig(serverConfig.hosted), + nuvio: publicBackendConfig(serverConfig.nuvio), + }, + }; +} + +function publicBackendConfig(config) { + return { + displayName: config.displayName, + supabaseUrl: normalizeBaseUrl(config.supabaseUrl), + anonKey: config.anonKey, + avatarPublicBaseUrl: normalizeBaseUrl(config.avatarPublicBaseUrl), + schemaVersion: config.schemaVersion, + }; +} + +async function readState() { + try { + const rawState = await fs.readFile(serverConfig.stateFile, 'utf8'); + return normalizeState(JSON.parse(rawState)); + } catch (error) { + if (error.code === 'ENOENT') { + const initialState = { + activeBackend: serverConfig.defaultBackend, + revision: 1, + updatedAt: new Date().toISOString(), + }; + await writeState(initialState); + return initialState; + } + + if (error instanceof SyntaxError) { + throw new Error(`State file is not valid JSON: ${serverConfig.stateFile}`); + } + + throw error; + } +} + +function normalizeState(state) { + const activeBackend = parseBackend(state.activeBackend); + const revision = Number.isInteger(state.revision) && state.revision > 0 ? state.revision : 1; + + return { + activeBackend, + revision, + updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : undefined, + }; +} + +async function writeBackend(activeBackend) { + const currentState = await readState(); + const nextRevision = + currentState.activeBackend === activeBackend + ? currentState.revision + : currentState.revision + 1; + + const nextState = { + activeBackend, + revision: nextRevision, + updatedAt: new Date().toISOString(), + }; + + await writeState(nextState); + return nextState; +} + +async function writeState(state) { + const stateDir = path.dirname(serverConfig.stateFile); + const tempFile = path.join( + stateDir, + `.${path.basename(serverConfig.stateFile)}.${process.pid}.${crypto.randomUUID()}.tmp` + ); + + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(tempFile, `${JSON.stringify(state, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await fs.rename(tempFile, serverConfig.stateFile); +} + +function requireAdmin(req, res) { + if (!serverConfig.adminPassword) { + sendJson(res, 503, { + error: 'admin_password_not_configured', + message: 'Set NUVIO_SWITCH_ADMIN_PASSWORD before using admin endpoints.', + }); + return false; + } + + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Basic ')) { + sendAuthRequired(res); + return false; + } + + const credentials = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8'); + const separatorIndex = credentials.indexOf(':'); + const username = separatorIndex >= 0 ? credentials.slice(0, separatorIndex) : ''; + const password = separatorIndex >= 0 ? credentials.slice(separatorIndex + 1) : ''; + + if ( + timingSafeEqual(username, serverConfig.adminUsername) && + timingSafeEqual(password, serverConfig.adminPassword) + ) { + return true; + } + + sendAuthRequired(res); + return false; +} + +function timingSafeEqual(input, expected) { + const inputBuffer = Buffer.from(input); + const expectedBuffer = Buffer.from(expected); + + if (inputBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(inputBuffer, expectedBuffer); +} + +function sendAuthRequired(res) { + sendText(res, 401, 'Authentication required.\n', { + 'WWW-Authenticate': 'Basic realm="Nuvio Sync Switch", charset="UTF-8"', + }); +} + +async function readRequestBody(req) { + const chunks = []; + let bytesRead = 0; + const maxBytes = 16 * 1024; + + for await (const chunk of req) { + bytesRead += chunk.length; + if (bytesRead > maxBytes) { + const error = new Error('Request body too large.'); + error.statusCode = 413; + throw error; + } + + chunks.push(chunk); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +function parseBackendFromRequest(req, body) { + const contentType = String(req.headers['content-type'] || '').split(';')[0].trim(); + + if (contentType === 'application/json') { + try { + const parsed = JSON.parse(body || '{}'); + return safeParseBackend(parsed.activeBackend || parsed.backend); + } catch { + return null; + } + } + + const formData = new URLSearchParams(body); + return safeParseBackend(formData.get('activeBackend') || formData.get('backend')); +} + +function parseBackend(value) { + const backend = String(value || '').trim().toLowerCase(); + if (BACKENDS.has(backend)) { + return backend; + } + + throw new Error(`Invalid backend "${value}". Expected hosted or nuvio.`); +} + +function safeParseBackend(value) { + const backend = String(value || '').trim().toLowerCase(); + return BACKENDS.has(backend) ? backend : null; +} + +function parseBoolean(value, defaultValue) { + if (value === undefined || value === '') { + return defaultValue; + } + + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +function parsePort(value) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port "${value}".`); + } + + return port; +} + +function parsePositiveInteger(value, name) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`Invalid ${name} "${value}".`); + } + + return parsed; +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/+$/, ''); +} + +function resolveStateFile(value) { + if (!value) { + return DEFAULT_STATE_FILE; + } + + if (path.isAbsolute(value)) { + return value; + } + + return path.resolve(process.cwd(), value); +} + +function prefersHtml(req) { + const accept = req.headers.accept || ''; + const contentType = req.headers['content-type'] || ''; + return contentType.includes('application/x-www-form-urlencoded') && accept.includes('text/html'); +} + +function sendJson(res, statusCode, payload, headers = {}) { + send(res, statusCode, JSON.stringify(payload, null, 2), { + 'Content-Type': 'application/json; charset=utf-8', + ...headers, + }); +} + +function sendHtml(res, statusCode, body, headers = {}) { + send(res, statusCode, body, { + 'Content-Type': 'text/html; charset=utf-8', + ...headers, + }); +} + +function sendText(res, statusCode, body, headers = {}) { + send(res, statusCode, body, { + 'Content-Type': 'text/plain; charset=utf-8', + ...headers, + }); +} + +function sendRedirect(res, location) { + res.writeHead(303, { + Location: location, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(); +} + +function send(res, statusCode, body, headers = {}) { + res.writeHead(statusCode, { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + ...headers, + }); + res.end(body); +} + +function renderAdminPage(state) { + const hostedActive = state.activeBackend === BACKEND_HOSTED; + const nuvioActive = state.activeBackend === BACKEND_NUVIO; + + return ` + + + + + Nuvio Sync Switch + + + +
+

Nuvio Sync Switch

+

Select the Supabase backend exposed by /config.json.

+
+
+

Active backend

+

${escapeHtml(state.activeBackend)}

+
+
+

Revision

+

${escapeHtml(String(state.revision))}

+
+
+

Force logout

+

${serverConfig.forceLogoutOnChange ? 'true' : 'false'}

+
+
+
+
+ + +
+
+ + +
+
+
+ + +`; +} + +function escapeHtml(value) { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return char; + } + }); +} diff --git a/server/sync-switch/package.json b/server/sync-switch/package.json new file mode 100644 index 000000000..6bde2beaa --- /dev/null +++ b/server/sync-switch/package.json @@ -0,0 +1,14 @@ +{ + "name": "nuvio-sync-switch", + "version": "0.1.0", + "private": true, + "description": "Minimal VPS-hosted switch API for Nuvio Supabase backend selection.", + "main": "index.js", + "scripts": { + "start": "node index.js", + "check": "node --check index.js" + }, + "engines": { + "node": ">=18" + } +} From 4936f3268affa46d9e6e0eada12cf545d7ecdd7b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:31:40 +0530 Subject: [PATCH 2/3] cleanup --- .gitignore | 1 + server/sync-switch/.gitignore | 2 - server/sync-switch/index.js | 613 -------------------------------- server/sync-switch/package.json | 14 - 4 files changed, 1 insertion(+), 629 deletions(-) delete mode 100644 server/sync-switch/.gitignore delete mode 100644 server/sync-switch/index.js delete mode 100644 server/sync-switch/package.json diff --git a/.gitignore b/.gitignore index c7156d5f7..80df24020 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ asset scripts/scrape_android_compose_animation_docs.py tools AGENTS.md +server \ No newline at end of file diff --git a/server/sync-switch/.gitignore b/server/sync-switch/.gitignore deleted file mode 100644 index 6b71ee4d3..000000000 --- a/server/sync-switch/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -data/*.json -*.log diff --git a/server/sync-switch/index.js b/server/sync-switch/index.js deleted file mode 100644 index 88a7fdb2f..000000000 --- a/server/sync-switch/index.js +++ /dev/null @@ -1,613 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); -const fs = require('fs/promises'); -const http = require('http'); -const path = require('path'); - -const BACKEND_HOSTED = 'hosted'; -const BACKEND_NUVIO = 'nuvio'; -const BACKENDS = new Set([BACKEND_HOSTED, BACKEND_NUVIO]); -const DEFAULT_STATE_FILE = path.join(__dirname, 'data', 'state.json'); - -const env = process.env; - -const serverConfig = { - host: env.NUVIO_SWITCH_HOST || env.HOST || '0.0.0.0', - port: parsePort(env.NUVIO_SWITCH_PORT || env.PORT || '3000'), - stateFile: resolveStateFile(env.NUVIO_SWITCH_STATE_FILE || env.STATE_FILE), - manifestVersion: parsePositiveInteger(env.NUVIO_SWITCH_VERSION || '1', 'NUVIO_SWITCH_VERSION'), - defaultBackend: parseBackend(env.NUVIO_SWITCH_DEFAULT_BACKEND || 'hosted'), - forceLogoutOnChange: parseBoolean(env.NUVIO_SWITCH_FORCE_LOGOUT_ON_CHANGE, true), - adminUsername: env.NUVIO_SWITCH_ADMIN_USERNAME || env.ADMIN_USERNAME || 'admin', - adminPassword: env.NUVIO_SWITCH_ADMIN_PASSWORD || env.ADMIN_PASSWORD || '', - hosted: { - displayName: env.NUVIO_SWITCH_HOSTED_DISPLAY_NAME || 'Hosted', - supabaseUrl: - env.NUVIO_SWITCH_HOSTED_SUPABASE_URL || - env.NUVIO_SWITCH_HOSTED_URL || - 'https://dpyhjjcoabcglfmgecug.supabase.co', - anonKey: - env.NUVIO_SWITCH_HOSTED_SUPABASE_ANON_KEY || - env.NUVIO_SWITCH_HOSTED_ANON_KEY || - '', - avatarPublicBaseUrl: - env.NUVIO_SWITCH_HOSTED_AVATAR_PUBLIC_BASE_URL || - 'https://dpyhjjcoabcglfmgecug.supabase.co/storage/v1/object/public/avatars', - schemaVersion: parsePositiveInteger( - env.NUVIO_SWITCH_HOSTED_SCHEMA_VERSION || '1', - 'NUVIO_SWITCH_HOSTED_SCHEMA_VERSION' - ), - }, - nuvio: { - displayName: env.NUVIO_SWITCH_NUVIO_DISPLAY_NAME || 'Nuvio', - supabaseUrl: - env.NUVIO_SWITCH_NUVIO_SUPABASE_URL || - env.NUVIO_SWITCH_NUVIO_URL || - 'https://api.nuvio.tv', - anonKey: - env.NUVIO_SWITCH_NUVIO_SUPABASE_ANON_KEY || - env.NUVIO_SWITCH_NUVIO_ANON_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI', - avatarPublicBaseUrl: - env.NUVIO_SWITCH_NUVIO_AVATAR_PUBLIC_BASE_URL || - 'https://api.nuvio.tv/storage/v1/object/public/avatars', - schemaVersion: parsePositiveInteger( - env.NUVIO_SWITCH_NUVIO_SCHEMA_VERSION || '1', - 'NUVIO_SWITCH_NUVIO_SCHEMA_VERSION' - ), - }, -}; - -const server = http.createServer((req, res) => { - handleRequest(req, res).catch((error) => { - console.error(error); - if (error.statusCode === 413) { - sendJson(res, 413, { error: 'request_body_too_large' }); - return; - } - - sendJson(res, 500, { error: 'internal_server_error' }); - }); -}); - -server.listen(serverConfig.port, serverConfig.host, () => { - console.log( - `Nuvio sync switch listening on http://${serverConfig.host}:${serverConfig.port}` - ); - console.log(`State file: ${serverConfig.stateFile}`); -}); - -async function handleRequest(req, res) { - const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); - const pathname = normalizePath(requestUrl.pathname); - - if (req.method === 'GET' && pathname === '/health') { - sendJson(res, 200, { ok: true }); - return; - } - - if (req.method === 'GET' && pathname === '/config.json') { - const state = await readState(); - sendJson(res, 200, buildManifest(state)); - return; - } - - if (req.method === 'GET' && pathname === '/admin') { - if (!requireAdmin(req, res)) return; - - const state = await readState(); - sendHtml(res, 200, renderAdminPage(state)); - return; - } - - if (req.method === 'POST' && pathname === '/admin/backend') { - if (!requireAdmin(req, res)) return; - - const body = await readRequestBody(req); - const backend = parseBackendFromRequest(req, body); - if (!backend) { - sendJson(res, 400, { - error: 'invalid_backend', - message: 'Expected activeBackend or backend to be hosted or nuvio.', - }); - return; - } - - const state = await writeBackend(backend); - if (prefersHtml(req)) { - sendRedirect(res, '/admin'); - return; - } - - sendJson(res, 200, { - ok: true, - activeBackend: state.activeBackend, - revision: state.revision, - }); - return; - } - - if (pathname === '/admin' || pathname === '/admin/backend') { - sendJson(res, 405, { error: 'method_not_allowed' }, { Allow: 'GET, POST' }); - return; - } - - sendJson(res, 404, { error: 'not_found' }); -} - -function normalizePath(pathname) { - if (pathname.length > 1 && pathname.endsWith('/')) { - return pathname.slice(0, -1); - } - - return pathname; -} - -function buildManifest(state) { - return { - version: serverConfig.manifestVersion, - activeBackend: state.activeBackend, - revision: String(state.revision), - forceLogoutOnChange: serverConfig.forceLogoutOnChange, - backends: { - hosted: publicBackendConfig(serverConfig.hosted), - nuvio: publicBackendConfig(serverConfig.nuvio), - }, - }; -} - -function publicBackendConfig(config) { - return { - displayName: config.displayName, - supabaseUrl: normalizeBaseUrl(config.supabaseUrl), - anonKey: config.anonKey, - avatarPublicBaseUrl: normalizeBaseUrl(config.avatarPublicBaseUrl), - schemaVersion: config.schemaVersion, - }; -} - -async function readState() { - try { - const rawState = await fs.readFile(serverConfig.stateFile, 'utf8'); - return normalizeState(JSON.parse(rawState)); - } catch (error) { - if (error.code === 'ENOENT') { - const initialState = { - activeBackend: serverConfig.defaultBackend, - revision: 1, - updatedAt: new Date().toISOString(), - }; - await writeState(initialState); - return initialState; - } - - if (error instanceof SyntaxError) { - throw new Error(`State file is not valid JSON: ${serverConfig.stateFile}`); - } - - throw error; - } -} - -function normalizeState(state) { - const activeBackend = parseBackend(state.activeBackend); - const revision = Number.isInteger(state.revision) && state.revision > 0 ? state.revision : 1; - - return { - activeBackend, - revision, - updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : undefined, - }; -} - -async function writeBackend(activeBackend) { - const currentState = await readState(); - const nextRevision = - currentState.activeBackend === activeBackend - ? currentState.revision - : currentState.revision + 1; - - const nextState = { - activeBackend, - revision: nextRevision, - updatedAt: new Date().toISOString(), - }; - - await writeState(nextState); - return nextState; -} - -async function writeState(state) { - const stateDir = path.dirname(serverConfig.stateFile); - const tempFile = path.join( - stateDir, - `.${path.basename(serverConfig.stateFile)}.${process.pid}.${crypto.randomUUID()}.tmp` - ); - - await fs.mkdir(stateDir, { recursive: true }); - await fs.writeFile(tempFile, `${JSON.stringify(state, null, 2)}\n`, { - encoding: 'utf8', - mode: 0o600, - }); - await fs.rename(tempFile, serverConfig.stateFile); -} - -function requireAdmin(req, res) { - if (!serverConfig.adminPassword) { - sendJson(res, 503, { - error: 'admin_password_not_configured', - message: 'Set NUVIO_SWITCH_ADMIN_PASSWORD before using admin endpoints.', - }); - return false; - } - - const authHeader = req.headers.authorization || ''; - if (!authHeader.startsWith('Basic ')) { - sendAuthRequired(res); - return false; - } - - const credentials = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8'); - const separatorIndex = credentials.indexOf(':'); - const username = separatorIndex >= 0 ? credentials.slice(0, separatorIndex) : ''; - const password = separatorIndex >= 0 ? credentials.slice(separatorIndex + 1) : ''; - - if ( - timingSafeEqual(username, serverConfig.adminUsername) && - timingSafeEqual(password, serverConfig.adminPassword) - ) { - return true; - } - - sendAuthRequired(res); - return false; -} - -function timingSafeEqual(input, expected) { - const inputBuffer = Buffer.from(input); - const expectedBuffer = Buffer.from(expected); - - if (inputBuffer.length !== expectedBuffer.length) { - return false; - } - - return crypto.timingSafeEqual(inputBuffer, expectedBuffer); -} - -function sendAuthRequired(res) { - sendText(res, 401, 'Authentication required.\n', { - 'WWW-Authenticate': 'Basic realm="Nuvio Sync Switch", charset="UTF-8"', - }); -} - -async function readRequestBody(req) { - const chunks = []; - let bytesRead = 0; - const maxBytes = 16 * 1024; - - for await (const chunk of req) { - bytesRead += chunk.length; - if (bytesRead > maxBytes) { - const error = new Error('Request body too large.'); - error.statusCode = 413; - throw error; - } - - chunks.push(chunk); - } - - return Buffer.concat(chunks).toString('utf8'); -} - -function parseBackendFromRequest(req, body) { - const contentType = String(req.headers['content-type'] || '').split(';')[0].trim(); - - if (contentType === 'application/json') { - try { - const parsed = JSON.parse(body || '{}'); - return safeParseBackend(parsed.activeBackend || parsed.backend); - } catch { - return null; - } - } - - const formData = new URLSearchParams(body); - return safeParseBackend(formData.get('activeBackend') || formData.get('backend')); -} - -function parseBackend(value) { - const backend = String(value || '').trim().toLowerCase(); - if (BACKENDS.has(backend)) { - return backend; - } - - throw new Error(`Invalid backend "${value}". Expected hosted or nuvio.`); -} - -function safeParseBackend(value) { - const backend = String(value || '').trim().toLowerCase(); - return BACKENDS.has(backend) ? backend : null; -} - -function parseBoolean(value, defaultValue) { - if (value === undefined || value === '') { - return defaultValue; - } - - return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); -} - -function parsePort(value) { - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid port "${value}".`); - } - - return port; -} - -function parsePositiveInteger(value, name) { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error(`Invalid ${name} "${value}".`); - } - - return parsed; -} - -function normalizeBaseUrl(value) { - return String(value || '').trim().replace(/\/+$/, ''); -} - -function resolveStateFile(value) { - if (!value) { - return DEFAULT_STATE_FILE; - } - - if (path.isAbsolute(value)) { - return value; - } - - return path.resolve(process.cwd(), value); -} - -function prefersHtml(req) { - const accept = req.headers.accept || ''; - const contentType = req.headers['content-type'] || ''; - return contentType.includes('application/x-www-form-urlencoded') && accept.includes('text/html'); -} - -function sendJson(res, statusCode, payload, headers = {}) { - send(res, statusCode, JSON.stringify(payload, null, 2), { - 'Content-Type': 'application/json; charset=utf-8', - ...headers, - }); -} - -function sendHtml(res, statusCode, body, headers = {}) { - send(res, statusCode, body, { - 'Content-Type': 'text/html; charset=utf-8', - ...headers, - }); -} - -function sendText(res, statusCode, body, headers = {}) { - send(res, statusCode, body, { - 'Content-Type': 'text/plain; charset=utf-8', - ...headers, - }); -} - -function sendRedirect(res, location) { - res.writeHead(303, { - Location: location, - 'Cache-Control': 'no-store', - 'X-Content-Type-Options': 'nosniff', - }); - res.end(); -} - -function send(res, statusCode, body, headers = {}) { - res.writeHead(statusCode, { - 'Cache-Control': 'no-store', - 'X-Content-Type-Options': 'nosniff', - ...headers, - }); - res.end(body); -} - -function renderAdminPage(state) { - const hostedActive = state.activeBackend === BACKEND_HOSTED; - const nuvioActive = state.activeBackend === BACKEND_NUVIO; - - return ` - - - - - Nuvio Sync Switch - - - -
-

Nuvio Sync Switch

-

Select the Supabase backend exposed by /config.json.

-
-
-

Active backend

-

${escapeHtml(state.activeBackend)}

-
-
-

Revision

-

${escapeHtml(String(state.revision))}

-
-
-

Force logout

-

${serverConfig.forceLogoutOnChange ? 'true' : 'false'}

-
-
-
-
- - -
-
- - -
-
-
- - -`; -} - -function escapeHtml(value) { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case '"': - return '"'; - case "'": - return '''; - default: - return char; - } - }); -} diff --git a/server/sync-switch/package.json b/server/sync-switch/package.json deleted file mode 100644 index 6bde2beaa..000000000 --- a/server/sync-switch/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "nuvio-sync-switch", - "version": "0.1.0", - "private": true, - "description": "Minimal VPS-hosted switch API for Nuvio Supabase backend selection.", - "main": "index.js", - "scripts": { - "start": "node index.js", - "check": "node --check index.js" - }, - "engines": { - "node": ">=18" - } -} From 232703c3cd7be745dedc6d67b6d582be717355c4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:00:25 +0530 Subject: [PATCH 3/3] Read sync backend credentials from env --- composeApp/build.gradle.kts | 39 +++++++++++++++-- .../app/core/network/SyncBackendConfig.kt | 43 +++---------------- .../app/core/network/SyncBackendRepository.kt | 9 ++-- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8dadc1c01..964b76961 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -26,6 +26,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val appVersionCode: Property + @get:Input + abstract val supabaseUrl: Property + + @get:Input + abstract val supabaseAnonKey: Property + + @get:Input + abstract val nuvioSupabaseUrl: Property + + @get:Input + abstract val nuvioSupabaseAnonKey: Property + + @get:Input + abstract val syncBackendManifestUrl: Property + @TaskAction fun generate() { val props = Properties() @@ -39,8 +54,10 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.core.network | |object SupabaseConfig { - | const val URL = "${props.getProperty("SUPABASE_URL", "")}" - | const val ANON_KEY = "${props.getProperty("SUPABASE_ANON_KEY", "")}" + | const val URL = "${supabaseUrl.get()}" + | const val ANON_KEY = "${supabaseAnonKey.get()}" + | const val NUVIO_URL = "${nuvioSupabaseUrl.get()}" + | const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}" |} """.trimMargin() ) @@ -49,7 +66,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.core.network | |object SyncBackendBootstrapConfig { - | const val SWITCH_MANIFEST_URL = "${props.getProperty("SYNC_BACKEND_MANIFEST_URL", "")}" + | const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}" |} """.trimMargin() ) @@ -208,12 +225,28 @@ val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName -> taskName.startsWith("bundlefull") || taskName.endsWith("bundle") } +val runtimeLocalProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +fun runtimeConfigValue(key: String, fallback: String = ""): String = + runtimeLocalProperties.getProperty(key)?.trim()?.takeIf { it.isNotBlank() } + ?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } + ?: fallback val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) + supabaseUrl.set(runtimeConfigValue("SUPABASE_URL")) + supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY")) + nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) + nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) + syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL")) } tasks.withType>().configureEach { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt index 0ff7db977..ca45da22e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt @@ -38,16 +38,6 @@ data class SyncBackendManifest( val activeBackend: String, val revision: String = "", val forceLogoutOnChange: Boolean = true, - val backends: Map = emptyMap(), -) - -@Serializable -data class SyncBackendManifestBackend( - val displayName: String = "", - val supabaseUrl: String = "", - val anonKey: String = "", - val avatarPublicBaseUrl: String = "", - val schemaVersion: Int = 1, ) data class SyncBackendState( @@ -75,7 +65,8 @@ sealed interface SyncBackendRefreshResult { @Serializable internal data class StoredSyncBackendSelection( - val backend: SyncBackendConfig, + val backend: SyncBackendConfig? = null, + val backendId: String = "", val appliedRevision: String = "", ) @@ -94,9 +85,9 @@ object SyncBackendDefaults { SyncBackendConfig( id = SYNC_BACKEND_NUVIO_ID, displayName = "Nuvio", - supabaseUrl = "https://api.nuvio.tv", - anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI", - avatarPublicBaseUrl = "https://api.nuvio.tv/storage/v1/object/public/avatars", + supabaseUrl = SupabaseConfig.NUVIO_URL, + anonKey = SupabaseConfig.NUVIO_ANON_KEY, + avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/storage/v1/object/public/avatars", schemaVersion = 1, ).normalized() @@ -118,28 +109,8 @@ internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendCon if (version != 1) return null val activeId = activeBackend.trim().lowercase() - val manifestBackend = backends.entries - .firstOrNull { (key, _) -> key.trim().lowercase() == activeId } - ?.value - ?: return null - val fallback = SyncBackendDefaults.byId(activeId) ?: return null - - val supabaseUrl = manifestBackend.supabaseUrl.trim().ifBlank { fallback.supabaseUrl } - val anonKey = manifestBackend.anonKey.trim().ifBlank { fallback.anonKey } - val avatarPublicBaseUrl = manifestBackend.avatarPublicBaseUrl.trim().ifBlank { - "$supabaseUrl/storage/v1/object/public/avatars" - } - - return SyncBackendConfig( - id = activeId, - displayName = manifestBackend.displayName.trim().ifBlank { fallback.displayName }, - supabaseUrl = supabaseUrl, - anonKey = anonKey, - avatarPublicBaseUrl = avatarPublicBaseUrl, - schemaVersion = manifestBackend.schemaVersion.takeIf { it > 0 } ?: fallback.schemaVersion, - ) - .normalized() - .takeIf { it.isUsableClientConfig() } + return SyncBackendDefaults.byId(activeId) + ?.takeIf { it.isUsableClientConfig() } } private fun SyncBackendConfig.isUsableClientConfig(): Boolean = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt index d38b49410..db3065293 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt @@ -36,9 +36,10 @@ object SyncBackendRepository { } val backend = storedSelection - ?.backend - ?.normalized() - ?.takeIf { storedBackend -> SyncBackendDefaults.byId(storedBackend.id) != null } + ?.let { selection -> + selection.backendId.ifBlank { selection.backend?.id.orEmpty() } + } + ?.let(SyncBackendDefaults::byId) ?: SyncBackendDefaults.hosted() _state.value = SyncBackendState( @@ -107,7 +108,7 @@ object SyncBackendRepository { val normalizedBackend = backend.normalized() val payload = json.encodeToString( StoredSyncBackendSelection( - backend = normalizedBackend, + backendId = normalizedBackend.id, appliedRevision = revision, ), )