windscout 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,11 @@
1
1
  // The final report, printed to the normal screen so it stays in the scrollback.
2
- import { sortOk } from "../probe.js";
2
+ import { sortOk, target } from "../probe.js";
3
+ import { ago } from "../time.js";
3
4
  import { CLI } from "../windscribe.js";
4
5
  import { flag } from "./country.js";
5
6
  import { matrix, resultTable } from "./tables.js";
6
- import { bold, cyan, dim, fmt, green, vw, yellow } from "./text.js";
7
+ import { bold, cyan, dim, fmt, green, red, vw, yellow } from "./text.js";
7
8
  import { box, grid } from "./widgets.js";
8
- // nickname pins the exact datacenter
9
- export const target = (r) => r.location.split(" - ").at(-1) || r.query;
10
9
  export const command = (r) => `${CLI} connect "${target(r)}" ${r.protocol}`;
11
10
  /** Prints the report; returns the connections it numbered: the best (#0), then the others (#1…) */
12
11
  export function report(results, protocols, { all, speedTest }) {
@@ -62,3 +61,14 @@ export function report(results, protocols, { all, speedTest }) {
62
61
  console.log(rest.join("\n"));
63
62
  return [b, ...others];
64
63
  }
64
+ /** Prints the list as the results screen left it, best first, as windscout connect will try it: on the way out, when it
65
+ * changed there (a test, a drop, a background check) */
66
+ export function printList(list) {
67
+ if (!list.length)
68
+ return;
69
+ const speeds = list.some((e) => e.speedMbps !== null);
70
+ console.log(grid([{ title: "#", align: "right" }, { title: "Location" }, { title: "Protocol" }, { title: "Latency", align: "right" },
71
+ ...(speeds ? [{ title: "Speed", align: "right" }] : []), { title: "Last check" }], list.map((e, i) => [dim(String(i + 1)), `${flag(e.query)} ${e.location || e.query}`, e.protocol,
72
+ e.latencyMs === null ? "-" : `${fmt(e.latencyMs, 0)} ms`, ...(speeds ? [e.speedMbps === null ? "-" : `${fmt(e.speedMbps, 1)} Mbps`] : []),
73
+ e.failed ? red(`✗ ${e.failed} · ${ago(e.failedAt ?? Date.now())}`) : e.checked ? green(`✓ ${ago(e.checked)}`) : dim("✓ in the scan")]), { caption: `${bold("Your connections now")} ${dim("· saved: windscout connect tries them in this order")}`, minWidth: 64 }).join("\n"));
74
+ }
@@ -0,0 +1,454 @@
1
+ import { loadSettings, saveConfig, saveLast } from "../config.js";
2
+ import { dockerState } from "../docker.js";
3
+ import { SPEED_MB, SPEED_SECONDS } from "../measure.js";
4
+ import { target } from "../probe.js";
5
+ import { ago, duration, now } from "../time.js";
6
+ import { versionLines } from "../update.js";
7
+ import { appIo, CHECK_EVERY, Keeper, label, WATCH_GAP, WATCH_MISSES } from "../watch.js";
8
+ import { CLI, place } from "../windscribe.js";
9
+ import { countryName, flag } from "./country.js";
10
+ import { setupDocker } from "./docker.js";
11
+ import { printList } from "./report.js";
12
+ import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
13
+ import { bold, color, cut, cyan, dim, fmt, green, red, under, vw, wrap, yellow } from "./text.js";
14
+ import { bar, box, centerIn, grid, spin } from "./widgets.js";
15
+ const CONFIRM = { speed: "s", latency: "l", scan: "n" }; // asked first: their key again starts them
16
+ const clip = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s);
17
+ // what an attempt says it's doing, short: "connecting Frankfurt - Castle" → "connecting", "measuring speed · …" → "measuring speed"
18
+ const brief = (step) => step.replace(/^(connecting|reconnecting) .*/, "$1").split(" · ")[0];
19
+ const badge = (code, text) => color(code, ` ${text} `);
20
+ const where = (e) => `${flag(e.query)} ${bold(e.location || countryName(e.query))} ${dim("over")} ${bold(e.protocol)}`;
21
+ /** The last round trips as a bar each, taller for slower; a red dot for one that didn't get through */
22
+ function sparkline(trips) {
23
+ const got = trips.filter((t) => t !== null);
24
+ if (trips.length < 2 || !got.length)
25
+ return "";
26
+ const lo = Math.min(...got), hi = Math.max(...got), bars = "▁▂▃▄▅▆▇█";
27
+ return trips.map((t) => (t === null ? red("·") : dim(bars[hi > lo ? Math.round(((t - lo) / (hi - lo)) * 7) : 0]))).join("");
28
+ }
29
+ /** Shows the results until q (resolves to "quit") or n (resolves to "scan": the picker, for a new scan) */
30
+ export function showResults(o) {
31
+ const settings = loadSettings();
32
+ const seen = new Set(); // each datacenter and protocol once, as you'd connect to it
33
+ const entries = o.entries.filter((e) => !seen.has(`${target(e)} ${e.protocol}`) && !!seen.add(`${target(e)} ${e.protocol}`)).map((e) => ({ ...e }));
34
+ // saved as it changes, for windscout connect and the scanning types; the saved ones of other countries stay
35
+ const save = (list) => saveLast(list, Date.now(), new Set(list.map((e) => `${e.query} ${e.protocol}`)));
36
+ const k = new Keeper(entries, entries.some((e) => e.speedMbps !== null) ? "speed" : "latency", settings.watch, appIo(o.timeout, save));
37
+ let background = settings.background;
38
+ let selected = k.ranked()[0] ?? null;
39
+ let top = 0, frame = 0, lastClick = 0, title = "";
40
+ let widest = 0; // the screen only grows while it's open, so it doesn't jump about as the statuses change; a resize starts over
41
+ let message = "", confirm = null;
42
+ let inDialog = false, leaving = false;
43
+ let hit = { firstRow: -1, shown: 0, buttons: [] };
44
+ let timer;
45
+ let checker = null, checking = false; // checking: the background loop is on
46
+ const busy = () => k.queued !== null || ["connecting", "reconnecting", "testing"].includes(k.phase) || (k.phase === "none" && k.watch);
47
+ // the connection, in two lines: what it is, then how it's doing
48
+ const connection = () => {
49
+ const s = cyan(spin(frame)), q = k.queued, t = k.trying;
50
+ if (q) {
51
+ return q.kind === "connect" ? [`${s} ${bold("CONNECTING")} ${where(q.entry)}`, dim(" stopping what's running first")]
52
+ : q.kind === "disconnect" ? [`${s} ${bold("DISCONNECTING")}`, ""]
53
+ : [`${s} ${bold(q.speed ? "SPEED TEST" : "LATENCY TEST")}`, dim(" starting")];
54
+ }
55
+ switch (k.phase) {
56
+ case "connected": {
57
+ const e = k.current;
58
+ const health = k.misses ? yellow(`no answer ${k.misses}/${WATCH_MISSES}`) : k.ms === null ? dim("checking") : `${Math.round(k.ms)} ms`;
59
+ return [`${badge("1;30;42", "● CONNECTED")} ${where(e)}`,
60
+ `${dim("up")} ${duration(now() - k.since)} ${dim("·")} ${health}${sparkline(k.trips) && ` ${sparkline(k.trips)}`}${k.app.ip ? ` ${dim("· IP")} ${k.app.ip}` : ""}${k.drops ? dim(` · connected again ${k.drops}×`) : ""}`];
61
+ }
62
+ case "down":
63
+ return [`${badge("1;30;43", "▲ NO INTERNET")} ${where(k.current)}`,
64
+ dim(`no answer through it for ${duration(k.misses * WATCH_GAP)} · the watch is off: w connects you to the next best when it drops`)];
65
+ case "connecting":
66
+ case "reconnecting": {
67
+ const lost = k.phase === "reconnecting" ? k.lost : null;
68
+ const head = lost && `${badge("1;30;43", "▲ CONNECTION LOST")} ${dim(`${label(lost.entry)}: ${lost.why} · it lasted ${duration(lost.lasted)}`)}`;
69
+ if (!t)
70
+ return [head || `${s} ${bold("CONNECTING")}`, ""];
71
+ const detail = `${dim(brief(t.step))} ${dim("·")} ${duration(now() - t.start)} ${dim(`of ${o.timeout}s`)}${t.of > 1 ? dim(` · ${t.n} of ${t.of}`) : ""}`;
72
+ return head ? [head, `${s} ${where(t.entry)} ${dim("·")} ${detail}`] : [`${s} ${bold("CONNECTING")} ${where(t.entry)}`, ` ${detail}`];
73
+ }
74
+ case "none":
75
+ return [badge("1;37;41", "✗ NONE CONNECT NOW"), k.watch && k.retryAt > now()
76
+ ? `trying them all again in ${duration(k.retryAt - now())} ${dim("· enter tries the selected one now · esc stops")}`
77
+ : dim("enter tries the selected one again · s tests them all · n starts a new scan")];
78
+ case "testing": {
79
+ const test = k.test;
80
+ const left = (test.total - test.done) * (test.done ? (now() - test.start) / test.done : test.speed ? 8 + SPEED_SECONDS : 8);
81
+ return [`${s} ${bold(test.speed ? "SPEED TEST" : "LATENCY TEST")} ${test.entry ? `${where(test.entry)} ${dim(`· ${brief(test.step)}`)}` : ""}`,
82
+ `${bar(test.done / Math.max(1, test.total), 24)} ${bold(`${test.done}/${test.total}`)} ${dim(`· ≈ ${duration(left)} left${test.had ? ` · then back on ${label(test.had)}` : ""} · esc stops`)}`];
83
+ }
84
+ default: {
85
+ const state = k.app.state;
86
+ if (state.startsWith("Connected")) {
87
+ return [`${badge("1;30;42", "● CONNECTED IN THE APP")} ${bold(place(state) || "somewhere")}`,
88
+ dim("not one windscout connected, so it doesn't watch it · enter switches to the selected one")];
89
+ }
90
+ if (state.startsWith("Connecting"))
91
+ return [`${s} ${bold("THE APP IS CONNECTING")} ${place(state)}`, dim("enter switches to the selected one")];
92
+ return [`${dim("○")} ${bold("NOT CONNECTED")}${k.app.ip ? dim(` · your IP ${k.app.ip}, without a VPN`) : ""}`,
93
+ dim(k.entries.length ? "enter connects to the selected one · #1 is the best" : "nothing to connect to: n starts a new scan")];
94
+ }
95
+ }
96
+ };
97
+ const watchLine = (tiny) => k.watch
98
+ ? `${green("◉")} ${bold("watch on")} ${dim(tiny ? "· when the connection drops, it connects you to the next best"
99
+ : k.current ? "· when this connection drops, windscout connects you to the next best"
100
+ : "· it watches the connection you make here, and connects you to the next best when it drops")}`
101
+ : `${dim("○")} watch off ${dim("· w: when the connection drops, connect to the next best")}`;
102
+ const backgroundLine = (tiny) => {
103
+ if (!background) {
104
+ return `${dim("○")} background checks off ${dim(`· b: ${tiny ? "" : "while watching, "}docker checks the list every ${duration(CHECK_EVERY)}${tiny ? "" : ", latency only"}`)}`;
105
+ }
106
+ if (!k.watch)
107
+ return `${dim("◌")} background checks ${dim("· they run while watching")}`;
108
+ const last = k.lastCheck, status = k.checker?.status;
109
+ const next = k.test ? "after the test" : `next in ${duration(Math.max(0, k.checkAt - now()))}`;
110
+ const text = status || `${last ? `${last.worked} of ${last.of} worked ${ago(last.at)} · ` : ""}${next}`;
111
+ return `${green("◉")} background checks ${dim(`· ${text}`)}`;
112
+ };
113
+ const status = (e) => {
114
+ const step = k.trying?.entry === e ? k.trying.step : k.test?.entry === e ? k.test.step : null;
115
+ if (step !== null)
116
+ return cyan(`${spin(frame)} ${clip(brief(step), 20)}`);
117
+ if (e === k.current)
118
+ return k.phase === "down" || k.misses ? yellow("● no answer") : bold(green("● connected"));
119
+ if (e.failed)
120
+ return `${red(`✗ ${e.failed}`)} ${dim(`· ${ago(e.failedAt ?? Date.now())}`)}`;
121
+ return dim(`✓ ${ago(e.checked ?? o.at)}`);
122
+ };
123
+ // narrow: no country column, the flag goes by the location; tiny: no units (the titles say them); statusWidth: the status cut to fit
124
+ const row = (e, i, sel, speeds, narrow, tiny, statusWidth) => {
125
+ const cur = e === k.current, shade = (s) => (e.failed && !sel ? dim(s) : s);
126
+ const place = narrow ? `${flag(e.query)} ${e.location || countryName(e.query)}` : e.location || "-";
127
+ return [
128
+ `${sel ? cyan("❯") : " "} ${sel ? bold(String(i + 1)) : dim(String(i + 1))}`,
129
+ ...(narrow ? [] : [shade(`${flag(e.query)} ${countryName(e.query)}`)]),
130
+ sel ? bold(cyan(place)) : cur ? bold(green(place)) : shade(place),
131
+ shade(e.protocol),
132
+ shade(e.latencyMs === null ? "-" : `${fmt(e.latencyMs, 0)}${tiny ? "" : " ms"}`),
133
+ ...(speeds ? [shade(e.speedMbps === null ? "-" : bold(`${fmt(e.speedMbps, 1)}${tiny ? "" : " Mbps"}`))] : []),
134
+ cut(status(e), statusWidth),
135
+ ];
136
+ };
137
+ const BUTTONS = [["connect", () => "▶ Connect"], ["disconnect", () => `${under("D")}isconnect`],
138
+ ["speed", (tiny) => `${under("S")}peed test${tiny ? "" : " all"}`], ["latency", (tiny) => `${under("L")}atency test${tiny ? "" : " all"}`],
139
+ ["watch", () => `${under("W")}atch: ${k.watch ? "on" : "off"}`], ["scan", () => `${under("N")}ew scan`], ["quit", () => `${under("Q")}uit`]];
140
+ const render = () => {
141
+ if (inDialog || (leaving && !message))
142
+ return;
143
+ const cols = out.columns || 80, rows = out.rows || 24, room = Math.max(40, cols - 2); // room: the widest a line may be
144
+ // a tall terminal shows what happened last, and the version; a short one goes without blank lines; a narrow one without the
145
+ // country column, and with shorter hints
146
+ const tall = rows >= 34, short = rows < 30, narrow = cols < 120, tiny = cols < 100;
147
+ const list = k.ranked();
148
+ if (!selected || !list.includes(selected))
149
+ selected = list[0] ?? null;
150
+ const cursor = selected ? list.indexOf(selected) : -1, speeds = list.some((e) => e.speedMbps !== null);
151
+ const gap = short ? [] : [""];
152
+ const events = tall ? k.events.slice(-2).map((e) => ` ${dim(e.at.toTimeString().slice(0, 8))} ${e.text}`) : [];
153
+ const panel = [...gap, ...connection().map((l) => ` ${l}`), ...gap, null, ` ${watchLine(narrow)}`, ` ${backgroundLine(narrow)}`,
154
+ ...(events.length ? [null, ...events] : [])].map((l) => (l === null ? null : cut(l, room - 4)));
155
+ // the buttons, in as many rows as the width takes
156
+ const buttonRows = [[]];
157
+ for (const [b, text] of BUTTONS.map(([b, t]) => [b, `[ ${t(tiny)} ]`])) {
158
+ const line = buttonRows.at(-1);
159
+ if (line.length && vw(line.map(([, t]) => t).join(" ")) + 2 + vw(text) > room)
160
+ buttonRows.push([[b, text]]);
161
+ else
162
+ line.push([b, text]);
163
+ }
164
+ const paint = (line) => line.map(([b, t]) => (b === "connect" && selected ? bold(cyan(t)) : dim(t))).join(" ");
165
+ const stop = busy() ? "esc stops what's running · " : "";
166
+ const hints = tiny
167
+ ? [`↑↓ move · enter connect · d disconnect · ${stop}q quit`, "s/l test all · o sort · w watch · b background · n new scan"]
168
+ : [`↑↓ move · enter connect · d disconnect · ${stop}q quit`, "s speed test all · l latency test all · o sort · w watch · b background checks · n new scan"];
169
+ const said = message ? wrap(message, room).map(yellow) : [""];
170
+ const version = tall ? [...versionLines(), dim(repoLink)] : [];
171
+ const footer = gap.length + buttonRows.length + gap.length + hints.length + said.length + (version.length ? version.length + 1 : 0);
172
+ const height = Math.max(3, rows - (panel.length + 2) - (o.note ? 1 : 0) - 6 - footer);
173
+ if (cursor >= 0)
174
+ top = Math.min(Math.max(top, cursor - height + 1), cursor);
175
+ top = Math.max(0, Math.min(top, list.length - height));
176
+ const shown = list.slice(top, top + height);
177
+ const columns = [{ title: "#", align: "right" }, ...(narrow ? [] : [{ title: "Country" }]), { title: "Location" },
178
+ { title: "Protocol" }, { title: tiny ? "ms" : "Latency", align: "right" }, ...(speeds ? [{ title: tiny ? "Mbps" : "Speed", align: "right" }] : []),
179
+ { title: "Status" }];
180
+ const working = list.filter((e) => !e.failed).length;
181
+ const table = (statusWidth) => {
182
+ const body = shown.map((e, j) => row(e, top + j, top + j === cursor, speeds, narrow, tiny, statusWidth));
183
+ if (!body.length)
184
+ body.push(columns.map((_, i) => (i === (narrow ? 1 : 2) ? dim("no working connections: n starts a new scan") : "")));
185
+ return grid(columns, body, {
186
+ caption: bold(o.saved ? "Saved connections" : "Working connections"),
187
+ captionRight: dim(`${list.length > height ? `${top + 1}–${top + shown.length} of ${list.length} · ` : ""}by ${k.sort}${tiny ? "" : ` · o: by ${k.sort === "speed" ? "latency" : "speed"}`}`),
188
+ minWidth: Math.min(room, Math.max(64, widest, ...panel.map((l) => (l === null ? 0 : vw(l) + 4)))),
189
+ stretch: narrow ? 1 : 2,
190
+ });
191
+ };
192
+ const statuses = Math.max(6, ...shown.map((e) => vw(status(e))));
193
+ let lines = table(statuses);
194
+ if (vw(lines[0]) > room)
195
+ lines = table(Math.max(8, statuses - (vw(lines[0]) - room))); // the statuses give way first
196
+ const width = (widest = vw(lines[0]));
197
+ const head = box(panel, {
198
+ title: bold(cyan("WindScout")),
199
+ right: `${bold(String(working))}${dim(` working · ${o.saved ? "saved" : "scanned"} ${ago(o.at)}`)}`,
200
+ width: width - 4,
201
+ });
202
+ const screen = [
203
+ ...head,
204
+ ...(o.note ? [centerIn(yellow(cut(o.note, room)), width)] : []),
205
+ ...lines,
206
+ ...gap,
207
+ ...buttonRows.map((line) => centerIn(paint(line), width)),
208
+ ...gap,
209
+ ...hints.map((h) => centerIn(dim(h), width)),
210
+ ...said.map((l) => centerIn(l, width)),
211
+ ...(version.length ? ["", ...version.map((l) => centerIn(l, width))] : []),
212
+ ];
213
+ const at = draw(screen);
214
+ // where things landed, for clicks: the rows under the table's caption and header; each button's columns
215
+ const tableTop = at.top + head.length + (o.note ? 1 : 0), buttonsTop = tableTop + lines.length + gap.length;
216
+ hit = {
217
+ firstRow: tableTop + 5, shown: shown.length,
218
+ buttons: buttonRows.flatMap((line, r) => {
219
+ let x = at.left + Math.max(0, Math.floor((width - vw(paint(line))) / 2));
220
+ return line.map(([b, t]) => {
221
+ const range = [b, buttonsTop + r, x, x + vw(t)];
222
+ x += vw(t) + 2;
223
+ return range;
224
+ });
225
+ }),
226
+ };
227
+ // the window's title says it too, for when the terminal is behind another window
228
+ const windowTitle = k.phase === "connected" && k.current ? `windscout · connected: ${label(k.current)}`
229
+ : k.phase === "reconnecting" ? "windscout · connecting again…" : k.phase === "testing" ? "windscout · testing…" : "windscout";
230
+ if (windowTitle !== title)
231
+ out.write(`\x1b]2;${(title = windowTitle)}\x07`);
232
+ };
233
+ const askTest = (speed) => {
234
+ const n = k.entries.length;
235
+ if (!n)
236
+ return void (message = "nothing to test: n starts a new scan");
237
+ const each = speed ? 8 + SPEED_SECONDS : 8; // ponytail: seconds per working connection, connecting and disconnecting included
238
+ message = `${speed ? "speed" : "latency"} test all ${n}: ≈ ${duration(n * each)}${speed ? `, up to ${n * SPEED_MB} MB of downloads` : ""}${k.current ? ", switching your VPN as it goes, then back" : ""} · press ${speed ? "s" : "l"} again to start`;
239
+ confirm = { key: speed ? "s" : "l", run: () => k.retest(speed) };
240
+ };
241
+ const askScan = () => {
242
+ if (!k.current && !busy())
243
+ return void leave("scan");
244
+ message = `a new scan ${k.current ? "switches your VPN as it goes" : "stops what's running here"} · press n again to start it`;
245
+ confirm = { key: "n", run: () => void leave("scan") };
246
+ };
247
+ // while watching, and only then: they keep the list fresh for when the connection drops. The first check is due once the
248
+ // oldest news in the list is CHECK_EVERY old: right after a scan, in a few minutes; for an old saved list, now
249
+ const syncBackground = () => {
250
+ const want = background && k.watch && !leaving;
251
+ if (want === checking)
252
+ return;
253
+ checking = want;
254
+ if (!want)
255
+ return void k.stopBackground();
256
+ const oldest = Math.min(Date.now(), ...k.entries.map((e) => e.failedAt ?? e.checked ?? o.at));
257
+ void k.background((checker ??= o.checker()), Math.min(CHECK_EVERY, Math.max(0, CHECK_EVERY - (Date.now() - oldest) / 1000))).catch(() => { });
258
+ };
259
+ // turning them on gets docker going first (installs or starts it, after asking), unless it answers already
260
+ let toggling = false; // asking docker: another b waits for that
261
+ const toggleBackground = async () => {
262
+ if (toggling)
263
+ return;
264
+ toggling = true;
265
+ try {
266
+ if (!background && (await dockerState()) !== "ready") {
267
+ inDialog = true;
268
+ input.off("keypress", onKey);
269
+ input.off("mouse", onMouse);
270
+ const ok = await setupDocker().catch((e) => ((message = e.message), false));
271
+ input.on("keypress", onKey);
272
+ input.on("mouse", onMouse);
273
+ inDialog = false;
274
+ if (!ok)
275
+ return render();
276
+ }
277
+ if (leaving)
278
+ return;
279
+ background = !background;
280
+ saveConfig({ background });
281
+ syncBackground();
282
+ render();
283
+ }
284
+ finally {
285
+ toggling = false;
286
+ }
287
+ };
288
+ const press = (b) => {
289
+ if (b === "connect") {
290
+ if (!selected)
291
+ message = "nothing to connect to: n starts a new scan";
292
+ else if (selected === k.current && k.phase === "connected")
293
+ message = "you're connected to it · d disconnects";
294
+ else
295
+ k.connect(selected);
296
+ }
297
+ else if (b === "disconnect") {
298
+ if (k.current || busy() || /^Connect/.test(k.app.state))
299
+ k.disconnect();
300
+ else
301
+ message = "not connected";
302
+ }
303
+ else if (b === "speed" || b === "latency")
304
+ askTest(b === "speed");
305
+ else if (b === "watch") {
306
+ k.setWatch(!k.watch);
307
+ saveConfig({ watch: k.watch });
308
+ syncBackground();
309
+ }
310
+ else if (b === "scan")
311
+ askScan();
312
+ else
313
+ void leave("quit");
314
+ };
315
+ const onKey = (_, key = {}) => {
316
+ const name = key.name;
317
+ if (leaving)
318
+ return void ((name === "q" || name === "escape" || (key.ctrl && name === "c")) && leave("quit"));
319
+ if (key.ctrl && name === "c")
320
+ return void leave("quit");
321
+ const asked = confirm;
322
+ confirm = null;
323
+ message = "";
324
+ if (asked && name === asked.key)
325
+ return asked.run(), render();
326
+ const list = k.ranked(), i = selected ? list.indexOf(selected) : 0;
327
+ const move = (to) => (selected = list[Math.max(0, Math.min(list.length - 1, to))] ?? null);
328
+ if (name === "up" || name === "k")
329
+ move(i - 1);
330
+ else if (name === "down" || name === "j")
331
+ move(i + 1);
332
+ else if (name === "pageup")
333
+ move(i - 10);
334
+ else if (name === "pagedown")
335
+ move(i + 10);
336
+ else if (name === "home")
337
+ move(0);
338
+ else if (name === "end")
339
+ move(list.length - 1);
340
+ else if (name === "return")
341
+ press("connect");
342
+ else if (name === "d")
343
+ press("disconnect");
344
+ else if (name === "s")
345
+ press("speed");
346
+ else if (name === "l")
347
+ press("latency");
348
+ else if (name === "w")
349
+ press("watch");
350
+ else if (name === "n")
351
+ press("scan");
352
+ else if (name === "o")
353
+ k.sort = k.sort === "speed" ? "latency" : "speed";
354
+ else if (name === "b")
355
+ return void toggleBackground();
356
+ else if (name === "q")
357
+ return void leave("quit");
358
+ else if (name === "escape") {
359
+ if (!busy())
360
+ return void leave("quit");
361
+ k.cancel();
362
+ message = "stopping…";
363
+ }
364
+ render();
365
+ };
366
+ const onMouse = (m) => {
367
+ if (!m.press || leaving)
368
+ return;
369
+ const list = k.ranked(), i = selected ? list.indexOf(selected) : 0;
370
+ if (m.button === 64 || m.button === 65) { // wheel
371
+ selected = list[Math.max(0, Math.min(list.length - 1, i + (m.button === 64 ? -3 : 3)))] ?? null;
372
+ return render();
373
+ }
374
+ if (m.button !== 0)
375
+ return;
376
+ const asked = confirm;
377
+ confirm = null;
378
+ message = "";
379
+ if (m.y >= hit.firstRow && m.y < hit.firstRow + hit.shown) {
380
+ const e = list[top + m.y - hit.firstRow];
381
+ const twice = e === selected && Date.now() - lastClick < 500; // a double click connects
382
+ selected = e ?? selected;
383
+ lastClick = Date.now();
384
+ if (twice)
385
+ press("connect");
386
+ }
387
+ else {
388
+ const b = hit.buttons.find(([, y, from, to]) => m.y === y && m.x >= from && m.x < to)?.[0];
389
+ if (b && asked && asked.key === CONFIRM[b])
390
+ asked.run();
391
+ else if (b)
392
+ press(b);
393
+ }
394
+ render();
395
+ };
396
+ let running = Promise.resolve();
397
+ let settle = () => { };
398
+ const onResize = () => ((widest = 0), render());
399
+ const finish = (to) => {
400
+ clearInterval(timer);
401
+ out.off("resize", onResize);
402
+ out.write("\x1b]2;\x07\x1b[23;2t\x1b[?7h"); // the window title as it was; wrapping back on
403
+ leaveScreen();
404
+ if (to === "quit") {
405
+ if (k.changed)
406
+ printList(k.ranked());
407
+ const e = k.current;
408
+ if (e && (k.phase === "connected" || k.phase === "down")) {
409
+ console.log(`\n${green("●")} connected to ${flag(e.query)} ${bold(e.location || e.query)} over ${bold(e.protocol)} ${dim(`· it stays connected: ${CLI} disconnect ends it`)}`);
410
+ }
411
+ else if (/^Connected/.test(k.app.state))
412
+ console.log(`\n${green("●")} connected in the app: ${place(k.app.state)}`);
413
+ else
414
+ console.log(`\n${dim("○ not connected")}${k.entries.length ? dim(" · windscout connect connects you to the best of the list") : ""}`);
415
+ }
416
+ settle(to);
417
+ };
418
+ /** Ends what's running and the containers. Quitting lets a connection you asked for finish, and a test put back the one
419
+ * you had: you stay connected. A new scan stops everything right away (an attempt gives up within a second or two). A second
420
+ * q doesn't wait */
421
+ const leave = async (to) => {
422
+ if (leaving) {
423
+ leaveScreen();
424
+ process.exit(130); // the exit handlers remove the containers and the route
425
+ }
426
+ const back = k.test?.had, making = k.phase === "connecting" && !k.test ? k.trying?.entry : null;
427
+ leaving = true;
428
+ message = `${to === "scan" ? "stopping, for the new scan" : back ? `quitting once you're back on ${label(back)}`
429
+ : making ? `quitting once ${label(making)} connects (or doesn't)` : "quitting"}… (q again: right now)`;
430
+ syncBackground();
431
+ render();
432
+ k.close(to === "quit");
433
+ await running;
434
+ await k.stopBackground();
435
+ finish(to);
436
+ };
437
+ return new Promise((resolve, reject) => {
438
+ settle = resolve;
439
+ enterScreen();
440
+ out.write("\x1b[22;2t\x1b[?7l"); // keep the window title to put back; no wrapping: a line wider than the terminal is cut
441
+ input.on("keypress", onKey);
442
+ input.on("mouse", onMouse);
443
+ out.on("resize", onResize);
444
+ timer = setInterval(() => (frame++, render()), 150);
445
+ if (o.connect && selected)
446
+ k.connect(selected);
447
+ running = k.run().catch((e) => {
448
+ finish("quit");
449
+ reject(e);
450
+ });
451
+ syncBackground();
452
+ render();
453
+ });
454
+ }
package/dist/tui/text.js CHANGED
@@ -20,3 +20,22 @@ export function pad(s, n, align = "left") {
20
20
  return " ".repeat(Math.floor(gap / 2)) + s + " ".repeat(Math.ceil(gap / 2));
21
21
  return align === "right" ? " ".repeat(gap) + s : s + " ".repeat(gap);
22
22
  }
23
+ /** s cut to n visible columns, ending in "…" when it was longer; its colors end there */
24
+ export function cut(s, n) {
25
+ if (vw(s) <= n)
26
+ return s;
27
+ let out = "", w = 0;
28
+ for (const [t] of s.matchAll(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\|[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]|[\s\S]/gu)) {
29
+ if (t.startsWith("\x1b")) {
30
+ out += t;
31
+ continue;
32
+ }
33
+ if (w + vw(t) > n - 1)
34
+ break;
35
+ out += t;
36
+ w += vw(t);
37
+ }
38
+ return out + (plain() ? "…" : "\x1b[0m…");
39
+ }
40
+ /** Plain text in lines of at most n columns, broken at spaces */
41
+ export const wrap = (s, n) => s.match(new RegExp(`.{1,${Math.max(1, n)}}(\\s+|$)|\\S+`, "g"))?.map((l) => l.trim()) ?? [s];