aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
blob: f7094a0a0e8b280a057d7b1302e572998019b993 (plain) (blame)
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
        }
    }
}