package org.meshbay.client.bridge import org.json.JSONArray import org.meshbay.client.cast.CastChannels import org.meshbay.client.hub.HubClient import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks import java.net.Inet4Address import java.net.InetAddress /** * The enumerated channels, and nothing else (main.js `registerBridge`). * * A channel that takes a path, a URL to anywhere, or bytes to sign from the * page is the shape to avoid. What the desktop offers and a phone does not — * the local node, shared folders, the tray — is not here at all, and the shim * does not offer it either: `platform.js` decides what to show from whether a * bridge object exists, so an object that only refused would put screens on * the page that fail when used. */ class Channels( private val hub: HubClient, private val onHubChanged: () -> Unit, private val hasCatalogue: (String) -> Boolean, private val keys: KeyChannels? = null, private val saves: SaveSinks? = null, private val cast: CastChannels? = null, private val onPlayback: (Boolean) -> Unit = {}, ) { @Volatile var locale = "en" private set fun call(channel: String, args: JSONArray): Any? = when (channel) { "hub:set" -> hub.setBase(args.optString(0, "")).also { onHubChanged() } "hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1)) "ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray()) "ui:locale" -> setLocale(args.optString(0, "")) "playback:keep-alive" -> { onPlayback(args.optBoolean(0, false)); true } // Where downloads go, chosen once; a display name, never a URI. "folder:choose" -> saves().chooseFolder() "folder:get" -> saves().getFolder() "folder:forget" -> saves().forgetFolder() // A sink the page refers to by an opaque id. "save:begin" -> saves().begin(args.optString(0, ""), args.optJSONObject(1)?.optBoolean("auto", false) ?: false) "save:write" -> { // the base64 path, for a WebView without ArrayBuffer messages val bytes = java.util.Base64.getDecoder().decode(args.optString(1, "")) saves().write(args.optLong(0, -1), bytes, 0, bytes.size) } "save:end" -> saves().end(args.optLong(0, -1)) "save:abort" -> saves().abort(args.optLong(0, -1)) "save:open" -> saves().open(args.optLong(0, -1)) else -> when { keys != null && keys.handles(channel) -> keys.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) else -> throw Refused("Refused: no such channel") } } private fun saves() = saves ?: throw Refused("Refused: no such channel") /** A binary message: one write, its bytes left where the message put them. */ fun binary(frame: BinaryFrame): Any? = when (frame.channel) { BinaryFrame.SAVE_WRITE -> saves().write(frame.handle, frame.bytes, frame.offset, frame.length) BinaryFrame.CAST_PUSH -> (cast ?: throw Refused("Refused: no such channel")).push(frame.bytes, frame.offset, frame.length) BinaryFrame.CAST_FILE -> (cast ?: throw Refused("Refused: no such channel")).writeFile(frame.bytes, frame.offset, frame.length) else -> throw Refused("Refused: no such channel") } /** * Calls whose order is part of their meaning: a relay start, the segments * pushed after it, a stop. The page does not await each push, so on a pool * they could overtake one another; these run on one thread, in arrival order. */ fun ordered(channel: String) = cast?.ordered(channel) == true fun ordered(frame: BinaryFrame) = frame.channel == BinaryFrame.CAST_PUSH || frame.channel == BinaryFrame.CAST_FILE private fun setLocale(code: String): String { // A code, never text, and only one the package has a catalogue for. if (LOCALE.matches(code) && hasCatalogue(code)) locale = code return locale } companion object { private val LOCALE = Regex("^[a-z]{2}(-[A-Z]{2})?$") private val STUN = Regex("^(stuns?):(\\[?[^\\]]+\\]?|[^:]+):(\\d+)$") /** * `stun:host:port` → `stun:ip:port`. Chromium's socket manager fails * STUN hostnames outright behind some resolvers, and the page cannot do * DNS. Unresolvable entries are dropped, literals pass through. */ fun resolveStun(urls: JSONArray, lookup: (String) -> Array = InetAddress::getAllByName): JSONArray { val out = JSONArray() for (i in 0 until urls.length()) { val u = urls.optString(i) val m = STUN.matchEntire(u) if (m == null) { out.put(u); continue } val (scheme, host, port) = m.destructured if (Regex("^[\\d.]+$").matches(host) || host.contains(':')) { out.put(u); continue } val ip = try { lookup(host).firstOrNull { it is Inet4Address }?.hostAddress } catch (e: Exception) { null } if (ip != null) out.put("$scheme:$ip:$port") } return out } } }