windscout 0.6.5 → 0.7.1
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 +96 -7
- package/dist/cli.js +122 -43
- package/dist/config.js +47 -2
- package/dist/docker.js +5 -5
- package/dist/measure.js +1 -1
- package/dist/probe.js +83 -45
- package/dist/scan.js +43 -22
- package/dist/tui/docker.js +2 -2
- package/dist/tui/picker.js +134 -65
- package/dist/tui/progress.js +20 -12
- package/dist/tui/tables.js +1 -1
- package/dist/tui/terminal.js +21 -0
- package/dist/tui/widgets.js +2 -0
- package/dist/worker.js +2 -2
- package/package.json +1 -1
package/dist/tui/picker.js
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
1
|
import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
|
|
2
2
|
import { dockerState, loginSeconds, notInContainers, workerCount } from "../docker.js";
|
|
3
|
-
import {
|
|
3
|
+
import { STEADY_SECONDS } from "../probe.js";
|
|
4
|
+
import { attemptSeconds, estimate, ORDERS, plan } from "../scan.js";
|
|
4
5
|
import { duration } from "../time.js";
|
|
5
6
|
import { canUpdate, checkUpdate, versionLines } from "../update.js";
|
|
6
|
-
import { COUNTRIES, protocolPorts, PROTOCOLS } from "../windscribe.js";
|
|
7
|
+
import { COUNTRIES, protocolPorts, PROTOCOLS, withPorts } from "../windscribe.js";
|
|
7
8
|
import { countryName, flag } from "./country.js";
|
|
8
9
|
import { setupDocker } from "./docker.js";
|
|
9
|
-
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
10
|
+
import { draw, drawUntilNext, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
10
11
|
import { bold, cyan, dim, green, under, vw, yellow } from "./text.js";
|
|
11
|
-
import { box, centerIn, grid } from "./widgets.js";
|
|
12
|
+
import { box, centerIn, grid, spin } from "./widgets.js";
|
|
13
|
+
const ORDER_NAMES = {
|
|
14
|
+
default: "default",
|
|
15
|
+
random: "by random country",
|
|
16
|
+
"last-first": "by last success result first",
|
|
17
|
+
"last-only": "only the last results",
|
|
18
|
+
};
|
|
19
|
+
const needsLast = (o) => o === "last-first" || o === "last-only";
|
|
20
|
+
// only the last results: the countries, protocols and ports are greyed out, and their keys and clicks say why
|
|
21
|
+
const SKIPPED = "skipped with only the last results: o changes the scanning type";
|
|
12
22
|
const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
|
|
23
|
+
// "5m ago", "3h ago", "2 days ago"
|
|
24
|
+
const ago = (ms) => {
|
|
25
|
+
const m = Math.max(0, Math.round((Date.now() - ms) / 60000)), h = Math.round(m / 60), d = Math.round(h / 24);
|
|
26
|
+
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : h < 48 ? `${h}h ago` : `${d} days ago`;
|
|
27
|
+
};
|
|
13
28
|
/** Resolves to the choice (also saved for next time), null when the user quits, or "update" for u */
|
|
14
|
-
export function pick(saved, { timeout, speedTest, firewall,
|
|
29
|
+
export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst, allPorts, docker, last }) {
|
|
15
30
|
const selected = new Set(saved?.countries ?? []);
|
|
16
31
|
const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
|
|
17
32
|
let cursor = 0, top = 0, message = "", query = "", searching = false;
|
|
18
33
|
let seconds = String(timeout), editing = false; // the timeout, as typed
|
|
19
34
|
let dockerAt = null, inDialog = false; // docker's state once checked; a docker dialog has the screen
|
|
20
35
|
let portLists = null; // each protocol's ports, once the app said
|
|
36
|
+
let starting = -1; // once start is pressed: the start button's spinner frame, until the next screen shows
|
|
21
37
|
let hit;
|
|
22
38
|
const visible = () => {
|
|
23
39
|
const q = query.toLowerCase();
|
|
@@ -25,8 +41,15 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
25
41
|
};
|
|
26
42
|
const ports = (p) => (allPorts && portLists?.get(p)?.length) || 1; // attempts per country for the protocol
|
|
27
43
|
const perCountry = (ps) => [...ps].reduce((n, p) => n + ports(p), 0);
|
|
28
|
-
const attempts = () => selected.size * perCountry(protos);
|
|
29
44
|
const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
|
|
45
|
+
// what start scans, in its order: as the CLI plans it, so the estimate counts the same attempts. A parallel scan is the
|
|
46
|
+
// containers' alone: the protocols they can't run are skipped
|
|
47
|
+
const todo = () => {
|
|
48
|
+
const ps = PROTOCOLS.filter((p) => protos.has(p));
|
|
49
|
+
const all = plan(order, COUNTRIES.filter((c) => selected.has(c)), allPorts && portLists ? withPorts(ps, portLists) : ps, last?.results, portLists ?? undefined);
|
|
50
|
+
const cant = notInContainers(docker ? all.map((a) => a.protocol) : [], wireguard);
|
|
51
|
+
return { list: all.filter((a) => !cant.has(a.protocol)), skipped: [...new Set([...cant].map((p) => p.split(":")[0]))] };
|
|
52
|
+
};
|
|
30
53
|
enterScreen();
|
|
31
54
|
return new Promise((resolve) => {
|
|
32
55
|
const render = () => {
|
|
@@ -34,89 +57,114 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
34
57
|
return;
|
|
35
58
|
const rows = out.rows || 24;
|
|
36
59
|
const version = versionLines(true);
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
const off = order === "last-only"; // only the saved connections: the countries, protocols and ports are skipped
|
|
61
|
+
const missing = needsLast(order) && !last; // a type by the last result, and no saved scan: start is off
|
|
62
|
+
timeout = Number(seconds);
|
|
63
|
+
const { list, skipped } = todo(), n = list.length;
|
|
64
|
+
// only the last results lists the countries it scans, all selected; the user's own selection is back with the other types
|
|
65
|
+
const matches = off ? [...new Set(list.map((a) => a.country))] : visible();
|
|
66
|
+
const height = Math.max(3, rows - 24 - version.length);
|
|
67
|
+
if (!off) { // so the selection's list keeps its cursor and scroll for then
|
|
68
|
+
cursor = Math.max(0, Math.min(cursor, matches.length - 1));
|
|
69
|
+
if (cursor < top)
|
|
70
|
+
top = cursor;
|
|
71
|
+
if (cursor >= top + height)
|
|
72
|
+
top = cursor - height + 1;
|
|
73
|
+
top = Math.max(0, Math.min(top, matches.length - height));
|
|
74
|
+
}
|
|
75
|
+
const from = off ? 0 : top; // the first row shown
|
|
76
|
+
const parallel = docker && dockerAt === "ready" && n > 0;
|
|
77
|
+
const slots = parallel ? workerCount(n) : 1;
|
|
78
|
+
const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
|
|
79
|
+
const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? (off ? dim : green)("● " + p) : dim("○ " + p)}`).join(" ");
|
|
46
80
|
const listed = portLists && [...portLists.values()].some((l) => l.length), each = perCountry(protos);
|
|
47
|
-
const portsLine = `${dim(under("p"))} ${allPorts ? green("● all ports") : dim("○ all ports")} ${dim(
|
|
48
|
-
? "
|
|
49
|
-
: !
|
|
50
|
-
|
|
51
|
-
|
|
81
|
+
const portsLine = `${dim(under("p"))} ${allPorts ? (off ? dim : green)("● all ports") : dim("○ all ports")} ${dim(off
|
|
82
|
+
? "not used: each saved connection on its own protocol and port"
|
|
83
|
+
: !allPorts
|
|
84
|
+
? "off: each protocol on its default port"
|
|
85
|
+
: !portLists ? "checking the ports…"
|
|
86
|
+
: !listed ? "this Windscribe app can't list its ports: 2.22 and newer can"
|
|
87
|
+
: `every port of each protocol: ${each} attempt${each === 1 ? "" : "s"} per country${each > protos.size ? `, not ${protos.size}` : ""}`)}`;
|
|
52
88
|
const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
|
|
53
89
|
? "ranks by download speed · adds ~15s per working connection"
|
|
54
|
-
: "off
|
|
90
|
+
: stopAtFirst ? "off · stop at the first still runs it on each connection"
|
|
91
|
+
: "off: connect-only, ranks by latency")}`;
|
|
55
92
|
const fwLabel = "block internet while scanning (firewall)";
|
|
56
93
|
const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(docker
|
|
57
94
|
? "not used with docker"
|
|
58
95
|
: firewall ? "only VPN traffic gets through" : "off: apps may leak in between")}`;
|
|
59
|
-
const randomLine = `${dim(under("r"))} ${random ? green("● random order") : dim("○ random order")} ${dim(random
|
|
60
|
-
? "scans the countries shuffled, a new order each run"
|
|
61
|
-
: "off: scans in list order")}`;
|
|
62
|
-
timeout = Number(seconds);
|
|
63
96
|
const short = timeout < MIN_TIMEOUT;
|
|
64
97
|
const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
|
|
65
|
-
const n = attempts();
|
|
66
|
-
const here = PROTOCOLS.filter((p) => docker && protos.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
|
|
67
|
-
const inContainers = selected.size * perCountry([...protos].filter((p) => !here.includes(p)));
|
|
68
|
-
const parallel = docker && dockerAt === "ready" && inContainers > 0;
|
|
69
|
-
const slots = parallel ? workerCount(inContainers) : 1;
|
|
70
|
-
const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
|
|
71
98
|
const dockerLabel = "parallel scan in docker containers";
|
|
72
99
|
const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
|
|
73
100
|
? "off: one attempt at a time"
|
|
74
|
-
: dockerAt === "ready" ? `${slots} at a time
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
: (dockerAt === "ready" ? `${slots} at a time` : dockerAt ? "docker isn't ready: start sets it up" : "checking docker…")
|
|
102
|
+
+ (skipped.length ? ` · ${skipped.join(" and ")} skipped` : ""))}`;
|
|
103
|
+
const saves = last ? plan("last-only", [], [], last.results, portLists ?? undefined).length : 0; // the saved connections, each once
|
|
104
|
+
const firsts = list.filter((a) => a.saved).length; // those this scan tries first
|
|
105
|
+
const orderLine = `${dim(under("o"))} scanning type ${bold(ORDER_NAMES[order])} ${dim(missing
|
|
106
|
+
? "no saved scan yet"
|
|
107
|
+
: order === "default" ? "countries in list order"
|
|
108
|
+
: order === "random" ? "countries shuffled, a new order each run"
|
|
109
|
+
: order === "last-first" ? `${firsts} of ${saves} saved selected · ${ago(last.at)}`
|
|
110
|
+
: `${saves} saved working connection${saves === 1 ? "" : "s"} · ${ago(last.at)}`)}`;
|
|
111
|
+
const stopLabel = "stop at the first working connection";
|
|
112
|
+
// the best saved connection goes first: when it still holds up, the scan ends there
|
|
113
|
+
const lead = list[0]?.saved ? list[0] : null;
|
|
114
|
+
const stopLine = `${dim(under("w"))} ${stopAtFirst ? green("● " + stopLabel) : dim("○ " + stopLabel)} ${dim(!stopAtFirst
|
|
115
|
+
? "off: tries every attempt"
|
|
116
|
+
: lead ? `≈ ${duration(login + STEADY_SECONDS)} if ${lead.country} ${lead.protocol} still holds up`
|
|
117
|
+
: "after a speed and stability check")}`;
|
|
118
|
+
const search = off
|
|
119
|
+
? yellow("the last results' countries: only these are scanned")
|
|
120
|
+
: searching ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
121
|
+
: query ? `🔍 ${query}` : dim("🔍 press / to search");
|
|
122
|
+
const shown = matches.slice(from, from + height);
|
|
123
|
+
const range = matches.length > height ? `${from + 1}–${from + shown.length} of ${matches.length}` : `${matches.length} countries`;
|
|
81
124
|
const body = shown.map((code, j) => {
|
|
82
|
-
const
|
|
83
|
-
const mark = selected.has(code) ? green("●") : dim("○");
|
|
125
|
+
const current = !off && top + j === cursor;
|
|
126
|
+
const mark = off || selected.has(code) ? (off ? dim : green)("●") : dim("○");
|
|
84
127
|
const name = `${flag(code)} ${countryName(code)}`;
|
|
85
|
-
return [`${
|
|
128
|
+
return [`${current ? cyan("❯") : " "} ${mark}`, current ? bold(code) : dim(code), current ? bold(cyan(name)) : off ? dim(name) : name];
|
|
86
129
|
});
|
|
87
130
|
if (!body.length)
|
|
88
|
-
body.push([" ", "", dim("no country matches")]);
|
|
131
|
+
body.push([" ", "", dim(off ? "no saved scan yet" : "no country matches")]);
|
|
89
132
|
const buttons = [
|
|
90
|
-
["start", "▶ Start"],
|
|
133
|
+
["start", starting < 0 ? "▶ Start" : `${spin(starting++)} Starting…`],
|
|
91
134
|
["all", under("A") + (query ? "ll shown" : "ll")],
|
|
92
135
|
["none", under("N") + (query ? "one shown" : "one")],
|
|
93
136
|
["quit", under("Q") + "uit"],
|
|
94
137
|
];
|
|
95
|
-
const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
96
|
-
const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine,
|
|
138
|
+
const buttonLine = buttons.map(([id, label]) => (id === "start" && n && !missing ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
139
|
+
const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine];
|
|
97
140
|
const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...settings.map(vw)) + 4 });
|
|
98
141
|
const width = vw(table[0]);
|
|
99
142
|
const head = box(settings, {
|
|
100
143
|
title: bold(cyan("WindScout")),
|
|
101
|
-
right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
144
|
+
right: `${(off ? dim : bold)(String(off ? matches.length : selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
102
145
|
width: width - 4,
|
|
103
146
|
});
|
|
147
|
+
const total = login + estimate(n, timeout, slots);
|
|
148
|
+
const costs = dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""}${login ? `, + ~${duration(login)} login` : ""})`);
|
|
104
149
|
const lines = [
|
|
105
150
|
...head,
|
|
106
151
|
...table,
|
|
107
152
|
"",
|
|
108
153
|
centerIn(buttonLine, width),
|
|
109
154
|
"",
|
|
110
|
-
centerIn(
|
|
111
|
-
?
|
|
112
|
-
: yellow(
|
|
155
|
+
centerIn(missing
|
|
156
|
+
? yellow("no saved scan yet, so start is off: press o for another scanning type")
|
|
157
|
+
: !n ? yellow(skipped.length ? `the docker containers can't run ${skipped.join(" or ")}: d turns docker off`
|
|
158
|
+
: `select at least one country and one protocol${last ? ", or o for only the last results" : ""}`)
|
|
159
|
+
: stopAtFirst // it can end at any attempt: this is when none holds up
|
|
160
|
+
? `up to ${bold(String(n))} attempts · at most ${bold("≈ " + duration(total))} ${costs}`
|
|
161
|
+
: `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(total))} ${costs}`, width),
|
|
113
162
|
centerIn(dim(editing
|
|
114
163
|
? "type the seconds to wait for each connection · enter/esc done"
|
|
115
164
|
: searching
|
|
116
165
|
? "type to filter · ↑↓ move · enter/esc done"
|
|
117
|
-
// both lines fit 80 columns
|
|
118
166
|
: `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
|
|
119
|
-
centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall ·
|
|
167
|
+
centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall · t timeout · d docker · o type · w first`), width),
|
|
120
168
|
];
|
|
121
169
|
if (message)
|
|
122
170
|
lines.push(centerIn(yellow(message), width));
|
|
@@ -140,7 +188,7 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
140
188
|
});
|
|
141
189
|
hit = {
|
|
142
190
|
protoRow: row(protoLine), protoHits, portsRow: row(portsLine), speedRow: SPEED_TEST ? row(speedLine) : -1,
|
|
143
|
-
firewallRow: row(fwLine),
|
|
191
|
+
firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine),
|
|
144
192
|
searchRow: at.top + head.length + 1,
|
|
145
193
|
firstRow: at.top + head.length + 5, shown: shown.length,
|
|
146
194
|
buttonRow: at.top + head.length + table.length + 1, buttonHits,
|
|
@@ -161,7 +209,9 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
161
209
|
resolve(value);
|
|
162
210
|
};
|
|
163
211
|
const start = () => {
|
|
164
|
-
if (
|
|
212
|
+
if (needsLast(order) && !last)
|
|
213
|
+
return false; // start is off: the warning under the table says why
|
|
214
|
+
if (!todo().list.length) {
|
|
165
215
|
message = "nothing to scan yet";
|
|
166
216
|
return false;
|
|
167
217
|
}
|
|
@@ -175,15 +225,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
175
225
|
protocols: PROTOCOLS.filter((p) => protos.has(p)),
|
|
176
226
|
speed: speedTest,
|
|
177
227
|
firewall,
|
|
178
|
-
|
|
228
|
+
order,
|
|
229
|
+
stopAtFirst,
|
|
179
230
|
allPorts,
|
|
180
231
|
timeout,
|
|
181
232
|
docker,
|
|
182
233
|
};
|
|
183
234
|
saveConfig(choice);
|
|
184
235
|
finish(choice);
|
|
236
|
+
// the scan can take a moment to show (the firewall, docker): the button spins until its screen, or a docker one, draws
|
|
237
|
+
starting = 0;
|
|
238
|
+
drawUntilNext(render);
|
|
185
239
|
return true;
|
|
186
240
|
};
|
|
241
|
+
const cycleOrder = () => (order = ORDERS[(ORDERS.indexOf(order) + 1) % ORDERS.length]); // o: the next scanning type
|
|
187
242
|
// turning it on gets docker going first: installs or starts it (after asking), unless it answers already
|
|
188
243
|
const toggleDocker = async () => {
|
|
189
244
|
if (!docker && (dockerAt = await dockerState()) !== "ready") {
|
|
@@ -205,14 +260,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
205
260
|
if (!m.press)
|
|
206
261
|
return;
|
|
207
262
|
message = "";
|
|
208
|
-
const matches = visible();
|
|
263
|
+
const matches = visible(), off = order === "last-only";
|
|
209
264
|
const { x, y } = m;
|
|
265
|
+
const button = y === hit.buttonRow ? hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0] : undefined;
|
|
266
|
+
const onCountries = y === hit.searchRow || (y >= hit.firstRow && y < hit.firstRow + hit.shown) || button === "all" || button === "none";
|
|
267
|
+
if (off && (m.button === 64 || m.button === 65))
|
|
268
|
+
return; // the skipped countries don't scroll
|
|
210
269
|
if (m.button === 64)
|
|
211
270
|
cursor = Math.max(0, cursor - 3); // wheel up
|
|
212
271
|
else if (m.button === 65)
|
|
213
272
|
cursor = Math.min(matches.length - 1, cursor + 3); // wheel down
|
|
214
273
|
else if (m.button !== 0)
|
|
215
274
|
return; // left button only
|
|
275
|
+
else if (off && (onCountries || y === hit.protoRow || y === hit.portsRow))
|
|
276
|
+
message = SKIPPED;
|
|
216
277
|
else if (y === hit.searchRow)
|
|
217
278
|
searching = true, editing = false;
|
|
218
279
|
else if (y === hit.timeoutRow)
|
|
@@ -236,19 +297,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
236
297
|
speedTest = !speedTest;
|
|
237
298
|
else if (y === hit.firewallRow)
|
|
238
299
|
firewall = !firewall;
|
|
239
|
-
else if (y === hit.randomRow)
|
|
240
|
-
random = !random;
|
|
241
300
|
else if (y === hit.dockerRow)
|
|
242
301
|
return void toggleDocker();
|
|
302
|
+
else if (y === hit.orderRow)
|
|
303
|
+
cycleOrder();
|
|
304
|
+
else if (y === hit.stopRow)
|
|
305
|
+
stopAtFirst = !stopAtFirst;
|
|
243
306
|
else if (y === hit.buttonRow) {
|
|
244
|
-
|
|
245
|
-
if (id === "start" && start())
|
|
307
|
+
if (button === "start" && start())
|
|
246
308
|
return;
|
|
247
|
-
if (
|
|
309
|
+
if (button === "quit")
|
|
248
310
|
return finish(null);
|
|
249
|
-
if (
|
|
311
|
+
if (button === "all")
|
|
250
312
|
matches.forEach((c) => selected.add(c));
|
|
251
|
-
if (
|
|
313
|
+
if (button === "none")
|
|
252
314
|
matches.forEach((c) => selected.delete(c));
|
|
253
315
|
}
|
|
254
316
|
}
|
|
@@ -267,9 +329,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
267
329
|
flip(selected, c);
|
|
268
330
|
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
269
331
|
};
|
|
332
|
+
// only the last results: the keys for the countries, protocols and ports change nothing
|
|
333
|
+
const skipped = order === "last-only" && !editing && !searching
|
|
334
|
+
&& (["up", "down", "pageup", "pagedown", "home", "end", "j", "k", "space", "a", "n", "p"].includes(k ?? "") || str === "/" || /^[1-9]$/.test(str ?? ""));
|
|
270
335
|
if (key.ctrl && k === "c")
|
|
271
336
|
return finish(null);
|
|
272
|
-
if (
|
|
337
|
+
if (skipped)
|
|
338
|
+
message = SKIPPED;
|
|
339
|
+
else if (k === "up")
|
|
273
340
|
cursor = Math.max(0, cursor - 1);
|
|
274
341
|
else if (k === "down")
|
|
275
342
|
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
@@ -318,12 +385,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
318
385
|
speedTest = !speedTest;
|
|
319
386
|
else if (k === "f")
|
|
320
387
|
firewall = !firewall;
|
|
321
|
-
else if (k === "r")
|
|
322
|
-
random = !random;
|
|
323
388
|
else if (k === "t")
|
|
324
389
|
editing = true, seconds = "";
|
|
325
390
|
else if (k === "d")
|
|
326
391
|
return void toggleDocker();
|
|
392
|
+
else if (k === "o")
|
|
393
|
+
cycleOrder();
|
|
394
|
+
else if (k === "w")
|
|
395
|
+
stopAtFirst = !stopAtFirst;
|
|
327
396
|
else if (k === "u" && canUpdate())
|
|
328
397
|
return finish("update");
|
|
329
398
|
else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
|
package/dist/tui/progress.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadView, saveView } from "../config.js";
|
|
1
2
|
import { sortOk } from "../probe.js";
|
|
2
3
|
import { attemptSeconds } from "../scan.js";
|
|
3
4
|
import { duration, now } from "../time.js";
|
|
@@ -7,12 +8,14 @@ import { countryName, flag } from "./country.js";
|
|
|
7
8
|
import { matrix, resultTable } from "./tables.js";
|
|
8
9
|
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
9
10
|
import { bold, cyan, dim, green, red, vw, yellow } from "./text.js";
|
|
10
|
-
import { bar, box, centerIn } from "./widgets.js";
|
|
11
|
-
/** Progress bar, current attempt, results so far. tab switches the table, p pauses, q stops (twice: quits).
|
|
11
|
+
import { bar, box, centerIn, spin } from "./widgets.js";
|
|
12
|
+
/** Progress bar, current attempt, results so far. tab switches the table (the next scans start on it), p pauses, q stops (twice: quits).
|
|
12
13
|
* blocking: we turned the firewall on for the scan */
|
|
13
14
|
export function showProgress(scan, blocking) {
|
|
14
|
-
const { protocols, options: { timeout
|
|
15
|
-
|
|
15
|
+
const { protocols, options: { timeout } } = scan;
|
|
16
|
+
const speedTest = scan.options.speedTest || Boolean(scan.options.stopAtFirst); // stop at the first measures speed too
|
|
17
|
+
let quitting = false; // q pressed: a second one quits right away
|
|
18
|
+
let view = loadView(); // tab switches, and saves it for the next scans
|
|
16
19
|
let frame = 0;
|
|
17
20
|
const render = () => {
|
|
18
21
|
const rows = out.rows || 24;
|
|
@@ -22,7 +25,7 @@ export function showProgress(scan, blocking) {
|
|
|
22
25
|
const partial = slots.reduce((sum, s) => sum + (s.current ? Math.min(0.95, (now() - s.attemptStart) / attemptSeconds(timeout)) : 0), 0);
|
|
23
26
|
const fraction = (done + (done < total ? partial : 0)) / total;
|
|
24
27
|
const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout) / slots.length);
|
|
25
|
-
const spinner =
|
|
28
|
+
const spinner = spin(frame++);
|
|
26
29
|
const ok = sortOk(results);
|
|
27
30
|
// blocked: nothing worked, and something was really tried (a key limit or a port the app doesn't offer tries nothing)
|
|
28
31
|
const failed = finished.filter((c) => !results.some((r) => r.query === c && r.status === "ok")
|
|
@@ -51,14 +54,15 @@ export function showProgress(scan, blocking) {
|
|
|
51
54
|
: [];
|
|
52
55
|
const width = Math.max(minW, table.length ? vw(table[0]) : 0);
|
|
53
56
|
const inner = width - 4;
|
|
54
|
-
const status =
|
|
55
|
-
:
|
|
56
|
-
: ""
|
|
57
|
+
const status = scan.found ? green(" ✓ a connection held up · stopping the other attempts…")
|
|
58
|
+
: stopping ? yellow(` stopping after the current attempt${many ? "s" : ""}…`)
|
|
59
|
+
: waiting ? yellow(` paused · VPN disconnected${blocking ? " · firewall blocking internet" : ""} · press p to resume`)
|
|
60
|
+
: "";
|
|
57
61
|
const pausing = paused && !waiting && frame % 8 < 5;
|
|
58
62
|
const slotLine = (s) => {
|
|
59
63
|
if (!s.current)
|
|
60
|
-
return s.step ? ` ${red("✗")} ${dim(clip(s.step, inner - 3))}` : dim(
|
|
61
|
-
const head = ` ${cyan(spinner)} ${
|
|
64
|
+
return s.step ? ` ${red("✗")} ${dim(clip(s.step, inner - 3))}` : dim(" · idle");
|
|
65
|
+
const head = ` ${cyan(spinner)} ${bold(attempt(s))} `, time = ` · ${duration(now() - s.attemptStart)}`;
|
|
62
66
|
return head + dim(clip(s.step, inner - vw(head) - time.length) + time);
|
|
63
67
|
};
|
|
64
68
|
const panel = box([
|
|
@@ -95,6 +99,7 @@ export function showProgress(scan, blocking) {
|
|
|
95
99
|
input.on("keypress", async (_str, key = {}) => {
|
|
96
100
|
if (key.name === "tab") {
|
|
97
101
|
view = view === "ranking" ? "availability" : "ranking";
|
|
102
|
+
saveView(view);
|
|
98
103
|
render();
|
|
99
104
|
}
|
|
100
105
|
else if (key.name === "p" || key.name === "space") {
|
|
@@ -102,12 +107,13 @@ export function showProgress(scan, blocking) {
|
|
|
102
107
|
render();
|
|
103
108
|
}
|
|
104
109
|
else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
105
|
-
if (
|
|
110
|
+
if (quitting) { // second press: bail out now
|
|
106
111
|
leaveScreen();
|
|
107
112
|
console.error(`stopped. the VPN may still be connecting: run '${CLI} disconnect'`);
|
|
108
113
|
await restoreFirewall();
|
|
109
114
|
process.exit(130);
|
|
110
115
|
}
|
|
116
|
+
quitting = true;
|
|
111
117
|
scan.stop();
|
|
112
118
|
render();
|
|
113
119
|
}
|
|
@@ -126,11 +132,13 @@ const clip = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" :
|
|
|
126
132
|
/** A line per attempt. Ctrl-C stops after the current attempt, a second one quits */
|
|
127
133
|
export function logProgress(scan) {
|
|
128
134
|
const many = scan.slots.length > 1; // attempts overlap: a whole line once each is done
|
|
135
|
+
let quitting = false; // Ctrl-C pressed: a second one quits right away
|
|
129
136
|
process.on("SIGINT", async () => {
|
|
130
|
-
if (
|
|
137
|
+
if (quitting) {
|
|
131
138
|
await restoreFirewall();
|
|
132
139
|
process.exit(130);
|
|
133
140
|
}
|
|
141
|
+
quitting = true;
|
|
134
142
|
scan.stop();
|
|
135
143
|
});
|
|
136
144
|
return {
|
package/dist/tui/tables.js
CHANGED
|
@@ -14,7 +14,7 @@ const resultRow = (r, i) => [
|
|
|
14
14
|
r.connectS === null ? "-" : `${fmt(r.connectS, 1)} s`,
|
|
15
15
|
r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`,
|
|
16
16
|
r.speedMbps === null ? "-" : bold(`${fmt(r.speedMbps, 1)} Mbps`),
|
|
17
|
-
r.status === "ok" ? green("✓ ok") :
|
|
17
|
+
r.status === "ok" ? green("✓ ok") : ["key limit", "no such port", "unreliable"].includes(r.status) ? yellow(r.status) : red(r.status),
|
|
18
18
|
];
|
|
19
19
|
/** Numbered attempts, in the given order; the Speed column only when the speed test ran */
|
|
20
20
|
export function resultTable(results, speedTest, options) {
|
package/dist/tui/terminal.js
CHANGED
|
@@ -13,6 +13,8 @@ export const input = new PassThrough();
|
|
|
13
13
|
emitKeypressEvents(input);
|
|
14
14
|
let fullscreen = false;
|
|
15
15
|
let link = null; // where draw() last put the repo link
|
|
16
|
+
let waiting; // drawUntilNext()'s timer
|
|
17
|
+
let redrawing = false; // drawUntilNext()'s own redraw is drawing
|
|
16
18
|
const openUrl = (url) => spawn({ darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open", [url], { stdio: "ignore", detached: true })
|
|
17
19
|
.on("error", () => { }).unref();
|
|
18
20
|
// SGR mouse report: ESC [ < button ; col ; row (M = press, m = release)
|
|
@@ -40,6 +42,7 @@ export function enterScreen() {
|
|
|
40
42
|
}
|
|
41
43
|
/** Back to the normal screen; drops every key and mouse listener */
|
|
42
44
|
export function leaveScreen() {
|
|
45
|
+
clearInterval(waiting);
|
|
43
46
|
if (!fullscreen)
|
|
44
47
|
return;
|
|
45
48
|
fullscreen = false;
|
|
@@ -51,8 +54,26 @@ export function leaveScreen() {
|
|
|
51
54
|
out.write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
52
55
|
}
|
|
53
56
|
process.on("exit", leaveScreen);
|
|
57
|
+
/** Calls redraw every 120 ms (for a spinner) while the screen waits for the next one: until another screen draws, or leaveScreen() */
|
|
58
|
+
export function drawUntilNext(redraw) {
|
|
59
|
+
const tick = () => {
|
|
60
|
+
redrawing = true;
|
|
61
|
+
try {
|
|
62
|
+
redraw();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
redrawing = false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
clearInterval(waiting);
|
|
69
|
+
waiting = setInterval(tick, 120);
|
|
70
|
+
waiting.unref(); // never what keeps the process alive
|
|
71
|
+
tick();
|
|
72
|
+
}
|
|
54
73
|
/** Redraws in place, centered: home, each line cleared to its end, then clear below (no flicker). Returns the top-left corner */
|
|
55
74
|
export function draw(lines) {
|
|
75
|
+
if (!redrawing)
|
|
76
|
+
clearInterval(waiting); // another screen: it takes over from drawUntilNext()
|
|
56
77
|
const cols = out.columns || 80, rows = out.rows || 24;
|
|
57
78
|
const w = Math.max(...lines.map(vw));
|
|
58
79
|
const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
|
package/dist/tui/widgets.js
CHANGED
|
@@ -32,6 +32,8 @@ export function grid(cols, rows, { caption, captionRight = "", minWidth = 0, str
|
|
|
32
32
|
out.push(row(cols.map((c) => bold(c.title))), rule("├", "┼", "┤"), ...rows.map(row), rule("╰", "┴", "╯"));
|
|
33
33
|
return out;
|
|
34
34
|
}
|
|
35
|
+
/** A spinner's character for this frame */
|
|
36
|
+
export const spin = (frame) => "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame % 10];
|
|
35
37
|
/** s, indented to sit centered in width columns */
|
|
36
38
|
export const centerIn = (s, width) => " ".repeat(Math.max(0, Math.floor((width - vw(s)) / 2))) + s;
|
|
37
39
|
/** Gradient bar (cyan → green) with eighth-block precision on the leading edge */
|
package/dist/worker.js
CHANGED
|
@@ -55,9 +55,9 @@ export async function worker() {
|
|
|
55
55
|
if (!m.probe)
|
|
56
56
|
continue;
|
|
57
57
|
stop = false;
|
|
58
|
-
const { query, protocol, timeout, speedTest } = m.probe;
|
|
58
|
+
const { query, protocol, timeout, speedTest, steady } = m.probe;
|
|
59
59
|
const result = await probe(query, protocol, {
|
|
60
|
-
timeout, speedTest,
|
|
60
|
+
timeout, speedTest, steady,
|
|
61
61
|
stopped: () => stop,
|
|
62
62
|
onStep: (step) => send({ step }),
|
|
63
63
|
measure: async (fn) => {
|