windscout 0.5.8 → 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 +48 -2
- package/dist/cli.js +42 -11
- package/dist/config.js +43 -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 +60 -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, 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
|
}
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windscout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc && node -e \"fs.chmodSync('dist/cli.js', 0o755)\"",
|
|
14
|
-
"prepack": "npm run build"
|
|
14
|
+
"prepack": "npm run build",
|
|
15
|
+
"test": "npm run build && node --test test/"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
17
18
|
"node": ">=20"
|
|
@@ -32,7 +33,8 @@
|
|
|
32
33
|
"firewall",
|
|
33
34
|
"unblock",
|
|
34
35
|
"vpn-speed-test",
|
|
35
|
-
"server-finder"
|
|
36
|
+
"server-finder",
|
|
37
|
+
"docker"
|
|
36
38
|
],
|
|
37
39
|
"author": "Dier Cohen <i.am.dier.cohen@gmail.com>",
|
|
38
40
|
"license": "MIT",
|