Add Kotlin Multiplatform mobile client

A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.

deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.
This commit is contained in:
2026-09-10 10:03:11 +02:00
parent aca7238020
commit c5b2b71eea
50 changed files with 2200 additions and 0 deletions
@@ -0,0 +1,10 @@
package com.musify.app
// Passed as -Dmusify.* JVM args by the `compose.desktop.application` block
// (composeApp/build.gradle.kts), sourced from mobile/gradle.properties.
actual object AppConfig {
actual val apiBaseUrl: String = System.getProperty("musify.apiBaseUrl", "http://localhost:5111")
actual val authIssuer: String = System.getProperty("musify.authIssuer", "http://host.docker.internal:8080")
actual val authClientId: String = System.getProperty("musify.authClientId", "")
actual val nativeRedirectUri: String = "http://127.0.0.1:17453/callback"
}
@@ -0,0 +1,18 @@
package com.musify.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.unit.dp
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Musify",
state = WindowState(size = DpSize(420.dp, 760.dp), position = WindowPosition(alignment = androidx.compose.ui.Alignment.Center))
) {
App()
}
}
@@ -0,0 +1,13 @@
package com.musify.app
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
actual fun createHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(jsonConfig) }
install(Logging) { level = LogLevel.INFO }
}
@@ -0,0 +1,81 @@
package com.musify.app.audio
import javafx.application.Platform as JfxPlatform
import javafx.scene.media.Media
import javafx.scene.media.MediaPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Boots the JavaFX toolkit headlessly (no Stage/window) so `MediaPlayer` can
* decode/play audio inside a plain Compose Desktop (Swing/Skiko) app.
*/
private object JavaFxBootstrap {
private var started = false
@Synchronized
fun ensureStarted() {
if (started) return
started = true
try {
JfxPlatform.startup {}
JfxPlatform.setImplicitExit(false)
} catch (_: IllegalStateException) {
// Toolkit already running (e.g. re-entrant init) — nothing to do.
}
}
}
actual class AudioPlayer actual constructor() {
private val _state = MutableStateFlow(PlaybackState.IDLE)
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
private var mediaPlayer: MediaPlayer? = null
actual fun play(url: String) {
JavaFxBootstrap.ensureStarted()
_state.value = PlaybackState.LOADING
JfxPlatform.runLater {
mediaPlayer?.stop()
mediaPlayer?.dispose()
val player = MediaPlayer(Media(url))
mediaPlayer = player
player.setOnPlaying { _state.value = PlaybackState.PLAYING }
player.setOnPaused { _state.value = PlaybackState.PAUSED }
player.setOnStopped { _state.value = PlaybackState.IDLE }
player.setOnEndOfMedia { _state.value = PlaybackState.PAUSED }
player.setOnError { _state.value = PlaybackState.ERROR }
player.play()
}
}
actual fun pause() {
if (mediaPlayer == null) return
JfxPlatform.runLater { mediaPlayer?.pause() }
}
actual fun resume() {
if (mediaPlayer == null) return
JfxPlatform.runLater { mediaPlayer?.play() }
}
actual fun stop() {
// Nothing was ever played (JavaFX toolkit never booted) — runLater would
// throw "Toolkit not initialized" here, e.g. when closing the app cold.
if (mediaPlayer == null) {
_state.value = PlaybackState.IDLE
return
}
JfxPlatform.runLater { mediaPlayer?.stop() }
_state.value = PlaybackState.IDLE
}
actual fun dispose() {
if (mediaPlayer == null) return
JfxPlatform.runLater {
mediaPlayer?.dispose()
mediaPlayer = null
}
}
}
@@ -0,0 +1,46 @@
package com.musify.app.auth
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.awt.Desktop
import java.net.InetSocketAddress
import java.net.URI
/**
* RFC 8252 §7.3 "loopback interface redirection": a one-shot local HTTP
* server receives the Authorization Code callback in the system browser, then
* shuts itself down. `redirectUri` must match `http://127.0.0.1:17453/callback`
* exactly, as registered for the "Musify Native" Zitadel app.
*/
actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
val redirect = URI(redirectUri)
val port = redirect.port.takeIf { it > 0 } ?: error("El redirect URI de Desktop necesita un puerto explícito.")
val path = redirect.path.ifEmpty { "/" }
val result = CompletableDeferred<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0)
server.createContext(path) { exchange ->
val query = exchange.requestURI.rawQuery.orEmpty()
val body = """
<html><body style="font-family:sans-serif;padding:2rem">
<h2>Musify</h2><p>Ya puedes volver a la app y cerrar esta pestaña.</p>
</body></html>
""".trimIndent().toByteArray()
exchange.responseHeaders.add("Content-Type", "text/html; charset=utf-8")
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
result.complete("$redirectUri?$query")
}
server.start()
return try {
withContext(Dispatchers.IO) { Desktop.getDesktop().browse(URI(authorizeUrl)) }
result.await()
} finally {
server.stop(0)
}
}
}
@@ -0,0 +1,7 @@
package com.musify.app.auth
import java.security.MessageDigest
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
@@ -0,0 +1,31 @@
package com.musify.app.auth
import com.musify.app.jsonConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import java.io.File
actual class TokenStore actual constructor() {
private val file = File(System.getProperty("user.home"), ".musify/tokens.json")
actual suspend fun save(tokens: StoredTokens) = withContext(Dispatchers.IO) {
file.parentFile?.mkdirs()
file.writeText(jsonConfig.encodeToString(tokens))
}
actual suspend fun load(): StoredTokens? = withContext(Dispatchers.IO) {
if (!file.exists()) return@withContext null
try {
jsonConfig.decodeFromString<StoredTokens>(file.readText())
} catch (t: Throwable) {
null
}
}
actual suspend fun clear(): Unit = withContext(Dispatchers.IO) {
file.delete()
Unit
}
}