windscout 0.4.6 → 0.5.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 +11 -0
- package/dist/cli.js +106 -0
- package/dist/config.js +28 -0
- package/dist/measure.js +41 -0
- package/dist/probe.js +56 -0
- package/dist/scan.js +75 -0
- package/dist/time.js +10 -0
- package/dist/tui/country.js +15 -0
- package/dist/tui/picker.js +251 -0
- package/dist/tui/progress.js +124 -0
- package/dist/tui/report.js +44 -0
- package/dist/tui/tables.js +62 -0
- package/dist/tui/terminal.js +67 -0
- package/dist/tui/text.js +22 -0
- package/dist/tui/widgets.js +53 -0
- package/dist/update.js +28 -0
- package/dist/windscribe.js +85 -0
- package/package.json +12 -4
- package/windscout.js +0 -866
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
|
-
});
|