diff --git a/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt b/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt
index 9f9a145..72d98e0 100644
--- a/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt
+++ b/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt
@@ -93,12 +93,17 @@ class AstraDesktopDiscoveryModule : Module() {
val endpointUuid = txt(resolved, "endpoint_uuid")
val desktopName = txt(resolved, "name")
val protocolVersion = txt(resolved, "protocol_version")?.toIntOrNull() ?: 1
+ val transport = txt(resolved, "transport")
+ val certificateFingerprint = txt(resolved, "certificate_fingerprint")
+ if (protocolVersion != 3 || transport != "https" || certificateFingerprint.isNullOrBlank()) return
val payload = mapOf(
"endpointUuid" to endpointUuid,
"desktopName" to desktopName,
"protocolVersion" to protocolVersion,
+ "certificateFingerprint" to certificateFingerprint,
+ "transport" to "https",
"name" to (desktopName ?: resolved.serviceName),
- "baseUrl" to "http://$address:$port",
+ "baseUrl" to "https://$address:$port",
"address" to address,
"port" to port,
"lastSeenAt" to System.currentTimeMillis(),
diff --git a/modules/astra-desktop-discovery/index.ts b/modules/astra-desktop-discovery/index.ts
index d360739..ba8d8bc 100644
--- a/modules/astra-desktop-discovery/index.ts
+++ b/modules/astra-desktop-discovery/index.ts
@@ -4,6 +4,8 @@ export interface AstraDesktopDiscoveryItem {
endpointUuid: string | null;
desktopName: string | null;
protocolVersion: number;
+ certificateFingerprint: string;
+ transport: 'https';
name: string;
baseUrl: string;
address: string;
diff --git a/modules/astra-desktop-transport/android/build.gradle b/modules/astra-desktop-transport/android/build.gradle
new file mode 100644
index 0000000..e23bcd7
--- /dev/null
+++ b/modules/astra-desktop-transport/android/build.gradle
@@ -0,0 +1,22 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.astradesktoptransport'
+version = '0.1.0'
+
+android {
+ namespace "expo.modules.astradesktoptransport"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ testImplementation 'junit:junit:4.13.2'
+}
diff --git a/modules/astra-desktop-transport/android/src/main/AndroidManifest.xml b/modules/astra-desktop-transport/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..94cbbcf
--- /dev/null
+++ b/modules/astra-desktop-transport/android/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/modules/astra-desktop-transport/android/src/main/java/expo/modules/astradesktoptransport/AstraDesktopTransportModule.kt b/modules/astra-desktop-transport/android/src/main/java/expo/modules/astradesktoptransport/AstraDesktopTransportModule.kt
new file mode 100644
index 0000000..e16175f
--- /dev/null
+++ b/modules/astra-desktop-transport/android/src/main/java/expo/modules/astradesktoptransport/AstraDesktopTransportModule.kt
@@ -0,0 +1,464 @@
+package expo.modules.astradesktoptransport
+
+import expo.modules.kotlin.functions.Coroutine
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.net.HttpURLConnection
+import java.net.URI
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.security.PrivateKey
+import java.security.cert.X509Certificate
+import java.security.spec.X509EncodedKeySpec
+import java.util.UUID
+import java.util.Base64
+import java.util.concurrent.ConcurrentHashMap
+import javax.crypto.Cipher
+import javax.crypto.KeyAgreement
+import javax.crypto.Mac
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+import javax.net.ssl.HostnameVerifier
+import javax.net.ssl.HttpsURLConnection
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSession
+import javax.net.ssl.TrustManager
+import javax.net.ssl.X509TrustManager
+
+private const val MAX_REQUEST_BYTES = 2 * 1024 * 1024
+private const val MAX_RESPONSE_BYTES = 5 * 1024 * 1024
+private const val PAIRING_INFO = "astra-phone-remote-v3-pairing"
+private const val PAIRING_AAD_PREFIX = "astra-phone-remote-v3-confirm:"
+
+private data class PairingAttempt(
+ val baseUrl: String,
+ val requestId: String,
+ val expiresAt: Long,
+ val certificateFingerprint: String,
+ val key: ByteArray,
+ val transcript: ByteArray,
+ val code: String,
+)
+
+private class ExactFingerprintTrustManager(expected: String) : X509TrustManager {
+ private val expectedFingerprint = normalizeFingerprint(expected)
+
+ init {
+ require(expectedFingerprint.length == 64) { "Invalid desktop certificate fingerprint." }
+ }
+
+ override fun checkClientTrusted(chain: Array?, authType: String?) = Unit
+
+ override fun checkServerTrusted(chain: Array?, authType: String?) {
+ val leaf = chain?.firstOrNull() ?: throw java.security.cert.CertificateException("Desktop sent no certificate.")
+ if (certificateFingerprint(leaf) != expectedFingerprint) {
+ throw java.security.cert.CertificateException("Desktop certificate changed.")
+ }
+ }
+
+ override fun getAcceptedIssuers(): Array = emptyArray()
+}
+
+internal class ObservingTrustManager : X509TrustManager {
+ @Volatile var observedFingerprint: String? = null
+ override fun checkClientTrusted(chain: Array?, authType: String?) = Unit
+ override fun checkServerTrusted(chain: Array?, authType: String?) {
+ val leaf = chain?.firstOrNull() ?: throw java.security.cert.CertificateException("Desktop sent no certificate.")
+ observedFingerprint = certificateFingerprint(leaf)
+ }
+ override fun getAcceptedIssuers(): Array = emptyArray()
+}
+
+private fun normalizeFingerprint(value: String): String =
+ value.uppercase().filter { it in '0'..'9' || it in 'A'..'F' }
+
+private fun certificateFingerprint(certificate: X509Certificate): String =
+ MessageDigest.getInstance("SHA-256").digest(certificate.encoded).joinToString("") { "%02X".format(it) }
+
+private fun displayFingerprint(value: String): String =
+ normalizeFingerprint(value).chunked(2).joinToString(":")
+
+private fun base64Url(bytes: ByteArray): String =
+ Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
+
+private fun decodeBase64Url(value: String): ByteArray =
+ Base64.getUrlDecoder().decode(value)
+
+private fun hmac(key: ByteArray, data: ByteArray): ByteArray =
+ Mac.getInstance("HmacSHA256").run {
+ init(SecretKeySpec(key, "HmacSHA256"))
+ doFinal(data)
+ }
+
+private fun hkdf(sharedSecret: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
+ val prk = hmac(salt, sharedSecret)
+ val output = ByteArrayOutputStream()
+ var previous = ByteArray(0)
+ var counter = 1
+ while (output.size() < length) {
+ previous = hmac(prk, previous + info + byteArrayOf(counter.toByte()))
+ output.write(previous)
+ counter += 1
+ }
+ return output.toByteArray().copyOf(length)
+}
+
+internal fun derivePairingKey(
+ privateKey: PrivateKey,
+ peerPublicKey: String,
+ transcript: ByteArray,
+): ByteArray {
+ val peerKey = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(decodeBase64Url(peerPublicKey)))
+ val sharedSecret = KeyAgreement.getInstance("ECDH").run {
+ init(privateKey)
+ doPhase(peerKey, true)
+ generateSecret()
+ }
+ return hkdf(
+ sharedSecret,
+ MessageDigest.getInstance("SHA-256").digest(transcript),
+ PAIRING_INFO.toByteArray(),
+ 32
+ )
+}
+
+internal fun derivePairingCode(key: ByteArray, transcript: ByteArray): String {
+ val digest = hmac(key, "astra-phone-remote-v3-code".toByteArray() + transcript)
+ val value = ((digest[0].toLong() and 0xff) shl 24) or
+ ((digest[1].toLong() and 0xff) shl 16) or
+ ((digest[2].toLong() and 0xff) shl 8) or (digest[3].toLong() and 0xff)
+ return (value % 1_000_000L).toString().padStart(6, '0')
+}
+
+internal fun derivePairingProof(key: ByteArray, transcript: ByteArray): String =
+ base64Url(hmac(key, "astra-phone-remote-v3-proof".toByteArray() + transcript))
+
+private fun validateBaseUrl(baseUrl: String): URI {
+ val uri = URI(baseUrl)
+ if (!uri.scheme.equals("https", ignoreCase = true) || uri.host.isNullOrBlank()) {
+ throw IllegalArgumentException("Desktop Remote requires HTTPS.")
+ }
+ if (uri.userInfo != null || uri.port == 0 || uri.port < -1 || uri.port > 65_535) {
+ throw IllegalArgumentException("Invalid desktop base URL.")
+ }
+ if (uri.rawQuery != null || uri.rawFragment != null || (uri.path.isNotBlank() && uri.path != "/")) {
+ throw IllegalArgumentException("Invalid desktop base URL.")
+ }
+ return uri
+}
+
+private fun sslContext(trustManager: X509TrustManager): SSLContext =
+ SSLContext.getInstance("TLSv1.2").apply {
+ init(null, arrayOf(trustManager), SecureRandom())
+ }
+
+private fun pinnedHostnameVerifier(expected: String): HostnameVerifier = HostnameVerifier { _, session ->
+ sessionFingerprint(session) == normalizeFingerprint(expected)
+}
+
+private fun sessionFingerprint(session: SSLSession): String? = try {
+ val leaf = session.peerCertificates.firstOrNull() as? X509Certificate
+ leaf?.let(::certificateFingerprint)
+} catch (_: Throwable) {
+ null
+}
+
+private fun openConnection(
+ baseUrl: String,
+ path: String,
+ fingerprint: String,
+ observingTrustManager: ObservingTrustManager? = null,
+): HttpsURLConnection {
+ val base = validateBaseUrl(baseUrl)
+ if (!path.startsWith('/') || path.startsWith("//")) throw IllegalArgumentException("Invalid desktop request path.")
+ val url = base.resolve(path).toURL()
+ val trustManager = observingTrustManager ?: ExactFingerprintTrustManager(fingerprint)
+ return (url.openConnection() as HttpsURLConnection).apply {
+ sslSocketFactory = sslContext(trustManager).socketFactory
+ hostnameVerifier = if (observingTrustManager != null) {
+ HostnameVerifier { _, session ->
+ val observed = observingTrustManager.observedFingerprint
+ observed != null && sessionFingerprint(session) == observed
+ }
+ } else {
+ pinnedHostnameVerifier(fingerprint)
+ }
+ instanceFollowRedirects = false
+ useCaches = false
+ connectTimeout = 8_000
+ readTimeout = 8_000
+ }
+}
+
+private fun readBounded(connection: HttpURLConnection): String {
+ val stream = if (connection.responseCode >= 400) connection.errorStream else connection.inputStream
+ if (stream == null) return ""
+ stream.use { input ->
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(8192)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ if (output.size() + count > MAX_RESPONSE_BYTES) throw IllegalStateException("Desktop response is too large.")
+ output.write(buffer, 0, count)
+ }
+ return output.toString(Charsets.UTF_8.name())
+ }
+}
+
+internal fun executeJson(
+ baseUrl: String,
+ path: String,
+ method: String,
+ body: String?,
+ token: String?,
+ fingerprint: String,
+ timeoutMs: Int,
+ observingTrustManager: ObservingTrustManager? = null,
+): Pair {
+ if (method != "GET" && method != "POST") throw IllegalArgumentException("Unsupported desktop request method.")
+ val bodyBytes = body?.toByteArray(Charsets.UTF_8)
+ if (bodyBytes != null && bodyBytes.size > MAX_REQUEST_BYTES) throw IllegalArgumentException("Desktop request is too large.")
+ val connection = openConnection(baseUrl, path, fingerprint, observingTrustManager)
+ try {
+ connection.requestMethod = method
+ connection.connectTimeout = timeoutMs.coerceIn(1_000, 60_000)
+ connection.readTimeout = timeoutMs.coerceIn(1_000, 65_000)
+ connection.setRequestProperty("Accept", "application/json")
+ // Authorization is configured only on an exact-pin connection. TLS runs before HTTP bytes.
+ if (observingTrustManager == null && !token.isNullOrBlank()) {
+ connection.setRequestProperty("Authorization", "Bearer $token")
+ }
+ if (bodyBytes != null) {
+ connection.doOutput = true
+ connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
+ connection.setFixedLengthStreamingMode(bodyBytes.size)
+ connection.outputStream.use { it.write(bodyBytes) }
+ }
+ val status = connection.responseCode
+ if (status in 300..399) throw IllegalStateException("Desktop Remote refuses redirects.")
+ return status to readBounded(connection)
+ } finally {
+ connection.disconnect()
+ }
+}
+
+class AstraDesktopTransportModule : Module() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val streams = ConcurrentHashMap()
+ private val pairingAttempts = ConcurrentHashMap()
+
+ override fun definition() = ModuleDefinition {
+ Name("AstraDesktopTransport")
+ Events("onDesktopTransportSse", "onDesktopTransportClosed")
+
+ AsyncFunction("requestJson").Coroutine { baseUrl: String, path: String, method: String,
+ body: String?, token: String?, fingerprint: String, timeoutMs: Int ->
+ withContext(Dispatchers.IO) {
+ val (status, responseBody) = executeJson(baseUrl, path, method, body, token, fingerprint, timeoutMs)
+ mapOf("status" to status, "body" to responseBody)
+ }
+ }
+
+ AsyncFunction("startEventStream") { baseUrl: String, token: String, fingerprint: String ->
+ validateBaseUrl(baseUrl)
+ val streamId = UUID.randomUUID().toString()
+ val job = scope.launch(start = CoroutineStart.LAZY) { runEventStream(streamId, baseUrl, token, fingerprint) }
+ streams[streamId] = job
+ job.start()
+ streamId
+ }
+
+ Function("stopEventStream") { streamId: String ->
+ streams.remove(streamId)?.cancel()
+ }
+
+ AsyncFunction("beginPinPairing").Coroutine { baseUrl: String, deviceName: String, clientLabel: String ->
+ withContext(Dispatchers.IO) { beginPinPairing(baseUrl, deviceName, clientLabel) }
+ }
+
+ AsyncFunction("confirmPinPairing").Coroutine { attemptId: String, enteredCode: String ->
+ withContext(Dispatchers.IO) { confirmPinPairing(attemptId, enteredCode) }
+ }
+
+ OnDestroy {
+ streams.values.forEach { it.cancel() }
+ streams.clear()
+ pairingAttempts.clear()
+ }
+ }
+
+ private fun beginPinPairing(baseUrl: String, deviceName: String, clientLabel: String): Map {
+ val now = System.currentTimeMillis()
+ pairingAttempts.entries.removeIf { it.value.expiresAt <= now }
+ validateBaseUrl(baseUrl)
+ val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair()
+ val phonePublicKey = base64Url(keyPair.public.encoded)
+ val observer = ObservingTrustManager()
+ val requestBody = JSONObject()
+ .put("deviceName", deviceName)
+ .put("clientLabel", clientLabel)
+ .put("phoneEphemeralPublicKey", phonePublicKey)
+ // Filled after the TLS handshake by the observing trust manager, before this credential-free body is sent.
+ val uri = validateBaseUrl(baseUrl)
+ val connection = openConnection(baseUrl, "/v1/pairing/pin-request", "", observer)
+ val bodyBytes: ByteArray
+ val responseBody: String
+ val status: Int
+ try {
+ connection.requestMethod = "POST"
+ connection.doOutput = true
+ connection.setChunkedStreamingMode(4096)
+ connection.setRequestProperty("Accept", "application/json")
+ connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
+ connection.connect()
+ val observed = observer.observedFingerprint ?: throw IllegalStateException("Desktop certificate was not observed.")
+ requestBody.put("observedCertificateFingerprint", displayFingerprint(observed))
+ bodyBytes = requestBody.toString().toByteArray(Charsets.UTF_8)
+ connection.outputStream.use { it.write(bodyBytes) }
+ status = connection.responseCode
+ if (status in 300..399) throw IllegalStateException("Desktop Remote refuses redirects.")
+ responseBody = readBounded(connection)
+ } finally {
+ connection.disconnect()
+ }
+ if (status !in 200..299) throw IllegalStateException(JSONObject(responseBody).optString("error", "PIN pairing failed ($status)."))
+ val response = JSONObject(responseBody)
+ val certificateFingerprint = response.optString("certificateFingerprint")
+ val observed = observer.observedFingerprint ?: throw IllegalStateException("Desktop certificate was not observed.")
+ if (normalizeFingerprint(certificateFingerprint) != observed) throw IllegalStateException("Pairing transcript certificate mismatch.")
+ if (response.optInt("protocolVersion", 0) != 3) throw IllegalStateException("Desktop does not support secure protocol v3.")
+ val requestId = response.getString("requestId")
+ val desktopPublicKey = response.getString("desktopEphemeralPublicKey")
+ val identity = response.optJSONObject("identity") ?: JSONObject()
+ val endpointUuid = identity.optString("endpointUuid", "")
+ val desktopPort = if (uri.port > 0) uri.port else 443
+ val transcript = JSONArray()
+ .put(3)
+ .put(requestId)
+ .put(phonePublicKey)
+ .put(desktopPublicKey)
+ .put(normalizeFingerprint(certificateFingerprint))
+ .put(endpointUuid)
+ .put(desktopPort)
+ .toString().toByteArray(Charsets.UTF_8)
+ val pairingKey = derivePairingKey(keyPair.private, desktopPublicKey, transcript)
+ val code = derivePairingCode(pairingKey, transcript)
+ val attemptId = UUID.randomUUID().toString()
+ val expiresAt = response.optLong("expiresAt", 0L)
+ pairingAttempts[attemptId] = PairingAttempt(
+ baseUrl, requestId, expiresAt, displayFingerprint(certificateFingerprint), pairingKey, transcript, code
+ )
+ return mapOf(
+ "attemptId" to attemptId,
+ "requestId" to requestId,
+ "expiresAt" to expiresAt,
+ "desktopName" to identity.optString("desktopName").ifBlank { null },
+ "certificateFingerprint" to displayFingerprint(certificateFingerprint),
+ "protocolVersion" to 3,
+ )
+ }
+
+ private fun confirmPinPairing(attemptId: String, enteredCode: String): Map {
+ val attempt = pairingAttempts[attemptId] ?: throw IllegalStateException("PIN pairing attempt expired.")
+ if (System.currentTimeMillis() >= attempt.expiresAt) {
+ pairingAttempts.remove(attemptId)
+ throw IllegalStateException("PIN pairing attempt expired.")
+ }
+ if (enteredCode != attempt.code) throw IllegalArgumentException("Wrong PIN. Try again.")
+ val proof = derivePairingProof(attempt.key, attempt.transcript)
+ val requestBody = JSONObject().put("requestId", attempt.requestId).put("proof", proof).toString()
+ val (status, responseBody) = executeJson(
+ attempt.baseUrl, "/v1/pairing/pin-confirm", "POST", requestBody, null,
+ attempt.certificateFingerprint, 8_000
+ )
+ if (status !in 200..299) throw IllegalStateException(JSONObject(responseBody).optString("error", "PIN confirmation failed ($status)."))
+ val response = JSONObject(responseBody)
+ if (normalizeFingerprint(response.optString("certificateFingerprint")) != normalizeFingerprint(attempt.certificateFingerprint)) {
+ throw IllegalStateException("Pairing transcript certificate mismatch.")
+ }
+ val sealed = response.getJSONObject("sealed")
+ val nonce = decodeBase64Url(sealed.getString("nonce"))
+ val encrypted = decodeBase64Url(sealed.getString("ciphertext")) + decodeBase64Url(sealed.getString("authTag"))
+ val aad = PAIRING_AAD_PREFIX.toByteArray() + MessageDigest.getInstance("SHA-256").digest(attempt.transcript)
+ val plaintext = Cipher.getInstance("AES/GCM/NoPadding").run {
+ init(Cipher.DECRYPT_MODE, SecretKeySpec(attempt.key, "AES"), GCMParameterSpec(128, nonce))
+ updateAAD(aad)
+ doFinal(encrypted)
+ }
+ pairingAttempts.remove(attemptId)
+ val credentials = JSONObject(String(plaintext, Charsets.UTF_8))
+ if (normalizeFingerprint(credentials.getString("certificateFingerprint")) != normalizeFingerprint(attempt.certificateFingerprint)) {
+ throw IllegalStateException("Sealed credential certificate mismatch.")
+ }
+ return mapOf(
+ "controlToken" to credentials.getString("controlToken"),
+ "syncToken" to credentials.getString("syncToken"),
+ "deviceId" to credentials.optString("deviceId").ifBlank { null },
+ "issuedAt" to credentials.getLong("issuedAt"),
+ "identityJson" to credentials.getJSONObject("identity").toString(),
+ "certificateFingerprint" to attempt.certificateFingerprint,
+ )
+ }
+
+ private suspend fun runEventStream(streamId: String, baseUrl: String, token: String, fingerprint: String) {
+ var unauthorized = false
+ var message = "Desktop event stream closed."
+ val connection = openConnection(baseUrl, "/v1/events", fingerprint)
+ try {
+ connection.requestMethod = "GET"
+ connection.readTimeout = 65_000
+ connection.setRequestProperty("Accept", "text/event-stream")
+ connection.setRequestProperty("Authorization", "Bearer $token")
+ val status = connection.responseCode
+ if (status in 300..399) throw IllegalStateException("Desktop Remote refuses redirects.")
+ if (status == 401) {
+ unauthorized = true
+ message = "Desktop pairing was revoked."
+ return
+ }
+ if (status !in 200..299) throw IllegalStateException("Desktop event stream failed ($status).")
+ connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
+ var eventName = "message"
+ val data = mutableListOf()
+ while (true) {
+ val line = reader.readLine() ?: break
+ if (line.isEmpty()) {
+ if (data.isNotEmpty()) sendEvent("onDesktopTransportSse", mapOf(
+ "streamId" to streamId, "event" to eventName, "data" to data.joinToString("\n")
+ ))
+ eventName = "message"
+ data.clear()
+ } else if (line.startsWith("event:")) {
+ eventName = line.substring(6).trim()
+ } else if (line.startsWith("data:")) {
+ data.add(line.substring(5).trimStart())
+ }
+ }
+ }
+ } catch (_: CancellationException) {
+ message = ""
+ } catch (error: Throwable) {
+ message = error.message ?: "Desktop event stream failed."
+ } finally {
+ connection.disconnect()
+ streams.remove(streamId)
+ sendEvent("onDesktopTransportClosed", mapOf(
+ "streamId" to streamId, "unauthorized" to unauthorized, "message" to message
+ ))
+ }
+ }
+}
diff --git a/modules/astra-desktop-transport/android/src/test/java/expo/modules/astradesktoptransport/AstraDesktopTransportModuleTest.kt b/modules/astra-desktop-transport/android/src/test/java/expo/modules/astradesktoptransport/AstraDesktopTransportModuleTest.kt
new file mode 100644
index 0000000..808e511
--- /dev/null
+++ b/modules/astra-desktop-transport/android/src/test/java/expo/modules/astradesktoptransport/AstraDesktopTransportModuleTest.kt
@@ -0,0 +1,175 @@
+package expo.modules.astradesktoptransport
+
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertArrayEquals
+import org.junit.Test
+import java.net.InetSocketAddress
+import java.net.InetAddress
+import java.security.KeyFactory
+import java.security.KeyStore
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.security.spec.PKCS8EncodedKeySpec
+import java.util.Base64
+import java.util.concurrent.CopyOnWriteArrayList
+import javax.net.ssl.KeyManagerFactory
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLServerSocket
+import kotlin.concurrent.thread
+
+class AstraDesktopTransportModuleTest {
+ private var server: SSLServerSocket? = null
+ private var serverThread: Thread? = null
+
+ @After fun tearDown() {
+ server?.close()
+ serverThread?.join(1_000)
+ }
+
+ @Test fun correctPinSendsAuthorizationAndReturnsJson() {
+ val fixture = startServer(200, "{\"ok\":true}")
+ val (status, body) = executeJson(
+ fixture.baseUrl, "/v1/identity", "GET", null, "control-secret",
+ fixture.fingerprint, 5_000
+ )
+ assertEquals(200, status)
+ assertEquals("{\"ok\":true}", body)
+ assertEquals(listOf("Bearer control-secret"), fixture.authorizationHeaders)
+ }
+
+ @Test fun wrongCertificateFailsBeforeAuthorizationOrPayload() {
+ val fixture = startServer(200, "{\"ok\":true}")
+ val wrongFingerprint = "00".repeat(32)
+ assertThrows(Throwable::class.java) {
+ executeJson(
+ fixture.baseUrl, "/v1/sync/apply", "POST", "{\"private\":\"sync-payload\"}",
+ "sync-secret", wrongFingerprint, 5_000
+ )
+ }
+ assertEquals(emptyList(), fixture.authorizationHeaders)
+ assertEquals(emptyList(), fixture.requestBodies)
+ }
+
+ @Test fun redirectIsNeverFollowed() {
+ val fixture = startServer(302, "")
+ assertThrows(IllegalStateException::class.java) {
+ executeJson(
+ fixture.baseUrl, "/v1/identity", "GET", null, null,
+ fixture.fingerprint, 5_000
+ )
+ }
+ assertEquals(1, fixture.requestCount())
+ }
+
+ @Test fun pairingTranscriptMatchesTheDesktopProtocolVector() {
+ val privateKey = KeyFactory.getInstance("EC").generatePrivate(
+ PKCS8EncodedKeySpec(Base64.getDecoder().decode(KEY_DER_BASE64))
+ )
+ val transcript = "[3,\"pairing-test\",\"$PUBLIC_KEY_BASE64URL\",\"$PUBLIC_KEY_BASE64URL\",\"$CERT_FINGERPRINT\",\"endpoint-test\",38402]"
+ .toByteArray()
+ val key = derivePairingKey(privateKey, PUBLIC_KEY_BASE64URL, transcript)
+ assertArrayEquals(hex("f6755aa6d05cb1f5ce79f7d01c716feaa97efa552521fc8b5841877c859c9038"), key)
+ assertEquals("824321", derivePairingCode(key, transcript))
+ assertEquals("8VqyqyI_ndBul51SD2OU4eia4e_SGr2pu_0EdJ-iJVw", derivePairingProof(key, transcript))
+ }
+
+ private fun hex(value: String): ByteArray = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+
+ private data class Fixture(
+ val baseUrl: String,
+ val fingerprint: String,
+ val authorizationHeaders: CopyOnWriteArrayList,
+ val requestBodies: CopyOnWriteArrayList,
+ val requestCount: () -> Int,
+ )
+
+ private fun startServer(status: Int, responseBody: String): Fixture {
+ val certificate = CertificateFactory.getInstance("X.509")
+ .generateCertificate(CERT_DER_BASE64.byteInputStream().let(Base64.getDecoder()::wrap)) as X509Certificate
+ val privateKey = KeyFactory.getInstance("EC").generatePrivate(
+ PKCS8EncodedKeySpec(Base64.getDecoder().decode(KEY_DER_BASE64))
+ )
+ val password = "test-password".toCharArray()
+ val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
+ load(null, null)
+ setKeyEntry("server", privateKey, password, arrayOf(certificate))
+ }
+ val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
+ init(keyStore, password)
+ }
+ val context = SSLContext.getInstance("TLSv1.2").apply {
+ init(keyManagers.keyManagers, null, SecureRandom())
+ }
+ val auth = CopyOnWriteArrayList()
+ val bodies = CopyOnWriteArrayList()
+ var count = 0
+ server = context.serverSocketFactory.createServerSocket(
+ 0, 16, InetAddress.getByName("127.0.0.1")
+ ) as SSLServerSocket
+ val socketServer = server!!
+ serverThread = thread(name = "astra-desktop-transport-test-server", isDaemon = true) {
+ while (!socketServer.isClosed) {
+ try {
+ socketServer.accept().use { socket ->
+ val input = socket.getInputStream().bufferedReader(Charsets.UTF_8)
+ val requestLine = input.readLine() ?: return@use
+ if (requestLine.isBlank()) return@use
+ val headers = mutableMapOf()
+ while (true) {
+ val line = input.readLine() ?: break
+ if (line.isEmpty()) break
+ val separator = line.indexOf(':')
+ if (separator > 0) headers[line.substring(0, separator).lowercase()] = line.substring(separator + 1).trim()
+ }
+ count += 1
+ headers["authorization"]?.let(auth::add)
+ val length = headers["content-length"]?.toIntOrNull() ?: 0
+ if (length > 0) {
+ val body = CharArray(length)
+ var offset = 0
+ while (offset < length) {
+ val read = input.read(body, offset, length - offset)
+ if (read < 0) break
+ offset += read
+ }
+ if (offset > 0) bodies.add(String(body, 0, offset))
+ }
+ val bytes = responseBody.toByteArray()
+ val reason = if (status in 300..399) "Found" else "OK"
+ val response = buildString {
+ append("HTTP/1.1 $status $reason\r\n")
+ append("Content-Type: application/json\r\n")
+ append("Content-Length: ${bytes.size}\r\n")
+ if (status in 300..399) append("Location: /redirected\r\n")
+ append("Connection: close\r\n\r\n")
+ }.toByteArray()
+ socket.getOutputStream().use { output ->
+ output.write(response)
+ output.write(bytes)
+ output.flush()
+ }
+ }
+ } catch (_: Throwable) {
+ if (!socketServer.isClosed) continue
+ }
+ }
+ }
+ val fingerprint = MessageDigest.getInstance("SHA-256")
+ .digest(certificate.encoded)
+ .joinToString("") { "%02X".format(it) }
+ return Fixture(
+ "https://127.0.0.1:${(server!!.localSocketAddress as InetSocketAddress).port}", fingerprint, auth, bodies, { count }
+ )
+ }
+
+ companion object {
+ private const val CERT_DER_BASE64 = "MIIBgzCCASmgAwIBAgIQb1XrfH3Pjja0eaxzR9J2NTAKBggqhkjOPQQDAjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMjYwNzEyMjMwMzE2WhcNMzYwNzEwMjMwMzE2WjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQyKPCr7mEqvp/LVhnUgexpbTOPcq09dRNMfeF96uSbPXaL2E7qktUQFLYNm6A0kv3i6XK0aZiIcjUHrhNiJkjKo10wWzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIChDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUT3YyZJjuQU9t1PTIM6ShuJX1j6QwCgYIKoZIzj0EAwIDSAAwRQIgcoTHim922rYnRyOduB8pMQNplwQ4N33xxV3BmphP09ICIQCV+FdMVUE8ykFdluehZ63HXYa+UUJOk9v3K8LKdS6HKQ=="
+ private const val KEY_DER_BASE64 = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgC2+uZe2nmL2ChsSJDBMu9bkQu9NSlaNn0eCuf1CxcPqhRANCAAQyKPCr7mEqvp/LVhnUgexpbTOPcq09dRNMfeF96uSbPXaL2E7qktUQFLYNm6A0kv3i6XK0aZiIcjUHrhNiJkjK"
+ private const val PUBLIC_KEY_BASE64URL = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMijwq-5hKr6fy1YZ1IHsaW0zj3KtPXUTTH3hferkmz12i9hO6pLVEBS2DZugNJL94ulytGmYiHI1B64TYiZIyg"
+ private const val CERT_FINGERPRINT = "7FE09E5A9EE622987491D2877F9E3661AAA0ABC253CC3220BCC4AA9E28C3D14F"
+ }
+}
diff --git a/modules/astra-desktop-transport/expo-module.config.json b/modules/astra-desktop-transport/expo-module.config.json
new file mode 100644
index 0000000..573fb92
--- /dev/null
+++ b/modules/astra-desktop-transport/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.astradesktoptransport.AstraDesktopTransportModule"]
+ }
+}
diff --git a/modules/astra-desktop-transport/index.ts b/modules/astra-desktop-transport/index.ts
new file mode 100644
index 0000000..079b504
--- /dev/null
+++ b/modules/astra-desktop-transport/index.ts
@@ -0,0 +1,58 @@
+import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
+
+export interface AstraDesktopTransportResponse {
+ status: number;
+ body: string;
+}
+
+export interface AstraDesktopPinAttempt {
+ attemptId: string;
+ requestId: string;
+ expiresAt: number;
+ desktopName: string | null;
+ certificateFingerprint: string;
+ protocolVersion: number;
+}
+
+export interface AstraDesktopPinResult {
+ controlToken: string;
+ syncToken: string;
+ deviceId: string | null;
+ issuedAt: number;
+ identityJson: string;
+ certificateFingerprint: string;
+}
+
+type AstraDesktopTransportEvents = {
+ onDesktopTransportSse: (event: {
+ streamId: string;
+ event: string;
+ data: string;
+ }) => void;
+ onDesktopTransportClosed: (event: {
+ streamId: string;
+ unauthorized: boolean;
+ message: string;
+ }) => void;
+};
+
+declare class AstraDesktopTransportModuleType extends NativeModule {
+ requestJson(
+ baseUrl: string,
+ path: string,
+ method: 'GET' | 'POST',
+ body: string | null,
+ token: string | null,
+ certificateFingerprint: string,
+ timeoutMs: number
+ ): Promise;
+ startEventStream(baseUrl: string, token: string, certificateFingerprint: string): Promise;
+ stopEventStream(streamId: string): void;
+ beginPinPairing(baseUrl: string, deviceName: string, clientLabel: string): Promise;
+ confirmPinPairing(attemptId: string, enteredCode: string): Promise;
+}
+
+export const AstraDesktopTransport =
+ requireOptionalNativeModule('AstraDesktopTransport');
+
+export const desktopPinnedTransportAvailable = AstraDesktopTransport != null;
diff --git a/package.json b/package.json
index 0397641..e331bc2 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts",
- "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts",
+ "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts",
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 81449e6..8699ccc 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -34,6 +34,7 @@ import {
setDesktopRemoteMediaSession,
subscribeDesktopRemoteMediaSessionCommands,
} from '@/services/desktopRemoteMediaSession';
+import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
import {
getDesktopRemoteConnection,
setDesktopRemoteConnection,
@@ -170,6 +171,8 @@ const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000;
function DesktopSyncAutoTrigger() {
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const discovered = useDesktopRemoteStore((s) => s.discovered);
+ const desktopSyncHydrated = useDesktopSyncStore((s) => s.hydrated);
+ const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled);
useEffect(() => {
void useDesktopSyncStore.getState().hydrate();
@@ -182,6 +185,8 @@ function DesktopSyncAutoTrigger() {
let startupRetryTimer: ReturnType | null = null;
const onActive = () => {
void (async () => {
+ const sync = useDesktopSyncStore.getState();
+ if (!sync.hydrated || !sync.desktopSyncEnabled) return;
const connection = await getDesktopRemoteConnection();
if (!connection) return;
useDesktopSyncStore.getState().maybeAutoSync('foreground');
@@ -210,31 +215,41 @@ function DesktopSyncAutoTrigger() {
subscription.remove();
if (burstTimer !== null) clearTimeout(burstTimer);
if (startupRetryTimer !== null) clearTimeout(startupRetryTimer);
+ if (!useDesktopSyncStore.getState().desktopSyncEnabled) {
+ void useDesktopRemoteStore.getState().stopDiscovery();
+ }
};
- }, []);
+ }, [desktopSyncEnabled, desktopSyncHydrated]);
// Desktop-initiated "Sync now" pickup: a cheap identity poll while
// foregrounded (the SSE nudge only reaches us while the remote screen's
// stream happens to be connected). fetchDesktopRemoteIdentity swallows
// errors, so a powered-off desktop costs one timed-out request per minute.
useEffect(() => {
+ if (!desktopSyncHydrated || !desktopSyncEnabled) return;
const timer = setInterval(() => {
if (AppState.currentState !== 'active') return;
void (async () => {
+ const sync = useDesktopSyncStore.getState();
+ if (!sync.hydrated || !sync.desktopSyncEnabled) return;
const connection = await getDesktopRemoteConnection();
if (!connection) return;
- const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
+ const identity = await fetchDesktopRemoteIdentity(
+ connection.baseUrl,
+ connection.certificateFingerprint
+ );
if (identity?.syncRequestedAt) {
useDesktopSyncStore.getState().handleSyncRequest();
}
})();
}, DESKTOP_SYNC_REQUEST_POLL_MS);
return () => clearInterval(timer);
- }, []);
+ }, [desktopSyncEnabled, desktopSyncHydrated]);
// Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves)
// and trigger a sync.
useEffect(() => {
+ if (!desktopSyncHydrated || !desktopSyncEnabled) return;
if (discovered.length === 0) return;
void (async () => {
const connection = await getDesktopRemoteConnection();
@@ -242,6 +257,15 @@ function DesktopSyncAutoTrigger() {
const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid);
if (!match) return;
if (match.baseUrl && match.baseUrl !== connection.baseUrl) {
+ const identity = await fetchDesktopRemoteIdentity(
+ match.baseUrl,
+ connection.certificateFingerprint
+ );
+ if (!identity || !identityMatchesPinnedConnection(
+ connection.endpointUuid,
+ identity.protocolVersion,
+ identity.endpointUuid
+ )) return;
const updated = { ...connection, baseUrl: match.baseUrl };
await setDesktopRemoteConnection(updated);
if (useDesktopRemoteStore.getState().connection) {
@@ -250,14 +274,14 @@ function DesktopSyncAutoTrigger() {
}
useDesktopSyncStore.getState().maybeAutoSync('discovery');
})();
- }, [discovered]);
+ }, [desktopSyncEnabled, desktopSyncHydrated, discovered]);
// The remote screen connected — the desktop is definitely reachable.
useEffect(() => {
- if (connectionState === 'connected') {
+ if (desktopSyncHydrated && desktopSyncEnabled && connectionState === 'connected') {
useDesktopSyncStore.getState().maybeAutoSync('connected');
}
- }, [connectionState]);
+ }, [desktopSyncEnabled, desktopSyncHydrated, connectionState]);
return null;
}
diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx
index 07a0821..408e87d 100644
--- a/src/app/desktop-remote.tsx
+++ b/src/app/desktop-remote.tsx
@@ -225,7 +225,20 @@ export default function DesktopRemoteScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
- const { pair } = useLocalSearchParams<{ pair?: string }>();
+ const pairingParams = useLocalSearchParams<{
+ pair?: string;
+ baseUrl?: string;
+ ticket?: string;
+ endpointUuid?: string;
+ fingerprint?: string;
+ protocolVersion?: string;
+ }>();
+ const pairingRoutePair = pairingParams.pair;
+ const pairingRouteBaseUrl = pairingParams.baseUrl;
+ const pairingRouteTicket = pairingParams.ticket;
+ const pairingRouteEndpointUuid = pairingParams.endpointUuid;
+ const pairingRouteFingerprint = pairingParams.fingerprint;
+ const pairingRouteProtocolVersion = pairingParams.protocolVersion;
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const initialized = useDesktopRemoteStore((s) => s.initialized);
@@ -253,7 +266,6 @@ export default function DesktopRemoteScreen() {
const [pinInput, setPinInput] = useState('');
const [pinClock, setPinClock] = useState(() => Date.now());
const [manualBaseUrl, setManualBaseUrl] = useState('');
- const [manualTicket, setManualTicket] = useState('');
useEffect(() => {
void init();
@@ -267,11 +279,35 @@ export default function DesktopRemoteScreen() {
}, [startDiscovery, stopDiscovery]);
useEffect(() => {
- if (typeof pair === 'string' && pair.trim()) {
- void pairFromInput(pair);
+ if (typeof pairingRoutePair === 'string' && pairingRoutePair.trim()) {
+ void pairFromInput(pairingRoutePair);
router.setParams({ pair: undefined });
+ return;
}
- }, [pair, pairFromInput, router]);
+ if (
+ typeof pairingRouteBaseUrl === 'string' && typeof pairingRouteTicket === 'string' &&
+ typeof pairingRouteEndpointUuid === 'string' && typeof pairingRouteFingerprint === 'string' &&
+ pairingRouteProtocolVersion === '3'
+ ) {
+ const url = new URL('astra://desktop-remote');
+ url.searchParams.set('baseUrl', pairingRouteBaseUrl);
+ url.searchParams.set('ticket', pairingRouteTicket);
+ url.searchParams.set('endpointUuid', pairingRouteEndpointUuid);
+ url.searchParams.set('fingerprint', pairingRouteFingerprint);
+ url.searchParams.set('protocolVersion', '3');
+ void pairFromInput(url.toString());
+ router.setParams({ baseUrl: undefined, ticket: undefined, endpointUuid: undefined, fingerprint: undefined, protocolVersion: undefined });
+ }
+ }, [
+ pairFromInput,
+ pairingRouteBaseUrl,
+ pairingRouteEndpointUuid,
+ pairingRouteFingerprint,
+ pairingRoutePair,
+ pairingRouteProtocolVersion,
+ pairingRouteTicket,
+ router,
+ ]);
useEffect(() => {
if (!pinPairing) return undefined;
@@ -453,40 +489,31 @@ export default function DesktopRemoteScreen() {
Manual fallback
- Enter the desktop URL and pairing code from Astra Desktop.
+ Enter the Astra Desktop HTTPS URL, then compare the six-digit code shown on both devices.
-
void pairManual(manualBaseUrl, manualTicket)}
+ disabled={!manualBaseUrl.trim()}
+ onPress={() => void pairManual(manualBaseUrl)}
>
- Pair manually
+ Request secure PIN
diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx
index 228b88e..d91d019 100644
--- a/src/app/desktop-sync.tsx
+++ b/src/app/desktop-sync.tsx
@@ -140,9 +140,9 @@ export default function DesktopSyncScreen() {
const lastSummary = useDesktopSyncStore((s) => s.lastSummary);
const conflicts = useDesktopSyncStore((s) => s.conflicts);
const errorMessage = useDesktopSyncStore((s) => s.errorMessage);
- const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled);
+ const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled);
const syncNow = useDesktopSyncStore((s) => s.syncNow);
- const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled);
+ const setDesktopSyncEnabled = useDesktopSyncStore((s) => s.setDesktopSyncEnabled);
const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
const [connection, setConnection] = useState(null);
@@ -229,8 +229,8 @@ export default function DesktopSyncScreen() {
void syncNow()}
accessibilityLabel="Sync favorites and playlists now"
>
@@ -259,15 +259,15 @@ export default function DesktopSyncScreen() {
- Sync automatically
+ Desktop Sync
- Sync when this desktop appears on the network or the app returns to the
- foreground. Manual and desktop-requested syncs always work.
+ Allow favorites and playlists to sync securely with this desktop on any
+ network. Turn this off to suppress every sync trigger without forgetting it.
void setAutoSyncEnabled(value)}
+ value={desktopSyncEnabled}
+ onValueChange={(value) => void setDesktopSyncEnabled(value)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
diff --git a/src/services/desktopRemoteClient.ts b/src/services/desktopRemoteClient.ts
index df04da6..6835f48 100644
--- a/src/services/desktopRemoteClient.ts
+++ b/src/services/desktopRemoteClient.ts
@@ -1,4 +1,8 @@
import * as Device from 'expo-device';
+import {
+ AstraDesktopTransport,
+ desktopPinnedTransportAvailable,
+} from '../../modules/astra-desktop-transport';
import type {
DesktopRemoteControlCommand,
DesktopRemoteIdentity,
@@ -26,7 +30,7 @@ interface JsonRequestOptions {
token?: string | null;
body?: unknown;
timeoutMs?: number;
- signal?: AbortSignal;
+ fingerprint: string;
}
export class DesktopRemoteHttpError extends Error {
@@ -40,52 +44,26 @@ export class DesktopRemoteHttpError extends Error {
}
}
-function timeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
-
- const abort = () => controller.abort();
- if (parentSignal) {
- if (parentSignal.aborted) controller.abort();
- else parentSignal.addEventListener('abort', abort, { once: true });
- }
-
- controller.signal.addEventListener(
- 'abort',
- () => {
- clearTimeout(timer);
- parentSignal?.removeEventListener('abort', abort);
- },
- { once: true }
- );
-
- return controller.signal;
-}
-
async function fetchJson(
baseUrl: string,
path: string,
- options: JsonRequestOptions = {}
+ options: JsonRequestOptions
): Promise {
- const headers: Record = {
- Accept: 'application/json',
- };
- let body: string | undefined;
- if (options.body !== undefined) {
- headers['Content-Type'] = 'application/json; charset=utf-8';
- body = JSON.stringify(options.body);
+ if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
+ throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
- if (options.token) headers.Authorization = `Bearer ${options.token}`;
-
- const response = await fetch(`${baseUrl}${path}`, {
- method: options.method ?? (body ? 'POST' : 'GET'),
- headers,
+ const body = options.body === undefined ? null : JSON.stringify(options.body);
+ const response = await AstraDesktopTransport.requestJson(
+ baseUrl,
+ path,
+ options.method ?? (body ? 'POST' : 'GET'),
body,
- cache: 'no-store',
- signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal),
- });
- const payload = await response.json().catch(() => null);
- if (!response.ok) {
+ options.token ?? null,
+ options.fingerprint,
+ options.timeoutMs ?? REQUEST_TIMEOUT_MS
+ );
+ const payload = response.body ? JSON.parse(response.body) as unknown : null;
+ if (response.status < 200 || response.status >= 300) {
const message =
payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
? payload.error
@@ -127,9 +105,12 @@ export function defaultDesktopRemoteDeviceName(): string {
return model ? `${model} Remote` : 'Astra Mobile Remote';
}
-export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise {
+export async function fetchDesktopRemoteIdentity(
+ baseUrl: string,
+ certificateFingerprint: string
+): Promise {
try {
- const payload = await fetchJson(baseUrl, '/v1/identity');
+ const payload = await fetchJson(baseUrl, '/v1/identity', { fingerprint: certificateFingerprint });
return normalizeIdentity(payload);
} catch {
return null;
@@ -139,9 +120,11 @@ export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise {
const payload = await fetchJson>(baseUrl, '/v1/pairing/claim', {
+ fingerprint: certificateFingerprint,
method: 'POST',
body: {
ticket,
@@ -163,58 +146,67 @@ export async function requestDesktopRemotePinPairing(
baseUrl: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise {
- const payload = await fetchJson>(baseUrl, '/v1/pairing/pin-request', {
- method: 'POST',
- body: {
- deviceName,
- clientLabel: clientLabel(),
- },
- });
+ if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
+ throw new Error('Secure Desktop Remote transport is unavailable on this device.');
+ }
+ const payload = await AstraDesktopTransport.beginPinPairing(baseUrl, deviceName, clientLabel());
return {
- requestId: String(payload.requestId ?? ''),
- pollToken: String(payload.pollToken ?? ''),
- expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
- deviceName: String(payload.deviceName ?? deviceName),
- clientLabel: String(payload.clientLabel ?? clientLabel()),
- identity: normalizeIdentity(payload.identity ?? payload),
+ attemptId: payload.attemptId,
+ requestId: payload.requestId,
+ pollToken: '',
+ expiresAt: payload.expiresAt,
+ deviceName,
+ clientLabel: clientLabel(),
+ identity: { endpointUuid: null, desktopName: payload.desktopName, protocolVersion: 3 },
+ certificateFingerprint: payload.certificateFingerprint,
+ protocolVersion: 3,
};
}
export async function confirmDesktopRemotePinPairing(
- baseUrl: string,
- requestId: string,
+ attemptId: string,
pin: string
): Promise {
- const payload = await fetchJson>(baseUrl, '/v1/pairing/pin-confirm', {
- method: 'POST',
- body: {
- requestId,
- pin,
- },
- });
- const state = typeof payload.state === 'string' ? payload.state : 'approved';
+ if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
+ throw new Error('Secure Desktop Remote transport is unavailable on this device.');
+ }
+ const payload = await AstraDesktopTransport.confirmPinPairing(attemptId, pin);
return {
- state: state as DesktopRemotePairingStatus['state'],
- expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
- token: typeof payload.token === 'string' ? payload.token : undefined,
- deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
- identity: normalizeIdentity(payload.identity ?? payload),
+ state: 'approved',
+ expiresAt: 0,
+ token: payload.controlToken,
+ controlToken: payload.controlToken,
+ syncToken: payload.syncToken,
+ issuedAt: payload.issuedAt,
+ scopes: ['control', 'sync'],
+ certificateFingerprint: payload.certificateFingerprint,
+ deviceId: payload.deviceId,
+ identity: normalizeIdentity(JSON.parse(payload.identityJson)),
};
}
export async function fetchDesktopRemotePairingStatus(
baseUrl: string,
- pollToken: string
+ pollToken: string,
+ certificateFingerprint: string
): Promise {
const payload = await fetchJson>(
baseUrl,
- `/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`
+ `/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`,
+ { fingerprint: certificateFingerprint }
);
const state = typeof payload.state === 'string' ? payload.state : 'pending';
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
+ controlToken: typeof payload.controlToken === 'string' ? payload.controlToken : undefined,
+ syncToken: typeof payload.syncToken === 'string' ? payload.syncToken : undefined,
+ issuedAt: typeof payload.issuedAt === 'number' ? payload.issuedAt : undefined,
+ scopes: Array.isArray(payload.scopes)
+ ? payload.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync')
+ : undefined,
+ certificateFingerprint: typeof payload.certificateFingerprint === 'string' ? payload.certificateFingerprint : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
};
@@ -223,24 +215,27 @@ export async function fetchDesktopRemotePairingStatus(
export async function fetchDesktopRemoteNowPlaying(
baseUrl: string,
token: string,
+ certificateFingerprint: string,
inlineArtwork = false
): Promise {
return fetchJson(
baseUrl,
`/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
- { token }
+ { token, fingerprint: certificateFingerprint }
);
}
export async function sendDesktopRemoteControl(
baseUrl: string,
token: string,
+ certificateFingerprint: string,
command: DesktopRemoteControlCommand,
time?: number
): Promise {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
+ fingerprint: certificateFingerprint,
body: command === 'seek' ? { command, time } : { command },
});
}
@@ -248,38 +243,87 @@ export async function sendDesktopRemoteControl(
export async function sendDesktopRemotePlayQueueItem(
baseUrl: string,
token: string,
+ certificateFingerprint: string,
queueId: string
): Promise {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
+ fingerprint: certificateFingerprint,
body: { command: 'play-queue-item', queueId },
});
}
export async function fetchDesktopRemoteQueue(
baseUrl: string,
- token: string
+ token: string,
+ certificateFingerprint: string
): Promise {
- return fetchJson(baseUrl, '/v1/queue', { token });
+ return fetchJson(baseUrl, '/v1/queue', { token, fingerprint: certificateFingerprint });
+}
+
+export interface DesktopRemoteSessionInfo {
+ deviceId: string;
+ scopes: ('control' | 'sync')[];
+ issuedAt: number;
+ rotatedAt: number;
+ rotateAfter: number;
+ rotateRequiredAt: number;
+ expiresAt: number;
+ rotationRequired: boolean;
+ usingPreviousCredential: boolean;
+}
+
+export interface DesktopRemoteRotatedCredentials {
+ controlToken: string;
+ syncToken: string;
+ issuedAt: number;
+ previousValidUntil: number;
+ rotateAfter: number;
+}
+
+export async function inspectDesktopRemoteSession(
+ baseUrl: string,
+ controlToken: string,
+ certificateFingerprint: string
+): Promise {
+ return fetchJson(baseUrl, '/v1/session', {
+ token: controlToken,
+ fingerprint: certificateFingerprint,
+ });
+}
+
+export async function rotateDesktopRemoteCredentials(
+ baseUrl: string,
+ controlToken: string,
+ certificateFingerprint: string
+): Promise {
+ return fetchJson(baseUrl, '/v1/session/rotate', {
+ method: 'POST',
+ token: controlToken,
+ fingerprint: certificateFingerprint,
+ body: {},
+ });
}
// ── Favorites/playlists LAN sync (protocolVersion >= 2) ──────────────────────
// Sync payloads can carry thousands of favorites/playlist entries, so both
// calls get generous timeouts compared to the 8 s control default.
-export async function fetchDesktopSyncState(baseUrl: string, token: string): Promise {
- return fetchJson(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 });
+export async function fetchDesktopSyncState(baseUrl: string, token: string, certificateFingerprint: string): Promise {
+ return fetchJson(baseUrl, '/v1/sync/state', { token, fingerprint: certificateFingerprint, timeoutMs: 30_000 });
}
export async function postDesktopSyncApply(
baseUrl: string,
token: string,
+ certificateFingerprint: string,
payload: DesktopSyncApplyPayload
): Promise {
return fetchJson(baseUrl, '/v1/sync/apply', {
method: 'POST',
token,
+ fingerprint: certificateFingerprint,
body: payload,
timeoutMs: 60_000,
});
@@ -288,11 +332,13 @@ export async function postDesktopSyncApply(
export async function postDesktopSyncConflicts(
baseUrl: string,
token: string,
+ certificateFingerprint: string,
payload: DesktopSyncConflictReportPayload
): Promise {
await fetchJson<{ ok: true }>(baseUrl, '/v1/sync/conflicts', {
method: 'POST',
token,
+ fingerprint: certificateFingerprint,
body: payload,
});
}
@@ -307,103 +353,53 @@ export type DesktopRemoteSseHandlers = {
onError?: (error: unknown) => void;
};
-function processSseChunk(
- buffer: { value: string },
- chunk: string,
- onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void,
- onQueue?: (queue: DesktopRemoteQueueSnapshot) => void,
- onSyncRequest?: () => void
-): void {
- buffer.value += chunk.replace(/\r/g, '');
- let boundary = buffer.value.indexOf('\n\n');
- while (boundary !== -1) {
- const raw = buffer.value.slice(0, boundary);
- buffer.value = buffer.value.slice(boundary + 2);
- let eventName = 'message';
- const data: string[] = [];
- for (const line of raw.split('\n')) {
- if (!line || line.startsWith(':')) continue;
- if (line.startsWith('event:')) {
- eventName = line.slice(6).trim();
- continue;
- }
- if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
- }
- if (eventName === 'now-playing' && data.length > 0) {
- try {
- onSnapshot(JSON.parse(data.join('\n')) as DesktopRemoteNowPlayingSnapshot);
- } catch {
- // Ignore a malformed event; polling/reconnect will correct the UI.
- }
- } else if (eventName === 'queue' && data.length > 0 && onQueue) {
- try {
- onQueue(JSON.parse(data.join('\n')) as DesktopRemoteQueueSnapshot);
- } catch {
- // Ignore a malformed event; the on-demand queue fetch will correct it.
- }
- } else if (eventName === 'sync-request' && onSyncRequest) {
- onSyncRequest();
- }
- boundary = buffer.value.indexOf('\n\n');
- }
-}
-
export function startDesktopRemoteEventStream(
baseUrl: string,
token: string,
+ certificateFingerprint: string,
handlers: DesktopRemoteSseHandlers
): () => void {
- const controller = new AbortController();
let closed = false;
+ let streamId: string | null = null;
+ const transport = AstraDesktopTransport;
+ if (!transport) {
+ handlers.onError?.(new Error('Secure Desktop Remote transport is unavailable on this device.'));
+ return () => {};
+ }
- void (async () => {
- try {
- const response = await fetch(`${baseUrl}/v1/events`, {
- headers: {
- Accept: 'text/event-stream',
- Authorization: `Bearer ${token}`,
- },
- cache: 'no-store',
- signal: controller.signal,
- });
- if (response.status === 401) {
- handlers.onUnauthorized();
- return;
- }
- const body = response.body as unknown as {
- getReader?: () => {
- read: () => Promise<{ done: boolean; value?: Uint8Array }>;
- };
- } | null;
- if (!response.ok || !body?.getReader) throw new Error(`SSE unavailable (${response.status})`);
-
- const reader = body.getReader();
- const decoder = new TextDecoder();
- const buffer = { value: '' };
- while (!closed) {
- const next = await reader.read();
- if (next.done) break;
- if (next.value) {
- processSseChunk(
- buffer,
- decoder.decode(next.value, { stream: true }),
- handlers.onSnapshot,
- handlers.onQueue,
- handlers.onSyncRequest
- );
- }
- }
- processSseChunk(buffer, decoder.decode(), handlers.onSnapshot, handlers.onQueue, handlers.onSyncRequest);
- if (!closed) handlers.onDisconnect();
- } catch (error) {
- if (closed || controller.signal.aborted) return;
- handlers.onError?.(error);
+ const eventSubscription = transport.addListener('onDesktopTransportSse', (event) => {
+ if (closed || !streamId || event.streamId !== streamId) return;
+ if (event.event === 'now-playing') {
+ try { handlers.onSnapshot(JSON.parse(event.data) as DesktopRemoteNowPlayingSnapshot); } catch { /* ignore */ }
+ } else if (event.event === 'queue' && handlers.onQueue) {
+ try { handlers.onQueue(JSON.parse(event.data) as DesktopRemoteQueueSnapshot); } catch { /* ignore */ }
+ } else if (event.event === 'sync-request') {
+ handlers.onSyncRequest?.();
+ }
+ });
+ const closedSubscription = transport.addListener('onDesktopTransportClosed', (event) => {
+ if (closed || !streamId || event.streamId !== streamId) return;
+ if (event.unauthorized) handlers.onUnauthorized();
+ else if (event.message) {
+ handlers.onError?.(new Error(event.message));
handlers.onDisconnect();
}
- })();
+ });
+
+ void transport.startEventStream(baseUrl, token, certificateFingerprint).then((id) => {
+ streamId = id;
+ if (closed) transport.stopEventStream(id);
+ }).catch((error) => {
+ if (closed) return;
+ handlers.onError?.(error);
+ handlers.onDisconnect();
+ });
return () => {
+ if (closed) return;
closed = true;
- controller.abort();
+ eventSubscription.remove();
+ closedSubscription.remove();
+ if (streamId) transport.stopEventStream(streamId);
};
}
diff --git a/src/services/desktopRemoteCredentials.ts b/src/services/desktopRemoteCredentials.ts
index 9348941..5c22c45 100644
--- a/src/services/desktopRemoteCredentials.ts
+++ b/src/services/desktopRemoteCredentials.ts
@@ -1,25 +1,70 @@
import * as SecureStore from 'expo-secure-store';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
-const CONNECTION_KEY = 'desktop_remote_connection_v1';
-const TOKEN_KEY = 'desktop_remote_token_v1';
+const CONNECTION_KEY = 'desktop_remote_connection_v3';
+const CREDENTIALS_KEY = 'desktop_remote_credentials_v3';
+const LEGACY_CONNECTION_KEY = 'desktop_remote_connection_v1';
+const LEGACY_TOKEN_KEY = 'desktop_remote_token_v1';
+const SECURITY_UPGRADE_NOTICE_KEY = 'desktop_remote_security_upgrade_notice_v3';
+
+export interface DesktopRemoteCredentials {
+ controlToken: string;
+ syncToken: string;
+ issuedAt: number;
+}
+
+let legacyMigrationPromise: Promise | null = null;
+
+async function migrateLegacyPairing(): Promise {
+ legacyMigrationPromise ??= (async () => {
+ const [legacyConnection, legacyToken] = await Promise.all([
+ SecureStore.getItemAsync(LEGACY_CONNECTION_KEY),
+ SecureStore.getItemAsync(LEGACY_TOKEN_KEY),
+ ]);
+ if (!legacyConnection && !legacyToken) return;
+ let label = 'Astra Desktop';
+ try {
+ const parsed = legacyConnection ? JSON.parse(legacyConnection) as Record : null;
+ if (parsed && typeof parsed.desktopName === 'string' && parsed.desktopName.trim()) label = parsed.desktopName.trim();
+ } catch {
+ // Only a non-secret label is retained.
+ }
+ await Promise.all([
+ SecureStore.deleteItemAsync(LEGACY_CONNECTION_KEY),
+ SecureStore.deleteItemAsync(LEGACY_TOKEN_KEY),
+ SecureStore.setItemAsync(SECURITY_UPGRADE_NOTICE_KEY, label),
+ ]);
+ })();
+ await legacyMigrationPromise;
+}
export async function getDesktopRemoteConnection(): Promise {
+ await migrateLegacyPairing();
const raw = await SecureStore.getItemAsync(CONNECTION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial;
if (!parsed || typeof parsed !== 'object') return null;
- if (typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string') return null;
+ if (
+ typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string' ||
+ !parsed.baseUrl.startsWith('https://') || typeof parsed.certificateFingerprint !== 'string' ||
+ parsed.protocolVersion !== 3
+ ) return null;
+ const scopes = Array.isArray(parsed.scopes)
+ ? parsed.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync')
+ : [];
+ if (!scopes.includes('control') || !scopes.includes('sync')) return null;
return {
id: parsed.id,
baseUrl: parsed.baseUrl,
+ certificateFingerprint: parsed.certificateFingerprint,
+ scopes,
+ credentialIssuedAt: typeof parsed.credentialIssuedAt === 'number' ? parsed.credentialIssuedAt : parsed.pairedAt ?? Date.now(),
+ credentialRotatedAt: typeof parsed.credentialRotatedAt === 'number' ? parsed.credentialRotatedAt : parsed.pairedAt ?? Date.now(),
+ securityUpgradeState: 'none',
endpointUuid: typeof parsed.endpointUuid === 'string' ? parsed.endpointUuid : null,
desktopName: typeof parsed.desktopName === 'string' ? parsed.desktopName : null,
- protocolVersion:
- typeof parsed.protocolVersion === 'number' && Number.isFinite(parsed.protocolVersion)
- ? parsed.protocolVersion
- : 1,
+ protocolVersion: 3,
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null,
pairedAt:
typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt)
@@ -39,18 +84,42 @@ export async function setDesktopRemoteConnection(connection: DesktopRemoteConnec
await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection));
}
-export async function getDesktopRemoteToken(): Promise {
- const token = await SecureStore.getItemAsync(TOKEN_KEY);
- return token && token.trim() ? token.trim() : null;
+export async function getDesktopRemoteCredentials(): Promise {
+ const raw = await SecureStore.getItemAsync(CREDENTIALS_KEY);
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (!parsed.controlToken?.trim() || !parsed.syncToken?.trim() || !Number.isFinite(parsed.issuedAt)) return null;
+ return { controlToken: parsed.controlToken.trim(), syncToken: parsed.syncToken.trim(), issuedAt: parsed.issuedAt! };
+ } catch {
+ return null;
+ }
}
-export async function setDesktopRemoteToken(token: string): Promise {
- await SecureStore.setItemAsync(TOKEN_KEY, token);
+export async function setDesktopRemoteCredentials(credentials: DesktopRemoteCredentials): Promise {
+ await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
+}
+
+export async function getDesktopRemoteToken(): Promise {
+ return (await getDesktopRemoteCredentials())?.controlToken ?? null;
+}
+
+export async function getDesktopRemoteSyncToken(): Promise {
+ return (await getDesktopRemoteCredentials())?.syncToken ?? null;
+}
+
+export async function getDesktopRemoteSecurityUpgradeNotice(): Promise {
+ await migrateLegacyPairing();
+ return SecureStore.getItemAsync(SECURITY_UPGRADE_NOTICE_KEY);
+}
+
+export async function clearDesktopRemoteSecurityUpgradeNotice(): Promise {
+ await SecureStore.deleteItemAsync(SECURITY_UPGRADE_NOTICE_KEY);
}
export async function clearDesktopRemotePairing(): Promise {
await Promise.all([
SecureStore.deleteItemAsync(CONNECTION_KEY),
- SecureStore.deleteItemAsync(TOKEN_KEY),
+ SecureStore.deleteItemAsync(CREDENTIALS_KEY),
]);
}
diff --git a/src/services/desktopRemotePairing.test.mts b/src/services/desktopRemotePairing.test.mts
index c4ce29a..32eb729 100644
--- a/src/services/desktopRemotePairing.test.mts
+++ b/src/services/desktopRemotePairing.test.mts
@@ -6,13 +6,16 @@ import {
parseDesktopRemotePairingInput,
} from './desktopRemotePairing.ts';
-test('parses current PWA pairing URL format', () => {
- assert.deepEqual(
+const fingerprint = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
+
+test('rejects browser and legacy HTTP pairing links on the native client', () => {
+ assert.equal(
+ parseDesktopRemotePairingInput('https://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
+ null
+ );
+ assert.equal(
parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
- {
- baseUrl: 'http://192.168.1.20:38402',
- ticket: 'abcDEF_1234567890',
- }
+ null
);
});
@@ -21,21 +24,33 @@ test('parses native pairing links without accepting missing base URLs', () => {
parseDesktopRemotePairingInput(
'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890'
),
+ null
+ );
+ assert.deepEqual(
+ parseDesktopRemotePairingInput(
+ `astra://desktop-remote?baseUrl=https%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890&endpointUuid=endpoint-1&fingerprint=${encodeURIComponent(fingerprint)}&protocolVersion=3`
+ ),
{
- baseUrl: 'http://10.0.0.8:38402',
+ baseUrl: 'https://10.0.0.8:38402',
ticket: 'abcDEF_1234567890',
+ endpointUuid: 'endpoint-1',
+ protocolVersion: 3,
+ certificateFingerprint: fingerprint,
}
);
assert.equal(parseDesktopRemotePairingInput('astra://desktop-remote/pair?ticket=abcDEF_1234567890'), null);
});
-test('manual pairing requires a reachable http base URL and ticket-shaped code', () => {
- assert.deepEqual(parseDesktopRemoteManualInput('http://desktop.local:38402/remote/', 'abcDEF_1234567890'), {
- baseUrl: 'http://desktop.local:38402',
+test('manual ticket parsing requires HTTPS, endpoint identity, and fingerprint', () => {
+ assert.deepEqual(parseDesktopRemoteManualInput('https://desktop.local:38402/remote/', 'abcDEF_1234567890', 'endpoint-1', fingerprint), {
+ baseUrl: 'https://desktop.local:38402',
ticket: 'abcDEF_1234567890',
+ endpointUuid: 'endpoint-1',
+ protocolVersion: 3,
+ certificateFingerprint: fingerprint,
});
- assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null);
- assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null);
+ assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'abcDEF_1234567890', 'endpoint-1', fingerprint), null);
+ assert.equal(parseDesktopRemoteManualInput('https://desktop.local:38402', 'short', 'endpoint-1', fingerprint), null);
});
test('PIN pairing accepts only six digits with optional spacing', () => {
diff --git a/src/services/desktopRemotePairing.ts b/src/services/desktopRemotePairing.ts
index 88369ed..9401696 100644
--- a/src/services/desktopRemotePairing.ts
+++ b/src/services/desktopRemotePairing.ts
@@ -8,13 +8,19 @@ function normalizeBaseUrl(value: string): string | null {
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
+ if (parsed.protocol !== 'https:') return null;
return parsed.origin;
} catch {
return null;
}
}
+function normalizeFingerprint(value: string): string | null {
+ const compact = value.trim().toUpperCase().replace(/[^0-9A-F]/g, '');
+ if (!/^[0-9A-F]{64}$/.test(compact)) return null;
+ return compact.match(/.{2}/g)?.join(':') ?? null;
+}
+
function extractPairFromUrl(url: URL): string {
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
const hashTicket = hashParams.get('pair')?.trim();
@@ -31,22 +37,46 @@ export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemoteP
if (parsed.protocol === 'astra:') {
const baseUrl = normalizeBaseUrl(parsed.searchParams.get('baseUrl') ?? '');
const ticket = parsed.searchParams.get('ticket')?.trim() ?? parsed.searchParams.get('pair')?.trim() ?? '';
- return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
+ const endpointUuid = parsed.searchParams.get('endpointUuid')?.trim() ?? '';
+ const fingerprint = normalizeFingerprint(parsed.searchParams.get('fingerprint') ?? '');
+ const protocolVersion = Number(parsed.searchParams.get('protocolVersion'));
+ return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket)
+ ? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint }
+ : null;
}
const ticket = extractPairFromUrl(parsed);
const baseUrl = normalizeBaseUrl(parsed.origin);
- return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
+ const hashParams = new URLSearchParams(parsed.hash.replace(/^#/, ''));
+ const endpointUuid = hashParams.get('endpointUuid')?.trim() ?? parsed.searchParams.get('endpointUuid')?.trim() ?? '';
+ const fingerprint = normalizeFingerprint(hashParams.get('fingerprint') ?? parsed.searchParams.get('fingerprint') ?? '');
+ const protocolVersion = Number(hashParams.get('protocolVersion') ?? parsed.searchParams.get('protocolVersion'));
+ return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket)
+ ? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint }
+ : null;
} catch {
- return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null;
+ return null;
}
}
-export function parseDesktopRemoteManualInput(baseUrl: string, ticket: string): DesktopRemotePairingInput | null {
+export function parseDesktopRemoteManualInput(
+ baseUrl: string,
+ ticket: string,
+ endpointUuid: string,
+ certificateFingerprint: string
+): DesktopRemotePairingInput | null {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedTicket = ticket.trim();
- if (!normalizedBaseUrl || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
- return { baseUrl: normalizedBaseUrl, ticket: normalizedTicket };
+ const normalizedEndpointUuid = endpointUuid.trim();
+ const normalizedFingerprint = normalizeFingerprint(certificateFingerprint);
+ if (!normalizedBaseUrl || !normalizedEndpointUuid || !normalizedFingerprint || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
+ return {
+ baseUrl: normalizedBaseUrl,
+ ticket: normalizedTicket,
+ endpointUuid: normalizedEndpointUuid,
+ protocolVersion: 3,
+ certificateFingerprint: normalizedFingerprint,
+ };
}
export function normalizeDesktopRemotePinInput(pin: string): string | null {
diff --git a/src/services/desktopRemoteSession.ts b/src/services/desktopRemoteSession.ts
new file mode 100644
index 0000000..030ac2f
--- /dev/null
+++ b/src/services/desktopRemoteSession.ts
@@ -0,0 +1,58 @@
+import {
+ DesktopRemoteHttpError,
+ inspectDesktopRemoteSession,
+ rotateDesktopRemoteCredentials,
+} from '@/services/desktopRemoteClient';
+import {
+ getDesktopRemoteCredentials,
+ setDesktopRemoteConnection,
+ setDesktopRemoteCredentials,
+ type DesktopRemoteCredentials,
+} from '@/services/desktopRemoteCredentials';
+import type { DesktopRemoteConnection } from '@/types/desktopRemote';
+
+export async function ensureDesktopRemoteCredentialsFresh(
+ connection: DesktopRemoteConnection,
+ suppliedCredentials?: DesktopRemoteCredentials
+): Promise<{ connection: DesktopRemoteConnection; credentials: DesktopRemoteCredentials }> {
+ const credentials = suppliedCredentials ?? await getDesktopRemoteCredentials();
+ if (!credentials) throw new Error('Desktop credentials are unavailable. Pair again.');
+ let shouldRotate = false;
+ try {
+ const session = await inspectDesktopRemoteSession(
+ connection.baseUrl,
+ credentials.controlToken,
+ connection.certificateFingerprint
+ );
+ shouldRotate = session.usingPreviousCredential || Date.now() >= session.rotateAfter;
+ } catch (error) {
+ if (!(error instanceof DesktopRemoteHttpError) || error.status !== 401) throw error;
+ shouldRotate = true;
+ }
+ if (!shouldRotate) return { connection, credentials };
+
+ const rotated = await rotateDesktopRemoteCredentials(
+ connection.baseUrl,
+ credentials.controlToken,
+ connection.certificateFingerprint
+ );
+ if (!rotated.controlToken?.trim() || !rotated.syncToken?.trim()) {
+ throw new Error('Desktop returned incomplete rotated credentials.');
+ }
+ const nextCredentials: DesktopRemoteCredentials = {
+ controlToken: rotated.controlToken,
+ syncToken: rotated.syncToken,
+ issuedAt: rotated.issuedAt,
+ };
+ const nextConnection: DesktopRemoteConnection = {
+ ...connection,
+ credentialIssuedAt: rotated.issuedAt,
+ credentialRotatedAt: rotated.issuedAt,
+ lastConnectedAt: Date.now(),
+ };
+ await Promise.all([
+ setDesktopRemoteConnection(nextConnection),
+ setDesktopRemoteCredentials(nextCredentials),
+ ]);
+ return { connection: nextConnection, credentials: nextCredentials };
+}
diff --git a/src/services/desktopSync.ts b/src/services/desktopSync.ts
index 062a3c9..e4bc1e3 100644
--- a/src/services/desktopSync.ts
+++ b/src/services/desktopSync.ts
@@ -62,10 +62,11 @@ import {
postDesktopSyncConflicts,
} from './desktopRemoteClient';
import {
+ getDesktopRemoteCredentials,
getDesktopRemoteConnection,
- getDesktopRemoteToken,
- setDesktopRemoteConnection,
+ getDesktopRemoteSyncToken,
} from './desktopRemoteCredentials';
+import { ensureDesktopRemoteCredentialsFresh } from './desktopRemoteSession';
const CLOCK_SKEW_WARN_MS = 5 * 60_000;
@@ -160,10 +161,10 @@ export async function runDesktopSync(): Promise {
// Report remaining conflicts (best-effort — older desktops 404 here).
const connection = await getDesktopRemoteConnection();
- const token = await getDesktopRemoteToken();
+ const token = await getDesktopRemoteSyncToken();
if (connection && token) {
try {
- await postDesktopSyncConflicts(connection.baseUrl, token, {
+ await postDesktopSyncConflicts(connection.baseUrl, token, connection.certificateFingerprint, {
syncFormat: DESKTOP_SYNC_FORMAT,
conflicts: summary.conflicts.map((conflict) => ({
kind: conflict.kind,
@@ -194,22 +195,22 @@ async function runDesktopSyncOnce(): Promise<{
pendingResolutions: DesktopSyncPendingResolution[];
}> {
const startedAt = Date.now();
- const connection = await getDesktopRemoteConnection();
- const token = await getDesktopRemoteToken();
- if (!connection || !token) {
+ let connection = await getDesktopRemoteConnection();
+ const credentials = await getDesktopRemoteCredentials();
+ if (!connection || !credentials) {
throw new Error('No paired desktop.');
}
+ const fresh = await ensureDesktopRemoteCredentialsFresh(connection, credentials);
+ connection = fresh.connection;
+ const token = fresh.credentials.syncToken;
// The stored protocolVersion predates any desktop upgrade — re-check live and
// persist the refreshed value before gating.
- const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
+ const identity = await fetchDesktopRemoteIdentity(connection.baseUrl, connection.certificateFingerprint);
if (!identity) {
throw new Error('Desktop is unreachable.');
}
- if (identity.protocolVersion !== connection.protocolVersion) {
- await setDesktopRemoteConnection({ ...connection, protocolVersion: identity.protocolVersion });
- }
- if (identity.protocolVersion < DESKTOP_SYNC_MIN_PROTOCOL_VERSION) {
+ if (identity.protocolVersion !== DESKTOP_SYNC_MIN_PROTOCOL_VERSION || identity.endpointUuid !== connection.endpointUuid) {
throw new DesktopSyncUnsupportedError();
}
@@ -218,7 +219,7 @@ async function runDesktopSyncOnce(): Promise<{
const index = buildImportIndex(await getAllTracks(db));
await resolvePendingFavorites(db, index);
const local = await getLocalSyncState(db);
- const remote = await fetchDesktopSyncState(connection.baseUrl, token);
+ const remote = await fetchDesktopSyncState(connection.baseUrl, token, connection.certificateFingerprint);
if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) {
throw new DesktopSyncUnsupportedError();
}
@@ -556,7 +557,12 @@ async function runDesktopSyncOnce(): Promise<{
payload.playlistUpserts.length > 0 ||
payload.playlistDeletes.length > 0;
if (hasDiff) {
- const result = await postDesktopSyncApply(connection.baseUrl, token, payload);
+ const result = await postDesktopSyncApply(
+ connection.baseUrl,
+ token,
+ connection.certificateFingerprint,
+ payload
+ );
summary.pushedToDesktop = true;
summary.favoritesAdded += result.favorites.added;
summary.favoritesPending += result.favorites.pending;
diff --git a/src/services/desktopSyncPolicy.test.mts b/src/services/desktopSyncPolicy.test.mts
new file mode 100644
index 0000000..eea1462
--- /dev/null
+++ b/src/services/desktopSyncPolicy.test.mts
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ canStartDesktopSync,
+ decideDesktopSyncEnabled,
+ identityMatchesPinnedConnection,
+} from './desktopSyncPolicy.ts';
+
+test('legacy automatic-sync migration preserves explicit off and defaults to on', () => {
+ assert.equal(decideDesktopSyncEnabled(null, '0'), false);
+ assert.equal(decideDesktopSyncEnabled(null, '1'), true);
+ assert.equal(decideDesktopSyncEnabled(null, null), true);
+ assert.equal(decideDesktopSyncEnabled('0', '1'), false);
+});
+
+test('master switch gates manual, automatic, and follow-up starts', () => {
+ assert.equal(canStartDesktopSync(false, 'idle'), false);
+ assert.equal(canStartDesktopSync(false, 'error'), false);
+ assert.equal(canStartDesktopSync(true, 'syncing'), false);
+ assert.equal(canStartDesktopSync(true, 'idle'), true);
+});
+
+test('discovered address requires v3 identity and the paired endpoint UUID', () => {
+ assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'endpoint-1'), true);
+ assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'impostor'), false);
+ assert.equal(identityMatchesPinnedConnection('endpoint-1', 2, 'endpoint-1'), false);
+});
diff --git a/src/services/desktopSyncPolicy.ts b/src/services/desktopSyncPolicy.ts
new file mode 100644
index 0000000..abcf3a6
--- /dev/null
+++ b/src/services/desktopSyncPolicy.ts
@@ -0,0 +1,23 @@
+export function decideDesktopSyncEnabled(
+ masterSetting: string | null,
+ legacyAutoSetting: string | null
+): boolean {
+ if (masterSetting !== null) return masterSetting !== '0';
+ // Conservative migration: an explicit legacy 0 stays off; 1 or absence is on.
+ return legacyAutoSetting !== '0';
+}
+
+export function canStartDesktopSync(
+ enabled: boolean,
+ status: 'idle' | 'syncing' | 'error'
+): boolean {
+ return enabled && status !== 'syncing';
+}
+
+export function identityMatchesPinnedConnection(
+ expectedEndpointUuid: string | null,
+ protocolVersion: number,
+ observedEndpointUuid: string | null
+): boolean {
+ return protocolVersion === 3 && Boolean(expectedEndpointUuid) && observedEndpointUuid === expectedEndpointUuid;
+}
diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts
index 9da18ed..6f0ed32 100644
--- a/src/stores/desktopRemoteStore.ts
+++ b/src/stores/desktopRemoteStore.ts
@@ -12,7 +12,6 @@ import {
fetchDesktopRemoteNowPlaying,
fetchDesktopRemotePairingStatus,
fetchDesktopRemoteQueue,
- parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
requestDesktopRemotePinPairing,
sendDesktopRemoteControl,
@@ -22,14 +21,18 @@ import {
import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
import {
clearDesktopRemotePairing,
+ clearDesktopRemoteSecurityUpgradeNotice,
+ getDesktopRemoteCredentials,
getDesktopRemoteConnection,
- getDesktopRemoteToken,
+ getDesktopRemoteSecurityUpgradeNotice,
setDesktopRemoteConnection,
- setDesktopRemoteToken,
+ setDesktopRemoteCredentials,
} from '@/services/desktopRemoteCredentials';
import { openLibraryDb } from '@/db/database';
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
+import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
+import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import type {
DesktopRemoteConnection,
@@ -58,11 +61,14 @@ interface PairingAttempt {
baseUrl: string;
pollToken: string;
expiresAt: number;
+ certificateFingerprint: string;
+ endpointUuid: string;
}
interface PinPairingAttempt {
baseUrl: string;
requestId: string;
+ attemptId: string;
expiresAt: number;
desktopName: string | null;
}
@@ -89,7 +95,7 @@ interface DesktopRemoteStore {
requestPinPairing: (baseUrl: string) => Promise;
confirmPinPairing: (pin: string) => Promise;
pairFromInput: (input: string) => Promise;
- pairManual: (baseUrl: string, ticket: string) => Promise;
+ pairManual: (baseUrl: string) => Promise;
connect: () => Promise;
reconnect: () => Promise;
disconnect: () => void;
@@ -148,6 +154,13 @@ function errorMessage(error: unknown): string {
return 'Desktop remote request failed.';
}
+function isDesktopCertificateError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
+ return message.includes('certificate changed') ||
+ message.includes('certificate mismatch') ||
+ message.includes('pinning failure');
+}
+
function mergeSnapshotArtwork(
previous: DesktopRemoteNowPlayingSnapshot | null,
next: DesktopRemoteNowPlayingSnapshot
@@ -168,25 +181,39 @@ function mergeSnapshotArtwork(
async function persistConnectedDesktop(
baseUrl: string,
- token: string,
+ credentials: { controlToken: string; syncToken: string; issuedAt: number },
+ certificateFingerprint: string,
deviceId: string | null,
- identity: DesktopRemoteIdentity | null
+ identity: DesktopRemoteIdentity | null,
+ expectedEndpointUuid?: string
): Promise {
- const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl));
+ const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl, certificateFingerprint));
+ if (!resolvedIdentity || resolvedIdentity.protocolVersion !== 3) {
+ throw new Error('Desktop does not support secure protocol v3.');
+ }
+ if (expectedEndpointUuid && resolvedIdentity.endpointUuid !== expectedEndpointUuid) {
+ throw new Error('Desktop identity does not match the pairing QR.');
+ }
const now = Date.now();
const connection: DesktopRemoteConnection = {
id: stableConnectionId(resolvedIdentity, baseUrl),
baseUrl,
+ certificateFingerprint,
+ scopes: ['control', 'sync'],
+ credentialIssuedAt: credentials.issuedAt,
+ credentialRotatedAt: credentials.issuedAt,
+ securityUpgradeState: 'none',
endpointUuid: resolvedIdentity?.endpointUuid ?? null,
desktopName: displayName(resolvedIdentity, baseUrl),
- protocolVersion: resolvedIdentity?.protocolVersion ?? 1,
+ protocolVersion: 3,
deviceId,
pairedAt: now,
lastConnectedAt: now,
};
await Promise.all([
setDesktopRemoteConnection(connection),
- setDesktopRemoteToken(token),
+ setDesktopRemoteCredentials(credentials),
+ clearDesktopRemoteSecurityUpgradeNotice(),
]);
return connection;
}
@@ -199,7 +226,7 @@ export const useDesktopRemoteStore = create((set, get) => {
const requestKey = `${connection.id}:${track.id}`;
if (inlineArtworkRequestKey === requestKey) return;
inlineArtworkRequestKey = requestKey;
- void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true).then(
+ void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, connection.certificateFingerprint, true).then(
(inlineSnapshot) => {
inlineArtworkRequestKey = null;
set((state) => ({
@@ -219,7 +246,7 @@ export const useDesktopRemoteStore = create((set, get) => {
snapshotPollTimer = setInterval(() => {
const { connection, token, connectionState } = get();
if (!connection || !token || connectionState === 'connecting') return;
- void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
+ void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, connection.certificateFingerprint).then(
(snapshot) => {
const wasConnected = get().connectionState === 'connected';
set((state) => ({
@@ -233,6 +260,10 @@ export const useDesktopRemoteStore = create((set, get) => {
refreshInlineArtwork();
},
(error) => {
+ if (isDesktopCertificateError(error)) {
+ void get().forget().then(() => set({ errorMessage: 'Desktop certificate changed—pair again.' }));
+ return;
+ }
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
void get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -262,18 +293,26 @@ export const useDesktopRemoteStore = create((set, get) => {
const pairing = get().pairing;
if (!pairing) return;
try {
- const status = await fetchDesktopRemotePairingStatus(pairing.baseUrl, pairing.pollToken);
- if (status.state === 'approved' && status.token?.trim()) {
+ const status = await fetchDesktopRemotePairingStatus(
+ pairing.baseUrl,
+ pairing.pollToken,
+ pairing.certificateFingerprint
+ );
+ const controlToken = status.controlToken?.trim() || status.token?.trim();
+ const syncToken = status.syncToken?.trim();
+ if (status.state === 'approved' && controlToken && syncToken) {
clearPairingPoll();
const connection = await persistConnectedDesktop(
pairing.baseUrl,
- status.token.trim(),
+ { controlToken, syncToken, issuedAt: status.issuedAt ?? Date.now() },
+ pairing.certificateFingerprint,
status.deviceId ?? null,
- status.identity ?? null
+ status.identity ?? null,
+ pairing.endpointUuid
);
set({
connection,
- token: status.token.trim(),
+ token: controlToken,
pairing: null,
pinPairing: null,
connectionState: 'connecting',
@@ -321,7 +360,12 @@ export const useDesktopRemoteStore = create((set, get) => {
}
};
- const claimPairing = async (baseUrl: string, ticket: string) => {
+ const claimPairing = async (
+ baseUrl: string,
+ ticket: string,
+ certificateFingerprint: string,
+ endpointUuid: string
+ ) => {
clearPairingPoll();
stopRealtime();
set({
@@ -336,6 +380,7 @@ export const useDesktopRemoteStore = create((set, get) => {
const claim = await claimDesktopRemotePairingTicket(
baseUrl,
ticket,
+ certificateFingerprint,
defaultDesktopRemoteDeviceName()
);
if (!claim.pollToken) throw new Error('Desktop did not return a pairing poll token.');
@@ -345,6 +390,8 @@ export const useDesktopRemoteStore = create((set, get) => {
baseUrl,
pollToken: claim.pollToken,
expiresAt: claim.expiresAt,
+ certificateFingerprint,
+ endpointUuid,
},
message: 'Approve this phone in Astra on desktop.',
});
@@ -380,6 +427,7 @@ export const useDesktopRemoteStore = create((set, get) => {
pinPairing: {
baseUrl,
requestId: request.requestId,
+ attemptId: request.attemptId,
expiresAt: request.expiresAt,
desktopName,
},
@@ -406,19 +454,22 @@ export const useDesktopRemoteStore = create((set, get) => {
}
set({ connectionState: 'pairing', message: 'Confirming PIN...', errorMessage: '' });
try {
- const status = await confirmDesktopRemotePinPairing(attempt.baseUrl, attempt.requestId, normalizedPin);
- if (status.state !== 'approved' || !status.token?.trim()) {
+ const status = await confirmDesktopRemotePinPairing(attempt.attemptId, normalizedPin);
+ const controlToken = status.controlToken?.trim() || status.token?.trim();
+ const syncToken = status.syncToken?.trim();
+ if (status.state !== 'approved' || !controlToken || !syncToken || !status.certificateFingerprint) {
throw new Error('Desktop did not approve this PIN pairing.');
}
const connection = await persistConnectedDesktop(
attempt.baseUrl,
- status.token.trim(),
+ { controlToken, syncToken, issuedAt: status.issuedAt ?? Date.now() },
+ status.certificateFingerprint,
status.deviceId ?? null,
status.identity ?? null
);
set({
connection,
- token: status.token.trim(),
+ token: controlToken,
pairing: null,
pinPairing: null,
connectionState: 'connecting',
@@ -428,7 +479,10 @@ export const useDesktopRemoteStore = create((set, get) => {
void usePlaybackTargetStore.getState().setTarget('desktop');
void get().connect();
} catch (error) {
- if (error instanceof DesktopRemoteHttpError && error.status === 401) {
+ if (
+ (error instanceof DesktopRemoteHttpError && error.status === 401) ||
+ errorMessage(error).toLowerCase().includes('wrong pin')
+ ) {
set({
connectionState: 'pinEntry',
message: `Enter the PIN shown on ${attempt.desktopName || 'Astra Desktop'}.`,
@@ -462,15 +516,20 @@ export const useDesktopRemoteStore = create((set, get) => {
init: async () => {
if (get().initialized) return;
- const [connection, token] = await Promise.all([
+ const [connection, credentials, securityUpgradeLabel] = await Promise.all([
getDesktopRemoteConnection(),
- getDesktopRemoteToken(),
+ getDesktopRemoteCredentials(),
+ getDesktopRemoteSecurityUpgradeNotice(),
]);
+ const token = credentials?.controlToken ?? null;
set({
initialized: true,
connection,
token,
connectionState: connection && token ? 'connecting' : 'unpaired',
+ errorMessage: securityUpgradeLabel
+ ? `Security upgrade required—pair again. Previously paired: ${securityUpgradeLabel}.`
+ : '',
});
if (connection && token) void get().connect();
},
@@ -489,6 +548,22 @@ export const useDesktopRemoteStore = create((set, get) => {
),
};
});
+ const connection = get().connection;
+ if (
+ connection?.endpointUuid && desktop.endpointUuid === connection.endpointUuid &&
+ desktop.baseUrl !== connection.baseUrl
+ ) {
+ void fetchDesktopRemoteIdentity(desktop.baseUrl, connection.certificateFingerprint).then(async (identity) => {
+ if (!identity || !identityMatchesPinnedConnection(
+ connection.endpointUuid,
+ identity.protocolVersion,
+ identity.endpointUuid
+ )) return;
+ const nextConnection = { ...connection, baseUrl: desktop.baseUrl };
+ await setDesktopRemoteConnection(nextConnection);
+ set({ connection: nextConnection });
+ });
+ }
}),
AstraDesktopDiscovery.addListener('onDesktopRemoteLost', (event) => {
set((state) => ({
@@ -520,23 +595,27 @@ export const useDesktopRemoteStore = create((set, get) => {
});
return;
}
- await claimPairing(parsed.baseUrl, parsed.ticket);
+ await claimPairing(
+ parsed.baseUrl,
+ parsed.ticket,
+ parsed.certificateFingerprint,
+ parsed.endpointUuid
+ );
},
- pairManual: async (baseUrl: string, ticket: string) => {
- const parsed = parseDesktopRemoteManualInput(baseUrl, ticket);
- if (!parsed) {
+ pairManual: async (baseUrl: string) => {
+ if (!baseUrl.trim().toLowerCase().startsWith('https://')) {
set({
connectionState: 'error',
- errorMessage: 'Enter a valid desktop URL and pairing code.',
+ errorMessage: 'Enter the HTTPS desktop URL shown by Astra.',
});
return;
}
- await claimPairing(parsed.baseUrl, parsed.ticket);
+ await requestPinPairing(baseUrl.trim().replace(/\/+$/, ''));
},
connect: async () => {
- const { connection, token } = get();
+ let { connection, token } = get();
if (!connection || !token) {
set({ connectionState: 'unpaired' });
return false;
@@ -544,18 +623,33 @@ export const useDesktopRemoteStore = create((set, get) => {
stopRealtime();
set({ connectionState: 'connecting', message: 'Connecting to desktop...', errorMessage: '' });
try {
- const snapshot = await fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true);
+ const storedCredentials = await getDesktopRemoteCredentials();
+ if (!storedCredentials) throw new Error('Desktop credentials are unavailable. Pair again.');
+ const fresh = await ensureDesktopRemoteCredentialsFresh(connection, storedCredentials);
+ connection = fresh.connection;
+ token = fresh.credentials.controlToken;
+ const snapshot = await fetchDesktopRemoteNowPlaying(
+ connection.baseUrl,
+ token,
+ connection.certificateFingerprint,
+ true
+ );
const nextConnection = { ...connection, lastConnectedAt: Date.now() };
await setDesktopRemoteConnection(nextConnection);
set({
connection: nextConnection,
+ token,
snapshot,
connectionState: 'connected',
message: '',
errorMessage: '',
});
useDesktopSyncStore.getState().maybeAutoSync('connected');
- stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
+ stopEventStream = startDesktopRemoteEventStream(
+ connection.baseUrl,
+ token,
+ connection.certificateFingerprint,
+ {
onSnapshot: (nextSnapshot) => {
const wasConnected = get().connectionState === 'connected';
set((state) => ({
@@ -583,11 +677,17 @@ export const useDesktopRemoteStore = create((set, get) => {
onError: () => {
scheduleSnapshotPoll();
},
- });
+ }
+ );
scheduleSnapshotPoll();
void get().refreshQueue();
return true;
} catch (error) {
+ if (isDesktopCertificateError(error)) {
+ await get().forget();
+ set({ errorMessage: 'Desktop certificate changed—pair again.' });
+ return false;
+ }
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -638,7 +738,13 @@ export const useDesktopRemoteStore = create((set, get) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
- await sendDesktopRemoteControl(connection.baseUrl, token, command, time);
+ await sendDesktopRemoteControl(
+ connection.baseUrl,
+ token,
+ connection.certificateFingerprint,
+ command,
+ time
+ );
set({ errorMessage: '' });
if (command === 'seek' && typeof time === 'number') {
set((state) => state.snapshot
@@ -646,6 +752,11 @@ export const useDesktopRemoteStore = create((set, get) => {
: {});
}
} catch (error) {
+ if (isDesktopCertificateError(error)) {
+ await get().forget();
+ set({ errorMessage: 'Desktop certificate changed—pair again.' });
+ return;
+ }
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -659,9 +770,17 @@ export const useDesktopRemoteStore = create((set, get) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
- const queue = await fetchDesktopRemoteQueue(connection.baseUrl, token);
+ const queue = await fetchDesktopRemoteQueue(
+ connection.baseUrl,
+ token,
+ connection.certificateFingerprint
+ );
set({ queue });
- } catch {
+ } catch (error) {
+ if (isDesktopCertificateError(error)) {
+ await get().forget();
+ set({ errorMessage: 'Desktop certificate changed—pair again.' });
+ }
// Protocol-1 desktops 404 here; the queue UI simply stays hidden.
}
},
@@ -670,9 +789,19 @@ export const useDesktopRemoteStore = create((set, get) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
- await sendDesktopRemotePlayQueueItem(connection.baseUrl, token, queueId);
+ await sendDesktopRemotePlayQueueItem(
+ connection.baseUrl,
+ token,
+ connection.certificateFingerprint,
+ queueId
+ );
set({ errorMessage: '' });
} catch (error) {
+ if (isDesktopCertificateError(error)) {
+ await get().forget();
+ set({ errorMessage: 'Desktop certificate changed—pair again.' });
+ return;
+ }
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
diff --git a/src/stores/desktopSyncStore.ts b/src/stores/desktopSyncStore.ts
index e686f19..81313d9 100644
--- a/src/stores/desktopSyncStore.ts
+++ b/src/stores/desktopSyncStore.ts
@@ -15,6 +15,7 @@ import {
} from '@/services/desktopSync';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
+import { canStartDesktopSync, decideDesktopSyncEnabled } from '@/services/desktopSyncPolicy';
import type {
DesktopSyncConflictResolution,
DesktopSyncPlaylistConflict,
@@ -23,13 +24,15 @@ import type {
const AUTO_SYNC_DEBOUNCE_MS = 5_000;
const AUTO_SYNC_MIN_INTERVAL_MS = 15 * 60_000;
-const AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto';
+const DESKTOP_SYNC_ENABLED_SETTING_KEY = 'desktop_sync_enabled_v1';
+const LEGACY_AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto';
export type DesktopSyncStatus = 'idle' | 'syncing' | 'error';
export type DesktopSyncAutoReason = 'discovery' | 'connected' | 'foreground';
interface DesktopSyncStore {
+ hydrated: boolean;
status: DesktopSyncStatus;
/** Wall-clock ms of the last successful sync with the paired desktop. */
lastSyncAt: number | null;
@@ -42,14 +45,13 @@ interface DesktopSyncStore {
errorMessage: string;
/** False once the paired desktop reported a pre-sync protocol version. */
supported: boolean;
- /** Automatic syncing (foreground/discovery). Manual + desktop-requested
- * syncs run regardless. */
- autoSyncEnabled: boolean;
+ /** Master gate for every desktop library-sync trigger. Remote playback is independent. */
+ desktopSyncEnabled: boolean;
hydrate: () => Promise;
syncNow: () => Promise;
dismissConflictPrompt: () => void;
- setAutoSyncEnabled: (enabled: boolean) => Promise;
+ setDesktopSyncEnabled: (enabled: boolean) => Promise;
resolveConflict: (
conflict: DesktopSyncPlaylistConflict,
resolution: DesktopSyncConflictResolution
@@ -66,6 +68,7 @@ let autoSyncDebounceTimer: ReturnType | null = null;
const promptedConflictUids = new Set();
export const useDesktopSyncStore = create((set, get) => ({
+ hydrated: false,
status: 'idle',
lastSyncAt: null,
lastSummary: null,
@@ -73,39 +76,49 @@ export const useDesktopSyncStore = create((set, get) => ({
conflictPromptVisible: false,
errorMessage: '',
supported: true,
- autoSyncEnabled: true,
+ desktopSyncEnabled: false,
hydrate: async () => {
try {
const db = await openLibraryDb();
- const autoSetting = await getSetting(db, AUTO_SYNC_SETTING_KEY);
- if (autoSetting !== null) {
- set({ autoSyncEnabled: autoSetting !== '0' });
+ const masterSetting = await getSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY);
+ const legacySetting = await getSetting(db, LEGACY_AUTO_SYNC_SETTING_KEY);
+ const enabled = decideDesktopSyncEnabled(masterSetting, legacySetting);
+ set({ desktopSyncEnabled: enabled });
+ if (masterSetting === null) {
+ await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
}
const connection = await getDesktopRemoteConnection();
- if (!connection) return;
- const stored = await getSetting(db, desktopSyncSettingKey(connection));
- const lastSyncAt = stored ? Number(stored) : NaN;
- if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) {
- set({ lastSyncAt });
+ if (connection) {
+ const stored = await getSetting(db, desktopSyncSettingKey(connection));
+ const lastSyncAt = stored ? Number(stored) : NaN;
+ if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) {
+ set({ lastSyncAt });
+ }
}
} catch {
// Hydration is best-effort; the first sync will set lastSyncAt.
+ } finally {
+ set({ hydrated: true });
}
},
- setAutoSyncEnabled: async (enabled) => {
- set({ autoSyncEnabled: enabled });
+ setDesktopSyncEnabled: async (enabled) => {
+ if (!enabled && autoSyncDebounceTimer !== null) {
+ clearTimeout(autoSyncDebounceTimer);
+ autoSyncDebounceTimer = null;
+ }
+ set({ desktopSyncEnabled: enabled });
try {
const db = await openLibraryDb();
- await setSetting(db, AUTO_SYNC_SETTING_KEY, enabled ? '1' : '0');
+ await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
} catch {
// The in-memory value still applies for this session.
}
},
syncNow: async () => {
- if (get().status === 'syncing') return;
+ if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return;
set({ status: 'syncing', errorMessage: '' });
try {
const summary = await runDesktopSync();
@@ -138,7 +151,7 @@ export const useDesktopSyncStore = create((set, get) => ({
},
resolveConflict: async (conflict, resolution) => {
- if (get().status === 'syncing') return;
+ if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return;
try {
await applyDesktopSyncConflictResolution(conflict, resolution);
} catch (error) {
@@ -163,14 +176,14 @@ export const useDesktopSyncStore = create((set, get) => ({
},
handleSyncRequest: () => {
- const { status } = get();
- if (status === 'syncing') return;
+ const { status, desktopSyncEnabled } = get();
+ if (!canStartDesktopSync(desktopSyncEnabled, status)) return;
void get().syncNow();
},
maybeAutoSync: (reason) => {
- const { status, lastSyncAt, supported, autoSyncEnabled } = get();
- if (!supported || !autoSyncEnabled) return;
+ const { status, lastSyncAt, supported, desktopSyncEnabled } = get();
+ if (!supported || !desktopSyncEnabled) return;
if (status === 'syncing') return;
if (AppState.currentState !== 'active') return;
if (lastSyncAt !== null && Date.now() - lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return;
@@ -181,6 +194,7 @@ export const useDesktopSyncStore = create((set, get) => ({
autoSyncDebounceTimer = null;
void (async () => {
const state = useDesktopSyncStore.getState();
+ if (!state.desktopSyncEnabled) return;
if (state.status === 'syncing') return;
if (AppState.currentState !== 'active') return;
if (state.lastSyncAt !== null && Date.now() - state.lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return;
@@ -189,7 +203,10 @@ export const useDesktopSyncStore = create((set, get) => ({
// user never asked for.
const connection = await getDesktopRemoteConnection();
if (!connection) return;
- const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
+ const identity = await fetchDesktopRemoteIdentity(
+ connection.baseUrl,
+ connection.certificateFingerprint
+ );
if (!identity) return;
void state.syncNow();
})();
diff --git a/src/types/desktopRemote.ts b/src/types/desktopRemote.ts
index 0914ee9..b453c09 100644
--- a/src/types/desktopRemote.ts
+++ b/src/types/desktopRemote.ts
@@ -1,4 +1,6 @@
-export const DESKTOP_REMOTE_PROTOCOL_VERSION = 2;
+export const DESKTOP_REMOTE_PROTOCOL_VERSION = 3;
+export type DesktopRemoteCredentialScope = 'control' | 'sync';
+export type DesktopRemoteSecurityUpgradeState = 'none' | 'repair-required';
export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
export type DesktopRemoteRepeatMode = 'none' | 'one' | 'all';
@@ -22,8 +24,14 @@ export interface DesktopRemoteIdentity {
}
export interface DesktopRemoteConnection extends DesktopRemoteIdentity {
+ protocolVersion: 3;
id: string;
baseUrl: string;
+ certificateFingerprint: string;
+ scopes: DesktopRemoteCredentialScope[];
+ credentialIssuedAt: number;
+ credentialRotatedAt: number;
+ securityUpgradeState: DesktopRemoteSecurityUpgradeState;
deviceId: string | null;
pairedAt: number;
lastConnectedAt: number | null;
@@ -77,7 +85,11 @@ export interface DesktopRemotePairingClaim {
identity: DesktopRemoteIdentity | null;
}
-export type DesktopRemotePinPairingRequest = DesktopRemotePairingClaim;
+export interface DesktopRemotePinPairingRequest extends DesktopRemotePairingClaim {
+ attemptId: string;
+ certificateFingerprint: string;
+ protocolVersion: 3;
+}
export type DesktopRemotePairingState =
| 'pending'
@@ -90,6 +102,11 @@ export interface DesktopRemotePairingStatus {
state: DesktopRemotePairingState;
expiresAt: number;
token?: string;
+ controlToken?: string;
+ syncToken?: string;
+ issuedAt?: number;
+ scopes?: DesktopRemoteCredentialScope[];
+ certificateFingerprint?: string;
deviceId?: string | null;
identity?: DesktopRemoteIdentity | null;
}
@@ -97,11 +114,16 @@ export interface DesktopRemotePairingStatus {
export interface DesktopRemotePairingInput {
baseUrl: string;
ticket: string;
+ endpointUuid: string;
+ protocolVersion: 3;
+ certificateFingerprint: string;
}
export interface DesktopRemoteDiscoveredDesktop extends DesktopRemoteIdentity {
name: string;
baseUrl: string;
+ certificateFingerprint: string;
+ transport: 'https';
address: string;
port: number;
lastSeenAt: number;
diff --git a/src/types/desktopSync.ts b/src/types/desktopSync.ts
index 72d2c0d..848fcc8 100644
--- a/src/types/desktopSync.ts
+++ b/src/types/desktopSync.ts
@@ -9,7 +9,7 @@
export const DESKTOP_SYNC_FORMAT = 1;
/** Desktop protocol version that introduced /v1/sync/* (and queue/shuffle). */
-export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 2;
+export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 3;
export interface SyncFavorite {
key: string;