Repository navigation
Expand file tree
/
Copy pathModel.js
More file actions
715 lines (644 loc) · 30 KB
/
Copy pathModel.js
File metadata and controls
715 lines (644 loc) · 30 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
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
.pragma library
// Everything the panel derives rather than displays raw: the CLI invocations it
// schedules, the one shell probe it runs per refresh, and the formatting and
// state rules the views read. Kept out of QML so it can be tested with plain
// node, without a compositor.
var MODES = [
{ value: "rule", label: "Rule", hint: "Match traffic against the subscription's rules" },
{ value: "global", label: "Global", hint: "Send everything through the selected proxy" },
{ value: "direct", label: "Direct", hint: "Bypass the proxy entirely" }
]
function modeLabel(value) {
for (var i = 0; i < MODES.length; i++) if (MODES[i].value === value) return MODES[i].label
return "Rule"
}
function modeIndex(value) {
for (var i = 0; i < MODES.length; i++) if (MODES[i].value === value) return i
return 0
}
function modeSelectionAction(next, current) {
var wanted = String(next || "").toLowerCase()
if (modeIndex(wanted) >= MODES.length || MODES[modeIndex(wanted)].value !== wanted) return "none"
if (wanted === "global") return "choose_proxy"
return wanted === String(current || "").toLowerCase() ? "none" : "switch"
}
function routeTestEntries() {
return [
{ label: "Google", host: "google.com", result: "Testing..." },
{ label: "X", host: "x.com", result: "Waiting..." },
{ label: "GitHub", host: "github.com", result: "Waiting..." },
{ label: "Claude", host: "claude.ai", result: "Waiting..." },
{ label: "ChatGPT", host: "chatgpt.com", result: "Waiting..." },
{ label: "xAI", host: "x.ai", result: "Waiting..." },
{ label: "Douyin", host: "douyin.com", result: "Waiting..." },
{ label: "Wechat", host: "weixin.com", result: "Waiting..." },
{ label: "Taobao", host: "taobao.com", result: "Waiting..." }
]
}
// ------------------------------------------------------------------ the CLI
//
// mihoro owns the mihomo binary, the systemd unit, and the subscription
// download. The plugin opens the upstream installation guide and leaves
// installation entirely to the user.
var INSTALL_DOCS_URL = "https://github.com/spencerwooo/mihoro#installation"
var PROJECT_URL = "https://github.com/spencerwooo/mihoro"
function initCommand() { return ["mihoro", "init", "-y"] }
function updateConfigCommand() { return ["mihoro", "update", "--config"] }
function applyCommand() { return ["mihoro", "apply"] }
function startCommand() { return ["mihoro", "start"] }
function stopCommand() { return ["mihoro", "stop"] }
function restartCommand() { return ["mihoro", "restart"] }
function proxyExportCommand() { return ["mihoro", "proxy", "export"] }
function installationGuideCommand() {
return ["omarchy", "launch", "browser", INSTALL_DOCS_URL]
}
// ------------------------------------------------------------ TUN DNS
//
// A TUN catches traffic by IP. The host a rule matches on comes from the DNS
// answer, so mihomo also has to be the resolver: it runs `resolvectl` three
// times to make the TUN link systemd-resolved's DNS and default route. Each run
// is a polkit prompt for a core that is not root, and at login the core starts
// before the shell's polkit agent does — every run fails, and DNS stays on the
// physical link. Traffic still flows, but only as bare IPs: the rules see no
// domain, and the route test finds no request to report on.
//
// Reading the link back needs no privilege, so it is checked on every refresh.
var TUN_DNS_SCRIPT = "resolvectl dns \"$1\" && resolvectl default-route \"$1\""
function validDevice(device) {
var name = String(device || "")
return name !== "" && name.charAt(0) !== "-"
}
function tunDnsCommand(device) {
if (!validDevice(device)) return []
return ["sh", "-c", TUN_DNS_SCRIPT, "sh", String(device)]
}
// "ok" when the link has a DNS server and is the default route, "bypassed"
// when either is missing, and "" when the output is not the two lines asked
// for — a host without resolved has nothing to report, not a fault.
function parseTunDns(text) {
var lines = String(text || "").split("\n").filter(function(line) { return line.trim() !== "" })
if (lines.length < 2) return ""
function value(line) {
var colon = line.indexOf(":")
return colon < 0 ? null : line.substring(colon + 1).trim()
}
var servers = value(lines[0])
var defaultRoute = value(lines[1])
if (servers === null || defaultRoute === null) return ""
return servers !== "" && defaultRoute === "yes" ? "ok" : "bypassed"
}
// The address after the TUN's own, which is where mihomo puts the resolver it
// hands to resolved. IPv4 only, as mihomo does.
function tunDnsServer(cidr) {
var address = String(cidr || "").split("/")[0]
var parts = address.split(".")
if (parts.length !== 4) return ""
var value = 0
for (var i = 0; i < 4; i++) {
if (!/^\d{1,3}$/.test(parts[i])) return ""
var octet = Number(parts[i])
if (octet > 255) return ""
value = value * 256 + octet
}
value += 1
if (value > 0xffffffff) return ""
var out = []
for (var j = 0; j < 4; j++) {
out.unshift(String(value % 256))
value = Math.floor(value / 256)
}
return out.join(".")
}
var DNS_BYPASSED_NOTICE = "DNS is bypassing the TUN, so rules only see IPs."
var DNS_ROUTED_STATUS = "DNS routed through mihomo."
// The same three settings mihomo makes, run by the user's own `resolvectl`:
// resolved asks polkit for each, so the password dialog comes from the system
// and the plugin itself never runs anything as root. That is three prompts —
// the same three mihomo costs at startup — because each setting is its own
// polkit action. Run on the user's click only: a password dialog that appears
// by itself after login is indistinguishable from something asking for a
// password it should not have.
var TUN_DNS_REPAIR_SCRIPT = [
"resolvectl dns \"$1\" \"$2\"",
"resolvectl domain \"$1\" \"~.\"",
"resolvectl default-route \"$1\" yes"
].join(" && ")
function tunDnsRepairCommand(device, server) {
if (!validDevice(device) || String(server || "") === "") return []
return ["sh", "-c", TUN_DNS_REPAIR_SCRIPT, "sh", String(device), String(server)]
}
// One probe per refresh instead of five processes. It answers: is the CLI on
// PATH, where is the mihomo binary, what does systemd think of mihomo.service,
// when did it last come up, and has the subscription ever been written to disk.
//
// mihomo is looked for where mihoro.toml says it is (`$2`, already
// home-expanded) before PATH is consulted: `mihoro init` installs it to
// `~/.local/bin`, which is not on every shell's PATH, and a user who moved it
// elsewhere recorded that move in `mihomo_binary_path`. PATH remains the
// fallback so a distro-packaged mihomo in `/usr/bin` is still found when the
// configured path is stale or absent.
//
// `date -d` converts systemd's human timestamp to an epoch here rather than in
// QML, because "Sun 2026-08-17 19:15:03 CST" is not something Date.parse can be
// trusted with.
var PROBE_SCRIPT = [
"unit=mihomo.service",
"mihoro_bin=$(command -v mihoro 2>/dev/null || true)",
"printf 'mihoro_bin=%s\\n' \"$mihoro_bin\"",
"if [ -n \"$mihoro_bin\" ]; then printf 'mihoro_version=%s\\n' \"$(\"$mihoro_bin\" --version 2>/dev/null | head -n 1 || true)\"; fi",
"mihomo_bin=${2:-}",
"[ -n \"$mihomo_bin\" ] && [ -x \"$mihomo_bin\" ] || mihomo_bin=$(command -v mihomo 2>/dev/null || true)",
"printf 'mihomo_bin=%s\\n' \"$mihomo_bin\"",
"systemctl --user show \"$unit\" -p LoadState -p ActiveState -p SubState -p UnitFileState -p MainPID -p ActiveEnterTimestamp 2>/dev/null || true",
"started=$(systemctl --user show \"$unit\" -p ActiveEnterTimestamp --value 2>/dev/null || true)",
"if [ -n \"$started\" ]; then printf 'active_enter_epoch=%s\\n' \"$(date -d \"$started\" +%s 2>/dev/null || echo 0)\"; fi",
"if [ -f \"$1\" ]; then printf 'config_present=1\\nconfig_mtime=%s\\n' \"$(stat -c %Y -- \"$1\" 2>/dev/null || echo 0)\"; else printf 'config_present=0\\n'; fi",
"printf 'now=%s\\n' \"$(date +%s)\""
].join("\n")
function probeCommand(mihomoConfigPath, mihomoBinaryPath) {
return ["bash", "-c", PROBE_SCRIPT, "omahoro-probe",
String(mihomoConfigPath || ""), String(mihomoBinaryPath || "")]
}
function emptyProbe() {
return {
mihoroInstalled: false,
mihoroVersion: "",
mihomoInstalled: false,
mihomoPath: "",
unitLoaded: false,
activeState: "unknown",
subState: "",
unitFileState: "",
mainPid: 0,
startedAt: 0,
configPresent: false,
configMtime: 0,
now: 0
}
}
function parseProbe(text) {
var probe = emptyProbe()
var lines = String(text === undefined || text === null ? "" : text).split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
var eq = line.indexOf("=")
if (eq < 0) continue
var key = line.substring(0, eq).trim()
var value = line.substring(eq + 1).trim()
if (key === "mihoro_bin") probe.mihoroInstalled = value !== ""
else if (key === "mihoro_version") probe.mihoroVersion = value
else if (key === "mihomo_bin") { probe.mihomoPath = value; probe.mihomoInstalled = value !== "" }
else if (key === "LoadState") probe.unitLoaded = value === "loaded"
else if (key === "ActiveState") probe.activeState = value || "unknown"
else if (key === "SubState") probe.subState = value
else if (key === "UnitFileState") probe.unitFileState = value
else if (key === "MainPID") probe.mainPid = Number(value) || 0
else if (key === "active_enter_epoch") probe.startedAt = Number(value) || 0
else if (key === "config_present") probe.configPresent = value === "1"
else if (key === "config_mtime") probe.configMtime = Number(value) || 0
else if (key === "now") probe.now = Number(value) || 0
}
return probe
}
// ------------------------------------------------------- mihoro CLI output
//
// `mihoro update` and `mihoro init` report in stages — a `●` line per stage
// while running, then a summary of `✓` / `↷` / `✗`. Following that gives the
// panel a live progress line and, on failure, the stage that actually broke
// instead of a generic "command failed".
// `colored` turns itself off when stdout is not a tty, so mihoro's output
// through a pipe is normally plain. It is stripped anyway: a user with
// CLICOLOR_FORCE set would otherwise see escape codes in the panel.
function stripAnsi(text) {
return String(text === undefined || text === null ? "" : text)
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
}
function parseStageLine(line) {
var text = stripAnsi(line).replace(/\s+$/, "")
var trimmed = text.trim()
if (trimmed === "") return null
if (trimmed.indexOf("●") === 0)
return { kind: "stage", name: trimmed.substring(1).trim(), detail: "" }
if (trimmed.indexOf("⎿") === 0)
return { kind: "detail", name: "", detail: trimmed.substring(1).trim() }
if (trimmed.indexOf("✓") === 0)
return { kind: "ok", name: trimmed.substring(1).trim(), detail: "" }
if (trimmed.indexOf("↷") === 0)
return { kind: "skip", name: trimmed.substring(1).trim(), detail: "" }
if (trimmed.indexOf("✗") === 0) {
var body = trimmed.substring(1).trim()
var split = body.indexOf(":")
return split < 0
? { kind: "fail", name: body, detail: "" }
: { kind: "fail", name: body.substring(0, split).trim(), detail: body.substring(split + 1).trim() }
}
if (/^error:/i.test(trimmed))
return { kind: "fail", name: "", detail: trimmed.replace(/^error:\s*/i, "") }
return { kind: "other", name: "", detail: trimmed }
}
// A failure message quotes back what mihoro was given and what it got: the
// subscription URL on a network error, the whole response body when it could
// not be parsed. Both are hostile to a notice line — the URL is the whole of
// the authentication, and a body runs to kilobytes on one line.
// Only the host survives. It is what identifies the subscription when
// diagnosing, and it is the part that is not a credential.
function redactUrls(text) {
return String(text === undefined || text === null ? "" : text)
.replace(/https?:\/\/[^\s'"()<>]+/g, function (url) {
var rest = url.replace(/^https?:\/\//, "")
var cut = rest.search(/[\/?#]/)
return cut < 0 ? rest : rest.substring(0, cut) + "/…"
})
}
// A quoted run long enough to be a payload rather than a field name says
// nothing a reader can use, and truncation would leave part of it on screen.
// Short quotes are the ones carrying the meaning — `missing field "proxies"`.
function collapseQuoted(text, max) {
var limit = Number(max) || 60
return String(text === undefined || text === null ? "" : text)
.replace(/"([^"]*)"/g, function (whole, inner) {
return inner.length > limit ? '"…"' : whole
})
}
// Redact before shortening, always: eliding first could stop halfway through a
// token and leave the front of it on screen.
function noticeMessage(text) {
return elide(collapseQuoted(redactUrls(text), 60), 240)
}
// The message to show when a staged command exits non-zero. A failed stage is
// far more useful than the exit code, so it wins over stderr.
function stageFailureMessage(output, fallback) {
var lines = String(output === undefined || output === null ? "" : output).split("\n")
for (var i = 0; i < lines.length; i++) {
var parsed = parseStageLine(lines[i])
if (parsed && parsed.kind === "fail") {
var named = parsed.name !== "" && parsed.detail !== ""
return noticeMessage(named ? parsed.name + ": " + parsed.detail
: (parsed.detail !== "" ? parsed.detail : parsed.name))
}
}
var trimmed = stripAnsi(output).trim()
if (trimmed !== "") {
var last = trimmed.split("\n")
return noticeMessage(last[last.length - 1].trim())
}
return String(fallback || "mihoro reported a failure.")
}
function elide(text, max) {
var value = String(text === undefined || text === null ? "" : text).replace(/\s+/g, " ").trim()
var limit = Number(max) || 80
return value.length > limit ? value.substring(0, limit - 1) + "…" : value
}
// -------------------------------------------------------------- AI diagnosis
//
// Omarchy's crash watcher already answers the question of how a shell offers a
// diagnosis: check that a default agent has been chosen, gather the facts, and
// point the agent at them instead of pasting them into the prompt. Both halves
// matter here — `omarchy-default-agent` prints nothing until the user picks
// one, and a button that opens nothing explains nothing.
// Only a staged mihoro command qualifies. A rejected URL is the user's to fix
// and an agent would only repeat the validation message back; a failed write is
// a permissions problem with no output worth reading.
var DIAGNOSABLE = [
{ kind: "update", command: "mihoro update --config" },
{ kind: "init", command: "mihoro init -y" },
{ kind: "apply", command: "mihoro apply" }
]
function diagnosableCommand(kind) {
var key = String(kind === undefined || kind === null ? "" : kind)
for (var i = 0; i < DIAGNOSABLE.length; i++)
if (DIAGNOSABLE[i].kind === key) return DIAGNOSABLE[i].command
return ""
}
function defaultAgentCommand() { return ["omarchy-default-agent"] }
function canDiagnose(kind, agent) {
return String(agent || "") !== "" && diagnosableCommand(kind) !== ""
}
function failureLogPath(home) {
return String(home || "") + "/.local/state/omarchy/mihoro/last-failure.log"
}
// Written the way the subscription store is, and for the same reason: what the
// command printed quotes back the URL it was given and whatever the server
// answered with. State rather than config — it is a transcript of one failure,
// replaced by the next one, and worth nothing after it.
var FAILURE_LOG_SCRIPT = [
"set -eu",
"target=$1",
"dir=$(dirname -- \"$target\")",
"mkdir -p -- \"$dir\"",
"tmp=$(mktemp -- \"$dir/.mihoro-failure.XXXXXX\")",
"trap 'rm -f -- \"$tmp\"' EXIT",
"cat > \"$tmp\"",
"chmod 600 -- \"$tmp\"",
"mv -f -- \"$tmp\" \"$target\"",
"trap - EXIT"
].join("\n")
function failureLogWriteCommand(path) {
return ["bash", "-c", FAILURE_LOG_SCRIPT, "omahoro-failure-log", String(path)]
}
// Paths and the command that failed, nothing more. This string becomes argv,
// which the process list shows to every user on the machine.
function diagnosePrompt(kind, logPath, configPath) {
var command = diagnosableCommand(kind)
if (command === "") command = "mihoro"
return [
"`" + command + "` failed on this Omarchy machine and I want to know why.",
"",
"Its output, stdout and stderr together, is in:",
" " + String(logPath || ""),
"",
"mihoro's configuration is in:",
" " + String(configPath || ""),
"",
"The subscription URL in that file is a bearer credential — the whole of the",
"authentication. Read it if you need it, but do not print it, and do not put",
"it in a commit, an issue, or a paste.",
"",
"Work out what failed and tell me how to fix it. Worth ruling out: the",
"subscription answering with something that is not a Clash config, a URL that",
"has expired or is being rejected, and mihomo.service failing to come back up",
"(journalctl --user -u mihomo.service -n 50 --no-pager)."
].join("\n")
}
function diagnoseCommand(prompt) {
return ["omarchy-agent", "--prompt", String(prompt || "")]
}
// ---------------------------------------------------------- connection state
//
// One place decides what the panel is looking at, so the hero, the bar icon,
// and the enabled state of every control cannot disagree.
function connectionState(probe, api) {
var state = probe || emptyProbe()
var apiState = String(api || "unknown")
if (!state.mihoroInstalled)
return { key: "cli_missing", label: "mihoro not installed", detail: "Install the mihoro CLI to use this panel.", active: false, tone: "idle" }
if (!state.configPresent)
return { key: "not_initialized", label: "Not set up", detail: "Add a subscription URL to get started.", active: false, tone: "idle" }
if (!state.unitLoaded)
return { key: "unit_missing", label: "Service not installed", detail: "Run setup to install mihomo.service.", active: false, tone: "idle" }
if (state.activeState === "failed")
return { key: "failed", label: "Failed", detail: "mihomo.service failed to start.", active: false, tone: "urgent" }
if (state.activeState === "activating")
return { key: "starting", label: "Starting…", detail: "mihomo.service is coming up.", active: true, tone: "idle" }
if (state.activeState === "deactivating")
return { key: "stopping", label: "Stopping…", detail: "mihomo.service is shutting down.", active: false, tone: "idle" }
if (state.activeState !== "active")
return { key: "stopped", label: "Stopped", detail: "mihomo.service is not running.", active: false, tone: "idle" }
if (apiState === "unauthorized")
return { key: "unauthorized", label: "Running", detail: "Running, but the API secret is rejected.", active: true, tone: "urgent" }
if (apiState !== "ok")
return { key: "running_no_api", label: "Running", detail: "Running; mihomo's API is not reachable.", active: true, tone: "idle" }
return { key: "running", label: "Connected", detail: "", active: true, tone: "good" }
}
// The mode switch talks to the running core. With nothing running there is
// nothing to switch, and writing the file alone would show a mode that is not
// in effect — so the control goes quiet instead of lying.
function canSwitchMode(probe, api) {
var state = connectionState(probe, api)
return state.key === "running" || state.key === "running_no_api"
}
function modeHint(probe, api) {
var state = connectionState(probe, api)
if (state.key === "running") return ""
if (state.key === "running_no_api") return "Switching restarts mihomo — its API is unreachable."
if (state.key === "cli_missing" || state.key === "not_initialized" || state.key === "unit_missing") return ""
return "Start mihomo to switch modes."
}
// ------------------------------------------------------------- subscriptions
function isValidSubscriptionUrl(url) {
var text = String(url === undefined || url === null ? "" : url).trim()
return /^https?:\/\/[^\s\/]+\.?[^\s]*$/i.test(text)
}
function subscriptionUrlError(url) {
var text = String(url === undefined || url === null ? "" : url).trim()
if (text === "") return "Enter a subscription URL."
if (!/^https?:\/\//i.test(text)) return "The URL must start with http:// or https://."
if (!isValidSubscriptionUrl(text)) return "That does not look like a valid URL."
return ""
}
var MASK = "*******"
// A subscription URL is a bearer credential: the token is the whole of the
// authentication. Keep only the origin recognizable; paths, query names,
// values, and fragments can all identify an account or expose credentials.
function maskUrl(url) {
var text = String(url === undefined || url === null ? "" : url).trim()
if (text === "") return ""
var parts = text.match(/^([a-z][a-z0-9+.\-]*:\/\/)([^\/?#]+)/i)
if (!parts) return MASK
return String(parts[1]) + String(parts[2]) + "/" + MASK
}
function displayUrl(url, revealed) {
var text = String(url === undefined || url === null ? "" : url).trim()
if (text === "") return "No subscription URL yet"
return revealed === true ? text : maskUrl(text)
}
// ------------------------------------------------------------------ history
//
// Throughput is sampled from the `/traffic` stream that already feeds the two
// speed readouts, so the curve behind each one is drawn from the very numbers
// printed on top of it and cannot drift from them.
//
// The series survives the panel closing, so reopening continues the curve
// instead of starting from nothing. What it must not do is close the gap: the
// curve spaces its samples evenly, so butting the last sample from ten minutes
// ago against the first from now would draw them as neighbours and misstate
// when the traffic happened. The seconds the stream was not running are filled
// in as baseline slots instead — the same flat line the chart shows before it
// has any data, which is exactly what those seconds are.
// One minute at the stream's one-second cadence. The window is sized to how
// long the panel is realistically open, not to how much history would be nice:
// the curve grows in from the right, so a window that never fills would only
// ever draw a stub against empty space.
var HISTORY_LIMIT = 60
// QML re-evaluates a `var` property's bindings when it is assigned, not when
// the array it holds is mutated, so a new array is returned rather than the
// sample being pushed into the old one.
// `Array.isArray` rather than `instanceof Array` throughout: the check has to
// hold for an array that was not built in this realm, which is what the tests
// hand it when they load this file into a vm context.
function pushHistory(history, sample, limit) {
var cap = Math.max(1, Math.floor(Number(limit) || 0) || 1)
var list = Array.isArray(history) ? history : []
var next = list.slice(Math.max(0, list.length - cap + 1))
var reading = Number(sample)
next.push(isFinite(reading) && reading > 0 ? reading : 0)
return next
}
// Records `slots` seconds during which nothing was sampled. Capped at the
// window: a gap longer than the history leaves nothing of the old series to
// keep, and the chart correctly comes back as a flat line.
function padHistory(history, slots, limit) {
var list = Array.isArray(history) ? history : []
var count = Math.floor(Number(slots))
if (!isFinite(count) || count <= 0) return list
var cap = Math.max(1, Math.floor(Number(limit) || 0) || 1)
var next = list
var fill = Math.min(count, cap)
for (var i = 0; i < fill; i++) next = pushHistory(next, 0, cap)
return next
}
// Sparkline geometry in a 0..1 box: x runs left to right, y is 0 at the
// baseline and 1 at the peak, leaving the QML nothing to do but scale.
//
// The newest sample is pinned to the right edge and the series grows leftward,
// so a history that has only just started reads as a short line at "now"
// instead of a full-width line that is mostly invention.
//
// The scale runs from zero to the peak, not from the window's minimum: twice
// the throughput should draw twice as tall. Normalising to min..max instead
// would blow an idle line's own jitter up to full height and make a quiet
// minute look identical to a busy one.
function peakOf(history) {
var list = Array.isArray(history) ? history : []
var peak = 0
for (var i = 0; i < list.length; i++) {
var seen = Number(list[i])
if (isFinite(seen) && seen > peak) peak = seen
}
return peak
}
function sparkline(history, capacity, scalePeak) {
var list = Array.isArray(history) ? history : []
var span = Math.max(2, Math.floor(Number(capacity) || 0) || 2) - 1
// A caller that draws several series side by side passes one peak for all of
// them, so equal heights mean equal throughput across the set. Left at zero,
// each series scales to its own window.
var forced = Number(scalePeak)
var peak = (isFinite(forced) && forced > 0) ? forced : peakOf(list)
var points = []
var i
var newest = list.length - 1
for (i = 0; i < list.length; i++) {
var reading = Number(list[i])
if (!isFinite(reading) || reading < 0) reading = 0
points.push({
x: Math.max(0, 1 - (newest - i) / span),
// Clamped: a shared peak can lag a series that just spiked past it.
y: peak > 0 ? Math.min(1, reading / peak) : 0
})
}
// Slots the panel was not open for. They are drawn as a flat line along the
// baseline so the box reads as a chart from the first second rather than as
// a stub floating at the right edge — but they are returned separately from
// the measured points, so the filled area can still mark exactly what was
// sampled and the empty stretch stays a bare line.
//
// With nothing sampled at all, `firstX` is the right edge and the lead spans
// the whole width: an empty chart is a flat baseline, not a blank box.
var lead = []
var firstX = points.length > 0 ? points[0].x : 1
if (firstX > 0) {
lead.push({ x: 0, y: 0 })
lead.push({ x: firstX, y: 0 })
}
return { points: points, lead: lead, peak: peak }
}
// ------------------------------------------------------------- node delays
//
// Surge-style reading of a delay: fast nodes are green, slow ones stand out,
// and a failed probe is not a fast node. mihomo reports a failed probe as
// delay 0, so 0 means timeout — a node never probed is NaN and stays "unknown".
var DELAY_FAST_MS = 800
function delayState(ms) {
var delay = Number(ms)
if (!isFinite(delay)) return "unknown"
if (delay <= 0) return "timeout"
return delay <= DELAY_FAST_MS ? "fast" : "slow"
}
function formatDelay(ms) {
var state = delayState(ms)
if (state === "unknown") return "—"
if (state === "timeout") return "timeout"
return String(Math.round(Number(ms))) + "ms"
}
// An array that crossed a QML delegate boundary (Repeater/Instantiator
// modelData) arrives as a sequence wrapper: `length` and indexing work, and
// `instanceof Array` is even true, but `Array.isArray` is false — it is not
// a real JS array. `instanceof` in turn fails across realms (the tests load
// this file into a vm context). Neither type check holds everywhere, so copy
// by length instead of asking what the value is.
function toArray(value) {
if (Array.isArray(value)) return value.slice()
if (!value || typeof value !== "object") return []
var count = Number(value.length)
if (!isFinite(count) || count < 0) return []
var out = []
for (var i = 0; i < count; i++) out.push(value[i])
return out
}
// Measured nodes fastest-first, then the never-probed, then timeouts: the
// order someone picking a node actually wants. Ties keep the subscription's
// own order, so the list does not shuffle between refreshes that measure
// nothing new.
function sortNodesByDelay(nodes) {
var list = toArray(nodes)
function rank(node) {
var state = delayState(node && node.delay)
return state === "fast" || state === "slow" ? 0 : state === "unknown" ? 1 : 2
}
var decorated = []
for (var i = 0; i < list.length; i++) decorated.push({ index: i, node: list[i] })
decorated.sort(function(a, b) {
var ra = rank(a.node)
var rb = rank(b.node)
if (ra !== rb) return ra - rb
if (ra === 0) {
var da = Number(a.node.delay)
var db = Number(b.node.delay)
if (da !== db) return da - db
}
return a.index - b.index
})
var sorted = []
for (var j = 0; j < decorated.length; j++) sorted.push(decorated[j].node)
return sorted
}
// ---------------------------------------------------------------- formatting
// Binary units, named as binary units. The arithmetic below divides by 1024,
// which is what mihomo's own dashboard does, so the numbers stay comparable
// with metacubexd — but calling 854,590,870 bytes "815 MB" understated it by
// 4.6% against the decimal megabyte every other tool and every ISP quotes.
var UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
function formatBytes(bytes) {
var value = Number(bytes)
if (!isFinite(value) || value <= 0) return "0 B"
var unit = 0
while (value >= 1024 && unit < UNITS.length - 1) {
value /= 1024
unit++
}
var digits = unit === 0 ? 0 : (value < 10 ? 1 : 0)
return value.toFixed(digits) + " " + UNITS[unit]
}
function formatSpeed(bytesPerSecond) {
return formatBytes(bytesPerSecond) + "/s"
}
function formatDuration(seconds) {
var total = Math.floor(Number(seconds))
if (!isFinite(total) || total < 0) return "—"
if (total < 60) return total + "s"
var minutes = Math.floor(total / 60)
if (minutes < 60) return minutes + "m " + (total % 60) + "s"
var hours = Math.floor(minutes / 60)
if (hours < 24) return hours + "h " + (minutes % 60) + "m"
return Math.floor(hours / 24) + "d " + (hours % 24) + "h"
}
function formatAgo(epochSeconds, nowSeconds) {
var then = Number(epochSeconds)
var now = Number(nowSeconds)
if (!isFinite(then) || then <= 0 || !isFinite(now) || now <= 0) return "—"
var delta = now - then
if (delta < 0) return "just now"
if (delta < 60) return "just now"
return formatDuration(delta).split(" ")[0] + " ago"
}
function formatPorts(config, live) {
var mixed = (live && live.mixedPort) || (config && config.mixedPort) || 0
var http = (live && live.port) || (config && config.port) || 0
var socks = (live && live.socksPort) || (config && config.socksPort) || 0
var parts = []
if (mixed > 0) parts.push("mixed " + mixed)
if (http > 0) parts.push("http " + http)
if (socks > 0) parts.push("socks " + socks)
return parts.length > 0 ? parts.join(" · ") : "—"
}