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.
@@ -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 { attemptSeconds, estimate } from "../scan.js";
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, random, allPorts, docker }) {
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 matches = visible();
38
- const height = Math.max(3, rows - 23 - version.length);
39
- cursor = Math.max(0, Math.min(cursor, matches.length - 1));
40
- if (cursor < top)
41
- top = cursor;
42
- if (cursor >= top + height)
43
- top = cursor - height + 1;
44
- top = Math.max(0, Math.min(top, matches.length - height));
45
- const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? green("● " + p) : dim("○ " + p)}`).join(" ");
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(!allPorts
48
- ? "off: each protocol on its default port"
49
- : !portLists ? "checking the ports…"
50
- : !listed ? "this Windscribe app can't list its ports: 2.22 and newer can"
51
- : `every port of each protocol: ${each} attempt${each === 1 ? "" : "s"} per country${each > protos.size ? `, not ${protos.size}` : ""}`)}`;
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: connect-only, ranks by latency")}`;
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${here.length ? ` · ${here.join(" and ")} here, after them` : ""}`
75
- : dockerAt ? "docker isn't ready: start sets it up" : "checking docker…")}`;
76
- const search = searching
77
- ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
78
- : query ? `🔍 ${query}` : dim("🔍 press / to search");
79
- const shown = matches.slice(top, top + height);
80
- const range = matches.length > height ? `${top + 1}–${top + shown.length} of ${matches.length}` : `${matches.length} countries`;
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 here = top + j === cursor;
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 [`${here ? cyan("❯") : " "} ${mark}`, here ? bold(code) : dim(code), here ? bold(cyan(name)) : name];
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, randomLine, timeoutLine, dockerLine];
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(n
111
- ? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(login + estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""}${login ? `, + ~${duration(login)} login` : ""})`)}`
112
- : yellow("select at least one country and one protocol"), width),
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 · r random · t timeout · d docker`), width),
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), randomRow: row(randomLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine),
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 (!attempts()) {
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
- random,
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
- const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
245
- if (id === "start" && start())
307
+ if (button === "start" && start())
246
308
  return;
247
- if (id === "quit")
309
+ if (button === "quit")
248
310
  return finish(null);
249
- if (id === "all")
311
+ if (button === "all")
250
312
  matches.forEach((c) => selected.add(c));
251
- if (id === "none")
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 (k === "up")
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])
@@ -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, speedTest } } = scan;
15
- let view = speedTest ? "ranking" : "availability"; // tab switches
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 = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10];
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 = stopping ? yellow(` stopping after the current attempt${many ? "s" : ""}…`)
55
- : waiting ? yellow(` paused · VPN disconnected${blocking ? " · firewall blocking internet" : ""} · press p to resume`)
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(s.name ? ` · ${s.name}: after the containers` : " · idle");
61
- const head = ` ${cyan(spinner)} ${s.name ? dim(`${s.name}: `) : ""}${bold(attempt(s))} `, time = ` · ${duration(now() - s.attemptStart)}`;
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 (scan.stopping) { // second press: bail out now
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 (scan.stopping) {
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 {
@@ -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") : r.status === "key limit" || r.status === "no such port" ? yellow(r.status) : red(r.status),
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) {
@@ -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)));
@@ -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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windscout",
3
- "version": "0.6.5",
3
+ "version": "0.7.1",
4
4
  "description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
5
5
  "type": "module",
6
6
  "bin": {