windscout 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/windscout.js DELETED
@@ -1,866 +0,0 @@
1
- #!/usr/bin/env node
2
- // WindScout: find which Windscribe locations and protocols actually work from here.
3
- // Drives the official windscribe-cli: connects to each country/protocol pair,
4
- // measures latency and download speed through the tunnel, prints a ranked table.
5
- import { spawn } from "node:child_process";
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { join } from "node:path";
9
- import readline from "node:readline";
10
- import { PassThrough } from "node:stream";
11
- import { parseArgs } from "node:util";
12
-
13
- const CLI = "windscribe-cli";
14
- // the desktop app installs the CLI here and links it into /usr/bin (/usr/local/bin on macOS); use it directly if the link is missing.
15
- // Windows doesn't put it on PATH at all, so there the fallback is the normal case
16
- const DESKTOP_CLI = {
17
- darwin: "/Applications/Windscribe.app/Contents/MacOS/windscribe-cli",
18
- win32: join(process.env.ProgramFiles || "C:\\Program Files", "Windscribe", "windscribe-cli.exe"),
19
- }[process.platform] ?? "/opt/windscribe/windscribe-cli";
20
- const DOWNLOAD_URL = "https://windscribe.com/download";
21
- const REPO_URL = "https://github.com/Diercohen/windscout";
22
- // OSC 8 makes the text a link in terminals that support it; clicks we receive ourselves are handled in onData
23
- const repoLink = `\x1b]8;;${REPO_URL}\x1b\\${REPO_URL}\x1b]8;;\x1b\\`;
24
- let cliBin = CLI;
25
- // IKEv2 is offered by the macOS/Windows apps only
26
- const PROTOCOLS = ["wireguard", ...(process.platform === "linux" ? [] : ["ikev2"]), "stealth", "wstunnel", "udp", "tcp"];
27
- // ponytail: hardcoded because `windscribe-cli locations` returns nothing on 2.24; refresh when Windscribe adds countries
28
- const COUNTRIES = `US CA MX BR AR CL CO PE GB IE FR DE NL BE LU CH AT IT ES PT DK NO SE FI IS
29
- PL CZ SK HU RO BG GR CY TR RS HR SI BA MK AL MD UA EE LV LT IL AE AZ GE AM KZ IN JP KR
30
- HK SG TW ID MY TH VN PH AU NZ ZA NG KE EG`.split(/\s+/);
31
-
32
- const LATENCY_URL = "https://www.gstatic.com/generate_204";
33
- const SPEED_URL = "https://speed.cloudflare.com/__down?bytes=10000000";
34
- const SPEED_SECONDS = 15;
35
- const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
36
-
37
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
- const now = () => performance.now() / 1000;
39
- const regionName = new Intl.DisplayNames(["en"], { type: "region" });
40
- const countryName = (code) => {
41
- try { return regionName.of(code); } catch { return code; } // city names / nicknames aren't region codes
42
- };
43
- // two regional-indicator letters render as the country's flag emoji (Windows has no flag glyphs: it would show
44
- // two boxed letters of unpredictable width and break the table borders, so leave the slot blank there).
45
- // macOS Terminal draws flags ~half a column wider than the 2 it advances, nudging table borders right: blank there too
46
- const noFlags = process.platform === "win32" || process.env.TERM_PROGRAM === "Apple_Terminal";
47
- const flag = (code) => !noFlags && /^[A-Z]{2}$/.test(code) ? String.fromCodePoint(...[...code].map((c) => 0x1f1a5 + c.charCodeAt(0))) : " ";
48
- let stop = 0;
49
- let speedTest = true; // false = connect-only mode: skip the download test, rank by latency
50
- let firewall = false; // turn on the app's firewall while scanning, so nothing goes online outside the VPN
51
- let restoreFirewall = async () => {}; // set by scan(); also called on a crash so the internet isn't left blocked
52
-
53
- // ---------- windscribe + measurements ----------
54
-
55
- // spawn, not execFile: if the app isn't running, the CLI launches it and the app inherits our stdout pipe.
56
- // The pipe then stays open after the CLI exits, and execFile would wait for its timeout. Settle on exit instead
57
- function run(args) {
58
- return new Promise((resolve, reject) => {
59
- const p = spawn(cliBin, args, { stdio: ["ignore", "pipe", "ignore"] });
60
- let out = "";
61
- p.stdout.on("data", (d) => (out += d));
62
- const done = () => (clearTimeout(kill), p.stdout.destroy(), resolve(out));
63
- const kill = setTimeout(() => p.kill(), 90_000);
64
- p.on("error", (e) => (clearTimeout(kill), reject(e)));
65
- p.on("close", done);
66
- // the CLI's last output may still be in the pipe when it exits; read for a moment, then stop waiting
67
- p.on("exit", () => setTimeout(done, 300));
68
- });
69
- }
70
-
71
- async function cli(...args) {
72
- let out;
73
- try {
74
- out = await run(args);
75
- } catch (e) {
76
- if (e.code === "ENOENT") {
77
- if (cliBin === CLI && existsSync(DESKTOP_CLI)) return (cliBin = DESKTOP_CLI), cli(...args);
78
- throw new Error(`${CLI} not found. Install the Windscribe desktop app (it includes ${CLI}): ${DOWNLOAD_URL}`);
79
- }
80
- throw e;
81
- }
82
- // the CLI mixes JSON log lines into stdout
83
- return out.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n");
84
- }
85
-
86
- async function state() {
87
- // macOS marks the line with a leading "*" once connected: "*Connect state: Connected: Miami - Vice"
88
- return (await cli("status")).match(/^\*?Connect state: (.*)$/m)?.[1].trim() ?? "";
89
- }
90
-
91
- async function latencyMs(tries = 3) {
92
- let best = null;
93
- for (let i = 0; i < tries; i++) {
94
- const t = now();
95
- try {
96
- await (await fetch(LATENCY_URL, { signal: AbortSignal.timeout(5000) })).arrayBuffer();
97
- } catch {
98
- continue;
99
- }
100
- const ms = (now() - t) * 1000;
101
- best = best === null ? ms : Math.min(best, ms);
102
- }
103
- return best;
104
- }
105
-
106
- async function speedMbps() {
107
- const t = now();
108
- let n = 0;
109
- try {
110
- const res = await fetch(SPEED_URL, {
111
- headers: { "User-Agent": "curl/8" }, // Cloudflare 403s unknown clients
112
- signal: AbortSignal.timeout(SPEED_SECONDS * 1000),
113
- });
114
- if (!res.ok) return null;
115
- for await (const chunk of res.body) n += chunk.length;
116
- } catch {
117
- if (!n) return null; // timeout mid-download is fine: we measured what arrived
118
- }
119
- return (n * 8) / (now() - t) / 1e6;
120
- }
121
-
122
- // onStep(text) reports what the probe is doing, for the live view
123
- async function probe(query, protocol, timeout, onStep = () => {}) {
124
- const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null, status: "" };
125
- onStep("connecting");
126
- await cli("connect", "-n", query, protocol);
127
- const t = now();
128
- try {
129
- for (;;) {
130
- if (stop) return (r.status = "interrupted"), r;
131
- if (now() - t >= timeout) return (r.status = "timeout"), r;
132
- const s = await state();
133
- if (s.startsWith("Connecting:")) r.location = s.split(":").slice(1).join(":").trim();
134
- else if (s.startsWith("Connected")) {
135
- if (s.includes(":")) r.location = s.split(":").slice(1).join(":").trim();
136
- r.connectS = now() - t;
137
- break;
138
- } else if (s.includes("does not exist")) return (r.status = "no such location"), r;
139
- else if (s === "Disconnected" && now() - t > 3) return (r.status = "failed"), r;
140
- onStep(`connecting ${r.location}`.trim());
141
- await sleep(1000);
142
- }
143
- onStep(`measuring latency · ${r.location}`);
144
- r.latencyMs = await latencyMs();
145
- if (r.latencyMs === null) return (r.status = "no internet"), r;
146
- if (speedTest) {
147
- onStep(`measuring speed · ${r.location}`);
148
- r.speedMbps = await speedMbps();
149
- }
150
- r.status = "ok";
151
- return r;
152
- } finally {
153
- onStep("disconnecting");
154
- await cli("disconnect");
155
- // the CLI's disconnect can return while the app is still tearing down (it does after a timed-out connect);
156
- // a connect sent then is swallowed and the next attempt reads as an instant "failed", so wait it out
157
- for (let i = 0; i < 30 && (await state()) === "Disconnecting"; i++) await sleep(1000);
158
- }
159
- }
160
-
161
- // ---------- update check ----------
162
-
163
- const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
164
- let update = null; // newer version on npm, once known
165
- let updateCheck = Promise.resolve();
166
- // numeric x.y.z compare; parseInt drops a prerelease suffix ("0-beta" → 0)
167
- const newer = (a, b) => {
168
- const x = a.split(".").map((n) => parseInt(n)), y = b.split(".").map((n) => parseInt(n));
169
- for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
170
- return false;
171
- };
172
- // best effort: offline or registry down just means no notice
173
- const checkUpdate = () =>
174
- fetch("https://registry.npmjs.org/windscout/latest", { signal: AbortSignal.timeout(3000) })
175
- .then((r) => r.json())
176
- .then(({ version }) => { if (version && newer(version, VERSION)) update = version; })
177
- .catch(() => {});
178
- const updateNotice = () => `update available: ${VERSION} → ${update} · run: ${
179
- process.env.npm_command === "exec" ? "npx windscout@latest" : "npm install -g windscout@latest"}`;
180
-
181
- // ---------- formatting ----------
182
-
183
- const color = (code, s) => (process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[${code}m${s}\x1b[0m`);
184
- const bold = (s) => color(1, s), dim = (s) => color(2, s), green = (s) => color(32, s);
185
- // underline off (24) instead of a full reset, so it nests inside dim/bold
186
- const under = (s) => (process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[4m${s}\x1b[24m`);
187
- const cyan = (s) => color(36, s), yellow = (s) => color(33, s), red = (s) => color(31, s);
188
- const fmt = (v, digits) => (v === null || v === undefined ? "-" : v.toFixed(digits));
189
- const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
190
-
191
- function duration(sec) {
192
- sec = Math.max(0, Math.round(sec));
193
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
194
- return h ? `${h}h ${String(m).padStart(2, "0")}m` : m ? `${m}m ${String(s).padStart(2, "0")}s` : `${s}s`;
195
- }
196
-
197
- // ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
198
- const attemptSeconds = (timeout) => timeout + 5;
199
- const estimate = (nCountries, nProtocols, timeout) => nCountries * nProtocols * attemptSeconds(timeout);
200
-
201
- // ---------- boxes (rounded box-drawing) ----------
202
-
203
- // visible width: ANSI codes take no space; flags and emoji (🔍) take 2 columns. text symbols like ▶ ★ stay 1
204
- const vw = (s) =>
205
- [...s.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]/gu, "xx")].length;
206
- // align: falsy = left, true = right, "center"
207
- const pad = (s, n, align = false) => {
208
- const gap = Math.max(0, n - vw(s));
209
- if (align === "center") return " ".repeat(Math.floor(gap / 2)) + s + " ".repeat(Math.ceil(gap / 2));
210
- return align ? " ".repeat(gap) + s : s + " ".repeat(gap);
211
- };
212
- const line = (s) => dim(s);
213
-
214
- // box around content lines; null in lines draws a ├──┤ divider. title/right sit in the top border
215
- function box(lines, { title = "", right = "", width = 0 } = {}) {
216
- const inner = Math.max(width, ...lines.map((l) => (l === null ? 0 : vw(l))), vw(title) + vw(right) + 6);
217
- const left = title ? line("─ ") + title + " " : "";
218
- const tail = right ? " " + right + line(" ─") : "";
219
- const fill = inner + 2 - vw(left) - vw(tail);
220
- return [
221
- line("╭") + left + line("─".repeat(fill)) + tail + line("╮"),
222
- ...lines.map((l) => (l === null ? line("├" + "─".repeat(inner + 2) + "┤") : line("│") + " " + pad(l, inner) + " " + line("│"))),
223
- line("╰" + "─".repeat(inner + 2) + "╯"),
224
- ];
225
- }
226
-
227
- // bordered table; optional caption row (spanning all columns) sits on top inside the same border
228
- // cols: { title, align }; stretch = index of the column that absorbs extra width
229
- function grid(cols, rows, { caption, captionRight = "", minWidth = 0, stretch = cols.length - 1 } = {}) {
230
- const w = cols.map((c, i) => Math.max(vw(c.title), ...rows.map((r) => vw(r[i]))));
231
- const total = () => w.reduce((a, n) => a + n + 3, 1);
232
- if (caption !== undefined) w[stretch] += Math.max(0, vw(caption) + vw(captionRight) + 5 - total());
233
- w[stretch] += Math.max(0, minWidth - total());
234
- const rule = (l, m, r) => line(l + w.map((n) => "─".repeat(n + 2)).join(m) + r);
235
- const row = (cells) => line("│") + cells.map((c, i) => " " + pad(c, w[i], cols[i].align) + " ").join(line("│")) + line("│");
236
- const out = [];
237
- if (caption !== undefined) {
238
- const inner = total() - 4;
239
- out.push(line("╭" + "─".repeat(total() - 2) + "╮"),
240
- line("│") + " " + pad(caption, inner - vw(captionRight)) + captionRight + " " + line("│"),
241
- rule("├", "┬", "┤"));
242
- } else out.push(rule("╭", "┬", "╮"));
243
- out.push(row(cols.map((c) => bold(c.title))), rule("├", "┼", "┤"), ...rows.map(row), rule("╰", "┴", "╯"));
244
- return out;
245
- }
246
-
247
- const centerIn = (s, width) => " ".repeat(Math.max(0, Math.floor((width - vw(s)) / 2))) + s;
248
-
249
- const sortOk = (results) =>
250
- results.filter((r) => r.status === "ok")
251
- .sort((x, y) => (y.speedMbps ?? 0) - (x.speedMbps ?? 0) || x.latencyMs - y.latencyMs);
252
-
253
- const ALL_RESULT_COLS = [
254
- { title: "#", align: true }, { title: "Country" }, { title: "Location" }, { title: "Protocol" },
255
- { title: "Connect", align: true }, { title: "Latency", align: true }, { title: "Speed", align: true }, { title: "Status" },
256
- ];
257
- const SPEED_COL = 6;
258
- const resultCols = () => ALL_RESULT_COLS.filter((_, i) => speedTest || i !== SPEED_COL);
259
- const resultRow = (r, i) => [
260
- dim(String(i + 1)),
261
- `${flag(r.query)} ${countryName(r.query)}`,
262
- r.location || "-",
263
- r.protocol,
264
- r.connectS === null ? "-" : `${fmt(r.connectS, 1)} s`,
265
- r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`,
266
- r.speedMbps === null ? "-" : bold(`${fmt(r.speedMbps, 1)} Mbps`),
267
- r.status === "ok" ? green("✓ ok") : red(r.status),
268
- ].filter((_, i) => speedTest || i !== SPEED_COL);
269
-
270
- // availability grid: one row per country, ✓/✗ per protocol (· = not tried yet)
271
- function matrix(results, protocols, { minWidth = 0, limit = Infinity } = {}) {
272
- const byCountry = new Map();
273
- for (const r of results) {
274
- if (r.status === "no such location" || r.status === "interrupted") continue;
275
- if (!byCountry.has(r.query)) byCountry.set(r.query, []);
276
- byCountry.get(r.query).push(r);
277
- }
278
- const ranked = [...byCountry].map(([q, rs]) => ({ q, rs, good: rs.filter((r) => r.status === "ok").length }))
279
- .sort((a, b) => b.good - a.good); // stable: ties keep scan order
280
- const reachable = ranked.filter((c) => c.good).length;
281
- const rows = ranked.slice(0, limit).flatMap(({ q, rs }) => {
282
- const seen = rs.find((r) => r.status === "ok") ?? rs.find((r) => r.location);
283
- // protocols can land on different datacenters (even cities), so list each one that connected, one per line
284
- const places = [...new Set(rs.filter((r) => r.status === "ok").map((r) => r.location).filter(Boolean))];
285
- const [first = seen?.location?.split(" - ")[0] || dim("-"), ...more] = places;
286
- return [
287
- [
288
- `${flag(q)} ${countryName(q)}`,
289
- first,
290
- ...protocols.map((p) => {
291
- const r = rs.find((x) => x.protocol === p);
292
- return !r ? dim("·") : r.status === "ok" ? green("✓") : red("✗");
293
- }),
294
- ],
295
- ...more.map((place) => ["", place, ...protocols.map(() => "")]),
296
- ];
297
- }).slice(0, limit);
298
- return grid(
299
- [{ title: "Country" }, { title: "Location(s)" }, ...protocols.map((p) => ({ title: p, align: "center" }))],
300
- rows,
301
- {
302
- caption: bold("Availability"),
303
- captionRight: dim(`${reachable}/${ranked.length} countries reachable${ranked.length > limit ? ` · top ${limit}` : ""}`),
304
- minWidth,
305
- stretch: 1,
306
- },
307
- );
308
- }
309
-
310
- // ---------- config ----------
311
-
312
- function loadConfig() {
313
- try {
314
- const c = JSON.parse(readFileSync(CONFIG, "utf8"));
315
- return {
316
- countries: (c.countries ?? []).filter((x) => COUNTRIES.includes(x)),
317
- protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
318
- speed: c.speed !== false,
319
- firewall: c.firewall === true,
320
- };
321
- } catch {
322
- return null; // first run or unreadable file
323
- }
324
- }
325
-
326
- function saveConfig(c) {
327
- try {
328
- mkdirSync(join(CONFIG, ".."), { recursive: true });
329
- writeFileSync(CONFIG, JSON.stringify(c, null, 2) + "\n");
330
- } catch {} // not being able to remember isn't worth failing the scan
331
- }
332
-
333
- // ---------- terminal ----------
334
-
335
- const out = process.stdout;
336
- // stdin minus mouse reports: readline would split those into stray keypresses ("0", ";", "M"…)
337
- const input = new PassThrough();
338
- readline.emitKeypressEvents(input);
339
- // SGR mouse report: ESC [ < button ; col ; row (M = press, m = release); 64/65 = wheel up/down
340
- // ponytail: assumes a report never straddles two reads, true for terminals in practice
341
- let link = null; // where draw() last put the repo link: { y, x }
342
- const openUrl = (url) =>
343
- spawn({ darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open", [url], { stdio: "ignore", detached: true })
344
- .on("error", () => {}).unref();
345
- const onData = (buf) => {
346
- const rest = buf.toString().replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, b, x, y, kind) => {
347
- if (link && kind === "M" && b === "0" && y - 1 === link.y && x - 1 >= link.x && x - 1 < link.x + REPO_URL.length) openUrl(REPO_URL);
348
- input.emit("mouse", { button: Number(b), x: Number(x) - 1, y: Number(y) - 1, press: kind === "M" });
349
- return "";
350
- });
351
- if (rest) input.write(rest);
352
- };
353
-
354
- let fullscreen = false;
355
- function enterScreen() {
356
- fullscreen = true;
357
- out.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"); // alternate screen, hide cursor, mouse on
358
- process.stdin.setRawMode(true);
359
- process.stdin.on("data", onData);
360
- process.stdin.resume();
361
- }
362
- function leaveScreen() {
363
- if (!fullscreen) return;
364
- fullscreen = false;
365
- process.stdin.setRawMode(false);
366
- process.stdin.off("data", onData);
367
- process.stdin.pause();
368
- input.removeAllListeners("keypress");
369
- input.removeAllListeners("mouse");
370
- out.write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
371
- }
372
- process.on("exit", leaveScreen);
373
-
374
- // redraw in place, centered: home, each line cleared to its end, then clear below (no flicker)
375
- function draw(lines) {
376
- const cols = out.columns || 80, rows = out.rows || 24;
377
- const w = Math.max(...lines.map(vw));
378
- const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
379
- const top = Math.max(0, Math.floor((rows - lines.length) / 2));
380
- const screen = [...Array(top).fill(""), ...lines.map((l) => left + l)].slice(0, rows);
381
- const i = screen.findIndex((l) => l.includes(REPO_URL));
382
- link = i < 0 ? null : { y: i, x: vw(screen[i].slice(0, screen[i].indexOf("\x1b]8;;"))) };
383
- out.write("\x1b[H" + screen.map((l) => l + "\x1b[K").join("\n") + "\x1b[J");
384
- return { left: left.length, top };
385
- }
386
-
387
- // gradient bar (cyan → green) with eighth-block precision on the leading edge
388
- function bar(fraction, width) {
389
- const parts = " ▏▎▍▌▋▊▉";
390
- const cells = Math.max(0, Math.min(1, fraction)) * width;
391
- const full = Math.floor(cells);
392
- const palette = [45, 44, 43, 42, 41, 47, 48, 49, 83, 82];
393
- const shade = (i) => palette[Math.floor((i / width) * palette.length)];
394
- let s = "";
395
- for (let i = 0; i < full; i++) s += color(`38;5;${shade(i)}`, "█");
396
- if (full < width) {
397
- const p = parts[Math.floor((cells - full) * 8)];
398
- s += p === " " ? dim("░") : color(`38;5;${shade(full)}`, p);
399
- s += dim("░".repeat(width - full - 1));
400
- }
401
- return s;
402
- }
403
-
404
- // ---------- screen 1: picker ----------
405
-
406
- function pick(saved, timeout) {
407
- const selected = new Set(saved?.countries ?? []);
408
- const protos = new Set(saved?.protocols?.length ? saved.protocols : PROTOCOLS);
409
- let cursor = 0, top = 0, message = "", query = "", searching = false;
410
- let hit = {}; // where things landed on screen last render, for mouse clicks
411
- const visible = () => {
412
- const q = query.toLowerCase();
413
- return q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q) || countryName(c).toLowerCase().includes(q)) : COUNTRIES;
414
- };
415
- enterScreen();
416
-
417
- return new Promise((resolve) => {
418
- const render = () => {
419
- const rows = out.rows || 24;
420
- const list = visible();
421
- const height = Math.max(3, rows - 20 - (update ? 1 : 0));
422
- cursor = Math.max(0, Math.min(cursor, list.length - 1));
423
- if (cursor < top) top = cursor;
424
- if (cursor >= top + height) top = cursor - height + 1;
425
- top = Math.max(0, Math.min(top, list.length - height));
426
-
427
- const protoLine = PROTOCOLS.map((p, i) =>
428
- `${dim(under(String(i + 1)))} ${protos.has(p) ? green("● " + p) : dim("○ " + p)}`).join(" ");
429
- const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
430
- ? "ranks by download speed · adds ~15s per working connection"
431
- : "off: connect-only, ranks by latency")}`;
432
- const fwLabel = "block internet while scanning (firewall)";
433
- const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(firewall
434
- ? "only VPN traffic gets through"
435
- : "off: apps may leak in between")}`;
436
- const search = searching
437
- ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
438
- : query ? `🔍 ${query}` : dim("🔍 press / to search");
439
- const shown = list.slice(top, top + height);
440
- const range = list.length > height ? `${top + 1}–${top + shown.length} of ${list.length}` : `${list.length} countries`;
441
- const body = shown.map((code, j) => {
442
- const here = top + j === cursor;
443
- const mark = selected.has(code) ? green("●") : dim("○");
444
- const name = `${flag(code)} ${countryName(code)}`;
445
- return [`${here ? cyan("❯") : " "} ${mark}`, here ? bold(code) : dim(code), here ? bold(cyan(name)) : name];
446
- });
447
- if (!body.length) body.push([" ", "", dim("no country matches")]);
448
- const buttons = [["start", "▶ Start"], ["all", under("A") + (query ? "ll shown" : "ll")], ["none", under("N") + (query ? "one shown" : "one")], ["quit", under("Q") + "uit"]];
449
- const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
450
-
451
- const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body,
452
- { caption: search, captionRight: dim(range), minWidth: Math.max(vw(protoLine), vw(speedLine), vw(fwLine)) + 4 });
453
- const width = vw(table[0]);
454
- const head = box([protoLine, speedLine, fwLine], {
455
- title: bold(cyan("WindScout")),
456
- right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
457
- width: width - 4,
458
- });
459
- const n = selected.size * protos.size;
460
- const lines = [
461
- ...head,
462
- ...table,
463
- "",
464
- centerIn(n
465
- ? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(selected.size, protos.size, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each)`)}`
466
- : yellow("select at least one country and one protocol"), width),
467
- centerIn(dim(searching
468
- ? "type to filter · ↑↓ move · enter/esc done"
469
- : `↑↓ move · space select · a/n ${query ? "all/none shown" : "all/none"} · / search`), width),
470
- centerIn(dim(searching ? "" : `1-${PROTOCOLS.length} protocols · s speed test · f firewall · enter start · ${query ? "esc clear · " : ""}q quit`), width),
471
- ];
472
- lines.splice(head.length + table.length + 1, 0, centerIn(buttonLine, width), "");
473
- if (message) lines.push(centerIn(yellow(message), width));
474
- lines.push("", centerIn(dim(repoLink), width));
475
- if (update) lines.push(centerIn(yellow(updateNotice()), width));
476
- const at = draw(lines);
477
-
478
- // click map, in screen coordinates
479
- const x0 = at.left, y0 = at.top;
480
- let px = x0 + 2; // "│ " before the protocol line
481
- const protoHits = PROTOCOLS.map((p, i) => {
482
- const len = vw(`${i + 1} ● ${p}`);
483
- const range = [px, px + len];
484
- px += len + 3;
485
- return range;
486
- });
487
- let bx = x0 + Math.floor((width - vw(buttonLine)) / 2);
488
- const buttonHits = buttons.map(([id, label]) => {
489
- const len = vw(`[ ${label} ]`);
490
- const range = [id, bx, bx + len];
491
- bx += len + 2;
492
- return range;
493
- });
494
- hit = {
495
- protoRow: y0 + 1, speedRow: y0 + 2, firewallRow: y0 + 3, protoHits,
496
- searchRow: y0 + head.length + 1,
497
- firstRow: y0 + head.length + 5, shown: shown.length,
498
- buttonRow: y0 + head.length + table.length + 1, buttonHits,
499
- };
500
- };
501
-
502
- let closed = false;
503
- updateCheck.then(() => update && !closed && render()); // the check usually lands after the first draw
504
- const finish = (value) => {
505
- closed = true;
506
- out.off("resize", render);
507
- input.removeAllListeners("keypress");
508
- input.removeAllListeners("mouse");
509
- if (!value) leaveScreen();
510
- resolve(value);
511
- };
512
-
513
- const start = () => {
514
- if (!selected.size || !protos.size) return void (message = "nothing to scan yet");
515
- // keep the user's order of preference stable: list order
516
- const choice = {
517
- countries: COUNTRIES.filter((c) => selected.has(c)),
518
- protocols: PROTOCOLS.filter((p) => protos.has(p)),
519
- speed: speedTest,
520
- firewall,
521
- };
522
- saveConfig(choice);
523
- finish(choice);
524
- return true;
525
- };
526
-
527
- input.on("mouse", (m) => {
528
- if (!m.press) return;
529
- message = "";
530
- const list = visible();
531
- const { x, y } = m;
532
- if (m.button === 64) cursor = Math.max(0, cursor - 3); // wheel up
533
- else if (m.button === 65) cursor = Math.min(list.length - 1, cursor + 3); // wheel down
534
- else if (m.button !== 0) return; // left button only
535
- else if (y === hit.searchRow) searching = true;
536
- else {
537
- searching = false; // clicking anywhere else ends typing, like leaving a text field
538
- if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
539
- cursor = top + (y - hit.firstRow);
540
- const c = list[cursor];
541
- if (c) selected.has(c) ? selected.delete(c) : selected.add(c);
542
- } else if (y === hit.protoRow) {
543
- const i = hit.protoHits.findIndex(([a, b]) => x >= a && x < b);
544
- if (i >= 0) protos.has(PROTOCOLS[i]) ? protos.delete(PROTOCOLS[i]) : protos.add(PROTOCOLS[i]);
545
- } else if (y === hit.speedRow) speedTest = !speedTest;
546
- else if (y === hit.firewallRow) firewall = !firewall;
547
- else if (y === hit.buttonRow) {
548
- const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
549
- if (id === "start" && start()) return;
550
- if (id === "quit") return finish(null);
551
- if (id === "all") list.forEach((c) => selected.add(c));
552
- if (id === "none") list.forEach((c) => selected.delete(c));
553
- }
554
- }
555
- render();
556
- });
557
-
558
- out.on("resize", render);
559
- input.on("keypress", (str, key = {}) => {
560
- message = "";
561
- const k = key.name;
562
- const list = visible();
563
- const toggle = () => {
564
- const c = list[cursor];
565
- if (!c) return;
566
- selected.has(c) ? selected.delete(c) : selected.add(c);
567
- cursor = Math.min(list.length - 1, cursor + 1);
568
- };
569
-
570
- if (key.ctrl && k === "c") return finish(null);
571
- if (k === "up") cursor = Math.max(0, cursor - 1);
572
- else if (k === "down") cursor = Math.min(list.length - 1, cursor + 1);
573
- else if (k === "pageup") cursor = Math.max(0, cursor - 10);
574
- else if (k === "pagedown") cursor = Math.min(list.length - 1, cursor + 10);
575
- else if (searching) {
576
- // typing goes into the search box; letters are not shortcuts here
577
- if (k === "return" || k === "escape") searching = false;
578
- else if (k === "backspace") query = query.slice(0, -1), cursor = 0;
579
- else if (k === "space" && !query) toggle();
580
- else if (str && !key.ctrl && !key.meta && str >= " ") query += str, cursor = 0;
581
- } else if (k === "home") cursor = 0;
582
- else if (k === "end") cursor = list.length - 1;
583
- else if (k === "k") cursor = Math.max(0, cursor - 1);
584
- else if (k === "j") cursor = Math.min(list.length - 1, cursor + 1);
585
- else if (str === "/") searching = true;
586
- else if (k === "space") toggle();
587
- else if (k === "a") list.forEach((c) => selected.add(c));
588
- else if (k === "n") list.forEach((c) => selected.delete(c));
589
- else if (k === "s") speedTest = !speedTest;
590
- else if (k === "f") firewall = !firewall;
591
- else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1]) {
592
- const p = PROTOCOLS[Number(str) - 1];
593
- protos.has(p) ? protos.delete(p) : protos.add(p);
594
- } else if (k === "escape" && query) query = "", cursor = 0;
595
- else if (k === "return") {
596
- if (start()) return;
597
- } else if (k === "q" || k === "escape") return finish(null);
598
- render();
599
- });
600
- render();
601
- });
602
- }
603
-
604
- // ---------- screen 2: scan ----------
605
-
606
- async function scan(countries, protocols, timeout, tty) {
607
- const results = [];
608
- const total = countries.length * protocols.length;
609
- const started = now();
610
- let done = 0, attemptStart = now(), current = "", step = "", frame = 0, finishedCountries = [];
611
- let view = speedTest ? "ranking" : "availability"; // tab switches
612
- // pause lands between attempts, so the VPN is disconnected while waiting; idle = seconds spent paused
613
- let paused = false, waiting = false, waitStart = 0, idle = 0, resume = () => {};
614
- // firewall: block everything outside the VPN while switching locations; put it back the way the user had it
615
- const fwChanged = firewall && /^Firewall state: Off/m.test(await cli("status"));
616
- if (fwChanged) await cli("firewall", "on");
617
- restoreFirewall = async () => { if (fwChanged) await cli("firewall", "off"); };
618
-
619
- const render = () => {
620
- const rows = out.rows || 24;
621
- const elapsed = now() - started - idle - (waiting ? now() - waitStart : 0);
622
- // smooth bar: count the running attempt as partially done
623
- const partial = waiting ? 0 : Math.min(0.95, (now() - attemptStart) / attemptSeconds(timeout));
624
- const fraction = (done + (done < total ? partial : 0)) / total;
625
- const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout));
626
- const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10];
627
- const ok = sortOk(results);
628
- const failed = finishedCountries.filter((c) => !results.some((r) => r.query === c && r.status === "ok"));
629
-
630
- const pct = bold(`${String(Math.floor(fraction * 100)).padStart(3)}%`);
631
- const stats = [
632
- `${dim("elapsed")} ${duration(elapsed)}`,
633
- `${dim("remaining")} ${done < total ? "≈ " + duration(eta) : "-"}`,
634
- `${green("✓")} ${ok.length} ${dim("working")}`,
635
- `${red("✗")} ${failed.length} ${dim("blocked")}`,
636
- ].join(dim(" │ "));
637
- const minW = Math.max(64, vw(stats) + 6); // panel and table share one width
638
- // results table gets whatever height the status panel and footer leave
639
- const room = Math.max(1, rows - 22);
640
- const tried = results.some((r) => !["no such location", "interrupted"].includes(r.status));
641
- const table = view === "availability"
642
- ? (tried ? matrix(results, protocols, { minWidth: minW, limit: room }) : [])
643
- : ok.length
644
- ? grid(resultCols(), ok.slice(0, room).map(resultRow), {
645
- caption: bold("Working so far"),
646
- captionRight: dim(ok.length > room ? `top ${room} of ${ok.length}` : `${ok.length} found`),
647
- minWidth: minW,
648
- })
649
- : [];
650
- const width = Math.max(minW, table.length ? vw(table[0]) : 0);
651
- const inner = width - 4;
652
-
653
- const panel = box([
654
- "",
655
- ` ${bar(fraction, inner - 8)} ${pct}`,
656
- "",
657
- stop
658
- ? yellow(" stopping after the current attempt…")
659
- : waiting ? yellow(` paused · VPN disconnected${fwChanged ? " · firewall blocking internet" : ""} · press p to resume`)
660
- : ` ${cyan(spinner)} ${bold(current)}`,
661
- stop || waiting ? "" : ` ${dim(step)} ${dim("·")} ${duration(now() - attemptStart)}${paused && frame % 8 < 5 ? yellow(" · pausing after this attempt") : ""}`,
662
- null,
663
- centerIn(stats, inner),
664
- ], {
665
- title: bold(cyan("WindScout")),
666
- right: `${bold(String(done))}${dim(`/${total} attempts`)}`,
667
- width: inner,
668
- });
669
-
670
- const lines = [...panel];
671
- if (table.length) lines.push(...table);
672
- else lines.push("", centerIn(dim(finishedCountries.length ? "nothing has connected yet" : "results appear here after each country"), width));
673
- if (failed.length) {
674
- let txt = `${red("✗")} ${dim("no connection:")} ${failed.map((c) => `${flag(c)} ${c}`).join(" ")}`;
675
- while (vw(txt) > width && failed.length) txt = txt.slice(0, -8) + "…"; // ponytail: crude trim, fine for a status line
676
- lines.push("", centerIn(txt, width));
677
- }
678
- lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · p ${paused ? "resume" : "pause"} · q stop · q twice force quit`), width), "", centerIn(dim(repoLink), width));
679
- draw(lines);
680
- };
681
-
682
- let timer;
683
- if (tty) {
684
- if (!fullscreen) enterScreen();
685
- input.on("keypress", async (str, key = {}) => {
686
- if (key.name === "tab") {
687
- view = view === "ranking" ? "availability" : "ranking";
688
- render();
689
- } else if (key.name === "p" || key.name === "space") {
690
- paused = !paused;
691
- if (!paused) resume();
692
- render();
693
- } else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
694
- if (stop++) { // second press: bail out now
695
- leaveScreen();
696
- console.error(`stopped. the VPN may still be connecting: run '${CLI} disconnect'`);
697
- await restoreFirewall();
698
- process.exit(130);
699
- }
700
- resume(); // a paused scan stops right away
701
- render();
702
- }
703
- });
704
- out.on("resize", render);
705
- timer = setInterval(render, 120);
706
- } else {
707
- process.on("SIGINT", async () => { if (stop++) await restoreFirewall(), process.exit(130); });
708
- }
709
-
710
- outer: for (const loc of countries) {
711
- for (const proto of protocols) {
712
- if (paused && !stop) {
713
- waiting = true, waitStart = now();
714
- await new Promise((r) => (resume = r));
715
- waiting = false, idle += now() - waitStart;
716
- }
717
- if (stop) break outer;
718
- current = `${flag(loc)} ${countryName(loc)} ${dim("·")} ${proto}`;
719
- attemptStart = now();
720
- if (!tty) process.stdout.write(`[${done + 1}/${total}] ${loc} ${proto} ... `);
721
- const r = await probe(loc, proto, timeout, (s) => (step = s));
722
- results.push(r);
723
- done++;
724
- if (!tty) console.log([r.status, r.location, r.speedMbps ? `${r.speedMbps.toFixed(1)} Mbps` : ""].join(" ").trim());
725
- if (r.status === "no such location") { done += protocols.length - protocols.indexOf(proto) - 1; break; }
726
- }
727
- finishedCountries.push(loc); // table/failed list refresh per finished country
728
- }
729
- await restoreFirewall();
730
-
731
- if (tty) {
732
- clearInterval(timer);
733
- out.off("resize", render);
734
- leaveScreen();
735
- }
736
- return results;
737
- }
738
-
739
- // ---------- final report (normal screen, stays in scrollback) ----------
740
-
741
- function report(results, protocols, all) {
742
- const ok = sortOk(results);
743
- const rows = [...ok, ...(all ? results.filter((r) => r.status !== "ok") : [])];
744
- const tried = results.some((r) => !["no such location", "interrupted"].includes(r.status));
745
- // render once to learn the widest table, then again so every box lines up
746
- const build = (minWidth) => ({
747
- avail: tried ? matrix(results, protocols, { minWidth }) : [],
748
- main: rows.length
749
- ? grid(resultCols(), rows.map(resultRow), { caption: bold("Results"), captionRight: dim(`${ok.length} working`), minWidth })
750
- : [],
751
- });
752
- const first = build(0);
753
- const width = Math.max(64, ...[first.avail[0], first.main[0]].filter(Boolean).map(vw));
754
- const { avail, main } = build(width);
755
-
756
- console.log();
757
- if (avail.length) console.log(avail.join("\n"));
758
- if (!ok.length) {
759
- console.log(box([yellow("Nothing connected."),
760
- dim("Try other countries, ports (e.g. -p stealth:443,tcp:443) or a longer --timeout.")],
761
- { title: bold(cyan("WindScout")), width: width - 4 }).join("\n"));
762
- return;
763
- }
764
- console.log(main.join("\n"));
765
-
766
- const stats = protocols.map((p) => {
767
- const tried = results.filter((r) => r.protocol === p && r.status !== "no such location");
768
- const good = tried.filter((r) => r.status === "ok");
769
- return { p, good: good.length, tried: tried.length, best: sortOk(good)[0] };
770
- }).sort((x, y) => y.good - x.good || (y.best?.speedMbps ?? 0) - (x.best?.speedMbps ?? 0) || (x.best?.latencyMs ?? 1e9) - (y.best?.latencyMs ?? 1e9));
771
- const bestValue = (r) => (speedTest ? `${fmt(r.speedMbps, 1)} Mbps` : `${fmt(r.latencyMs, 0)} ms`);
772
- console.log(grid(
773
- [{ title: "Protocol" }, { title: "Worked", align: true }, { title: speedTest ? "Best speed" : "Best latency", align: true }, { title: "Best location" }],
774
- stats.map((s) => [s.p, `${s.good}/${s.tried}`, s.best ? bestValue(s.best) : "-", s.best ? `${flag(s.best.query)} ${s.best.location}` : "-"]),
775
- { caption: bold("Per protocol"), minWidth: width },
776
- ).join("\n"));
777
- const b = ok[0];
778
- const target = b.location.split(" - ").at(-1) || b.query; // nickname pins the exact datacenter
779
- const facts = [speedTest && `${fmt(b.speedMbps, 1)} Mbps`, `${fmt(b.latencyMs, 0)} ms`].filter(Boolean).join(" · ");
780
- console.log(box([
781
- `${flag(b.query)} ${bold(b.location)} over ${bold(b.protocol)} ${dim("· " + facts)}`,
782
- cyan(`${CLI} connect "${target}" ${b.protocol}`),
783
- ], { title: green(speedTest ? "★ Best" : "★ Best (lowest latency)"), width: width - 4 }).join("\n"));
784
- }
785
-
786
- // ---------- main ----------
787
-
788
- const HELP = `Usage: windscout [options]
789
-
790
- Find which Windscribe locations and protocols work from your network, ranked by speed.
791
- Without -c it opens an interactive country picker (your selection is remembered).
792
-
793
- Options:
794
- -c, --countries LIST comma-separated ISO codes, city names or nicknames (skips the picker)
795
- -p, --protocols LIST comma-separated protocols, optional :port (default: ${PROTOCOLS.join(",")})
796
- -t, --timeout SEC seconds to wait for each connection (default: 20)
797
- -a, --all also show failed attempts in the final table
798
- -n, --no-speed connect-only: skip the download test, rank by latency (faster scan)
799
- -f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
800
- -v, --version show the installed version (and whether a newer one is out)
801
- -h, --help show this help
802
-
803
- Selection is stored in ${CONFIG}
804
- Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
805
-
806
- async function main() {
807
- const { values: a } = parseArgs({
808
- options: {
809
- countries: { type: "string", short: "c" },
810
- protocols: { type: "string", short: "p" },
811
- timeout: { type: "string", short: "t", default: "20" },
812
- all: { type: "boolean", short: "a", default: false },
813
- help: { type: "boolean", short: "h", default: false },
814
- version: { type: "boolean", short: "v", default: false },
815
- "no-speed": { type: "boolean", short: "n", default: false },
816
- firewall: { type: "boolean", short: "f", default: false },
817
- },
818
- });
819
- if (a.help) return console.log(HELP);
820
- updateCheck = checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
821
- if (a.version) {
822
- console.log(`windscout ${VERSION}`);
823
- await updateCheck;
824
- return update && console.error(yellow(updateNotice()));
825
- }
826
- const timeout = Number(a.timeout);
827
- if (!(timeout > 0)) throw new Error("--timeout must be a positive number");
828
-
829
- // the first CLI call launches the app if it isn't running; give it a moment to start and restore the session
830
- let status = await cli("status");
831
- for (let i = 0; i < 15 && !status.includes("Logged in"); i++) await sleep(1000), status = await cli("status");
832
- if (!status.includes("Logged in"))
833
- throw new Error("Windscribe is not logged in, or the app is not running. Open the Windscribe app and log in, then try again."
834
- + `\nDon't have it? ${DOWNLOAD_URL}`);
835
-
836
- const tty = Boolean(process.stdin.isTTY && out.isTTY);
837
- let countries = a.countries && list(a.countries);
838
- let protocols = a.protocols && list(a.protocols);
839
- if (!countries) {
840
- if (!tty) throw new Error("no terminal for the picker: pass countries with -c");
841
- const saved = loadConfig();
842
- if (protocols && saved) saved.protocols = protocols.filter((p) => PROTOCOLS.includes(p));
843
- speedTest = !a["no-speed"] && (saved?.speed ?? false); // first run: connect-only
844
- // already on in the app: show it on, since the scan won't turn it off
845
- firewall = a.firewall || /^Firewall state: On/m.test(status) || (saved?.firewall ?? false);
846
- const choice = await pick(saved, timeout);
847
- if (!choice) return;
848
- countries = choice.countries;
849
- protocols = protocols ?? choice.protocols;
850
- }
851
- protocols ??= PROTOCOLS;
852
- if (a.countries) speedTest = !a["no-speed"], firewall = a.firewall;
853
-
854
- const results = await scan(countries, protocols, timeout, tty);
855
- if (stop) console.log(yellow("interrupted, showing partial results"));
856
- report(results, protocols, a.all);
857
- await updateCheck;
858
- if (update) console.error("\n" + yellow(updateNotice()));
859
- }
860
-
861
- main().catch(async (e) => {
862
- leaveScreen();
863
- console.error(`windscout: ${e.message}`);
864
- await restoreFirewall().catch(() => console.error(`the firewall may still be on: run '${CLI} firewall off'`));
865
- process.exit(1);
866
- });