windscout 0.6.4 → 0.7.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/dist/probe.js CHANGED
@@ -1,62 +1,100 @@
1
1
  // One attempt: connect to a location over a protocol, measure through the tunnel, disconnect.
2
- import { latencyMs, speedMbps } from "./measure.js";
2
+ // windscout connect makes the same attempt and, when it works, stays connected.
3
+ import { latencyMs, SPEED_SECONDS, speedMbps } from "./measure.js";
3
4
  import { now, sleep } from "./time.js";
4
5
  import { connectState, windscribe } from "./windscribe.js";
6
+ // Stop at the first ends the scan at one connection, so that one has to last: a tunnel can connect and then be cut or throttled
7
+ // once traffic flows. It counts once it downloads at MIN_MBPS or more, then stays up with internet for STEADY_CHECKS round trips
8
+ export const MIN_MBPS = 1;
9
+ const STEADY_CHECKS = 5, STEADY_GAP = 2; // seconds between the round trips
10
+ // shortcut: guesses ~5 s to connect and the whole speed test; refine from real runs
11
+ export const STEADY_SECONDS = 5 + SPEED_SECONDS + STEADY_CHECKS * (STEADY_GAP + 0.5);
12
+ /** Whether stop at the first can stop at this result: it worked, held up, and is fast enough for real use */
13
+ export const reliable = (r) => r.status === "ok" && (r.speedMbps ?? 0) >= MIN_MBPS;
5
14
  // "Connected: Miami - Vice" → "Miami - Vice"
6
15
  const place = (state) => state.split(":").slice(1).join(":").trim();
