-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathGmClient.qml
More file actions
219 lines (192 loc) · 6.8 KB
/
Copy pathGmClient.qml
File metadata and controls
219 lines (192 loc) · 6.8 KB
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
import QtQuick
import Quickshell
import Quickshell.Io
// Transport to gmessagesd. Holds one Unix socket, correlates request IDs to
// callbacks, and republishes daemon pushes as signals. Everything the panel
// knows about the account comes through here.
Item {
id: root
property string socketPath: ""
// Set from settings; when true a failed connect tries to start the service
// once before falling back to telling the user.
property bool autostart: true
property string serviceName: "gmessagesd.service"
readonly property bool connected: sockLoader.item ? sockLoader.item.connected : false
// Mirrors wire.Status. Kept as a plain object so bindings see whole-object
// replacement rather than partial mutation.
property var status: ({ state: "disconnected", unread: 0, phoneOK: false, qrURL: "", error: "" })
readonly property string state: status && status.state ? status.state : "disconnected"
readonly property int unread: status && status.unread ? status.unread : 0
property var conversations: []
signal messageReceived(var message)
signal conversationUpdated(var conversation)
signal paired()
signal transportError(string message)
property int _nextId: 1
property var _pending: ({})
property bool _triedAutostart: false
// ---- public API ----
// call sends a request; callback(ok, resultOrError) fires when the daemon
// answers. Calls made while disconnected fail fast rather than queue, so
// the UI never shows a spinner that cannot resolve.
function call(method, params, callback) {
var s = sockLoader.item
if (!s || !s.connected) {
if (callback) callback(false, "not connected to gmessagesd")
return
}
var id = String(_nextId++)
if (callback) _pending[id] = callback
var frame = { id: id, method: method }
if (params !== undefined && params !== null) frame.params = params
s.write(JSON.stringify(frame) + "\n")
s.flush()
}
function refreshConversations() {
call("conversations", { count: 50 }, function(ok, res) {
if (ok && res) root.conversations = res
})
}
function reconnect() { _recreateSocket() }
// A Quickshell Socket is single-use: once a connect attempt has failed it is
// inert. Assigning connected again, or clearing and restoring path, produces
// no further attempt and not even an error -- verified against a server that
// was started while the socket was retrying. Building a new object is the
// only thing that actually tries again, so the socket lives in a Loader that
// gets cycled.
function _recreateSocket() {
sockLoader.active = false
sockLoader.active = true
}
// ---- transport ----
Component {
id: sockComponent
Socket {
path: root.socketPath
connected: root.socketPath !== ""
parser: SplitParser {
splitMarker: "\n"
onRead: function(line) { root._handleLine(line) }
}
onConnectionStateChanged: {
if (connected) {
root._triedAutostart = false
reconnectTimer.stop()
reconnectTimer.interval = 1000
root.call("status", null, function(ok, res) { if (ok && res) root.status = res })
root.refreshConversations()
} else {
// Drop callbacks that can never be answered now.
root._pending = ({})
root.status = { state: "disconnected", unread: 0, phoneOK: false, qrURL: "", error: "" }
reconnectTimer.start()
}
}
onError: function(err) {
root.transportError("socket error: " + err)
if (root.autostart && !root._triedAutostart) {
root._triedAutostart = true
startService.running = true
}
}
}
}
Loader {
id: sockLoader
sourceComponent: sockComponent
}
Process {
id: startService
command: ["systemctl", "--user", "start", root.serviceName]
onExited: reconnectTimer.start()
}
// Keeps rebuilding the socket until one of them connects. This has to repeat
// on its own: a failed connect leaves the socket disconnected, which is where
// it already was, so connectionState never changes and onConnectionStateChanged
// never runs to re-arm anything. Without this the client gave up after a
// single attempt and a shell started while the daemon was down stayed dead
// until the shell itself was restarted.
//
// Backs off to 30s so a daemon that is down for hours costs almost nothing,
// while one that restarts is picked up within a second or two.
Timer {
id: reconnectTimer
interval: 1000
repeat: true
onTriggered: {
if (root.connected || root.socketPath === "") {
stop()
interval = 1000
return
}
if (interval < 30000) {
interval = Math.min(interval * 2, 30000)
} else {
// Once backed off fully, let autostart have another go: the daemon may
// have been stopped rather than crashed, and nothing else would start it.
root._triedAutostart = false
}
root._recreateSocket()
}
}
// Arm the retry even if the very first connect fails before anything above is
// wired up. The timer stops itself as soon as a socket connects.
Component.onCompleted: if (!root.connected) reconnectTimer.start()
function _handleLine(line) {
if (!line || line.length === 0) return
var frame
try {
frame = JSON.parse(line)
} catch (e) {
root.transportError("bad frame from daemon")
return
}
if (frame.event !== undefined) {
root._handleEvent(frame)
return
}
var cb = _pending[frame.id]
if (cb) {
delete _pending[frame.id]
cb(frame.ok === true, frame.ok === true ? frame.result : (frame.error || "unknown error"))
}
}
function _handleEvent(frame) {
switch (frame.event) {
case "status":
root.status = frame.data
break
case "conversation":
root._mergeConversation(frame.data)
break
case "message":
root.messageReceived(frame.data)
break
case "qr":
// The status event carries the same URL; this is just a nudge for a
// panel already sitting on the pairing screen.
break
case "paired":
root.paired()
root.refreshConversations()
break
}
}
// Splice an updated conversation into the list, preserving pinned-then-
// newest ordering so the view matches what the daemon would send.
function _mergeConversation(conv) {
if (!conv || !conv.id) return
var list = root.conversations.slice()
var found = false
for (var i = 0; i < list.length; i++) {
if (list[i].id === conv.id) { list[i] = conv; found = true; break }
}
if (!found) list.push(conv)
list.sort(function(a, b) {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
return (b.timestamp || 0) - (a.timestamp || 0)
})
root.conversations = list
root.conversationUpdated(conv)
}
}