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") })
}
}
neiam
/lambent-ex-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
8.9 KiB · text
History
cc97953