windscout 0.5.7 → 0.6.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 +50 -2
- package/dist/cli.js +49 -11
- package/dist/config.js +44 -2
- package/dist/docker.js +349 -0
- package/dist/probe.js +10 -10
- package/dist/scan.js +70 -31
- package/dist/tui/docker.js +241 -0
- package/dist/tui/picker.js +68 -18
- package/dist/tui/progress.js +27 -13
- package/dist/worker.js +92 -0
- package/package.json +5 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Docker on screen, for parallel scans: dialogs to install or start it (taking the sudo password),
|
|
2
|
+
// progress while the image builds and the containers start, and the Windscribe login the containers need.
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { LOGIN_FILE, loadLogin, saveLogin } from "../config.js";
|
|
5
|
+
import { buildImage, dockerState, INSTALL_DOCS, runSteps, startWorkers, steps, sudoLogin, sudoReady, waitReady, workerCount } from "../docker.js";
|
|
6
|
+
import { duration, now } from "../time.js";
|
|
7
|
+
import { draw, enterScreen, input, leaveScreen, out } from "./terminal.js";
|
|
8
|
+
import { bold, cyan, dim, green, vw, yellow } from "./text.js";
|
|
9
|
+
import { box, centerIn } from "./widgets.js";
|
|
10
|
+
const tty = () => Boolean(process.stdin.isTTY && out.isTTY);
|
|
11
|
+
const clip = (s, n = 76) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
12
|
+
// lines of at most n columns, broken at spaces: an error can be longer than the screen is wide
|
|
13
|
+
const wrap = (s, n = 76) => s.match(new RegExp(`.{1,${n}}(\\s+|$)|\\S+`, "g"))?.map((l) => l.trim()) ?? [s];
|
|
14
|
+
let asking = false; // a dialog waits for the user: progress doesn't draw over it
|
|
15
|
+
function paint({ lines, fields = [], check, buttons, error }, a = { values: [], checked: false, focus: 0 }) {
|
|
16
|
+
const many = fields.length + (check ? 1 : 0) > 1; // then a ❯ marks where the keys go
|
|
17
|
+
const mark = (i) => (!many ? "" : i === a.focus ? cyan("❯ ") : " ");
|
|
18
|
+
const body = [...lines], items = [];
|
|
19
|
+
if (fields.length || check)
|
|
20
|
+
body.push("");
|
|
21
|
+
fields.forEach((f, i) => {
|
|
22
|
+
items.push(body.length);
|
|
23
|
+
body.push(`${mark(i)}${f.label} ${bold(f.secret ? "•".repeat(a.values[i].length) : a.values[i])}${i === a.focus ? cyan("▏") : ""}`);
|
|
24
|
+
});
|
|
25
|
+
if (check) {
|
|
26
|
+
items.push(body.length);
|
|
27
|
+
body.push(`${mark(fields.length)}${a.checked ? green("[✓]") : dim("[ ]")} ${check.label}`);
|
|
28
|
+
if (check.note)
|
|
29
|
+
body.push(dim(`${many ? " " : ""} ${check.note}`));
|
|
30
|
+
}
|
|
31
|
+
if (error)
|
|
32
|
+
body.push("", yellow(error));
|
|
33
|
+
const width = Math.max(56, ...body.map(vw));
|
|
34
|
+
let row = -1, line = "";
|
|
35
|
+
if (buttons) {
|
|
36
|
+
const [ok, cancel] = buttons;
|
|
37
|
+
line = `${bold(cyan(`[ ${ok} ]`))}${cancel ? dim(` [ ${cancel} ]`) : ""}`;
|
|
38
|
+
row = body.length + 1;
|
|
39
|
+
body.push("", centerIn(line, width), centerIn(dim([many && "tab/↑↓ move", check && "space ticks the box", `enter ${ok.toLowerCase()}`,
|
|
40
|
+
cancel && `esc ${cancel.toLowerCase()}`].filter(Boolean).join(" · ")), width));
|
|
41
|
+
}
|
|
42
|
+
enterScreen();
|
|
43
|
+
const at = draw(box(body, { title: bold(cyan("WindScout · docker")), width }));
|
|
44
|
+
// a body line sits a row under the box's top border, two columns right of its left one
|
|
45
|
+
const rows = items.map((i) => at.top + 1 + i);
|
|
46
|
+
if (!buttons)
|
|
47
|
+
return { rows, buttons: -1, ok: [0, 0], cancel: [0, 0] };
|
|
48
|
+
const x = at.left + 2 + Math.floor((width - vw(line)) / 2), okEnd = x + vw(`[ ${buttons[0]} ]`);
|
|
49
|
+
return { rows, buttons: at.top + 1 + row, ok: [x, okEnd], cancel: buttons[1] ? [okEnd + 2, okEnd + 2 + vw(`[ ${buttons[1]} ]`)] : [0, 0] };
|
|
50
|
+
}
|
|
51
|
+
/** Shows the dialog until the user answers: what was typed and ticked on enter (or a click on ok), null on esc.
|
|
52
|
+
* tab, ↑↓ and enter move between the fields; space or a click ticks the checkbox */
|
|
53
|
+
function form(d) {
|
|
54
|
+
if (!tty())
|
|
55
|
+
return Promise.resolve(null);
|
|
56
|
+
asking = true;
|
|
57
|
+
const fields = d.fields ?? [];
|
|
58
|
+
const n = fields.length + (d.check ? 1 : 0);
|
|
59
|
+
const a = { values: fields.map((f) => f.value ?? ""), checked: d.check?.on ?? false, focus: 0 };
|
|
60
|
+
let hits = paint(d, a);
|
|
61
|
+
const redraw = () => (hits = paint(d, a));
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const done = (v) => {
|
|
64
|
+
asking = false;
|
|
65
|
+
input.off("keypress", onKey);
|
|
66
|
+
input.off("mouse", onMouse);
|
|
67
|
+
out.off("resize", redraw);
|
|
68
|
+
resolve(v);
|
|
69
|
+
};
|
|
70
|
+
const onKey = (str, key = {}) => {
|
|
71
|
+
const field = a.focus < fields.length;
|
|
72
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c"))
|
|
73
|
+
return done(null);
|
|
74
|
+
if (key.name === "return") {
|
|
75
|
+
if (a.focus >= fields.length - 1)
|
|
76
|
+
return done(a);
|
|
77
|
+
a.focus++; // like a form: on to the next field
|
|
78
|
+
}
|
|
79
|
+
else if (n > 1 && (key.name === "tab" || key.name === "down"))
|
|
80
|
+
a.focus = (a.focus + (key.shift ? n - 1 : 1)) % n;
|
|
81
|
+
else if (n > 1 && key.name === "up")
|
|
82
|
+
a.focus = (a.focus + n - 1) % n;
|
|
83
|
+
else if (!field) {
|
|
84
|
+
if (key.name === "space")
|
|
85
|
+
a.checked = !a.checked;
|
|
86
|
+
}
|
|
87
|
+
else if (key.name === "backspace")
|
|
88
|
+
a.values[a.focus] = a.values[a.focus].slice(0, -1);
|
|
89
|
+
else if (str && !key.ctrl && !key.meta && str >= " ")
|
|
90
|
+
a.values[a.focus] += str;
|
|
91
|
+
redraw();
|
|
92
|
+
};
|
|
93
|
+
const onMouse = (m) => {
|
|
94
|
+
if (!m.press || m.button !== 0)
|
|
95
|
+
return;
|
|
96
|
+
const i = hits.rows.indexOf(m.y);
|
|
97
|
+
const on = ([from, to]) => m.y === hits.buttons && m.x >= from && m.x < to;
|
|
98
|
+
if (i >= 0) {
|
|
99
|
+
a.focus = i;
|
|
100
|
+
if (i === fields.length)
|
|
101
|
+
a.checked = !a.checked; // the checkbox
|
|
102
|
+
}
|
|
103
|
+
else if (on(hits.ok))
|
|
104
|
+
return done(a);
|
|
105
|
+
else if (on(hits.cancel))
|
|
106
|
+
return done(null);
|
|
107
|
+
redraw();
|
|
108
|
+
};
|
|
109
|
+
input.on("keypress", onKey);
|
|
110
|
+
input.on("mouse", onMouse);
|
|
111
|
+
out.on("resize", redraw);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** A dialog with one field or none: the field's text ("" without one) on enter, null on esc */
|
|
115
|
+
const ask = async (d) => {
|
|
116
|
+
const a = await form(d);
|
|
117
|
+
return a && (a.values[0] ?? "");
|
|
118
|
+
};
|
|
119
|
+
/** Waits for work while the screen shows a spinner, status.label, the time so far and status.line (its latest output). esc quits */
|
|
120
|
+
async function busy(status, work) {
|
|
121
|
+
if (!tty()) {
|
|
122
|
+
console.error(`${status.label}…`);
|
|
123
|
+
return work;
|
|
124
|
+
}
|
|
125
|
+
const t = now();
|
|
126
|
+
let frame = 0;
|
|
127
|
+
const tick = () => asking || paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
|
|
128
|
+
dim(clip(status.line)), "", dim("esc quits")] });
|
|
129
|
+
// a dialog on top (the login) takes its own keys; the exit handlers remove containers already started
|
|
130
|
+
const quit = (_, key = {}) => !asking && (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
|
|
131
|
+
&& (leaveScreen(), process.exit(130));
|
|
132
|
+
const timer = setInterval(tick, 120);
|
|
133
|
+
input.on("keypress", quit);
|
|
134
|
+
tick();
|
|
135
|
+
try {
|
|
136
|
+
return await work;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearInterval(timer);
|
|
140
|
+
input.off("keypress", quit);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const WHY = {
|
|
144
|
+
missing: "A parallel scan runs each attempt in a docker container, and docker isn't installed.",
|
|
145
|
+
stopped: "A parallel scan runs each attempt in a docker container, and docker isn't running.",
|
|
146
|
+
denied: "Docker is installed, but your user can't use it yet.",
|
|
147
|
+
};
|
|
148
|
+
/** Gets docker answering: asks, then installs or starts it. False when the user cancels, it fails, or (Linux) a new login is needed first */
|
|
149
|
+
export async function setupDocker() {
|
|
150
|
+
const state = await dockerState();
|
|
151
|
+
if (state === "ready")
|
|
152
|
+
return true;
|
|
153
|
+
if (!tty())
|
|
154
|
+
throw new Error(`${WHY[state]} Run windscout in a terminal to set it up, or scan without --docker.`);
|
|
155
|
+
const todo = steps(state);
|
|
156
|
+
const root = todo.some((s) => s.root) && !(await sudoReady());
|
|
157
|
+
const lines = [WHY[state], "", "WindScout will:", ...todo.map((s) => ` • ${s.label}${s.root ? dim(" (sudo)") : ""}`),
|
|
158
|
+
...(state === "missing" && process.platform !== "linux"
|
|
159
|
+
? ["", dim("Docker Desktop is free for personal use, education and small businesses;"), dim("bigger companies need a paid plan: docker.com/pricing")]
|
|
160
|
+
: [])];
|
|
161
|
+
let password, error = "";
|
|
162
|
+
for (;;) {
|
|
163
|
+
const a = await ask({ lines, fields: root ? [{ label: "password for sudo:", secret: true }] : [], buttons: [state === "missing" ? "Install" : "Start", "Cancel"], error });
|
|
164
|
+
if (a === null)
|
|
165
|
+
return false;
|
|
166
|
+
if (!root)
|
|
167
|
+
break;
|
|
168
|
+
if (!(error = await sudoLogin(a))) {
|
|
169
|
+
password = a; // kept until the steps are done: one may need sudo again after a long download
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const status = { label: "", line: "" };
|
|
174
|
+
try {
|
|
175
|
+
await busy(status, runSteps(todo, password, (label) => Object.assign(status, { label, line: "" }), (line) => (status.line = line)));
|
|
176
|
+
if (process.platform === "linux" && state !== "stopped") {
|
|
177
|
+
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",
|
|
178
|
+
`(or run ${bold("newgrp docker")}), then start windscout again.`], buttons: ["OK"] });
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
Object.assign(status, { label: "waiting for docker to start", line: "" });
|
|
182
|
+
await busy(status, waitReady());
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
const why = e.message;
|
|
187
|
+
// docker.com refuses some countries (Iran: US sanctions), and nobody mirrors Docker Desktop: only a VPN gets it there
|
|
188
|
+
const refused = /\b403\b|forbidden/i.test(why);
|
|
189
|
+
const a = await ask({ lines: [...wrap(why).map(yellow),
|
|
190
|
+
...(refused ? ["", ...wrap(`docker.com refuses downloads from some countries, Iran among them${process.platform === "linux" ? ""
|
|
191
|
+
: ", and no mirror carries Docker Desktop"}: connect a VPN (Windscribe will do), then retry.`)] : []),
|
|
192
|
+
...(state === "missing" ? ["", `To install docker yourself: ${INSTALL_DOCS}`] : [])], buttons: ["Retry", "Cancel"] });
|
|
193
|
+
return a !== null && setupDocker();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const LOGIN = [
|
|
197
|
+
"Each container runs its own Windscribe app, which logs in once:",
|
|
198
|
+
"the login stays in the container's docker volume for the next scans.",
|
|
199
|
+
];
|
|
200
|
+
let lastUser = "", remember = true; // the box as last ticked: ticked to start with
|
|
201
|
+
/** A remembered login unless that just failed, else the login form */
|
|
202
|
+
async function credentials(error) {
|
|
203
|
+
const saved = loadLogin();
|
|
204
|
+
if (saved && !error)
|
|
205
|
+
return saved;
|
|
206
|
+
const a = await form({
|
|
207
|
+
lines: LOGIN,
|
|
208
|
+
fields: [{ label: "Windscribe username:", value: lastUser || saved?.username }, { label: "Windscribe password:", secret: true }],
|
|
209
|
+
check: { label: "remember username and password", on: remember,
|
|
210
|
+
note: `saved in ${LOGIN_FILE.replace(homedir(), "~")}, only you can read it` },
|
|
211
|
+
buttons: ["Log in", "Cancel"],
|
|
212
|
+
error,
|
|
213
|
+
});
|
|
214
|
+
if (!a)
|
|
215
|
+
return null;
|
|
216
|
+
remember = a.checked;
|
|
217
|
+
if (!remember)
|
|
218
|
+
saveLogin(null); // unticked: forget the one saved before, if any
|
|
219
|
+
const [username, password] = a.values;
|
|
220
|
+
return { username: (lastUser = username), password };
|
|
221
|
+
}
|
|
222
|
+
/** Starts the containers for a parallel scan of this many attempts: docker, the image, the logins.
|
|
223
|
+
* Resolves to their probers and whether WireGuard works in them; null if the user backed out */
|
|
224
|
+
export async function startParallel(attempts) {
|
|
225
|
+
if (!(await setupDocker()))
|
|
226
|
+
return null;
|
|
227
|
+
const status = { label: "building the containers' image (the first time takes a few minutes)", line: "" };
|
|
228
|
+
await busy(status, buildImage((line) => (status.line = line)));
|
|
229
|
+
const n = workerCount(attempts);
|
|
230
|
+
Object.assign(status, { label: `starting ${n} container${n > 1 ? "s" : ""}`, line: "" });
|
|
231
|
+
const started = await busy(status, startWorkers(n, {
|
|
232
|
+
credentials,
|
|
233
|
+
answer: (question) => ask({ lines: ["Windscribe asks:", "", ...question.split("\n")], fields: [{ label: "answer:" }], buttons: ["Send", "Cancel"] }),
|
|
234
|
+
loggedIn: (c) => remember && saveLogin(c), // saved only once it worked
|
|
235
|
+
}, (line) => (status.line = line)));
|
|
236
|
+
if (!started.probers.length) {
|
|
237
|
+
const why = status.line.startsWith("container") ? ` (${status.line})` : ""; // the last container's trouble
|
|
238
|
+
throw new Error(why || tty() ? `no container got ready${why}` : "the containers aren't logged in to Windscribe yet: run windscout --docker in a terminal once");
|
|
239
|
+
}
|
|
240
|
+
return started;
|
|
241
|
+
}
|
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 }) {
|
|
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,12 +44,24 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
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")}`;
|
|
50
|
+
const randomLine = `${dim(under("r"))} ${random ? green("● random order") : dim("○ random order")} ${dim(random
|
|
51
|
+
? "scans the countries shuffled, a new order each run"
|
|
52
|
+
: "off: scans in list order")}`;
|
|
43
53
|
timeout = Number(seconds);
|
|
44
54
|
const short = timeout < MIN_TIMEOUT;
|
|
45
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…")}`;
|
|
46
65
|
const search = searching
|
|
47
66
|
? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
48
67
|
: query ? `🔍 ${query}` : dim("🔍 press / to search");
|
|
@@ -63,14 +82,13 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
63
82
|
["quit", under("Q") + "uit"],
|
|
64
83
|
];
|
|
65
84
|
const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
66
|
-
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 });
|
|
67
86
|
const width = vw(table[0]);
|
|
68
|
-
const head = box([protoLine, speedLine, fwLine, timeoutLine], {
|
|
87
|
+
const head = box([protoLine, speedLine, fwLine, randomLine, timeoutLine, dockerLine], {
|
|
69
88
|
title: bold(cyan("WindScout")),
|
|
70
89
|
right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
71
90
|
width: width - 4,
|
|
72
91
|
});
|
|
73
|
-
const n = selected.size * protos.size;
|
|
74
92
|
const lines = [
|
|
75
93
|
...head,
|
|
76
94
|
...table,
|
|
@@ -78,14 +96,15 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
78
96
|
centerIn(buttonLine, width),
|
|
79
97
|
"",
|
|
80
98
|
centerIn(n
|
|
81
|
-
? `${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` : ""})`)}`
|
|
82
100
|
: yellow("select at least one country and one protocol"), width),
|
|
83
101
|
centerIn(dim(editing
|
|
84
102
|
? "type the seconds to wait for each connection · enter/esc done"
|
|
85
103
|
: searching
|
|
86
104
|
? "type to filter · ↑↓ move · enter/esc done"
|
|
87
|
-
|
|
88
|
-
|
|
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),
|
|
89
108
|
];
|
|
90
109
|
if (message)
|
|
91
110
|
lines.push(centerIn(yellow(message), width));
|
|
@@ -107,7 +126,7 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
107
126
|
return range;
|
|
108
127
|
});
|
|
109
128
|
hit = {
|
|
110
|
-
protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3,
|
|
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,
|
|
111
130
|
searchRow: at.top + head.length + 1,
|
|
112
131
|
firstRow: at.top + head.length + 5, shown: shown.length,
|
|
113
132
|
buttonRow: at.top + head.length + table.length + 1, buttonHits,
|
|
@@ -115,6 +134,8 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
115
134
|
};
|
|
116
135
|
let closed = false;
|
|
117
136
|
checkUpdate().then(() => !closed && render()); // the check usually lands after the first draw
|
|
137
|
+
if (docker)
|
|
138
|
+
dockerState().then((s) => ((dockerAt = s), !closed && render()));
|
|
118
139
|
const finish = (value) => {
|
|
119
140
|
closed = true;
|
|
120
141
|
out.off("resize", render);
|
|
@@ -125,7 +146,7 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
125
146
|
resolve(value);
|
|
126
147
|
};
|
|
127
148
|
const start = () => {
|
|
128
|
-
if (!
|
|
149
|
+
if (!attempts()) {
|
|
129
150
|
message = "nothing to scan yet";
|
|
130
151
|
return false;
|
|
131
152
|
}
|
|
@@ -139,13 +160,32 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
139
160
|
protocols: PROTOCOLS.filter((p) => protos.has(p)),
|
|
140
161
|
speed: speedTest,
|
|
141
162
|
firewall,
|
|
163
|
+
random,
|
|
142
164
|
timeout,
|
|
165
|
+
docker,
|
|
143
166
|
};
|
|
144
167
|
saveConfig(choice);
|
|
145
168
|
finish(choice);
|
|
146
169
|
return true;
|
|
147
170
|
};
|
|
148
|
-
|
|
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) => {
|
|
149
189
|
if (!m.press)
|
|
150
190
|
return;
|
|
151
191
|
message = "";
|
|
@@ -178,6 +218,10 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
178
218
|
speedTest = !speedTest;
|
|
179
219
|
else if (y === hit.firewallRow)
|
|
180
220
|
firewall = !firewall;
|
|
221
|
+
else if (y === hit.randomRow)
|
|
222
|
+
random = !random;
|
|
223
|
+
else if (y === hit.dockerRow)
|
|
224
|
+
return void toggleDocker();
|
|
181
225
|
else if (y === hit.buttonRow) {
|
|
182
226
|
const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
|
|
183
227
|
if (id === "start" && start())
|
|
@@ -191,9 +235,10 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
191
235
|
}
|
|
192
236
|
}
|
|
193
237
|
render();
|
|
194
|
-
}
|
|
238
|
+
};
|
|
239
|
+
input.on("mouse", onMouse);
|
|
195
240
|
out.on("resize", render);
|
|
196
|
-
|
|
241
|
+
const onKey = (str, key = {}) => {
|
|
197
242
|
message = "";
|
|
198
243
|
const k = key.name;
|
|
199
244
|
const matches = visible();
|
|
@@ -253,8 +298,12 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
253
298
|
speedTest = !speedTest;
|
|
254
299
|
else if (k === "f")
|
|
255
300
|
firewall = !firewall;
|
|
301
|
+
else if (k === "r")
|
|
302
|
+
random = !random;
|
|
256
303
|
else if (k === "t")
|
|
257
304
|
editing = true, seconds = "";
|
|
305
|
+
else if (k === "d")
|
|
306
|
+
return void toggleDocker();
|
|
258
307
|
else if (k === "u" && canUpdate())
|
|
259
308
|
return finish("update");
|
|
260
309
|
else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
|
|
@@ -268,7 +317,8 @@ export function pick(saved, { timeout, speedTest, firewall }) {
|
|
|
268
317
|
else if (k === "q" || k === "escape")
|
|
269
318
|
return finish(null);
|
|
270
319
|
render();
|
|
271
|
-
}
|
|
320
|
+
};
|
|
321
|
+
input.on("keypress", onKey);
|
|
272
322
|
render();
|
|
273
323
|
});
|
|
274
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
|
}
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// windscout --worker: runs in a parallel scan's docker container (docker.ts), beside the container's own Windscribe app.
|
|
2
|
+
// Takes the host's messages from stdin and answers on stdout, a JSON object per line.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { probe } from "./probe.js";
|
|
6
|
+
import { sleep } from "./time.js";
|
|
7
|
+
import { CLI, windscribe } from "./windscribe.js";
|
|
8
|
+
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
9
|
+
// the CLI mixes JSON log lines into its output
|
|
10
|
+
const text = (s) => s.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n").trim();
|
|
11
|
+
export async function worker() {
|
|
12
|
+
const inbox = [];
|
|
13
|
+
let wake = () => { }, closed = false, stop = false;
|
|
14
|
+
createInterface({ input: process.stdin })
|
|
15
|
+
.on("line", (l) => {
|
|
16
|
+
const m = JSON.parse(l);
|
|
17
|
+
if (m.stop)
|
|
18
|
+
stop = true; // the scan is stopping: give up connecting
|
|
19
|
+
else
|
|
20
|
+
inbox.push(m), wake();
|
|
21
|
+
})
|
|
22
|
+
.on("close", () => ((closed = true), wake()));
|
|
23
|
+
/** The host's next message; null once it closed stdin: the scan is over */
|
|
24
|
+
const next = async () => {
|
|
25
|
+
while (!inbox.length && !closed)
|
|
26
|
+
await new Promise((resolve) => (wake = resolve));
|
|
27
|
+
return inbox.shift() ?? null;
|
|
28
|
+
};
|
|
29
|
+
// the app starts beside us (the image's CMD); its CLI answers once it's up
|
|
30
|
+
let status = "";
|
|
31
|
+
for (let i = 0; i < 60 && !(status = await windscribe("status")).includes("Login state"); i++)
|
|
32
|
+
await sleep(1000);
|
|
33
|
+
if (!status.includes("Login state"))
|
|
34
|
+
return void console.error("Windscribe's app didn't start in the container");
|
|
35
|
+
if (!status.includes("Logged in")) {
|
|
36
|
+
for (let error = "";;) {
|
|
37
|
+
send({ need: "login", text: error });
|
|
38
|
+
const m = await next();
|
|
39
|
+
if (!m?.login)
|
|
40
|
+
return; // no credentials for us: this container sits out the scan
|
|
41
|
+
if (!(error = await login(m.login.username, m.login.password, next)))
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
send({ ready: true, wireguard: process.env.WS_WIREGUARD !== "0" }); // the image's CMD checked the kernel
|
|
46
|
+
for (let m; (m = await next());) {
|
|
47
|
+
if (!m.probe)
|
|
48
|
+
continue;
|
|
49
|
+
stop = false;
|
|
50
|
+
const { query, protocol, timeout, speedTest } = m.probe;
|
|
51
|
+
const result = await probe(query, protocol, {
|
|
52
|
+
timeout, speedTest,
|
|
53
|
+
stopped: () => stop,
|
|
54
|
+
onStep: (step) => send({ step }),
|
|
55
|
+
measure: async (fn) => {
|
|
56
|
+
send({ measure: true });
|
|
57
|
+
await next(); // go
|
|
58
|
+
try {
|
|
59
|
+
await fn();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
send({ measured: true });
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
send({ result });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** windscribe-cli login, with the password on its stdin; what it asks next (a 2FA code, a captcha) goes to the host.
|
|
70
|
+
* "" once logged in, else why not */
|
|
71
|
+
function login(username, password, next) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const p = spawn(CLI, ["login", username], { stdio: ["pipe", "pipe", "ignore"] });
|
|
74
|
+
p.stdin.on("error", () => { });
|
|
75
|
+
p.stdin.write(password + "\n");
|
|
76
|
+
let out = "";
|
|
77
|
+
p.stdout.on("data", async (d) => {
|
|
78
|
+
out += d;
|
|
79
|
+
const asked = text(out);
|
|
80
|
+
// a question ends the output, with no newline after it: "2FA code:", or under a captcha "…to continue:"
|
|
81
|
+
if (!asked.endsWith(":"))
|
|
82
|
+
return;
|
|
83
|
+
out = "";
|
|
84
|
+
if (asked.endsWith("Password:"))
|
|
85
|
+
return; // answered already
|
|
86
|
+
send({ need: "input", text: asked });
|
|
87
|
+
p.stdin.write(((await next())?.input ?? "") + "\n");
|
|
88
|
+
});
|
|
89
|
+
// it exits 0 whether that worked or not: ask the app
|
|
90
|
+
p.on("close", async () => resolve((await windscribe("status")).includes("Logged in") ? "" : text(out).split("\n").at(-1) || "login failed"));
|
|
91
|
+
});
|
|
92
|
+
}
|