windscout 0.7.0 → 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.
- package/README.md +89 -16
- package/dist/background.js +131 -0
- package/dist/cli.js +180 -121
- package/dist/config.js +44 -5
- package/dist/docker.js +123 -25
- package/dist/measure.js +12 -0
- package/dist/probe.js +50 -24
- package/dist/scan.js +8 -16
- package/dist/time.js +5 -0
- package/dist/tui/connect.js +29 -0
- package/dist/tui/picker.js +80 -63
- package/dist/tui/progress.js +6 -4
- package/dist/tui/report.js +14 -4
- package/dist/tui/results.js +454 -0
- package/dist/tui/text.js +19 -0
- package/dist/watch.js +454 -0
- package/dist/windscribe.js +58 -10
- package/dist/worker.js +8 -6
- package/package.json +1 -1
|
@@ -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];
|