windscout 0.1.0 → 0.3.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 +8 -6
- package/package.json +23 -6
- package/windscout.js +76 -17
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# WindScout
|
|
2
2
|
|
|
3
|
-
Find which Windscribe locations and protocols actually work from your network, ranked by speed.
|
|
3
|
+
Find which Windscribe locations and protocols actually work from your network, ranked by speed. Runs on Linux, macOS and Windows.
|
|
4
4
|
|
|
5
5
|
WindScout drives the official `windscribe-cli`: it connects to each country × protocol pair, measures latency and download speed through the tunnel, disconnects, and prints a ranked table.
|
|
6
6
|
|
|
@@ -8,9 +8,9 @@ WindScout drives the official `windscribe-cli`: it connects to each country × p
|
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
|
-
- Linux or
|
|
11
|
+
- Linux, macOS or Windows 10/11
|
|
12
12
|
- Node.js 20+
|
|
13
|
-
- The [Windscribe desktop app](https://windscribe.com/download) installed, running and logged in. It includes `windscribe-cli`, which WindScout uses to control the app. Check with `windscribe-cli status`, which should show `Logged in`.
|
|
13
|
+
- The [Windscribe desktop app](https://windscribe.com/download) installed, running and logged in. It includes `windscribe-cli`, which WindScout uses to control the app. Check with `windscribe-cli status`, which should show `Logged in`. On Windows the app doesn't put the CLI on `PATH`, so run `"C:\Program Files\Windscribe\windscribe-cli.exe" status` instead. WindScout finds it there on its own.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -18,6 +18,8 @@ WindScout drives the official `windscribe-cli`: it connects to each country × p
|
|
|
18
18
|
npm install -g windscout # or run once with: npx windscout
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
On startup, WindScout checks npm for a newer version. If there is one, it shows the command to update, both in the picker and after the final report.
|
|
22
|
+
|
|
21
23
|
## Usage
|
|
22
24
|
|
|
23
25
|
Run `windscout` with no arguments to open the interactive picker:
|
|
@@ -49,9 +51,9 @@ Run `windscout` with no arguments to open the interactive picker:
|
|
|
49
51
|
| `enter` | start scanning |
|
|
50
52
|
| `q` | quit (during a scan: stop and show partial results; press again to force quit) |
|
|
51
53
|
|
|
52
|
-
**Mouse:** click a country to select or unselect it, click a protocol or the speed-test line to toggle it, click the search box to start typing (click anywhere else to leave it), and scroll the list with the wheel. The `[ ▶ Start ] [ All ] [ None ] [ Quit ]` buttons under the table do the same as their keys. This needs a terminal with mouse support, which most have.
|
|
54
|
+
**Mouse:** click a country to select or unselect it, click a protocol or the speed-test line to toggle it, click the search box to start typing (click anywhere else to leave it), and scroll the list with the wheel. The `[ ▶ Start ] [ All ] [ None ] [ Quit ]` buttons under the table do the same as their keys. This needs a terminal with mouse support, which most have. On Windows, use Windows Terminal; if clicks do nothing there, the keyboard does everything.
|
|
53
55
|
|
|
54
|
-
Your selection is saved to `~/.config/windscout/config.json` and preselected next time.
|
|
56
|
+
Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
|
|
55
57
|
|
|
56
58
|
While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting, measuring latency or measuring speed). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal.
|
|
57
59
|
|
|
@@ -68,7 +70,7 @@ windscout -t 40 -a # longer connect timeout, also list
|
|
|
68
70
|
| Option | Meaning | Default |
|
|
69
71
|
|---|---|---|
|
|
70
72
|
| `-c, --countries` | comma-separated ISO country codes, city names or nicknames | open the picker |
|
|
71
|
-
| `-p, --protocols` | comma-separated: `wireguard`, `ikev2` (macOS
|
|
73
|
+
| `-p, --protocols` | comma-separated: `wireguard`, `ikev2` (macOS and Windows), `stealth`, `wstunnel`, `udp`, `tcp`, optionally `:port` | saved, or all |
|
|
72
74
|
| `-t, --timeout` | seconds to wait for each connection | 20 |
|
|
73
75
|
| `-a, --all` | include failed attempts in the final table | off |
|
|
74
76
|
| `-n, --no-speed` | connect-only mode: skip the download test, rank by latency | saved, or off |
|
package/package.json
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windscout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"windscout": "windscout.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"windscout.js"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"os": [
|
|
16
|
+
"linux",
|
|
17
|
+
"darwin",
|
|
18
|
+
"win32"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"windscribe",
|
|
22
|
+
"vpn",
|
|
23
|
+
"speedtest",
|
|
24
|
+
"wireguard",
|
|
25
|
+
"stealth",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
11
28
|
"author": "Dier Cohen <i.am.dier.cohen@gmail.com>",
|
|
12
29
|
"license": "MIT"
|
|
13
30
|
}
|
package/windscout.js
CHANGED
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
// WindScout: find which Windscribe locations and protocols actually work from here.
|
|
3
3
|
// Drives the official windscribe-cli: connects to each country/protocol pair,
|
|
4
4
|
// measures latency and download speed through the tunnel, prints a ranked table.
|
|
5
|
-
import {
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import readline from "node:readline";
|
|
10
10
|
import { PassThrough } from "node:stream";
|
|
11
|
-
import { parseArgs
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
12
|
|
|
13
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
|
-
|
|
16
|
-
|
|
17
|
-
: "/
|
|
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";
|
|
18
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\\`;
|
|
19
24
|
let cliBin = CLI;
|
|
20
25
|
// IKEv2 is offered by the macOS/Windows apps only
|
|
21
26
|
const PROTOCOLS = ["wireguard", ...(process.platform === "linux" ? [] : ["ikev2"]), "stealth", "wstunnel", "udp", "tcp"];
|
|
@@ -29,30 +34,46 @@ const SPEED_URL = "https://speed.cloudflare.com/__down?bytes=10000000";
|
|
|
29
34
|
const SPEED_SECONDS = 15;
|
|
30
35
|
const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
|
|
31
36
|
|
|
32
|
-
const run = promisify(execFile);
|
|
33
37
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
38
|
const now = () => performance.now() / 1000;
|
|
35
39
|
const regionName = new Intl.DisplayNames(["en"], { type: "region" });
|
|
36
40
|
const countryName = (code) => {
|
|
37
41
|
try { return regionName.of(code); } catch { return code; } // city names / nicknames aren't region codes
|
|
38
42
|
};
|
|
39
|
-
// two regional-indicator letters render as the country's flag emoji
|
|
40
|
-
|
|
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
|
+
const flag = (code) => process.platform !== "win32" && /^[A-Z]{2}$/.test(code) ? String.fromCodePoint(...[...code].map((c) => 0x1f1a5 + c.charCodeAt(0))) : " ";
|
|
41
46
|
let stop = 0;
|
|
42
47
|
let speedTest = true; // false = connect-only mode: skip the download test, rank by latency
|
|
43
48
|
|
|
44
49
|
// ---------- windscribe + measurements ----------
|
|
45
50
|
|
|
51
|
+
// spawn, not execFile: if the app isn't running, the CLI launches it and the app inherits our stdout pipe.
|
|
52
|
+
// The pipe then stays open after the CLI exits, and execFile would wait for its timeout. Settle on exit instead
|
|
53
|
+
function run(args) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const p = spawn(cliBin, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
56
|
+
let out = "";
|
|
57
|
+
p.stdout.on("data", (d) => (out += d));
|
|
58
|
+
const done = () => (clearTimeout(kill), p.stdout.destroy(), resolve(out));
|
|
59
|
+
const kill = setTimeout(() => p.kill(), 90_000);
|
|
60
|
+
p.on("error", (e) => (clearTimeout(kill), reject(e)));
|
|
61
|
+
p.on("close", done);
|
|
62
|
+
// the CLI's last output may still be in the pipe when it exits; read for a moment, then stop waiting
|
|
63
|
+
p.on("exit", () => setTimeout(done, 300));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
async function cli(...args) {
|
|
47
|
-
let out
|
|
68
|
+
let out;
|
|
48
69
|
try {
|
|
49
|
-
|
|
70
|
+
out = await run(args);
|
|
50
71
|
} catch (e) {
|
|
51
72
|
if (e.code === "ENOENT") {
|
|
52
73
|
if (cliBin === CLI && existsSync(DESKTOP_CLI)) return (cliBin = DESKTOP_CLI), cli(...args);
|
|
53
74
|
throw new Error(`${CLI} not found. Install the Windscribe desktop app (it includes ${CLI}): ${DOWNLOAD_URL}`);
|
|
54
75
|
}
|
|
55
|
-
|
|
76
|
+
throw e;
|
|
56
77
|
}
|
|
57
78
|
// the CLI mixes JSON log lines into stdout
|
|
58
79
|
return out.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n");
|
|
@@ -130,6 +151,26 @@ async function probe(query, protocol, timeout, onStep = () => {}) {
|
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
153
|
|
|
154
|
+
// ---------- update check ----------
|
|
155
|
+
|
|
156
|
+
const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
|
|
157
|
+
let update = null; // newer version on npm, once known
|
|
158
|
+
let updateCheck = Promise.resolve();
|
|
159
|
+
// numeric x.y.z compare; parseInt drops a prerelease suffix ("0-beta" → 0)
|
|
160
|
+
const newer = (a, b) => {
|
|
161
|
+
const x = a.split(".").map((n) => parseInt(n)), y = b.split(".").map((n) => parseInt(n));
|
|
162
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
165
|
+
// best effort: offline or registry down just means no notice
|
|
166
|
+
const checkUpdate = () =>
|
|
167
|
+
fetch("https://registry.npmjs.org/windscout/latest", { signal: AbortSignal.timeout(3000) })
|
|
168
|
+
.then((r) => r.json())
|
|
169
|
+
.then(({ version }) => { if (version && newer(version, VERSION)) update = version; })
|
|
170
|
+
.catch(() => {});
|
|
171
|
+
const updateNotice = () => `update available: ${VERSION} → ${update} · run: ${
|
|
172
|
+
process.env.npm_command === "exec" ? "npx windscout@latest" : "npm install -g windscout@latest"}`;
|
|
173
|
+
|
|
133
174
|
// ---------- formatting ----------
|
|
134
175
|
|
|
135
176
|
const color = (code, s) => (process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[${code}m${s}\x1b[0m`);
|
|
@@ -152,7 +193,7 @@ const estimate = (nCountries, nProtocols, timeout) => nCountries * nProtocols *
|
|
|
152
193
|
|
|
153
194
|
// visible width: ANSI codes take no space; flags and emoji (🔍) take 2 columns. text symbols like ▶ ★ stay 1
|
|
154
195
|
const vw = (s) =>
|
|
155
|
-
[...s.replace(/\x1b\[[0-9;]*m
|
|
196
|
+
[...s.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]/gu, "xx")].length;
|
|
156
197
|
// align: falsy = left, true = right, "center"
|
|
157
198
|
const pad = (s, n, align = false) => {
|
|
158
199
|
const gap = Math.max(0, n - vw(s));
|
|
@@ -281,8 +322,13 @@ const input = new PassThrough();
|
|
|
281
322
|
readline.emitKeypressEvents(input);
|
|
282
323
|
// SGR mouse report: ESC [ < button ; col ; row (M = press, m = release); 64/65 = wheel up/down
|
|
283
324
|
// ponytail: assumes a report never straddles two reads, true for terminals in practice
|
|
325
|
+
let link = null; // where draw() last put the repo link: { y, x }
|
|
326
|
+
const openUrl = (url) =>
|
|
327
|
+
spawn({ darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open", [url], { stdio: "ignore", detached: true })
|
|
328
|
+
.on("error", () => {}).unref();
|
|
284
329
|
const onData = (buf) => {
|
|
285
330
|
const rest = buf.toString().replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, b, x, y, kind) => {
|
|
331
|
+
if (link && kind === "M" && b === "0" && y - 1 === link.y && x - 1 >= link.x && x - 1 < link.x + REPO_URL.length) openUrl(REPO_URL);
|
|
286
332
|
input.emit("mouse", { button: Number(b), x: Number(x) - 1, y: Number(y) - 1, press: kind === "M" });
|
|
287
333
|
return "";
|
|
288
334
|
});
|
|
@@ -316,6 +362,8 @@ function draw(lines) {
|
|
|
316
362
|
const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
|
|
317
363
|
const top = Math.max(0, Math.floor((rows - lines.length) / 2));
|
|
318
364
|
const screen = [...Array(top).fill(""), ...lines.map((l) => left + l)].slice(0, rows);
|
|
365
|
+
const i = screen.findIndex((l) => l.includes(REPO_URL));
|
|
366
|
+
link = i < 0 ? null : { y: i, x: vw(screen[i].slice(0, screen[i].indexOf("\x1b]8;;"))) };
|
|
319
367
|
out.write("\x1b[H" + screen.map((l) => l + "\x1b[K").join("\n") + "\x1b[J");
|
|
320
368
|
return { left: left.length, top };
|
|
321
369
|
}
|
|
@@ -354,7 +402,7 @@ function pick(saved, timeout) {
|
|
|
354
402
|
const render = () => {
|
|
355
403
|
const rows = out.rows || 24;
|
|
356
404
|
const list = visible();
|
|
357
|
-
const height = Math.max(3, rows -
|
|
405
|
+
const height = Math.max(3, rows - 19 - (update ? 1 : 0));
|
|
358
406
|
cursor = Math.max(0, Math.min(cursor, list.length - 1));
|
|
359
407
|
if (cursor < top) top = cursor;
|
|
360
408
|
if (cursor >= top + height) top = cursor - height + 1;
|
|
@@ -403,6 +451,8 @@ function pick(saved, timeout) {
|
|
|
403
451
|
];
|
|
404
452
|
lines.splice(head.length + table.length + 1, 0, centerIn(buttonLine, width), "");
|
|
405
453
|
if (message) lines.push(centerIn(yellow(message), width));
|
|
454
|
+
lines.push("", centerIn(dim(repoLink), width));
|
|
455
|
+
if (update) lines.push(centerIn(yellow(updateNotice()), width));
|
|
406
456
|
const at = draw(lines);
|
|
407
457
|
|
|
408
458
|
// click map, in screen coordinates
|
|
@@ -429,7 +479,10 @@ function pick(saved, timeout) {
|
|
|
429
479
|
};
|
|
430
480
|
};
|
|
431
481
|
|
|
482
|
+
let closed = false;
|
|
483
|
+
updateCheck.then(() => update && !closed && render()); // the check usually lands after the first draw
|
|
432
484
|
const finish = (value) => {
|
|
485
|
+
closed = true;
|
|
433
486
|
out.off("resize", render);
|
|
434
487
|
input.removeAllListeners("keypress");
|
|
435
488
|
input.removeAllListeners("mouse");
|
|
@@ -554,7 +607,7 @@ async function scan(countries, protocols, timeout, tty) {
|
|
|
554
607
|
].join(dim(" │ "));
|
|
555
608
|
const minW = Math.max(64, vw(stats) + 6); // panel and table share one width
|
|
556
609
|
// results table gets whatever height the status panel and footer leave
|
|
557
|
-
const room = Math.max(1, rows -
|
|
610
|
+
const room = Math.max(1, rows - 22);
|
|
558
611
|
const tried = results.some((r) => !["no such location", "interrupted"].includes(r.status));
|
|
559
612
|
const table = view === "availability"
|
|
560
613
|
? (tried ? matrix(results, protocols, { minWidth: minW, limit: room }) : [])
|
|
@@ -592,7 +645,7 @@ async function scan(countries, protocols, timeout, tty) {
|
|
|
592
645
|
while (vw(txt) > width && failed.length) txt = txt.slice(0, -8) + "…"; // ponytail: crude trim, fine for a status line
|
|
593
646
|
lines.push("", centerIn(txt, width));
|
|
594
647
|
}
|
|
595
|
-
lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · q stop · q twice force quit`), width));
|
|
648
|
+
lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · q stop · q twice force quit`), width), "", centerIn(dim(repoLink), width));
|
|
596
649
|
draw(lines);
|
|
597
650
|
};
|
|
598
651
|
|
|
@@ -718,10 +771,14 @@ async function main() {
|
|
|
718
771
|
},
|
|
719
772
|
});
|
|
720
773
|
if (a.help) return console.log(HELP);
|
|
774
|
+
updateCheck = checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
|
|
721
775
|
const timeout = Number(a.timeout);
|
|
722
776
|
if (!(timeout > 0)) throw new Error("--timeout must be a positive number");
|
|
723
777
|
|
|
724
|
-
if
|
|
778
|
+
// the first CLI call launches the app if it isn't running; give it a moment to start and restore the session
|
|
779
|
+
let status = await cli("status");
|
|
780
|
+
for (let i = 0; i < 15 && !status.includes("Logged in"); i++) await sleep(1000), status = await cli("status");
|
|
781
|
+
if (!status.includes("Logged in"))
|
|
725
782
|
throw new Error("Windscribe is not logged in, or the app is not running. Open the Windscribe app and log in, then try again."
|
|
726
783
|
+ `\nDon't have it? ${DOWNLOAD_URL}`);
|
|
727
784
|
|
|
@@ -744,6 +801,8 @@ async function main() {
|
|
|
744
801
|
const results = await scan(countries, protocols, timeout, tty);
|
|
745
802
|
if (stop) console.log(yellow("interrupted, showing partial results"));
|
|
746
803
|
report(results, protocols, a.all);
|
|
804
|
+
await updateCheck;
|
|
805
|
+
if (update) console.error("\n" + yellow(updateNotice()));
|
|
747
806
|
}
|
|
748
807
|
|
|
749
808
|
main().catch((e) => {
|