-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathVncModel.js
More file actions
634 lines (588 loc) · 32.3 KB
/
Copy pathVncModel.js
File metadata and controls
634 lines (588 loc) · 32.3 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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
// Pure helpers behind the VNC widget, for both directions it works in:
//
// serve — which port an output owns, and the exact command lines used to
// start, stop, and detect the wayvnc server for it.
// connect — which heads a bookmark expands to, and the argv used to launch a
// VNC client at one of them.
//
// Kept out of the QML so they can be exercised without a running shell — see
// tests/. Shell quoting is passed in as `quote` rather than reimplemented here,
// so the widget keeps using the shell's own Util.shellQuote and there is only
// ever one quoting implementation in play.
// ----------------------------------------------------------------- both sides
// Neither end reports a binary that is not installed: the server is launched
// detached with its output discarded, and the client is exec'd without a shell,
// so "command not found" reaches nobody. Asked of the shell rather than by
// looking for a file, so anything on PATH counts wherever it lives.
//
// The name travels as an argument and never inside the script, because it can
// come from the `command` key of a hand-edited omavnc.json: `command -v` looks
// up whatever it is handed as a name, so a value carrying shell syntax comes
// back unavailable instead of running.
function availabilityArgs(binary) {
return ["sh", "-c", 'command -v -- "$1" >/dev/null 2>&1', "sh", String(binary || "")]
}
// How much of omavnc.json is ever read into the shell. RENDER_HOSTS caps what a
// parsed file draws, but that cap is applied after the whole file has been read
// and JSON.parse'd on the UI thread — of every bar on every monitor, every time
// the file is saved, since it is watched and hot-reloaded. So the ceiling that
// matters first is this one, at the read: what reaches the parser is one buffer
// of at most this size, whatever is on disk. 64 KiB is hundreds of bookmarks,
// which is already an order of magnitude past what RENDER_HOSTS will draw.
var CONFIG_MAX_BYTES = 65536
// And how long that read may take. A regular file on local disk answers at once,
// but "regular file" is a fact about the moment it was tested and not about the
// moment it is opened, and a read that never returns is a Process that never
// goes idle — so the reader carries its own deadline rather than trusting the
// test below to have been the whole answer.
var CONFIG_READ_TIMEOUT_SECS = 5
// The argv that reads it. The FileView beside it only watches; the file itself
// is read by this, because a FileView reads whatever is at the path it is given
// however big it is and whatever kind of file it is, straight onto the UI
// thread, and there is no property on it that says "but only this much".
//
// What the path is allowed to be is decided before anything is read:
//
// [ -f "$1" ] a regular file, so a FIFO — a read that blocks until somebody
// writes — and a directory are refused rather than opened.
// [ ! -L "$1" ] and the file at that path rather than wherever a link points.
// `-f` follows symlinks, so it is no help here: the two tests
// are one condition and neither alone is it.
//
// The path and the ceiling travel as arguments, never inside the script, for the
// reason availabilityArgs states above: $HOME is not this plugin's to assume the
// shape of, and a path carrying shell syntax must be a file that does not exist
// rather than a command. There is nothing to interpolate here at all.
function configReaderArgs(path) {
return ["sh", "-c",
'[ -f "$1" ] && [ ! -L "$1" ] && exec timeout -- "$3" head -c "$2" -- "$1"',
"sh", String(path || ""), String(CONFIG_MAX_BYTES), String(CONFIG_READ_TIMEOUT_SECS)]
}
// ---------------------------------------------------------------- serve side
// Alphabetical output order is the one ordering every per-monitor instance of
// the widget can agree on without talking to its peers, so it is what the port
// offset is built from. Unnamed screens are dropped rather than counted, since
// a nameless output can neither be served nor matched.
function sortedScreenNames(screens) {
var names = []
for (var i = 0; i < (screens ? screens.length : 0); i++) {
var name = screens[i] ? String(screens[i].name || "") : ""
if (name) names.push(name)
}
names.sort()
return names
}
// -1 means "no port": the widget has not learned its screen yet, or the screen
// is gone. Callers must refuse to start or stop on it.
function portFor(name, names, base) {
if (!name) return -1
var index = (names || []).indexOf(name)
return index < 0 ? -1 : Number(base) + index
}
// One control socket per output, named after it, so sibling heads never race
// for wayvnc's single default socket (see startCommand). Falls back to /tmp
// when $XDG_RUNTIME_DIR is unset — unusual outside a bare login shell, but a
// missing runtime dir must still produce a usable path rather than a socket at
// the filesystem root.
function socketPathFor(name, runtimeDir) {
return (runtimeDir || "/tmp") + "/wayvnc-" + name + ".sock"
}
// Anchored at the start of the command line and closed by a trailing space, so
// it matches the server we spawned for this output and never the bash wrapper
// that spawned it (that line starts with `bash`), nor a sibling output whose
// name this one is a prefix of (DP-1 vs DP-11).
//
// The name is escaped on the way in, because it is not always an output name:
// the serveOutput IPC verb takes whatever string it is given, and this pattern
// is handed to `pkill -f` as an extended regex. Raw, `x|.` would be a top-level
// alternation the `^` binds to only the left of, and the right branch would
// match nearly every process on the machine. Escaping also keeps ordinary names
// literal — `DP.1` is a name that matches nothing, not a wildcard for `DP-1`.
// Shell quoting is a separate concern and no help here: it is what stops a
// metacharacter reaching the shell, not what stops one reaching the regex.
function matchPattern(name) {
return "^wayvnc --output=" + String(name).replace(/[.[\]{}()*+?^$|\\\/]/g, "\\$&") + " "
}
// setsid detaches the server from the shell that launches it, so it survives
// the wrapper exiting; the trailing & keeps bar.run from blocking.
//
// --output must stay the first argument, because matchPattern above anchors on
// it to find and stop this output's server; anything else goes after it.
// -S <socketPath> gives this head its own wayvnc control socket. wayvnc's
// control socket otherwise defaults to one fixed path per user
// ($XDG_RUNTIME_DIR/wayvncctl), so a second head launched without -S refuses to
// start at all ("Another wayvnc process is already running") — before it ever
// touches a port, so this has nothing to do with the per-output port scheme
// above. The launch discards its own stdout/stderr, so without -S every head
// after the first would die silently and its toggle would just flip itself
// back off at the settle probe. --render-cursor draws the pointer into the
// framebuffer, which clients that do not composite the VNC cursor
// pseudo-encoding themselves (TigerVNC) need in order to show a cursor at all.
//
// Every string interpolated here goes through `quote`, the output name
// included: it only ever arrives from Quickshell.screens today, but a shell
// string is not the place to rely on that, and an unquoted name carrying `;`
// or a space would run a second command or split into two arguments. The quotes
// are the shell's own and are gone by the time wayvnc has an argv, so the
// command line matchPattern and probeState read is unchanged by them.
function startCommand(name, fps, address, port, socketPath, quote) {
return "setsid wayvnc --output=" + quote(name)
+ " -S " + quote(socketPath)
+ " --max-fps=" + fps
+ " --render-cursor"
+ " " + quote(address)
+ " " + port
+ " >/dev/null 2>&1 &"
}
function stopCommand(name, quote) {
return "pkill -f " + quote(matchPattern(name))
}
// The panel shows every output's state, and a bar surface exists per monitor,
// so asking per output would cost outputs x monitors processes every cycle —
// nine pgreps here, and worse on a bigger desk. One listing costs one process
// per instance and tells every instance everything.
//
// Every wayvnc is listed, not only the shape startCommand builds: one started
// by hand or from a config file holds the same ports, and starting a second
// server on a taken port fails at the bind, out of sight. Still anchored, so
// the bash wrapper that launches a server is not in the listing either.
//
// A constant and never built from anything: this is the one string in the
// listing that is a regular expression, and every other string here is data on
// its way through it.
var PROBE_PATTERN = "^wayvnc( |$)"
// What the listing is allowed to cost, at the producer and then again at the
// parser. Every line of it is a command line, which is a string anybody who can
// start a process chooses — the plugin does not have to be the one that started
// it, and lines it did not start are kept verbatim for the panel to show. So a
// process named to be a megabyte long, or ten thousand of them, is a listing
// this shell asked for and then holds, parses and draws on the UI thread of
// every bar, out of one singleton every bar reads.
//
// The bytes are cut in the pipe, so the collector never holds more than that
// however much pgrep had to say; the rest are cut in probeState, so a single
// line cannot be long, a listing cannot be deep, and neither list can grow
// past what a panel could sensibly show. A desk with more than sixteen wayvnc
// servers on it is not a desk this panel was going to fit anyway, and the
// numbers are chosen to be past every real listing rather than to be tight:
// 8 KiB is around a hundred ordinary lines, and the deepest listing this plugin
// itself can produce is one per output.
var PROBE_TIMEOUT_SECS = 5
var PROBE_MAX_BYTES = 8192
var PROBE_MAX_LINES = 64
var PROBE_MAX_LINE_CHARS = 512
var PROBE_MAX_OUTPUTS = 16
var PROBE_MAX_EXTERNAL = 16
// The listing, capped where it is produced rather than only where it is read:
// the collector waits for the stream to end and holds all of it, so a cap that
// lived only in probeState would be one applied to a string the shell had
// already been handed. head closes the pipe at the ceiling and pgrep dies of
// the SIGPIPE that follows.
//
// The pattern, the deadline and the ceiling are arguments rather than script,
// the way availabilityArgs takes a binary name: nothing here comes from data,
// but the shape is what keeps it that way the next time somebody needs one of
// them to. The exit status is head's, so the 1 pgrep exits with when nothing is
// running is not something the caller could have read anyway — see the note on
// the Process in ServerProbe.qml, which reads the empty listing instead.
var PROBE_ARGS = ["sh", "-c",
'exec timeout -- "$1" pgrep -af -- "$2" | head -c "$3"',
"sh", String(PROBE_TIMEOUT_SECS), PROBE_PATTERN, String(PROBE_MAX_BYTES)]
// Splits a `pgrep -af` listing, whose lines are "<pid> <command line>", into the
// servers this plugin can act on and the ones it must only report:
//
// outputs — lines of the exact shape startCommand builds, named once each in
// the order first seen. The trailing space is required because it
// is what matchPattern needs to find the process again: a line
// this parser calls managed but pkill could not match would be a
// toggle that cannot switch off.
// external — any other line whose command is wayvnc, kept verbatim so the
// panel can show which process is holding the ports. Never a name
// to act on: nothing in this plugin may stop what it did not start.
//
// A command merely containing wayvnc (the bash wrapper, or wayvncctl) is
// neither, since the command has to be wayvnc itself.
//
// Every ceiling above is spent here: the walk stops at PROBE_MAX_LINES, each
// line is cut to PROBE_MAX_LINE_CHARS before it is matched against anything or
// kept, and both lists stop growing at their own. `external` is deduped as well
// — it used to keep every line it was given, so twenty copies of one command
// line were twenty rows — and exact strings are the right identity for it,
// since the whole line is what the panel shows.
function probeState(text) {
var outputs = []
var external = []
var listing = String(text || "")
var lines = listing.split("\n")
// pgrep terminates every line it prints, so a whole listing ends in a newline
// and the last element of the split is the empty string after it. One that
// does not end in a newline stopped mid-line — at the byte cut PROBE_ARGS
// makes, at the timeout killing pgrep as it was writing, or at anything else
// that can end a stream early — and that last element is the front of
// somebody's command line rather than a command line. Read as an unmanaged
// server it would be a row on the panel for a process that does not exist and,
// worse, an externalRunning that locks every serve switch on the machine. So
// an unterminated line is dropped unread, and a whole listing loses only the
// empty string it always ends with.
//
// Asked as "was this line terminated" rather than "did the listing reach the
// ceiling", because the ceiling is a count of bytes and this string is a count
// of characters: a listing whose tail is multibyte is cut below the ceiling,
// and a comparison against it would call a cut listing whole — exactly where
// it matters, since the names on those lines are chosen by whoever started the
// processes. Termination is the same question asked in a way no encoding moves.
//
// The contract that follows, for anyone handing this a listing of their own: a
// line is a server only if it is terminated. A hand-built string ending in its
// last server rather than in a newline is one server short, which is why the
// fixtures in tests/ are written the way pgrep writes.
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.pop()
var counted = Math.min(lines.length, PROBE_MAX_LINES)
for (var i = 0; i < counted; i++) {
// Cut first, so nothing below ever sees the whole line: a name matched out
// of a line this plugin did not write is a string it goes on to hold, draw
// and hand to pkill as a pattern.
var line = lines[i].slice(0, PROBE_MAX_LINE_CHARS)
var managed = line.match(/^\s*\d+\s+wayvnc\s+--output=(\S+)\s/)
if (managed) {
if (outputs.length < PROBE_MAX_OUTPUTS && outputs.indexOf(managed[1]) === -1)
outputs.push(managed[1])
continue
}
var other = line.match(/^\s*\d+\s+(wayvnc(?:\s.*)?)$/)
if (!other) continue
var command = other[1].trim()
if (external.length < PROBE_MAX_EXTERNAL && external.indexOf(command) === -1)
external.push(command)
}
return { outputs: outputs, external: external }
}
// Whether the listing above is worth asking for on a timer. A poll that runs
// forever costs a pgrep every 5s for as long as the shell is up, to learn
// nothing on a machine that is not sharing anything and has no panel open to
// show an answer in. So it runs while somebody is looking (a panel is open) or
// while there is something to watch (a server of ours, or one we only report),
// and stops otherwise.
//
// `attached` is the count of widgets the answer would reach, and no answer
// reaches nobody: a plugin reload destroys every widget, recompiles the QML and
// builds a fresh singleton, while the engine keeps the old one alive with
// whatever its last listing said. Without this the stranded one would go on
// pgrepping for the life of the shell — one more every reload — for state
// nothing can read. It leads the decision because it is a precondition rather
// than a reason: the other three answer "is this worth knowing", this one
// answers "is there anybody to know it".
//
// The served list is what makes the stopAll verb's trust in the last probe
// safe: polling is on whenever it is non-empty, so a non-empty list was never
// more than one interval old, and an empty one is nothing this plugin knew how
// to stop anyway. The widget gate does not weaken that — the verb is handled by
// a widget, so a call that can arrive at all arrives at an attached one.
//
// Counts rather than booleans, because "a panel is open" is a count across
// every bar surface, and a non-array is read as nothing rather than throwing:
// the caller is a binding.
function shouldPoll(attached, openPanels, served, external) {
if (!(Number(attached) > 0)) return false
if (Number(openPanels) > 0) return true
if (served instanceof Array && served.length > 0) return true
return external instanceof Array && external.length > 0
}
// Every server the stopAll verb acts on, from the probe's own reading of the
// listing rather than from the connected outputs. The two are not the same set:
// unplugging a monitor does not stop the wayvnc that was started for it, so a
// server can be running, holding its port and sharing a framebuffer for an
// output Quickshell.screens no longer has — and it is the one a user removing
// this plugin most needs stopped, since no row in the panel can reach it.
//
// Connected first, then those, because that is the order the verb reports them
// in and a summary reads better as the desk followed by the leftovers.
//
// Every name here came off a command line the probe called managed, and every
// one of them leaves through VncModel.stopCommand, which escapes it into
// matchPattern's regex like every other stop: a name is data on the way to
// pkill, never pattern, whether it arrived from a screen or from a listing.
//
// Capped at PROBE_MAX_OUTPUTS, which is where the list it is handed was capped
// too. Stated twice on purpose: every name returned is one pkill the widget
// spawns in a loop, so the bound on that fan-out belongs where the fan-out is
// decided rather than only in the listing it happens to be reading today.
function stoppableOutputs(servedOutputs, screenNames) {
var connected = []
var orphaned = []
var names = servedOutputs instanceof Array ? servedOutputs : []
var screens = screenNames instanceof Array ? screenNames : []
for (var i = 0; i < names.length; i++) {
if (connected.length + orphaned.length >= PROBE_MAX_OUTPUTS) break
var name = String(names[i] || "")
if (!name || connected.indexOf(name) !== -1 || orphaned.indexOf(name) !== -1) continue
if (screens.indexOf(name) === -1) orphaned.push(name)
else connected.push(name)
}
return connected.concat(orphaned)
}
// -------------------------------------------------------------- connect side
// The client used when neither the bookmark nor the config file names one.
// wlvncc is the Wayland-native viewer; tigervnc's vncviewer works too and can
// be selected per host or globally through the `command` key in omavnc.json.
var FALLBACK_COMMAND = ["wlvncc", "{host}", "{port}"]
function commandFor(config) {
return config && config.command instanceof Array && config.command.length > 0
? config.command : FALLBACK_COMMAND
}
// The widest bookmark the panel will save. Nothing about a remote's monitors
// can be probed from here — a machine answers about the ports it is listening
// on and nothing about the outputs behind them, and one that is not sharing yet
// answers nothing at all — so the count is stated by hand at save time, and
// this is where that hand is stopped. Nine because the panel connects heads by
// digit key, 1-9: a tenth head would carry no shortcut, so saving one would
// only be a row nothing on the keyboard reaches. A hand-edited omavnc.json may
// still name more, and headsFor honours it.
var MAX_HEADS = 9
// Anything that is not a number reads as one head, which is also what a
// bookmark that says nothing gets below.
function clampHeads(count) {
var n = parseInt(String(count), 10)
if (!isFinite(n)) return 1
return Math.max(1, Math.min(MAX_HEADS, n))
}
// The widest bookmark the panel will draw, which is a different limit from
// MAX_HEADS above: that one is what the panel will save, this one is what any
// bookmark at all is allowed to cost to render. A hand-edited count past nine
// is honoured on purpose, but headsFor runs inside a property binding on the UI
// thread, allocating an object per head and feeding a Repeater that builds a
// button per head — and omavnc.json is hot-reloaded, so a slipped keypress
// making it `"heads": 1000000000` would freeze every bar on every monitor at
// the moment the file is saved, before the panel is ever opened, leaving no
// working UI to undo it with. 64 is past any real desk and cheap to draw; a
// bookmark cut down to it is not shown as if it were complete, since
// requestedHeads below tells the panel what the file asked for.
var RENDER_HEADS = 64
// How far into a hand-written `ports` list anything here will walk. RENDER_HEADS
// caps what a row draws, but both the count a cut row reports and the search for
// the entries worth drawing used to read the whole array, so a `ports` list of a
// million junk entries was a million String()+parseInt calls inside a UI-thread
// binding, in every bar on every monitor, at the moment omavnc.json was saved:
// the freeze RENDER_HEADS exists to prevent, entered by the other door. A
// thousand is an order of magnitude past that ceiling, so every port list a real
// desk could have is still counted exactly and the panel's "showing 64 of N
// heads" notice stays true; past it the count is a floor rather than a total,
// which is what headsUncounted below is for.
var COUNT_HEADS = 1000
// How many heads a bookmark asks for, before that ceiling: the count it states,
// or the number of usable entries in the port list it gives instead. What the
// panel compares against the row it actually drew.
//
// A stated count is a single parse and is honoured whatever it says. A port list
// is walked no further than COUNT_HEADS, so for a longer one this is "at least
// this many" — the panel says so rather than passing it off as a total.
function requestedHeads(host) {
if (!host) return 0
if (host.ports instanceof Array && host.ports.length > 0) {
var usable = 0
var counted = Math.min(host.ports.length, COUNT_HEADS)
for (var i = 0; i < counted; i++)
if (isFinite(parseInt(String(host.ports[i]), 10))) usable++
return usable
}
var count = parseInt(String(host.heads !== undefined ? host.heads : 1), 10)
return !isFinite(count) || count < 1 ? 1 : count
}
// Whether the number above is a floor rather than a total: the bookmark lists
// more ports than COUNT_HEADS, so nothing past that was ever looked at and the
// row reports "of 1000+ heads" instead of claiming a total it never counted.
function headsUncounted(host) {
return !!(host && host.ports instanceof Array && host.ports.length > COUNT_HEADS)
}
// A bookmark either lists its ports outright or describes them as a count of
// heads from a base port — the same base + index scheme the serve side uses to
// number outputs, which is what makes one bookmark cover a whole machine.
//
// A bookmark that says nothing gets a single head rather than a guess at a
// multi-monitor desk: fanning out is something the user asks for — with the
// panel's head stepper when saving, or by writing `heads` into omavnc.json —
// and a bookmark wider than the machine behind it is a row of buttons for ports
// nothing is listening on.
function headsFor(host) {
if (!host) return []
var heads = []
if (host.ports instanceof Array && host.ports.length > 0) {
// Two ceilings, because the drawing one is not a bound on the walk: a list
// of a million entries none of which parse never fills RENDER_HEADS, and the
// loop would read all of it looking. COUNT_HEADS is how far the list is read
// at all, here and in requestedHeads, so the two agree about what the
// bookmark asked for.
for (var i = 0; i < host.ports.length && i < COUNT_HEADS && heads.length < RENDER_HEADS; i++) {
var explicit = parseInt(String(host.ports[i]), 10)
if (isFinite(explicit)) heads.push({ index: heads.length + 1, port: explicit })
}
} else {
var base = parseInt(String(host.basePort !== undefined ? host.basePort : 5900), 10)
if (!isFinite(base)) base = 5900
var count = Math.min(requestedHeads(host), RENDER_HEADS)
for (var j = 0; j < count; j++) heads.push({ index: j + 1, port: base + j })
}
var names = host.headNames instanceof Array ? host.headNames : []
for (var k = 0; k < heads.length; k++)
heads[k].label = headLabel(names[k] !== undefined && names[k] !== null ? names[k] : heads[k].index)
return heads
}
// A head's label is the caption of a glyph-sized button the shell draws, through
// a Text of its own that nothing in this plugin can set textFormat on — see the
// note at the top of BarWidget.qml, where every Text this plugin does own is
// pinned to plain text. Qt's default AutoText sniffs a string that looks like
// markup and renders it as StyledText, which honours `<img src>`, so a
// hand-written headNames entry of `<img src='http://elsewhere/x'>` would fetch a
// URL the moment the bookmark's row was drawn. The three characters markup
// cannot be written without are dropped rather than escaped: this is a caption
// on a button the size of one glyph, and none of them say anything there.
function headLabel(value) {
return String(value).replace(/[<>&]/g, "")
}
// The most bookmark rows the panel will build, for the reason RENDER_HEADS caps
// a row's heads: the list is a Repeater over an array straight out of
// omavnc.json, so a file carrying a hundred thousand entries is a hundred
// thousand delegates built on the UI thread the moment the panel is opened — and
// the panel is the only place a bookmark can be removed from, so the file that
// wedges it is the one it takes to undo it. 64 is past any list worth walking
// with a cursor. The rest stay in the file, and the panel says how many it is
// not showing rather than looking like a shorter file than it was handed.
var RENDER_HOSTS = 64
// The rows to draw. The same array back when it is short enough, so an ordinary
// file costs no copy at all.
function renderedHosts(hosts) {
if (!(hosts instanceof Array)) return []
return hosts.length > RENDER_HOSTS ? hosts.slice(0, RENDER_HOSTS) : hosts
}
// How many the file asked for, which is what the drawn count is compared against
// — reading a length costs nothing however long the array is.
function hostCount(hosts) {
return hosts instanceof Array ? hosts.length : 0
}
function fillTemplate(part, host, head) {
return String(part)
.split("{host}").join(String(host.host || ""))
.split("{port}").join(String(head.port))
.split("{name}").join(String(host.name || host.host || ""))
.split("{head}").join(String(head.index))
}
// Built as an argv array and executed without a shell, so a hostile bookmark
// cannot smuggle in a second command the way a concatenated string could.
function connectArgv(host, head, defaultCommand) {
if (!host || !head) return []
var template = host.command instanceof Array && host.command.length > 0
? host.command
: (defaultCommand instanceof Array && defaultCommand.length > 0 ? defaultCommand : FALLBACK_COMMAND)
var argv = []
for (var i = 0; i < template.length; i++) argv.push(fillTemplate(template[i], host, head))
return argv
}
// Accepts "host", "host:port" or "host port"; port defaults to 5900.
function parseTarget(input) {
var s = String(input || "").trim()
if (s === "") return null
var host = s
var port = 5900
var m = s.match(/^(.*?)[:\s]+(\d+)$/)
if (m) {
host = m[1].trim()
port = parseInt(m[2], 10)
}
if (host === "") return null
return { host: host, port: port }
}
// --------------------------------------------------------------- passthrough
// The Hyprland submap the passthrough switch enters. Nothing here registers it
// — it is a handful of lines in the user's own Hyprland config (see the README)
// — and a submap that was never registered cannot be entered: the dispatch
// fails with "submap doesn't exist" inside Hyprland, where a detached
// `hyprctl` call cannot see it, so the switch would flip back and say nothing.
// Hence the probe below: the panel asks before it offers.
var REMOTE_SUBMAP = "remote"
// One listing of every keybind Hyprland knows, each carrying the submap it
// belongs to. There is no listing of submaps themselves, so their binds are
// where they are visible: a submap named by no bind in here is one Hyprland was
// never told about — or, at best, one defined with nothing bound inside it,
// which is a mode with no key out of it and not a mode worth entering either.
var SUBMAP_PROBE_ARGS = ["hyprctl", "-j", "binds"]
// True only when the listing above says so, which is what makes every other
// answer safe: hyprctl missing (no output at all), hyprctl failing (an error on
// stderr and nothing on stdout), a build that answers with something other than
// a JSON array, or an entry that is not an object all fall through to false,
// and false is the state that shows the setup hint rather than a switch that
// cannot work. Nothing here throws, because the caller is a binding.
function hasSubmap(text, name) {
var wanted = String(name || "")
if (wanted === "") return false
var parsed = null
try {
parsed = JSON.parse(String(text || ""))
} catch (e) {
return false
}
if (!(parsed instanceof Array)) return false
for (var i = 0; i < parsed.length; i++) {
var bind = parsed[i]
if (bind && typeof bind === "object" && String(bind.submap || "") === wanted) return true
}
return false
}
// ----------------------------------------------------------- panel navigation
// The panel shows one side at a time, in this order. Serving comes first
// because the panel is opened from a bar button that reports this machine's own
// sharing state, so the side the button speaks for is the side it lands on.
var TABS = ["serve", "connect"]
// A name nothing knows reads as the first tab, so a cleared or misspelled
// setting shows a panel rather than an empty one.
function tabIndexOf(tab) {
var at = TABS.indexOf(String(tab || ""))
return at < 0 ? 0 : at
}
function tabAt(index) {
return TABS[Math.max(0, Math.min(Number(index) || 0, TABS.length - 1))]
}
// One step along the strip, stopping at its ends rather than wrapping: the tabs
// are laid out side by side, so a direction that could arrive at either one
// would read as a coin toss. This is both how the ring walks the chips and how
// left and right switch tabs outright from anywhere else, so neither can
// disagree with the other about which way is which.
function stepTabIndex(index, dx) {
var at = Math.max(0, Math.min(Number(index) || 0, TABS.length - 1))
return Math.max(0, Math.min(TABS.length - 1, at + (dx > 0 ? 1 : (dx < 0 ? -1 : 0))))
}
function stepTab(tab, dx) {
return tabAt(stepTabIndex(tabIndexOf(tab), dx))
}
// One vertical walk for both tabs. The stops are the tab strip, the passthrough
// switch, and then "rows" — whichever list the visible tab owns, named for the
// position rather than the list, because that is what keeps the cursor out of
// the hidden tab's items: the other list is never a place the cursor can be,
// only a place where its remembered index waits.
//
// The header is no stop: it holds a title and a line of key shorthand, and a
// stop with nothing to activate is a keypress that appears to do nothing.
//
// Both of the other two come and go for that same reason. `rows` is the visible
// tab's row count, so a tab with nothing in it (no bookmarks, no outputs) has
// no row stop at all; `hasPassthrough` is the connect tab's switch, which is
// the only control the serve side does not have, so the serve walk is the strip
// and its monitors. A cursor left on a stop that is gone — the tab switched, or
// a list just emptied — lands back on the strip instead of nowhere.
function stepCursor(section, index, rows, dy, hasPassthrough) {
var count = Math.max(0, Number(rows) || 0)
var order = ["tabs"]
if (hasPassthrough) order.push("passthrough")
if (count > 0) order.push("rows")
var row = Math.max(0, Math.min(Number(index) || 0, Math.max(0, count - 1)))
var at = order.indexOf(String(section || ""))
if (at < 0) return { section: "tabs", index: row }
if (dy > 0) {
if (order[at] === "rows") return { section: "rows", index: Math.min(row + 1, count - 1) }
return { section: order[Math.min(at + 1, order.length - 1)], index: row }
}
if (dy < 0) {
if (order[at] === "rows" && row > 0) return { section: "rows", index: row - 1 }
return { section: order[Math.max(at - 1, 0)], index: row }
}
return { section: order[at], index: row }
}