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.
@@ -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
+ }
@@ -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 - 22 - version.length);
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(firewall
41
- ? "only VPN traffic gets through"
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(vw(protoLine), vw(speedLine), vw(fwLine), vw(randomLine), vw(timeoutLine)) + 4 });
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(selected.size, protos.size, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each)`)}`
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
- : `↑↓ move · space select · a/n ${query ? "all/none shown" : "all/none"} · / search`), width),
91
- centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · s speed test · f firewall · r random · t timeout · enter start · ${query ? "esc clear · " : ""}q quit`), width),
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 (!selected.size || !protos.size) {
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
- input.on("mouse", (m) => {
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
- input.on("keypress", (str, key = {}) => {
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
  }
@@ -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 attempt as partially done
22
- const partial = waiting ? 0 : Math.min(0.95, (now() - scan.attemptStart) / attemptSeconds(timeout));
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 current = scan.current ? `${flag(scan.current.country)} ${countryName(scan.current.country)} ${dim("·")} ${scan.current.protocol}` : "";
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 - 22 - version.length);
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
- stopping
56
- ? yellow(" stopping after the current attempt…")
57
- : waiting ? yellow(` paused · VPN disconnected${blocking ? " · firewall blocking internet" : ""} · press p to resume`)
58
- : ` ${cyan(spinner)} ${bold(current)}`,
59
- stopping || waiting ? "" : ` ${dim(scan.step)} ${dim("·")} ${duration(now() - scan.attemptStart)}${paused && frame % 8 < 5 ? yellow(" · pausing after this attempt") : ""}`,
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([r.status, r.location, r.speedMbps ? `${r.speedMbps.toFixed(1)} Mbps` : ""].join(" ").trim()),
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.5.8",
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",