windscout 0.5.8 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/cli.js +43 -11
- package/dist/config.js +27 -2
- package/dist/docker.js +330 -0
- package/dist/probe.js +10 -10
- package/dist/scan.js +70 -31
- package/dist/tui/docker.js +166 -0
- package/dist/tui/picker.js +60 -18
- package/dist/tui/progress.js +27 -13
- package/dist/windscribe.js +48 -2
- package/dist/worker.js +75 -0
- package/package.json +5 -3
package/dist/probe.js
CHANGED
|
@@ -4,7 +4,7 @@ import { now, sleep } from "./time.js";
|
|
|
4
4
|
import { connectState, windscribe } from "./windscribe.js";
|
|
5
5
|
// "Connected: Miami - Vice" → "Miami - Vice"
|
|
6
6
|
const place = (state) => state.split(":").slice(1).join(":").trim();
|
|
7
|
-
export async function probe(query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { } }) {
|
|
7
|
+
export async function probe(query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
|
|
8
8
|
const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null };
|
|
9
9
|
const end = (status) => ({ ...r, status });
|
|
10
10
|
onStep("connecting");
|
|
@@ -32,15 +32,15 @@ export async function probe(query, protocol, { timeout, speedTest, stopped = ()
|
|
|
32
32
|
onStep(`connecting ${r.location}`.trim());
|
|
33
33
|
await sleep(1000);
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
return end("ok");
|
|
35
|
+
await measure(async () => {
|
|
36
|
+
onStep(`measuring latency · ${r.location}`);
|
|
37
|
+
r.latencyMs = await latencyMs();
|
|
38
|
+
if (r.latencyMs !== null && speedTest) {
|
|
39
|
+
onStep(`measuring speed · ${r.location}`);
|
|
40
|
+
r.speedMbps = await speedMbps();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return end(r.latencyMs === null ? "no internet" : "ok");
|
|
44
44
|
}
|
|
45
45
|
finally {
|
|
46
46
|
onStep("disconnecting");
|
package/dist/scan.js
CHANGED
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
// A scan: every country × protocol pair, tried in order, with pause and stop.
|
|
2
|
+
// Each slot runs one attempt at a time: one slot here, or one per docker container in a parallel scan.
|
|
2
3
|
import { probe } from "./probe.js";
|
|
3
4
|
import { now } from "./time.js";
|
|
4
5
|
// ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
|
|
5
6
|
export const attemptSeconds = (timeout) => timeout + 5;
|
|
6
|
-
export const estimate = (
|
|
7
|
+
export const estimate = (attempts, timeout, slots = 1) => Math.ceil(attempts / slots) * attemptSeconds(timeout);
|
|
7
8
|
/** Progress views read the fields; their keys call togglePause() and stop() */
|
|
8
9
|
export class Scan {
|
|
9
10
|
countries;
|
|
10
11
|
protocols;
|
|
11
12
|
options;
|
|
13
|
+
probers;
|
|
14
|
+
after;
|
|
12
15
|
results = [];
|
|
13
16
|
finished = []; // countries with every attempt done: the views refresh their tables per country
|
|
17
|
+
errors = []; // why slots stopped early
|
|
18
|
+
slots;
|
|
14
19
|
total;
|
|
15
20
|
started = now();
|
|
16
21
|
done = 0;
|
|
17
|
-
|
|
18
|
-
current = null;
|
|
19
|
-
step = ""; // what the running attempt is doing
|
|
20
|
-
stopping = false; // stop after the current attempt
|
|
22
|
+
stopping = false; // stop after the current attempts
|
|
21
23
|
// a pause lands between attempts, so the VPN is disconnected while waiting
|
|
22
24
|
paused = false;
|
|
23
|
-
waiting = false;
|
|
25
|
+
waiting = false; // paused, and no attempt running
|
|
24
26
|
waitStart = 0;
|
|
25
27
|
idle = 0; // seconds spent paused
|
|
26
28
|
resume = () => { };
|
|
27
|
-
|
|
29
|
+
unpaused = Promise.resolve();
|
|
30
|
+
constructor(countries, protocols, options, probers = [probe], after) {
|
|
28
31
|
this.countries = countries;
|
|
29
32
|
this.protocols = protocols;
|
|
30
33
|
this.options = options;
|
|
34
|
+
this.probers = probers;
|
|
35
|
+
this.after = after;
|
|
31
36
|
this.total = countries.length * protocols.length;
|
|
37
|
+
this.slots = probers.map(() => ({ current: null, step: "", attemptStart: now() }));
|
|
38
|
+
if (after)
|
|
39
|
+
this.slots.push({ current: null, step: "", attemptStart: now(), name: after.name });
|
|
32
40
|
}
|
|
33
41
|
/** Seconds spent scanning, pauses not counted */
|
|
34
42
|
elapsed() {
|
|
@@ -36,40 +44,71 @@ export class Scan {
|
|
|
36
44
|
}
|
|
37
45
|
togglePause() {
|
|
38
46
|
this.paused = !this.paused;
|
|
39
|
-
if (
|
|
40
|
-
this.
|
|
47
|
+
if (this.paused)
|
|
48
|
+
this.unpaused = new Promise((resolve) => (this.resume = resolve));
|
|
49
|
+
else
|
|
50
|
+
this.wake();
|
|
41
51
|
}
|
|
42
52
|
stop() {
|
|
43
53
|
this.stopping = true;
|
|
44
|
-
this.
|
|
54
|
+
this.wake(); // a paused scan stops right away
|
|
55
|
+
}
|
|
56
|
+
wake() {
|
|
57
|
+
if (this.waiting)
|
|
58
|
+
this.idle += now() - this.waitStart;
|
|
59
|
+
this.waiting = false;
|
|
60
|
+
this.resume();
|
|
45
61
|
}
|
|
46
62
|
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
|
|
65
|
+
const later = this.after?.protocols ?? new Set();
|
|
66
|
+
await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], (p) => !later.has(p), queue, left, hooks)));
|
|
67
|
+
// then the rest, also what slots whose container stopped left over
|
|
68
|
+
if (this.after)
|
|
69
|
+
await this.work(this.after.prober, this.slots[this.probers.length], () => true, queue, left, hooks);
|
|
70
|
+
return this.results;
|
|
71
|
+
}
|
|
72
|
+
/** One slot: takes the attempts it can make from the queue, one at a time, until there are none */
|
|
73
|
+
async work(prober, slot, can, queue, left, hooks) {
|
|
47
74
|
const { timeout, speedTest } = this.options;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (this.
|
|
75
|
+
for (;;) {
|
|
76
|
+
while (this.paused && !this.stopping) {
|
|
77
|
+
if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
|
|
51
78
|
this.waiting = true;
|
|
52
79
|
this.waitStart = now();
|
|
53
|
-
await new Promise((resolve) => (this.resume = resolve));
|
|
54
|
-
this.waiting = false;
|
|
55
|
-
this.idle += now() - this.waitStart;
|
|
56
|
-
}
|
|
57
|
-
if (this.stopping)
|
|
58
|
-
break outer;
|
|
59
|
-
this.current = { country, protocol };
|
|
60
|
-
this.attemptStart = now();
|
|
61
|
-
hooks.attempt?.(country, protocol);
|
|
62
|
-
const r = await probe(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (this.step = s) });
|
|
63
|
-
this.results.push(r);
|
|
64
|
-
this.done++;
|
|
65
|
-
hooks.result?.(r);
|
|
66
|
-
if (r.status === "no such location") { // its other protocols won't connect either
|
|
67
|
-
this.done += this.protocols.length - this.protocols.indexOf(protocol) - 1;
|
|
68
|
-
break;
|
|
69
80
|
}
|
|
81
|
+
await this.unpaused;
|
|
70
82
|
}
|
|
71
|
-
this.
|
|
83
|
+
const i = this.stopping ? -1 : queue.findIndex((a) => can(a.protocol));
|
|
84
|
+
if (i < 0)
|
|
85
|
+
return;
|
|
86
|
+
const [next] = queue.splice(i, 1);
|
|
87
|
+
const { country, protocol } = next;
|
|
88
|
+
Object.assign(slot, { current: next, step: "", attemptStart: now() });
|
|
89
|
+
hooks.attempt?.(country, protocol);
|
|
90
|
+
let r;
|
|
91
|
+
try {
|
|
92
|
+
r = await prober(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
|
|
93
|
+
}
|
|
94
|
+
catch (e) { // this slot is done for (its container stopped): the others take over its attempt
|
|
95
|
+
queue.unshift(next);
|
|
96
|
+
Object.assign(slot, { current: null, step: e.message });
|
|
97
|
+
this.errors.push(e.message);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
Object.assign(slot, { current: null, step: "" });
|
|
101
|
+
this.results.push(r);
|
|
102
|
+
let n = 1;
|
|
103
|
+
if (r.status === "no such location") // its other protocols won't connect either
|
|
104
|
+
for (let j = queue.length - 1; j >= 0; j--)
|
|
105
|
+
if (queue[j].country === country)
|
|
106
|
+
queue.splice(j, 1), n++;
|
|
107
|
+
this.done += n;
|
|
108
|
+
left.set(country, left.get(country) - n);
|
|
109
|
+
if (!left.get(country))
|
|
110
|
+
this.finished.push(country);
|
|
111
|
+
hooks.result?.(r);
|
|
72
112
|
}
|
|
73
|
-
return this.results;
|
|
74
113
|
}
|
|
75
114
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { buildImage, dockerState, INSTALL_DOCS, runSteps, startWorkers, steps, sudoLogin, sudoReady, waitReady, workerCount } from "../docker.js";
|
|
2
|
+
import { duration, now } from "../time.js";
|
|
3
|
+
import { session } from "../windscribe.js";
|
|
4
|
+
import { draw, enterScreen, input, leaveScreen, out } from "./terminal.js";
|
|
5
|
+
import { bold, cyan, dim, vw, yellow } from "./text.js";
|
|
6
|
+
import { box, centerIn } from "./widgets.js";
|
|
7
|
+
const tty = () => Boolean(process.stdin.isTTY && out.isTTY);
|
|
8
|
+
const clip = (s, n = 76) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
9
|
+
// lines of at most n columns, broken at spaces: an error can be longer than the screen is wide
|
|
10
|
+
const wrap = (s, n = 76) => s.match(new RegExp(`.{1,${n}}(\\s+|$)|\\S+`, "g"))?.map((l) => l.trim()) ?? [s];
|
|
11
|
+
function paint({ lines, password, buttons, error }, typed = "") {
|
|
12
|
+
const body = [...lines];
|
|
13
|
+
if (password !== undefined)
|
|
14
|
+
body.push("", `${password} ${bold("•".repeat(typed.length))}${cyan("▏")}`);
|
|
15
|
+
if (error)
|
|
16
|
+
body.push("", yellow(error));
|
|
17
|
+
const width = Math.max(56, ...body.map(vw));
|
|
18
|
+
let row = -1, line = "";
|
|
19
|
+
if (buttons) {
|
|
20
|
+
const [ok, cancel] = buttons;
|
|
21
|
+
line = `${bold(cyan(`[ ${ok} ]`))}${cancel ? dim(` [ ${cancel} ]`) : ""}`;
|
|
22
|
+
row = body.length + 1;
|
|
23
|
+
body.push("", centerIn(line, width), centerIn(dim([`enter ${ok.toLowerCase()}`, cancel && `esc ${cancel.toLowerCase()}`].filter(Boolean).join(" · ")), width));
|
|
24
|
+
}
|
|
25
|
+
enterScreen();
|
|
26
|
+
const at = draw(box(body, { title: bold(cyan("WindScout · docker")), width }));
|
|
27
|
+
if (!buttons)
|
|
28
|
+
return { row: -1, ok: [0, 0], cancel: [0, 0] };
|
|
29
|
+
// a body line sits a row under the box's top border, two columns right of its left one
|
|
30
|
+
const x = at.left + 2 + Math.floor((width - vw(line)) / 2), okEnd = x + vw(`[ ${buttons[0]} ]`);
|
|
31
|
+
return { row: at.top + 1 + row, ok: [x, okEnd], cancel: buttons[1] ? [okEnd + 2, okEnd + 2 + vw(`[ ${buttons[1]} ]`)] : [0, 0] };
|
|
32
|
+
}
|
|
33
|
+
/** Shows the dialog until the user answers: what was typed in its field ("" without one) on enter or a click on ok, null on esc */
|
|
34
|
+
function ask(d) {
|
|
35
|
+
if (!tty())
|
|
36
|
+
return Promise.resolve(null);
|
|
37
|
+
let typed = "";
|
|
38
|
+
let hits = paint(d, typed);
|
|
39
|
+
const redraw = () => (hits = paint(d, typed));
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const done = (v) => {
|
|
42
|
+
input.off("keypress", onKey);
|
|
43
|
+
input.off("mouse", onMouse);
|
|
44
|
+
out.off("resize", redraw);
|
|
45
|
+
resolve(v);
|
|
46
|
+
};
|
|
47
|
+
const onKey = (str, key = {}) => {
|
|
48
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c"))
|
|
49
|
+
return done(null);
|
|
50
|
+
if (key.name === "return")
|
|
51
|
+
return done(typed);
|
|
52
|
+
if (key.name === "backspace")
|
|
53
|
+
typed = typed.slice(0, -1);
|
|
54
|
+
else if (d.password !== undefined && str && !key.ctrl && !key.meta && str >= " ")
|
|
55
|
+
typed += str;
|
|
56
|
+
redraw();
|
|
57
|
+
};
|
|
58
|
+
const onMouse = (m) => {
|
|
59
|
+
const on = ([from, to]) => m.press && m.button === 0 && m.y === hits.row && m.x >= from && m.x < to;
|
|
60
|
+
if (on(hits.ok))
|
|
61
|
+
done(typed);
|
|
62
|
+
else if (on(hits.cancel))
|
|
63
|
+
done(null);
|
|
64
|
+
};
|
|
65
|
+
input.on("keypress", onKey);
|
|
66
|
+
input.on("mouse", onMouse);
|
|
67
|
+
out.on("resize", redraw);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Waits for work while the screen shows a spinner, status.label, the time so far and status.line (its latest output). esc quits */
|
|
71
|
+
async function busy(status, work) {
|
|
72
|
+
if (!tty()) {
|
|
73
|
+
console.error(`${status.label}…`);
|
|
74
|
+
return work;
|
|
75
|
+
}
|
|
76
|
+
const t = now();
|
|
77
|
+
let frame = 0;
|
|
78
|
+
const tick = () => paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
|
|
79
|
+
dim(clip(status.line)), "", dim("esc quits")] });
|
|
80
|
+
// the exit handlers remove containers already started
|
|
81
|
+
const quit = (_, key = {}) => (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
|
|
82
|
+
&& (leaveScreen(), process.exit(130));
|
|
83
|
+
const timer = setInterval(tick, 120);
|
|
84
|
+
input.on("keypress", quit);
|
|
85
|
+
tick();
|
|
86
|
+
try {
|
|
87
|
+
return await work;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
clearInterval(timer);
|
|
91
|
+
input.off("keypress", quit);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const WHY = {
|
|
95
|
+
missing: "A parallel scan runs each attempt in a docker container, and docker isn't installed.",
|
|
96
|
+
stopped: "A parallel scan runs each attempt in a docker container, and docker isn't running.",
|
|
97
|
+
denied: "Docker is installed, but your user can't use it yet.",
|
|
98
|
+
};
|
|
99
|
+
/** Gets docker answering: asks, then installs or starts it. False when the user cancels, it fails, or (Linux) a new login is needed first */
|
|
100
|
+
export async function setupDocker() {
|
|
101
|
+
const state = await dockerState();
|
|
102
|
+
if (state === "ready")
|
|
103
|
+
return true;
|
|
104
|
+
if (!tty())
|
|
105
|
+
throw new Error(`${WHY[state]} Run windscout in a terminal to set it up, or scan without --docker.`);
|
|
106
|
+
const todo = steps(state);
|
|
107
|
+
const root = todo.some((s) => s.root) && !(await sudoReady());
|
|
108
|
+
const lines = [WHY[state], "", "WindScout will:", ...todo.map((s) => ` • ${s.label}${s.root ? dim(" (sudo)") : ""}`),
|
|
109
|
+
...(state === "missing" && process.platform !== "linux"
|
|
110
|
+
? ["", dim("Docker Desktop is free for personal use, education and small businesses;"), dim("bigger companies need a paid plan: docker.com/pricing")]
|
|
111
|
+
: [])];
|
|
112
|
+
let password, error = "";
|
|
113
|
+
for (;;) {
|
|
114
|
+
const a = await ask({ lines, password: root ? "password for sudo:" : undefined, buttons: [state === "missing" ? "Install" : "Start", "Cancel"], error });
|
|
115
|
+
if (a === null)
|
|
116
|
+
return false;
|
|
117
|
+
if (!root)
|
|
118
|
+
break;
|
|
119
|
+
if (!(error = await sudoLogin(a))) {
|
|
120
|
+
password = a; // kept until the steps are done: one may need sudo again after a long download
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const status = { label: "", line: "" };
|
|
125
|
+
try {
|
|
126
|
+
await busy(status, runSteps(todo, password, (label) => Object.assign(status, { label, line: "" }), (line) => (status.line = line)));
|
|
127
|
+
if (process.platform === "linux" && state !== "stopped") {
|
|
128
|
+
await ask({ lines: ["Docker is ready, and you're in the docker group now.", "That takes effect in a new login session: log out and back in",
|
|
129
|
+
`(or run ${bold("newgrp docker")}), then start windscout again.`], buttons: ["OK"] });
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
Object.assign(status, { label: "waiting for docker to start", line: "" });
|
|
133
|
+
await busy(status, waitReady());
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const why = e.message;
|
|
138
|
+
// docker.com refuses some countries (Iran: US sanctions), and nobody mirrors Docker Desktop: only a VPN gets it there
|
|
139
|
+
const refused = /\b403\b|forbidden/i.test(why);
|
|
140
|
+
const a = await ask({ lines: [...wrap(why).map(yellow),
|
|
141
|
+
...(refused ? ["", ...wrap(`docker.com refuses downloads from some countries, Iran among them${process.platform === "linux" ? ""
|
|
142
|
+
: ", and no mirror carries Docker Desktop"}: connect a VPN (Windscribe will do), then retry.`)] : []),
|
|
143
|
+
...(state === "missing" ? ["", `To install docker yourself: ${INSTALL_DOCS}`] : [])], buttons: ["Retry", "Cancel"] });
|
|
144
|
+
return a !== null && setupDocker();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Starts the containers for a parallel scan of this many attempts: docker, the image, the containers' apps logged in with
|
|
148
|
+
* this computer's session. Resolves to their probers and whether WireGuard works in them; null if the user backed out */
|
|
149
|
+
export async function startParallel(attempts) {
|
|
150
|
+
const s = session();
|
|
151
|
+
if (!s)
|
|
152
|
+
throw new Error("can't find the session this computer's Windscribe app is logged in with, which the containers' apps log in with:"
|
|
153
|
+
+ " update the Windscribe app, or scan without --docker (d in the picker)");
|
|
154
|
+
if (!(await setupDocker()))
|
|
155
|
+
return null;
|
|
156
|
+
const status = { label: "building the containers' image (the first time takes a few minutes)", line: "" };
|
|
157
|
+
await busy(status, buildImage((line) => (status.line = line)));
|
|
158
|
+
const n = workerCount(attempts);
|
|
159
|
+
Object.assign(status, { label: `starting ${n} container${n > 1 ? "s" : ""}`, line: "" });
|
|
160
|
+
const started = await busy(status, startWorkers(n, s, (line) => (status.line = line)));
|
|
161
|
+
if (!started.probers.length) {
|
|
162
|
+
const why = status.line.startsWith("container") ? ` (${status.line})` : ""; // the last container's trouble
|
|
163
|
+
throw new Error(`no container got ready${why}`);
|
|
164
|
+
}
|
|
165
|
+
return started;
|
|
166
|
+
}
|
package/dist/tui/picker.js
CHANGED
|
@@ -1,31 +1,38 @@
|
|
|
1
|
-
import { MIN_TIMEOUT, saveConfig } from "../config.js";
|
|
1
|
+
import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig } from "../config.js";
|
|
2
|
+
import { dockerState, notInContainers, workerCount } from "../docker.js";
|
|
2
3
|
import { attemptSeconds, estimate } from "../scan.js";
|
|
3
4
|
import { duration } from "../time.js";
|
|
4
5
|
import { canUpdate, checkUpdate, versionLines } from "../update.js";
|
|
5
6
|
import { COUNTRIES, PROTOCOLS } from "../windscribe.js";
|
|
6
7
|
import { countryName, flag } from "./country.js";
|
|
8
|
+
import { setupDocker } from "./docker.js";
|
|
7
9
|
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
8
10
|
import { bold, cyan, dim, green, under, vw, yellow } from "./text.js";
|
|
9
11
|
import { box, centerIn, grid } from "./widgets.js";
|
|
10
12
|
const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
|
|
11
13
|
/** Resolves to the choice (also saved for next time), null when the user quits, or "update" for u */
|
|
12
|
-
export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
14
|
+
export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
|
|
13
15
|
const selected = new Set(saved?.countries ?? []);
|
|
14
16
|
const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
|
|
15
17
|
let cursor = 0, top = 0, message = "", query = "", searching = false;
|
|
16
18
|
let seconds = String(timeout), editing = false; // the timeout, as typed
|
|
19
|
+
let dockerAt = null, inDialog = false; // docker's state once checked; a docker dialog has the screen
|
|
17
20
|
let hit;
|
|
18
21
|
const visible = () => {
|
|
19
22
|
const q = query.toLowerCase();
|
|
20
23
|
return q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q) || countryName(c).toLowerCase().includes(q)) : COUNTRIES;
|
|
21
24
|
};
|
|
25
|
+
const attempts = () => selected.size * protos.size;
|
|
26
|
+
const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
|
|
22
27
|
enterScreen();
|
|
23
28
|
return new Promise((resolve) => {
|
|
24
29
|
const render = () => {
|
|
30
|
+
if (inDialog)
|
|
31
|
+
return;
|
|
25
32
|
const rows = out.rows || 24;
|
|
26
33
|
const version = versionLines(true);
|
|
27
34
|
const matches = visible();
|
|
28
|
-
const height = Math.max(3, rows -
|
|
35
|
+
const height = Math.max(3, rows - 23 - version.length);
|
|
29
36
|
cursor = Math.max(0, Math.min(cursor, matches.length - 1));
|
|
30
37
|
if (cursor < top)
|
|
31
38
|
top = cursor;
|
|
@@ -37,15 +44,24 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
37
44
|
? "ranks by download speed · adds ~15s per working connection"
|
|
38
45
|
: "off: connect-only, ranks by latency")}`;
|
|
39
46
|
const fwLabel = "block internet while scanning (firewall)";
|
|
40
|
-
const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(
|
|
41
|
-
? "
|
|
42
|
-
: "off: apps may leak in between")}`;
|
|
47
|
+
const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(docker
|
|
48
|
+
? "not used with docker"
|
|
49
|
+
: firewall ? "only VPN traffic gets through" : "off: apps may leak in between")}`;
|
|
43
50
|
const randomLine = `${dim(under("r"))} ${random ? green("● random order") : dim("○ random order")} ${dim(random
|
|
44
51
|
? "scans the countries shuffled, a new order each run"
|
|
45
52
|
: "off: scans in list order")}`;
|
|
46
53
|
timeout = Number(seconds);
|
|
47
54
|
const short = timeout < MIN_TIMEOUT;
|
|
48
55
|
const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
|
|
56
|
+
const n = attempts();
|
|
57
|
+
const here = PROTOCOLS.filter((p) => docker && protos.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
|
|
58
|
+
const inContainers = selected.size * (protos.size - here.length);
|
|
59
|
+
const slots = docker && dockerAt === "ready" && inContainers ? workerCount(inContainers) : 1;
|
|
60
|
+
const dockerLabel = "parallel scan in docker containers";
|
|
61
|
+
const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
|
|
62
|
+
? "off: one attempt at a time"
|
|
63
|
+
: dockerAt === "ready" ? `${slots} at a time${here.length ? ` · ${here.join(" and ")} here, after them` : ""}`
|
|
64
|
+
: dockerAt ? "docker isn't ready: start sets it up" : "checking docker…")}`;
|
|
49
65
|
const search = searching
|
|
50
66
|
? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
51
67
|
: query ? `🔍 ${query}` : dim("🔍 press / to search");
|
|
@@ -66,14 +82,13 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
66
82
|
["quit", under("Q") + "uit"],
|
|
67
83
|
];
|
|
68
84
|
const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
69
|
-
const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(
|
|
85
|
+
const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...[protoLine, speedLine, fwLine, randomLine, timeoutLine, dockerLine].map(vw)) + 4 });
|
|
70
86
|
const width = vw(table[0]);
|
|
71
|
-
const head = box([protoLine, speedLine, fwLine, randomLine, timeoutLine], {
|
|
87
|
+
const head = box([protoLine, speedLine, fwLine, randomLine, timeoutLine, dockerLine], {
|
|
72
88
|
title: bold(cyan("WindScout")),
|
|
73
89
|
right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
74
90
|
width: width - 4,
|
|
75
91
|
});
|
|
76
|
-
const n = selected.size * protos.size;
|
|
77
92
|
const lines = [
|
|
78
93
|
...head,
|
|
79
94
|
...table,
|
|
@@ -81,14 +96,15 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
81
96
|
centerIn(buttonLine, width),
|
|
82
97
|
"",
|
|
83
98
|
centerIn(n
|
|
84
|
-
? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(
|
|
99
|
+
? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""})`)}`
|
|
85
100
|
: yellow("select at least one country and one protocol"), width),
|
|
86
101
|
centerIn(dim(editing
|
|
87
102
|
? "type the seconds to wait for each connection · enter/esc done"
|
|
88
103
|
: searching
|
|
89
104
|
? "type to filter · ↑↓ move · enter/esc done"
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
// both lines fit 80 columns
|
|
106
|
+
: `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
|
|
107
|
+
centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · s speed test · f firewall · r random · t timeout · d docker`), width),
|
|
92
108
|
];
|
|
93
109
|
if (message)
|
|
94
110
|
lines.push(centerIn(yellow(message), width));
|
|
@@ -110,7 +126,7 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
110
126
|
return range;
|
|
111
127
|
});
|
|
112
128
|
hit = {
|
|
113
|
-
protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3, randomRow: at.top + 4, timeoutRow: at.top + 5,
|
|
129
|
+
protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3, randomRow: at.top + 4, timeoutRow: at.top + 5, dockerRow: at.top + 6,
|
|
114
130
|
searchRow: at.top + head.length + 1,
|
|
115
131
|
firstRow: at.top + head.length + 5, shown: shown.length,
|
|
116
132
|
buttonRow: at.top + head.length + table.length + 1, buttonHits,
|
|
@@ -118,6 +134,8 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
118
134
|
};
|
|
119
135
|
let closed = false;
|
|
120
136
|
checkUpdate().then(() => !closed && render()); // the check usually lands after the first draw
|
|
137
|
+
if (docker)
|
|
138
|
+
dockerState().then((s) => ((dockerAt = s), !closed && render()));
|
|
121
139
|
const finish = (value) => {
|
|
122
140
|
closed = true;
|
|
123
141
|
out.off("resize", render);
|
|
@@ -128,7 +146,7 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
128
146
|
resolve(value);
|
|
129
147
|
};
|
|
130
148
|
const start = () => {
|
|
131
|
-
if (!
|
|
149
|
+
if (!attempts()) {
|
|
132
150
|
message = "nothing to scan yet";
|
|
133
151
|
return false;
|
|
134
152
|
}
|
|
@@ -144,12 +162,30 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
144
162
|
firewall,
|
|
145
163
|
random,
|
|
146
164
|
timeout,
|
|
165
|
+
docker,
|
|
147
166
|
};
|
|
148
167
|
saveConfig(choice);
|
|
149
168
|
finish(choice);
|
|
150
169
|
return true;
|
|
151
170
|
};
|
|
152
|
-
|
|
171
|
+
// turning it on gets docker going first: installs or starts it (after asking), unless it answers already
|
|
172
|
+
const toggleDocker = async () => {
|
|
173
|
+
if (!docker && (dockerAt = await dockerState()) !== "ready") {
|
|
174
|
+
inDialog = true;
|
|
175
|
+
input.off("keypress", onKey);
|
|
176
|
+
input.off("mouse", onMouse);
|
|
177
|
+
const ok = await setupDocker();
|
|
178
|
+
input.on("keypress", onKey);
|
|
179
|
+
input.on("mouse", onMouse);
|
|
180
|
+
inDialog = false;
|
|
181
|
+
dockerAt = await dockerState();
|
|
182
|
+
if (!ok)
|
|
183
|
+
return render();
|
|
184
|
+
}
|
|
185
|
+
docker = !docker;
|
|
186
|
+
render();
|
|
187
|
+
};
|
|
188
|
+
const onMouse = (m) => {
|
|
153
189
|
if (!m.press)
|
|
154
190
|
return;
|
|
155
191
|
message = "";
|
|
@@ -184,6 +220,8 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
184
220
|
firewall = !firewall;
|
|
185
221
|
else if (y === hit.randomRow)
|
|
186
222
|
random = !random;
|
|
223
|
+
else if (y === hit.dockerRow)
|
|
224
|
+
return void toggleDocker();
|
|
187
225
|
else if (y === hit.buttonRow) {
|
|
188
226
|
const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
|
|
189
227
|
if (id === "start" && start())
|
|
@@ -197,9 +235,10 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
197
235
|
}
|
|
198
236
|
}
|
|
199
237
|
render();
|
|
200
|
-
}
|
|
238
|
+
};
|
|
239
|
+
input.on("mouse", onMouse);
|
|
201
240
|
out.on("resize", render);
|
|
202
|
-
|
|
241
|
+
const onKey = (str, key = {}) => {
|
|
203
242
|
message = "";
|
|
204
243
|
const k = key.name;
|
|
205
244
|
const matches = visible();
|
|
@@ -263,6 +302,8 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
263
302
|
random = !random;
|
|
264
303
|
else if (k === "t")
|
|
265
304
|
editing = true, seconds = "";
|
|
305
|
+
else if (k === "d")
|
|
306
|
+
return void toggleDocker();
|
|
266
307
|
else if (k === "u" && canUpdate())
|
|
267
308
|
return finish("update");
|
|
268
309
|
else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
|
|
@@ -276,7 +317,8 @@ export function pick(saved, { timeout, speedTest, firewall, random }) {
|
|
|
276
317
|
else if (k === "q" || k === "escape")
|
|
277
318
|
return finish(null);
|
|
278
319
|
render();
|
|
279
|
-
}
|
|
320
|
+
};
|
|
321
|
+
input.on("keypress", onKey);
|
|
280
322
|
render();
|
|
281
323
|
});
|
|
282
324
|
}
|
package/dist/tui/progress.js
CHANGED
|
@@ -16,16 +16,17 @@ export function showProgress(scan, blocking) {
|
|
|
16
16
|
let frame = 0;
|
|
17
17
|
const render = () => {
|
|
18
18
|
const rows = out.rows || 24;
|
|
19
|
-
const { done, total, waiting, paused, stopping, results, finished } = scan;
|
|
19
|
+
const { done, total, waiting, paused, stopping, results, finished, slots } = scan;
|
|
20
20
|
const elapsed = scan.elapsed();
|
|
21
|
-
// smooth bar: count the running
|
|
22
|
-
const partial =
|
|
21
|
+
// smooth bar: count the running attempts as partially done
|
|
22
|
+
const partial = slots.reduce((sum, s) => sum + (s.current ? Math.min(0.95, (now() - s.attemptStart) / attemptSeconds(timeout)) : 0), 0);
|
|
23
23
|
const fraction = (done + (done < total ? partial : 0)) / total;
|
|
24
|
-
const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout));
|
|
24
|
+
const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout) / slots.length);
|
|
25
25
|
const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10];
|
|
26
26
|
const ok = sortOk(results);
|
|
27
27
|
const failed = finished.filter((c) => !results.some((r) => r.query === c && r.status === "ok"));
|
|
28
|
-
const
|
|
28
|
+
const attempt = ({ current: c }) => (c ? `${flag(c.country)} ${countryName(c.country)} ${dim("·")} ${c.protocol}` : "");
|
|
29
|
+
const many = slots.length > 1; // a parallel scan: a line per container
|
|
29
30
|
const pct = bold(`${String(Math.floor(fraction * 100)).padStart(3)}%`);
|
|
30
31
|
const stats = [
|
|
31
32
|
`${dim("elapsed")} ${duration(elapsed)}`,
|
|
@@ -36,7 +37,7 @@ export function showProgress(scan, blocking) {
|
|
|
36
37
|
const minW = Math.max(64, vw(stats) + 6); // panel and table share one width
|
|
37
38
|
// results table gets whatever height the status panel and footer leave
|
|
38
39
|
const version = versionLines();
|
|
39
|
-
const room = Math.max(1, rows -
|
|
40
|
+
const room = Math.max(1, rows - 20 - (many ? slots.length + 1 : 2) - version.length);
|
|
40
41
|
const table = view === "availability"
|
|
41
42
|
? matrix(results, protocols, { minWidth: minW, limit: room })
|
|
42
43
|
: ok.length
|
|
@@ -48,15 +49,25 @@ export function showProgress(scan, blocking) {
|
|
|
48
49
|
: [];
|
|
49
50
|
const width = Math.max(minW, table.length ? vw(table[0]) : 0);
|
|
50
51
|
const inner = width - 4;
|
|
52
|
+
const status = stopping ? yellow(` stopping after the current attempt${many ? "s" : ""}…`)
|
|
53
|
+
: waiting ? yellow(` paused · VPN disconnected${blocking ? " · firewall blocking internet" : ""} · press p to resume`)
|
|
54
|
+
: "";
|
|
55
|
+
const pausing = paused && !waiting && frame % 8 < 5;
|
|
56
|
+
const slotLine = (s) => {
|
|
57
|
+
if (!s.current)
|
|
58
|
+
return s.step ? ` ${red("✗")} ${dim(clip(s.step, inner - 3))}` : dim(s.name ? ` · ${s.name}: after the containers` : " · idle");
|
|
59
|
+
const head = ` ${cyan(spinner)} ${s.name ? dim(`${s.name}: `) : ""}${bold(attempt(s))} `, time = ` · ${duration(now() - s.attemptStart)}`;
|
|
60
|
+
return head + dim(clip(s.step, inner - vw(head) - time.length) + time);
|
|
61
|
+
};
|
|
51
62
|
const panel = box([
|
|
52
63
|
"",
|
|
53
64
|
` ${bar(fraction, inner - 8)} ${pct}`,
|
|
54
65
|
"",
|
|
55
|
-
|
|
56
|
-
? yellow("
|
|
57
|
-
:
|
|
58
|
-
: ` ${cyan(spinner)} ${bold(
|
|
59
|
-
|
|
66
|
+
...(many
|
|
67
|
+
? [...slots.map(slotLine), status || (pausing ? yellow(" pausing after these attempts") : "")]
|
|
68
|
+
: status ? [status, ""]
|
|
69
|
+
: [` ${cyan(spinner)} ${bold(attempt(slots[0]))}`,
|
|
70
|
+
` ${dim(slots[0].step)} ${dim("·")} ${duration(now() - slots[0].attemptStart)}${pausing ? yellow(" · pausing after this attempt") : ""}`]),
|
|
60
71
|
null,
|
|
61
72
|
centerIn(stats, inner),
|
|
62
73
|
], {
|
|
@@ -109,8 +120,10 @@ export function showProgress(scan, blocking) {
|
|
|
109
120
|
},
|
|
110
121
|
};
|
|
111
122
|
}
|
|
123
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s);
|
|
112
124
|
/** A line per attempt. Ctrl-C stops after the current attempt, a second one quits */
|
|
113
125
|
export function logProgress(scan) {
|
|
126
|
+
const many = scan.slots.length > 1; // attempts overlap: a whole line once each is done
|
|
114
127
|
process.on("SIGINT", async () => {
|
|
115
128
|
if (scan.stopping) {
|
|
116
129
|
await restoreFirewall();
|
|
@@ -119,8 +132,9 @@ export function logProgress(scan) {
|
|
|
119
132
|
scan.stop();
|
|
120
133
|
});
|
|
121
134
|
return {
|
|
122
|
-
attempt: (country, protocol) => process.stdout.write(`[${scan.done + 1}/${scan.total}] ${country} ${protocol} ... `),
|
|
123
|
-
result: (r) => console.log([
|
|
135
|
+
attempt: (country, protocol) => many || process.stdout.write(`[${scan.done + 1}/${scan.total}] ${country} ${protocol} ... `),
|
|
136
|
+
result: (r) => console.log([many ? `[${scan.done}/${scan.total}] ${r.query} ${r.protocol} ...` : "", r.status, r.location,
|
|
137
|
+
r.speedMbps ? `${r.speedMbps.toFixed(1)} Mbps` : ""].join(" ").trim()),
|
|
124
138
|
close() { },
|
|
125
139
|
};
|
|
126
140
|
}
|