1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
|
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> = 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
}
}
}
|