package dev.stave.upnp
import dev.stave.domain.model.MediaServer
import dev.stave.domain.model.RemoteRenderer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.jupnp.DefaultUpnpServiceConfiguration
import org.jupnp.UpnpService
import org.jupnp.controlpoint.ControlPoint
import org.jupnp.model.message.header.DeviceTypeHeader
import org.jupnp.model.meta.LocalDevice
import org.jupnp.model.meta.RemoteDevice
import org.jupnp.model.types.UDADeviceType
import org.jupnp.registry.DefaultRegistryListener
import org.jupnp.registry.Registry
import java.net.NetworkInterface
/**
* Desktop UPnP holder. Instrumented with stdout diagnostics.
*
* Critical jUPnP pitfall that bit us once already: `UpnpServiceImpl()` (no-arg) leaves
* `configuration` null and `startup()` NPEs at line 256 dereferencing `configuration.getClass()`.
* The no-arg constructor exists only so subclasses like `AndroidUpnpServiceImpl` can inject
* the configuration later. From plain-JVM code you MUST pass a `UpnpServiceConfiguration` —
* we use `DefaultUpnpServiceConfiguration`.
*
* Interface scoping: if the machine has multiple UP interfaces (VPN, docker, libvirt), jUPnP's
* default auto-selection can land on the wrong one. Before constructing the service we set
* the `org.jupnp.network.useInterfaces` system property to a comma-separated list of the
* interfaces we're confident are "real LAN" — UP, non-loopback, MULTICAST-capable, and
* whose name doesn't match known virtual/VPN patterns.
*/
class DesktopUpnpServiceHolder : UpnpServiceHolder {
val scope = CoroutineScope(SupervisorJob())
private val _servers = MutableStateFlow<List<MediaServer>>(emptyList())
override val servers: StateFlow<List<MediaServer>> = _servers.asStateFlow()
private val _renderers = MutableStateFlow<List<RemoteRenderer>>(emptyList())
override val renderers: StateFlow<List<RemoteRenderer>> = _renderers.asStateFlow()
private var service: UpnpService? = null
override val controlPoint: ControlPoint?
get() = service?.controlPoint
private val listener = object : DefaultRegistryListener() {
override fun remoteDeviceAdded(registry: Registry, device: RemoteDevice) {
val udn = device.identity.udn.identifierString
val kind = "${device.type.namespace}:${device.type.type}:${device.type.version}"
val name = device.details?.friendlyName ?: "(no friendly name)"
log("Remote device seen: $name [$kind] udn=$udn")
if (device.type.namespace == UDADeviceType.DEFAULT_NAMESPACE) {
when (device.type.type) {
"MediaServer" -> {
log(" → MediaServer; adding to server list")
// update {}: jUPnP calls this from several threads; a
// plain read-modify-write lost one of two devices that
// announced together (Team 3 P2-10).
val server = device.toMediaServer()
_servers.update { l -> l.filterNot { it.udn == udn } + server }
}
"MediaRenderer" -> {
// Skip the UDN we generated for ourselves — jUPnP doesn't
// typically loop back local devices via the remote listener
// (LocalDevice and RemoteDevice are separate registry shelves)
// but defensive filter in case a future jUPnP version does.
log(" → MediaRenderer; adding to renderer list")
val renderer = device.toRemoteRenderer()
_renderers.update { l -> l.filterNot { it.udn == udn } + renderer }
}
else -> { /* other types ignored — Earhorn doesn't drive Light, BinaryControl, etc. */ }
}
}
}
override fun remoteDeviceRemoved(registry: Registry, device: RemoteDevice) {
val udn = device.identity.udn.identifierString
log("Remote device removed: udn=$udn")
_servers.update { l -> l.filterNot { it.udn == udn } }
_renderers.update { l -> l.filterNot { it.udn == udn } }
}
}
override fun start() {
log("=== DesktopUpnpServiceHolder.start() ===")
logEnvironment()
configureInterfaces()
try {
if (service != null) {
log("Already started; firing a fresh M-SEARCH.")
service?.controlPoint?.let { searchByType(it) }
return
}
// Resolve the streaming port. We persist whatever port we pick on
// first run to ~/.local/share/earhorn/upnp.port (XDG_DATA_HOME-aware)
// and reuse it on subsequent runs. Random ephemeral ports change the
// SSDP LOCATION URL on every restart, and UPnP control points like
// BubbleUPnP cache the renderer endpoint by UDN — so a fresh port
// means the first play attempt after restart fails with a stale-cache
// 501 until the control point is force-killed. Persisting the port
// keeps the LOCATION URL stable across runs.
val port = resolveStreamingPort()
log("Constructing UpnpServiceImpl(DefaultUpnpServiceConfiguration(port=$port))…")
// Event subscriptions must call back the subscriber itself (CallStranger).
val svc = PeerOnlySubscriptionsUpnpService(DefaultUpnpServiceConfiguration(port))
log("Starting jUPnP (UpnpService.startup())…")
svc.startup()
log("jUPnP stack is up.")
svc.registry.addListener(listener)
val seeded = svc.registry.remoteDevices?.size ?: 0
log("Seeding from registry: $seeded existing device(s)")
svc.registry.remoteDevices?.forEach { listener.remoteDeviceAdded(svc.registry, it) }
service = svc
log("Firing initial SSDP M-SEARCH (ssdp:all)…")
searchByType(svc.controlPoint)
log("Discovery is now running. Devices will appear above as they respond.")
} catch (t: Throwable) {
log("!!! start() failed — discovery disabled this session")
t.printStackTrace(System.out)
}
}
override fun stop() {
log("DesktopUpnpServiceHolder.stop()")
service?.registry?.removeListener(listener)
runCatching { service?.shutdown() }
service = null
_servers.value = emptyList()
_renderers.value = emptyList()
}
override fun rescan() {
log("DesktopUpnpServiceHolder.rescan() — firing SSDP M-SEARCH")
val svc = service ?: run {
log(" (stack wasn't up — starting now)")
start()
return
}
runCatching { searchByType(svc.controlPoint) }
}
/** design decision: ask music servers and speakers, not everyone.
* A device answers for its own type and every older version of it. */
private fun searchByType(cp: ControlPoint) {
cp.search(DeviceTypeHeader(UDADeviceType("MediaServer", 1)))
cp.search(DeviceTypeHeader(UDADeviceType("MediaRenderer", 1)))
}
override fun findDevice(udn: String): RemoteDevice? =
service?.registry?.remoteDevices?.firstOrNull {
it.identity.udn.identifierString == udn
}
override fun registerLocalDevice(device: LocalDevice): Boolean {
val svc = service ?: run {
log("registerLocalDevice: stack not up; skipping")
return false
}
val udn = device.identity.udn.identifierString
// Idempotent: jUPnP's registry.addDevice() throws on duplicate, so check first.
val existing = svc.registry.localDevices?.any {
it.identity.udn.identifierString == udn
} ?: false
if (existing) {
log("registerLocalDevice: $udn already registered; no-op")
return true
}
return runCatching {
svc.registry.addDevice(device)
val name = device.details?.friendlyName ?: "(no friendly name)"
log("Registered LocalDevice '$name' udn=$udn — visible to LAN")
true
}.getOrElse { t ->
log("registerLocalDevice failed: ${t.message}")
t.printStackTrace(System.out)
false
}
}
override fun unregisterLocalDevice(device: LocalDevice) {
val svc = service ?: return
val udn = device.identity.udn.identifierString
runCatching {
svc.registry.removeDevice(device)
log("Unregistered LocalDevice udn=$udn (sent ssdp:byebye)")
}.onFailure { t ->
// Most likely "device not in registry" — fine, that's what we wanted.
log("unregisterLocalDevice: ${t.message}")
}
}
private fun RemoteDevice.toMediaServer(): MediaServer {
val details = details
return MediaServer(
udn = identity.udn.identifierString,
friendlyName = details?.friendlyName ?: displayString,
manufacturer = details?.manufacturerDetails?.manufacturer,
modelName = details?.modelDetails?.modelName,
modelDescription = details?.modelDetails?.modelDescription,
baseUrl = identity.descriptorURL.toExternalForm(),
iconUrl = icons?.firstOrNull()?.uri?.toString()
)
}
private fun RemoteDevice.toRemoteRenderer(): RemoteRenderer {
val details = details
return RemoteRenderer(
udn = identity.udn.identifierString,
friendlyName = details?.friendlyName ?: displayString,
manufacturer = details?.manufacturerDetails?.manufacturer,
modelName = details?.modelDetails?.modelName,
modelDescription = details?.modelDetails?.modelDescription,
baseUrl = identity.descriptorURL.toExternalForm(),
iconUrl = icons?.firstOrNull()?.uri?.toString()
)
}
// ---- Interface scoping ------------------------------------------------------------------
/**
* Restrict jUPnP to real LAN interfaces by setting `org.jupnp.network.useInterfaces`
* with a comma-separated list of names.
*
* We include interfaces that are: UP, not loopback, multicast-capable, not obviously
* virtual/VPN/container by name. Anything else is excluded so jUPnP never considers it.
*
* If the user has already set the property themselves, we respect their choice.
*
* With no such interface we do NOT let jUPnP auto-select (Team 4 #17): that
* picks every interface, VPN and tailnet included, for discovery, the stream
* server and the published renderer. We name a non-existent interface
* instead, so nothing is exposed and the log says why.
*/
private fun configureInterfaces() {
val existing = System.getProperty(JUPNP_USE_INTERFACES_PROP)
if (!existing.isNullOrBlank() && existing != interfacesWeSet) {
log("Respecting existing $JUPNP_USE_INTERFACES_PROP='$existing'")
return
}
val chosen = runCatching {
NetworkInterface.getNetworkInterfaces().toList().filter { nif ->
nif.isUp &&
!nif.isLoopback &&
nif.supportsMulticast() &&
!isVirtualByName(nif.name)
}.map { it.name }
}.getOrDefault(emptyList())
val joined = if (chosen.isEmpty()) {
log("No LAN interface found (only loopback, VPN or virtual ones are up) — " +
"music servers and speakers can't be found until one is connected.")
NO_INTERFACE
} else chosen.joinToString(",")
System.setProperty(JUPNP_USE_INTERFACES_PROP, joined)
interfacesWeSet = joined
log("Set $JUPNP_USE_INTERFACES_PROP=$joined")
}
/** What [configureInterfaces] last wrote, so a later start re-evaluates our
* own choice (Wi-Fi may have come up since) but never the user's. */
private var interfacesWeSet: String? = null
// ---- Diagnostics ------------------------------------------------------------------------
private fun log(msg: String) {
println("[EarhornUPnP] $msg")
}
private fun logEnvironment() {
log("Java: ${System.getProperty("java.version")} " +
"vendor=${System.getProperty("java.vendor")}")
log("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")} " +
"arch=${System.getProperty("os.arch")}")
log("preferIPv4Stack=${System.getProperty("java.net.preferIPv4Stack")} " +
"preferIPv6Addresses=${System.getProperty("java.net.preferIPv6Addresses")} " +
"org.jupnp.network.useIPv6=${System.getProperty("org.jupnp.network.useIPv6")}")
log("Available network interfaces:")
var hasActiveVirtual = false
try {
NetworkInterface.getNetworkInterfaces()?.toList()?.forEach { nif ->
val flags = buildList {
if (nif.isUp) add("UP") else add("DOWN")
if (nif.isLoopback) add("LOOPBACK")
if (nif.isVirtual) add("VIRTUAL")
if (nif.supportsMulticast()) add("MULTICAST")
}.joinToString(",")
val v4 = nif.inetAddresses?.toList().orEmpty()
.filter { it.address.size == 4 }
.joinToString(",") { it.hostAddress }
log(" ${nif.name}${if (nif.displayName != nif.name) " (${nif.displayName})" else ""} " +
"[$flags] v4=[$v4]")
// Only flag interfaces that could actually interfere: UP, not loopback,
// MULTICAST-capable, and matching a virtual/VPN name pattern.
if (nif.isUp && !nif.isLoopback && nif.supportsMulticast() &&
isVirtualByName(nif.name)
) hasActiveVirtual = true
}
} catch (t: Throwable) {
log(" (failed to enumerate: ${t.message})")
}
if (hasActiveVirtual) {
log("Note: a virtual/VPN interface is UP and multicast-capable. jUPnP may bind " +
"to it instead of the real LAN. useInterfaces filtering will exclude it.")
}
}
companion object {
private const val JUPNP_USE_INTERFACES_PROP = "org.jupnp.network.useInterfaces"
/** An interface name that matches nothing: jUPnP then binds nowhere. */
private const val NO_INTERFACE = "earhorn-no-lan-interface"
/**
* Resolve the streaming port. On first run, picks an ephemeral free port and
* persists it to the user data dir. On subsequent runs, reads the persisted
* port, verifies it's still bindable, and reuses it. If the persisted port is
* unreadable, corrupt, or no longer bindable (e.g. some other process took it),
* we fall back to a fresh ephemeral pick and overwrite the file.
*
* Why persist: random ephemeral ports change the SSDP LOCATION URL on every
* restart. UPnP control points cache renderer endpoints by UDN — so a fresh
* port means the first play attempt after restart fails with a stale-cache
* error until the user force-kills the control point. A stable port across
* runs eliminates that whole class of bug.
*/
private fun resolveStreamingPort(): Int {
val portFile = userDataDir().resolve("upnp.port")
val cached = runCatching { portFile.readText().trim().toIntOrNull() }.getOrNull()
if (cached != null && cached in 1..65535 && isPortFree(cached)) {
// Don't take the port now — there's still a tiny TOCTOU between this
// and jUPnP's bind, same as the ephemeral path. In practice fine.
return cached
}
val fresh = pickFreeTcpPort()
runCatching {
portFile.parentFile?.mkdirs()
portFile.writeText(fresh.toString())
}.onFailure { t ->
// Persisting failed (read-only home, permissions, etc.) — the port
// still works for this run; we just won't get the cross-run stability.
// Non-fatal, log and continue.
System.err.println(
"[EarhornUPnP] Could not persist streaming port to $portFile: " +
"${t.javaClass.simpleName}: ${t.message}"
)
}
return fresh
}
/**
* Test whether a TCP port is currently bindable on the wildcard address.
* Returns true if we successfully bound (and immediately released) the port,
* false if anything threw (BindException for in-use, SecurityException for
* privileged ports under a security manager, etc.).
*/
private fun isPortFree(port: Int): Boolean = runCatching {
java.net.ServerSocket(port).close()
true
}.getOrDefault(false)
/**
* User data directory for Earhorn, following the XDG Base Directory spec on
* Linux (where this desktop app lives). Honors `$XDG_DATA_HOME` if set, else
* falls back to `~/.local/share`. The directory is NOT created here —
* callers materialize it lazily via `parentFile.mkdirs()` only when writing.
*/
private fun userDataDir(): java.io.File {
val xdg = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }
?: (System.getProperty("user.home") + "/.local/share")
return java.io.File(xdg, "earhorn")
}
/**
* Ask the OS for a free TCP port by opening a ServerSocket on port 0, reading the
* assigned port, and immediately closing the socket. There's a tiny TOCTOU window
* between close and jUPnP's bind, but in practice this is reliable for an ephemeral
* port grab on a desktop. Used by [resolveStreamingPort] as the fallback when no
* persisted port is available or the persisted one is no longer bindable.
*/
private fun pickFreeTcpPort(): Int =
java.net.ServerSocket(0).use { it.localPort }
/** Interface name prefixes we treat as "not real LAN". */
private val VIRTUAL_PREFIXES = listOf(
"docker", // docker bridge
"br-", // docker custom networks
"virbr", // libvirt
"vmnet", // VMware
"vboxnet", // VirtualBox
"tailscale", // Tailscale
"zt", // ZeroTier
"wg", // WireGuard (covers wgpia0, wg0, etc.)
"tun", // OpenVPN / generic tun
"tap", // generic tap
"ppp", // PPP / older VPN
"utun", // macOS tun (if someone runs this on mac)
"veth", // container veth pair host side
)
private fun isVirtualByName(name: String): Boolean =
VIRTUAL_PREFIXES.any { name.startsWith(it) }
}
}