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/README.md +111 -18
- package/dist/cli.js +99 -36
- package/dist/config.js +31 -4
- package/dist/docker.js +2 -2
- package/dist/measure.js +1 -1
- package/dist/probe.js +83 -45
- package/dist/scan.js +36 -7
- package/dist/tui/docker.js +2 -2
- package/dist/tui/picker.js +118 -53
- package/dist/tui/progress.js +14 -8
- 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 +4 -2
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
|
-
|
|
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,
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
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 =
|
|
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.
|
|
64
|
-
const left = new Map(
|
|
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
|
}
|
package/dist/tui/docker.js
CHANGED
|
@@ -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(
|
|
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"))
|
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,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
|
-
|
|
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 -
|
|
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
|
|
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(
|
|
48
|
-
? "
|
|
49
|
-
: !
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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 [`${
|
|
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,
|
|
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(
|
|
111
|
-
?
|
|
112
|
-
: yellow(
|
|
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 ·
|
|
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),
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
if (id === "start" && start())
|
|
303
|
+
if (button === "start" && start())
|
|
246
304
|
return;
|
|
247
|
-
if (
|
|
305
|
+
if (button === "quit")
|
|
248
306
|
return finish(null);
|
|
249
|
-
if (
|
|
307
|
+
if (button === "all")
|
|
250
308
|
matches.forEach((c) => selected.add(c));
|
|
251
|
-
if (
|
|
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 (
|
|
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])
|