7
- export async function probe(query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
16
+ export async function probe(query, protocol, options) {
17
+ try {
18
+ return await attempt(query, protocol, options);
19
+ }
20
+ finally {
21
+ options.onStep?.("disconnecting");
22
+ await disconnect();
23
+ }
24
+ }
25
+ /** Disconnects, and waits until the app has */
26
+ export async function disconnect() {
27
+ await windscribe("disconnect");
28
+ // the CLI's disconnect can return while the app is still tearing down (it does after a timed-out connect);
29
+ // a connect sent then is swallowed and the next attempt reads as an instant "failed", so wait it out
30
+ for (let i = 0; i < 30 && (await connectState()) === "Disconnecting"; i++)
31
+ await sleep(1000);
32
+ }
33
+ /** Connects and measures through the tunnel, as probe does, but leaves the connection as it is: up when it worked */
34
+ export async function attempt(query, protocol, { timeout, speedTest, steady = false, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
8
35
  const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null };
9
36
  const end = (status) => ({ ...r, status });
10
37
  onStep("connecting");
11
38
  await windscribe("connect", "-n", query, protocol);
12
39
  const t = now();
13
- try {
14
- for (;;) {
15
- if (stopped())
16
- return end("interrupted");
17
- if (now() - t >= timeout)
18
- return end("timeout");
19
- const s = await connectState();
20
- if (s.startsWith("Connecting:"))
40
+ for (;;) {
41
+ if (stopped())
42
+ return end("interrupted");
43
+ if (now() - t >= timeout)
44
+ return end("timeout");
45
+ const s = await connectState();
46
+ if (s.startsWith("Connecting:"))
47
+ r.location = place(s);
48
+ else if (s.startsWith("Connected")) {
49
+ if (s.includes(":"))
21
50
  r.location = place(s);
22
- else if (s.startsWith("Connected")) {
23
- if (s.includes(":"))
24
- r.location = place(s);
25
- r.connectS = now() - t;
26
- break;
27
- }
28
- else if (s.includes("does not exist"))
29
- return end("no such location");
30
- // Windscribe refused this app another WireGuard key for the account: says nothing about this network
31
- else if (s.includes("WireGuard key limit") && now() - t > 3)
32
- return end("key limit");
33
- else if (s === "Disconnected" && now() - t > 3)
34
- return end("failed");
35
- onStep(`connecting ${r.location}`.trim());
36
- await sleep(1000);
51
+ r.connectS = now() - t;
52
+ break;
37
53
  }
38
- // a port the app doesn't offer for the protocol: it connects on the protocol's default instead, so this port wasn't tried
39
- const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol: .*:(\d+)\s*$/m)?.[1];
40
- if (port && shown && shown !== port)
41
- return end("no such port");
42
- await measure(async () => {
43
- onStep(`measuring latency · ${r.location}`);
44
- r.latencyMs = await latencyMs();
45
- if (r.latencyMs !== null && speedTest) {
46
- onStep(`measuring speed · ${r.location}`);
47
- r.speedMbps = await speedMbps();
48
- }
49
- });
50
- return end(r.latencyMs === null ? "no internet" : "ok");
54
+ else if (s.includes("does not exist"))
55
+ return end("no such location");
56
+ // Windscribe refused this app another WireGuard key for the account: says nothing about this network
57
+ else if (s.includes("WireGuard key limit") && now() - t > 3)
58
+ return end("key limit");
59
+ else if (s === "Disconnected" && now() - t > 3)
60
+ return end("failed");
61
+ onStep(`connecting ${r.location}`.trim());
62
+ await sleep(1000);
51
63
  }
52
- finally {
53
- onStep("disconnecting");
54
- await windscribe("disconnect");
55
- // the CLI's disconnect can return while the app is still tearing down (it does after a timed-out connect);
56
- // a connect sent then is swallowed and the next attempt reads as an instant "failed", so wait it out
57
- for (let i = 0; i < 30 && (await connectState()) === "Disconnecting"; i++)
58
- await sleep(1000);
64
+ // a port the app doesn't offer for the protocol: it connects on the protocol's default instead, so this port wasn't tried
65
+ const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol: .*:(\d+)\s*$/m)?.[1];
66
+ if (port && shown && shown !== port)
67
+ return end("no such port");
68
+ let measured = false, holds = true;
69
+ await measure(async () => {
70
+ // a stopping scan waits for no more measurements: a parallel scan's containers measure one at a time, so they'd queue up
71
+ if (stopped())
72
+ return;
73
+ measured = true;
74
+ onStep(`measuring latency · ${r.location}`);
75
+ r.latencyMs = await latencyMs();
76
+ if (r.latencyMs === null)
77
+ return;
78
+ if (speedTest || steady) {
79
+ onStep(`measuring speed · ${r.location}`);
80
+ r.speedMbps = await speedMbps();
81
+ }
82
+ if (steady)
83
+ holds = await holdsUp(r.location, onStep);
84
+ });
85
+ if (!measured)
86
+ return end("interrupted");
87
+ return end(r.latencyMs === null ? "no internet" : holds ? "ok" : "unreliable");
88
+ }
89
+ /** Whether the connection stays up with internet through it: STEADY_CHECKS round trips, STEADY_GAP seconds apart, each with a retry */
90
+ async function holdsUp(location, onStep) {
91
+ for (let i = 1; i <= STEADY_CHECKS; i++) {
92
+ onStep(`checking it holds up ${i}/${STEADY_CHECKS} · ${location}`);
93
+ await sleep(STEADY_GAP * 1000);
94
+ if (!(await connectState()).startsWith("Connected") || (await latencyMs(2)) === null)
95
+ return false;
59
96
  }
97
+ return true;
60
98
  }
61
99
  /** The attempts that worked, best first: fastest, then lowest latency */
62
100
  export const sortOk = (results) => results.filter((r) => r.status === "ok")
package/dist/scan.js CHANGED
@@ -1,10 +1,28 @@
1
- // A scan: every country × protocol pair, tried in order, with pause and stop.
1
+ // A scan: the attempts its scanning type plans (plan()), tried in order, with pause and stop.
2
2
  // Each slot runs one attempt at a time: one slot here, or one per docker container in a parallel scan.
3
- import { probe } from "./probe.js";
3
+ import { probe, reliable } from "./probe.js";
4
4
  import { now } from "./time.js";
5
5
  // ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
6
6
  export const attemptSeconds = (timeout) => timeout + 5;
7
7
  export const estimate = (attempts, timeout, slots = 1) => Math.ceil(attempts / slots) * attemptSeconds(timeout);
8
+ export const ORDERS = ["default", "random", "last-first", "last-only"];
9
+ const base = (protocol) => protocol.split(":")[0]; // "tcp:443" → "tcp"
10
+ /** What a scan of these countries and protocols tries, in the order of the scanning type, each once. saved: the last result's
11
+ * connections, best first. ports: each protocol's ports, its default first, so "tcp" and "tcp:443" are one attempt */
12
+ export function plan(order, countries, protocols, saved = [], ports = new Map()) {
13
+ const seen = new Set();
14
+ const once = ({ country, protocol }) => {
15
+ const [p, port] = protocol.split(":"), key = `${country} ${p}:${port ?? ports.get(p)?.[0] ?? ""}`;
16
+ return !seen.has(key) && !!seen.add(key);
17
+ };
18
+ const last = saved.map((r) => ({ country: r.query, protocol: r.protocol, saved: true }));
19
+ if (order === "last-only")
20
+ return last.filter(once);
21
+ const bases = new Set(protocols.map(base));
22
+ const first = order === "last-first" ? last.filter((a) => countries.includes(a.country) && bases.has(base(a.protocol))) : [];
23
+ const list = order === "random" ? countries.map((c) => [Math.random(), c]).sort(([x], [y]) => x - y).map(([, c]) => c) : countries;
24
+ return [...first, ...list.flatMap((country) => protocols.map((protocol) => ({ country, protocol })))].filter(once);
25
+ }
8
26
  /** Progress views read the fields; their keys call togglePause() and stop() */
9
27
  export class Scan {
10
28
  countries;
@@ -20,6 +38,7 @@ export class Scan {
20
38
  started = now();
21
39
  done = 0;
22
40
  stopping = false; // stop after the current attempts
41
+ found = false; // stopAtFirst: a connection held up, so the scan is stopping
23
42
  // a pause lands between attempts, so the VPN is disconnected while waiting
24
43
  paused = false;
25
44
  waiting = false; // paused, and no attempt running
@@ -33,7 +52,7 @@ export class Scan {
33
52
  this.options = options;
34
53
  this.probers = probers;
35
54
  this.after = after;
36
- this.total = countries.length * protocols.length;
55
+ this.total = this.attempts().length;
37
56
  this.slots = probers.map(() => ({ current: null, step: "", attemptStart: now() }));
38
57
  if (after)
39
58
  this.slots.push({ current: null, step: "", attemptStart: now(), name: after.name });
@@ -59,9 +78,15 @@ export class Scan {
59
78
  this.waiting = false;
60
79
  this.resume();
61
80
  }
81
+ attempts() {
82
+ return this.options.only?.map((a) => ({ ...a }))
83
+ ?? this.countries.flatMap((country) => this.protocols.map((protocol) => ({ country, protocol })));
84
+ }
62
85
  async run(hooks = {}) {
63
- const queue = this.countries.flatMap((country) => this.protocols.map((protocol) => ({ country, protocol })));
64
- const left = new Map(this.countries.map((c) => [c, this.protocols.length])); // attempts to go, per country
86
+ const queue = this.attempts();
87
+ const left = new Map(); // attempts to go, per country
88
+ for (const { country } of queue)
89
+ left.set(country, (left.get(country) ?? 0) + 1);
65
90
  const later = this.after?.protocols ?? new Set();
66
91
  await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], (p) => !later.has(p), queue, left, hooks)));
67
92
  // then the rest, also what slots whose container stopped left over
@@ -71,7 +96,7 @@ export class Scan {
71
96
  }
72
97
  /** One slot: takes the attempts it can make from the queue, one at a time, until there are none */
73
98
  async work(prober, slot, can, queue, left, hooks) {
74
- const { timeout, speedTest } = this.options;
99
+ const { timeout, speedTest, stopAtFirst = false } = this.options;
75
100
  for (;;) {
76
101
  while (this.paused && !this.stopping) {
77
102
  if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
@@ -89,7 +114,7 @@ export class Scan {
89
114
  hooks.attempt?.(country, protocol);
90
115
  let r;
91
116
  try {
92
- r = await prober(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
117
+ r = await prober(country, protocol, { timeout, speedTest, steady: stopAtFirst, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
93
118
  }
94
119
  catch (e) { // this slot is done for (its container stopped): the others take over its attempt
95
120
  queue.unshift(next);
@@ -109,6 +134,10 @@ export class Scan {
109
134
  if (!left.get(country))
110
135
  this.finished.push(country);
111
136
  hooks.result?.(r);
137
+ if (stopAtFirst && reliable(r) && !this.stopping) {
138
+ this.found = true;
139
+ this.stop();
140
+ }
112
141
  }
113
142
  }
114
143
  }
@@ -3,7 +3,7 @@ import { duration, now } from "../time.js";
3
3
  import { session } from "../windscribe.js";
4
4
  import { draw, enterScreen, input, leaveScreen, out } from "./terminal.js";
5
5
  import { bold, cyan, dim, vw, yellow } from "./text.js";
6
- import { box, centerIn } from "./widgets.js";
6
+ import { box, centerIn, spin } from "./widgets.js";
7
7
  const tty = () => Boolean(process.stdin.isTTY && out.isTTY);
8
8
  const clip = (s, n = 76) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
9
9
  // lines of at most n columns, broken at spaces: an error can be longer than the screen is wide
@@ -75,7 +75,7 @@ async function busy(status, work) {
75
75
  }
76
76
  const t = now();
77
77
  let frame = 0;
78
- const tick = () => paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
78
+ const tick = () => paint({ lines: [`${cyan(spin(frame++))} ${status.label} ${dim("· " + duration(now() - t))}`,
79
79
  dim(clip(status.line)), "", dim("esc quits")] });
80
80
  // the exit handlers remove containers already started
81
81
  const quit = (_, key = {}) => (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
@@ -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,7 +41,11 @@ 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);
44
+ // what start scans, in its order: as the CLI plans it, so the estimate counts the same attempts
45
+ const todo = () => {
46
+ const ps = PROTOCOLS.filter((p) => protos.has(p));
47
+ return plan(order, COUNTRIES.filter((c) => selected.has(c)), allPorts && portLists ? withPorts(ps, portLists) : ps, last?.results, portLists ?? undefined);
48
+ };
29
49
  const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
30
50
  enterScreen();
31
51
  return new Promise((resolve) => {
@@ -35,88 +55,112 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
35
55
  const rows = out.rows || 24;
36
56
  const version = versionLines(true);
37
57
  const matches = visible();
38
- const height = Math.max(3, rows - 23 - version.length);
58
+ const height = Math.max(3, rows - 24 - version.length);
39
59
  cursor = Math.max(0, Math.min(cursor, matches.length - 1));
40
60
  if (cursor < top)
41
61
  top = cursor;
42
62
  if (cursor >= top + height)
43
63
  top = cursor - height + 1;
44
64
  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(" ");
65
+ const off = order === "last-only"; // only the saved connections: the countries, protocols and ports are skipped
66
+ const missing = needsLast(order) && !last; // a type by the last result, and no saved scan: start is off
67
+ timeout = Number(seconds);
68
+ const list = todo(), n = list.length;
69
+ const base = (p) => p.split(":")[0]; // "wireguard:443" → "wireguard"
70
+ const scanned = new Set(list.map((a) => base(a.protocol)));
71
+ const here = PROTOCOLS.filter((p) => docker && scanned.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
72
+ const inContainers = list.filter((a) => !here.includes(base(a.protocol))).length;
73
+ const parallel = docker && dockerAt === "ready" && inContainers > 0;
74
+ const slots = parallel ? workerCount(inContainers) : 1;
75
+ const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
76
+ const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? (off ? dim : green)("● " + p) : dim("○ " + p)}`).join(" ");
46
77
  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}` : ""}`)}`;
78
+ const portsLine = `${dim(under("p"))} ${allPorts ? (off ? dim : green)("● all ports") : dim("○ all ports")} ${dim(off
79
+ ? "not used: each saved connection on its own protocol and port"
80
+ : !allPorts
81
+ ? "off: each protocol on its default port"
82
+ : !portLists ? "checking the ports…"
83
+ : !listed ? "this Windscribe app can't list its ports: 2.22 and newer can"
84
+ : `every port of each protocol: ${each} attempt${each === 1 ? "" : "s"} per country${each > protos.size ? `, not ${protos.size}` : ""}`)}`;
52
85
  const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
53
86
  ? "ranks by download speed · adds ~15s per working connection"
54
- : "off: connect-only, ranks by latency")}`;
87
+ : stopAtFirst ? "off · stop at the first still runs it on each connection"
88
+ : "off: connect-only, ranks by latency")}`;
55
89
  const fwLabel = "block internet while scanning (firewall)";
56
90
  const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(docker
57
91
  ? "not used with docker"
58
92
  : 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
93
  const short = timeout < MIN_TIMEOUT;
64
94
  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
95
  const dockerLabel = "parallel scan in docker containers";
72
96
  const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
73
97
  ? "off: one attempt at a time"
74
98
  : dockerAt === "ready" ? `${slots} at a time${here.length ? ` · ${here.join(" and ")} here, after them` : ""}`
75
99
  : 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");
100
+ const saves = last ? plan("last-only", [], [], last.results, portLists ?? undefined).length : 0; // the saved connections, each once
101
+ const firsts = list.filter((a) => a.saved).length; // those this scan tries first
102
+ const orderLine = `${dim(under("o"))} scanning type ${bold(ORDER_NAMES[order])} ${dim(missing
103
+ ? "no saved scan yet"
104
+ : order === "default" ? "countries in list order"
105
+ : order === "random" ? "countries shuffled, a new order each run"
106
+ : order === "last-first" ? `${firsts} of ${saves} saved selected · ${ago(last.at)}`
107
+ : `${saves} saved working connection${saves === 1 ? "" : "s"} · ${ago(last.at)}`)}`;
108
+ const stopLabel = "stop at the first working connection";
109
+ // the best saved connection goes first (unless it waits for the containers): when it still holds up, the scan ends there
110
+ const lead = list[0]?.saved && !here.includes(base(list[0].protocol)) ? list[0] : null;
111
+ const stopLine = `${dim(under("w"))} ${stopAtFirst ? green("● " + stopLabel) : dim("○ " + stopLabel)} ${dim(!stopAtFirst
112
+ ? "off: tries every attempt"
113
+ : lead ? `≈ ${duration(login + STEADY_SECONDS)} if ${lead.country} ${lead.protocol} still holds up`
114
+ : "after a speed and stability check")}`;
115
+ const search = off
116
+ ? yellow("countries skipped: only the last results are scanned")
117
+ : searching ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
118
+ : query ? `🔍 ${query}` : dim("🔍 press / to search");
79
119
  const shown = matches.slice(top, top + height);
80
120
  const range = matches.length > height ? `${top + 1}–${top + shown.length} of ${matches.length}` : `${matches.length} countries`;
81
121
  const body = shown.map((code, j) => {
82
- const here = top + j === cursor;
83
- const mark = selected.has(code) ? green("●") : dim("○");
122
+ const current = !off && top + j === cursor;
123
+ const mark = selected.has(code) ? (off ? dim : green)("●") : dim("○");
84
124
  const name = `${flag(code)} ${countryName(code)}`;
85
- return [`${here ? cyan("❯") : " "} ${mark}`, here ? bold(code) : dim(code), here ? bold(cyan(name)) : name];
125
+ return [`${current ? cyan("❯") : " "} ${mark}`, current ? bold(code) : dim(code), current ? bold(cyan(name)) : off ? dim(name) : name];
86
126
  });
87
127
  if (!body.length)
88
128
  body.push([" ", "", dim("no country matches")]);
89
129
  const buttons = [
90
- ["start", "▶ Start"],
130
+ ["start", starting < 0 ? "▶ Start" : `${spin(starting++)} Starting…`],
91
131
  ["all", under("A") + (query ? "ll shown" : "ll")],
92
132
  ["none", under("N") + (query ? "one shown" : "one")],
93
133
  ["quit", under("Q") + "uit"],
94
134
  ];
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];
135
+ const buttonLine = buttons.map(([id, label]) => (id === "start" && n && !missing ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
136
+ const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine];
97
137
  const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...settings.map(vw)) + 4 });
98
138
  const width = vw(table[0]);
99
139
  const head = box(settings, {
100
140
  title: bold(cyan("WindScout")),
101
- right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
141
+ right: `${(off ? dim : bold)(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
102
142
  width: width - 4,
103
143
  });
144
+ const total = login + estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout);
145
+ const costs = dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""}${login ? `, + ~${duration(login)} login` : ""})`);
104
146
  const lines = [
105
147
  ...head,
106
148
  ...table,
107
149
  "",
108
150
  centerIn(buttonLine, width),
109
151
  "",
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),
152
+ centerIn(missing
153
+ ? yellow("no saved scan yet, so start is off: press o for another scanning type")
154
+ : !n ? yellow(`select at least one country and one protocol${last ? ", or o for only the last results" : ""}`)
155
+ : stopAtFirst // it can end at any attempt: this is when none holds up
156
+ ? `up to ${bold(String(n))} attempts · at most ${bold("≈ " + duration(total))} ${costs}`
157
+ : `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(total))} ${costs}`, width),
113
158
  centerIn(dim(editing
114
159
  ? "type the seconds to wait for each connection · enter/esc done"
115
160
  : searching
116
161
  ? "type to filter · ↑↓ move · enter/esc done"
117
- // both lines fit 80 columns
118
162
  : `↑↓ 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),
163
+ 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
164
  ];
121
165
  if (message)
122
166
  lines.push(centerIn(yellow(message), width));
@@ -140,7 +184,7 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
140
184
  });
141
185
  hit = {
142
186
  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),
187
+ firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine),
144
188
  searchRow: at.top + head.length + 1,
145
189
  firstRow: at.top + head.length + 5, shown: shown.length,
146
190
  buttonRow: at.top + head.length + table.length + 1, buttonHits,
@@ -161,7 +205,9 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
161
205
  resolve(value);
162
206
  };
163
207
  const start = () => {
164
- if (!attempts()) {
208
+ if (needsLast(order) && !last)
209
+ return false; // start is off: the warning under the table says why
210
+ if (!todo().length) {
165
211
  message = "nothing to scan yet";
166
212
  return false;
167
213
  }
@@ -175,15 +221,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
175
221
  protocols: PROTOCOLS.filter((p) => protos.has(p)),
176
222
  speed: speedTest,
177
223
  firewall,
178
- random,
224
+ order,
225
+ stopAtFirst,
179
226
  allPorts,
180
227
  timeout,
181
228
  docker,
182
229
  };
183
230
  saveConfig(choice);
184
231
  finish(choice);
232
+ // the scan can take a moment to show (the firewall, docker): the button spins until its screen, or a docker one, draws
233
+ starting = 0;
234
+ drawUntilNext(render);
185
235
  return true;
186
236
  };
237
+ const cycleOrder = () => (order = ORDERS[(ORDERS.indexOf(order) + 1) % ORDERS.length]); // o: the next scanning type
187
238
  // turning it on gets docker going first: installs or starts it (after asking), unless it answers already
188
239
  const toggleDocker = async () => {
189
240
  if (!docker && (dockerAt = await dockerState()) !== "ready") {
@@ -205,14 +256,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
205
256
  if (!m.press)
206
257
  return;
207
258
  message = "";
208
- const matches = visible();
259
+ const matches = visible(), off = order === "last-only";
209
260
  const { x, y } = m;
261
+ const button = y === hit.buttonRow ? hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0] : undefined;
262
+ const onCountries = y === hit.searchRow || (y >= hit.firstRow && y < hit.firstRow + hit.shown) || button === "all" || button === "none";
263
+ if (off && (m.button === 64 || m.button === 65))
264
+ return; // the skipped countries don't scroll
210
265
  if (m.button === 64)
211
266
  cursor = Math.max(0, cursor - 3); // wheel up
212
267
  else if (m.button === 65)
213
268
  cursor = Math.min(matches.length - 1, cursor + 3); // wheel down
214
269
  else if (m.button !== 0)
215
270
  return; // left button only
271
+ else if (off && (onCountries || y === hit.protoRow || y === hit.portsRow))
272
+ message = SKIPPED;
216
273
  else if (y === hit.searchRow)
217
274
  searching = true, editing = false;
218
275
  else if (y === hit.timeoutRow)
@@ -236,19 +293,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
236
293
  speedTest = !speedTest;
237
294
  else if (y === hit.firewallRow)
238
295
  firewall = !firewall;
239
- else if (y === hit.randomRow)
240
- random = !random;
241
296
  else if (y === hit.dockerRow)
242
297
  return void toggleDocker();
298
+ else if (y === hit.orderRow)
299
+ cycleOrder();
300
+ else if (y === hit.stopRow)
301
+ stopAtFirst = !stopAtFirst;
243
302
  else if (y === hit.buttonRow) {
244
- const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
245
- if (id === "start" && start())
303
+ if (button === "start" && start())
246
304
  return;
247
- if (id === "quit")
305
+ if (button === "quit")
248
306
  return finish(null);
249
- if (id === "all")
307
+ if (button === "all")
250
308
  matches.forEach((c) => selected.add(c));
251
- if (id === "none")
309
+ if (button === "none")
252
310
  matches.forEach((c) => selected.delete(c));
253
311
  }
254
312
  }
@@ -267,9 +325,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
267
325
  flip(selected, c);
268
326
  cursor = Math.min(matches.length - 1, cursor + 1);
269
327
  };
328
+ // only the last results: the keys for the countries, protocols and ports change nothing
329
+ const skipped = order === "last-only" && !editing && !searching
330
+ && (["up", "down", "pageup", "pagedown", "home", "end", "j", "k", "space", "a", "n", "p"].includes(k ?? "") || str === "/" || /^[1-9]$/.test(str ?? ""));
270
331
  if (key.ctrl && k === "c")
271
332
  return finish(null);
272
- if (k === "up")
333
+ if (skipped)
334
+ message = SKIPPED;
335
+ else if (k === "up")
273
336
  cursor = Math.max(0, cursor - 1);
274
337
  else if (k === "down")
275
338
  cursor = Math.min(matches.length - 1, cursor + 1);
@@ -318,12 +381,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
318
381
  speedTest = !speedTest;
319
382
  else if (k === "f")
320
383
  firewall = !firewall;
321
- else if (k === "r")
322
- random = !random;
323
384
  else if (k === "t")
324
385
  editing = true, seconds = "";
325
386
  else if (k === "d")
326
387
  return void toggleDocker();
388
+ else if (k === "o")
389
+ cycleOrder();
390
+ else if (k === "w")
391
+ stopAtFirst = !stopAtFirst;
327
392
  else if (k === "u" && canUpdate())
328
393
  return finish("update");
329
394
  else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])