8.9 KiB · text History cc97953
import QtQuick

// A small Phoenix channels client (serializer vsn 2.0.0) over QtWebSockets
// — the same /socket the Android app speaks through JavaPhoenixClient. See
// docs/socket.md in the lambent_ex repo for the channels themselves.
//
// Frames are [join_ref, ref, topic, event, payload]. Joins and pushes are
// answered by "phx_reply" carrying the same ref; everything else is a
// server push and comes out of `message`.
//
// QtWebSockets is an optional QML module on most distributions, so the
// WebSocket is created at runtime: a missing module leaves `available`
// false and an install hint in `unavailableReason` instead of the whole
// widget failing to load.
Item {
    id: phx

    // ws://host:port/socket/websocket?vsn=2.0.0, or "" to stay offline
    property string url: ""

    property string connState: "idle"   // idle | connecting | live | offline
    property string lastError: ""
    readonly property bool live: connState === "live"
    readonly property bool available: ws !== null
    property string unavailableReason: ""

    property int heartbeatMs: 30000
    property int replyTimeoutMs: 10000

    signal message(string topic, string event, var payload)
    signal joined(string topic, var response)
    signal opened()

    property var ws: null
    property int ref: 0
    property var channels: ({})   // topic -> {params, joinRef, joined}
    property var pending: ({})    // ref -> {topic, callback, sentAt, join}
    property string heartbeatRef: ""
    property int attempt: 0

    // QtWebSockets' WebSocket.Status
    readonly property int sOpen: 1
    readonly property int sClosed: 3
    readonly property int sError: 4

    Component.onCompleted: {
        try {
            ws = Qt.createQmlObject("import QtWebSockets; WebSocket { active: false }", phx, "PhoenixWebSocket")
        } catch (e) {
            unavailableReason = i18n("The QtWebSockets QML module is missing. Install it (openSUSE: qt6-websockets-imports, Arch: qt6-websockets, Debian/Ubuntu: qml6-module-qtwebsockets) and restart plasmashell.")
            console.log("Lambent: QtWebSockets unavailable:", e)
            return
        }
        ws.textMessageReceived.connect(onFrame)
        ws.statusChanged.connect(onStatus)
        reconnect()
    }

    onUrlChanged: reconnect()

    function nextRef() {
        ref++
        return String(ref)
    }

    // ── transport ───────────────────────────────────────────────────────

    function reconnect() {
        if (!ws) return
        retry.stop()
        failPending(i18n("connection reset"))
        // idle first, so the close this causes doesn't schedule a retry
        connState = "idle"
        ws.active = false
        for (var t in channels) channels[t].joined = false
        if (url === "") return
        connState = "connecting"
        ws.url = url
        ws.active = true
    }

    function disconnect() {
        retry.stop()
        if (ws) ws.active = false
        connState = "idle"
    }

    function onStatus() {
        if (!ws) return
        if (ws.status === sOpen) {
            attempt = 0
            lastError = ""
            heartbeatRef = ""
            connState = "live"
            opened()
            for (var topic in channels) sendJoin(topic)
        } else if (ws.status === sClosed || ws.status === sError) {
            if (ws.status === sError && ws.errorString) lastError = ws.errorString
            for (var t in channels) channels[t].joined = false
            failPending(lastError || i18n("disconnected"))
            if (url !== "" && connState !== "idle") {
                connState = "offline"
                // 1, 2, 5, 10, 10… seconds
                var backoff = [1000, 2000, 5000, 10000]
                retry.interval = backoff[Math.min(attempt, backoff.length - 1)]
                attempt++
                retry.restart()
            }
        }
    }

    Timer {
        id: retry
        repeat: false
        onTriggered: phx.reconnect()
    }

    // An unanswered heartbeat means the socket is dead even if the OS has
    // not noticed yet (a suspend, a Wi-Fi hop): drop it and dial again.
    Timer {
        interval: phx.heartbeatMs
        running: phx.live
        repeat: true
        onTriggered: {
            if (phx.heartbeatRef !== "") {
                phx.lastError = i18n("heartbeat timed out")
                phx.reconnect()
                return
            }
            phx.heartbeatRef = phx.nextRef()
            phx.send(null, phx.heartbeatRef, "phoenix", "heartbeat", {})
        }
    }

    // Replies that never come (a push sent into a socket that died under
    // it) would otherwise leave their callbacks hanging forever.
    Timer {
        interval: 2000
        running: Object.keys(phx.pending).length > 0
        repeat: true
        onTriggered: {
            var now = Date.now()
            for (var r in phx.pending) {
                if (now - phx.pending[r].sentAt > phx.replyTimeoutMs) phx.resolve(r, false, { reason: i18n("timed out") })
            }
        }
    }

    function send(joinRef, ref, topic, event, payload) {
        if (!ws || ws.status !== sOpen) return false
        ws.sendTextMessage(JSON.stringify([joinRef, ref, topic, event, payload]))
        return true
    }

    function onFrame(text) {
        var frame
        try {
            frame = JSON.parse(text)
        } catch (e) {
            console.log("Lambent: unparseable frame", text)
            return
        }
        if (!Array.isArray(frame) || frame.length < 5) return
        var joinRef = frame[0], msgRef = frame[1], topic = frame[2], event = frame[3], payload = frame[4]

        if (event === "phx_reply") {
            if (topic === "phoenix" && msgRef === heartbeatRef) {
                heartbeatRef = ""
                return
            }
            var ok = payload && payload.status === "ok"
            resolve(msgRef, ok, payload ? payload.response : null)
            return
        }

        var ch = channels[topic]
        if (event === "phx_error" || event === "phx_close") {
            if (ch && joinRef === ch.joinRef) {
                ch.joined = false
                if (event === "phx_error") rejoin.restart()
            }
            return
        }

        // Pushes from a join we've since replaced are stale.
        if (ch && joinRef !== null && joinRef !== ch.joinRef) return
        message(topic, event, payload)
    }

    Timer {
        id: rejoin
        interval: 1000
        repeat: false
        onTriggered: {
            for (var topic in phx.channels) {
                if (!phx.channels[topic].joined) phx.sendJoin(topic)
            }
        }
    }

    function resolve(msgRef, ok, response) {
        var p = pending[msgRef]
        if (!p) return
        var copy = Object.assign({}, pending)
        delete copy[msgRef]
        pending = copy

        if (p.join) {
            var ch = channels[p.topic]
            if (ch && ch.joinRef === msgRef) {
                ch.joined = ok
                if (ok) joined(p.topic, response)
                else {
                    lastError = i18n("could not join %1", p.topic)
                    rejoin.restart()
                }
            }
        }
        if (p.callback) p.callback(ok, response || {})
    }

    function failPending(reason) {
        var old = pending
        pending = {}
        for (var r in old) {
            if (old[r].callback) old[r].callback(false, { reason: reason })
        }
    }

    // ── channels ────────────────────────────────────────────────────────

    // Joins now if connected, and again after every reconnect.
    function join(topic, params) {
        channels[topic] = { params: params || {}, joinRef: "", joined: false }
        if (live) sendJoin(topic)
    }

    function sendJoin(topic) {
        var ch = channels[topic]
        var r = nextRef()
        ch.joinRef = r
        ch.joined = false
        var copy = Object.assign({}, pending)
        copy[r] = { topic: topic, callback: null, sentAt: Date.now(), join: true }
        pending = copy
        send(r, r, topic, "phx_join", ch.params)
    }

    function isJoined(topic) {
        return !!channels[topic] && channels[topic].joined
    }

    // callback(ok, response). Commands are casts on the server, so ok
    // means accepted; the effect shows up in the next push.
    function push(topic, event, payload, callback) {
        var ch = channels[topic]
        if (!ch || !ch.joined) {
            if (callback) callback(false, { reason: i18n("not connected") })
            return
        }
        var r = nextRef()
        var copy = Object.assign({}, pending)
        copy[r] = { topic: topic, callback: callback || null, sentAt: Date.now(), join: false }
        pending = copy
        if (!send(ch.joinRef, r, topic, event, payload || {})) resolve(r, false, { reason: i18n("not connected") })
    }
}