windscout 0.1.0 → 0.2.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/README.md +6 -6
- package/package.json +23 -6
- package/windscout.js +49 -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
|
|
|
@@ -49,9 +49,9 @@ Run `windscout` with no arguments to open the interactive picker:
|
|
|
49
49
|
| `enter` | start scanning |
|
|
50
50
|
| `q` | quit (during a scan: stop and show partial results; press again to force quit) |
|
|
51
51
|
|
|
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.
|
|
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. On Windows, use Windows Terminal; if clicks do nothing there, the keyboard does everything.
|
|
53
53
|
|
|
54
|
-
Your selection is saved to `~/.config/windscout/config.json` and preselected next time.
|
|
54
|
+
Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
|
|
55
55
|
|
|
56
56
|
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
57
|
|
|
@@ -68,7 +68,7 @@ windscout -t 40 -a # longer connect timeout, also list
|
|
|
68
68
|
| Option | Meaning | Default |
|
|
69
69
|
|---|---|---|
|
|
70
70
|
| `-c, --countries` | comma-separated ISO country codes, city names or nicknames | open the picker |
|
|
71
|
-
| `-p, --protocols` | comma-separated: `wireguard`, `ikev2` (macOS
|
|
71
|
+
| `-p, --protocols` | comma-separated: `wireguard`, `ikev2` (macOS and Windows), `stealth`, `wstunnel`, `udp`, `tcp`, optionally `:port` | saved, or all |
|
|
72
72
|
| `-t, --timeout` | seconds to wait for each connection | 20 |
|
|
73
73
|
| `-a, --all` | include failed attempts in the final table | off |
|
|
74
74
|
| `-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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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");
|
|
@@ -152,7 +173,7 @@ const estimate = (nCountries, nProtocols, timeout) => nCountries * nProtocols *
|
|
|
152
173
|
|
|
153
174
|
// visible width: ANSI codes take no space; flags and emoji (🔍) take 2 columns. text symbols like ▶ ★ stay 1
|
|
154
175
|
const vw = (s) =>
|
|
155
|
-
[...s.replace(/\x1b\[[0-9;]*m
|
|
176
|
+
[...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
177
|
// align: falsy = left, true = right, "center"
|
|
157
178
|
const pad = (s, n, align = false) => {
|
|
158
179
|
const gap = Math.max(0, n - vw(s));
|
|
@@ -281,8 +302,13 @@ const input = new PassThrough();
|
|
|
281
302
|
readline.emitKeypressEvents(input);
|
|
282
303
|
// SGR mouse report: ESC [ < button ; col ; row (M = press, m = release); 64/65 = wheel up/down
|
|
283
304
|
// ponytail: assumes a report never straddles two reads, true for terminals in practice
|
|
305
|
+
let link = null; // where draw() last put the repo link: { y, x }
|
|
306
|
+
const openUrl = (url) =>
|
|
307
|
+
spawn({ darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open", [url], { stdio: "ignore", detached: true })
|
|
308
|
+
.on("error", () => {}).unref();
|
|
284
309
|
const onData = (buf) => {
|
|
285
310
|
const rest = buf.toString().replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, b, x, y, kind) => {
|
|
311
|
+
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
312
|
input.emit("mouse", { button: Number(b), x: Number(x) - 1, y: Number(y) - 1, press: kind === "M" });
|
|
287
313
|
return "";
|
|
288
314
|
});
|
|
@@ -316,6 +342,8 @@ function draw(lines) {
|
|
|
316
342
|
const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
|
|
317
343
|
const top = Math.max(0, Math.floor((rows - lines.length) / 2));
|
|
318
344
|
const screen = [...Array(top).fill(""), ...lines.map((l) => left + l)].slice(0, rows);
|
|
345
|
+
const i = screen.findIndex((l) => l.includes(REPO_URL));
|
|
346
|
+
link = i < 0 ? null : { y: i, x: vw(screen[i].slice(0, screen[i].indexOf("\x1b]8;;"))) };
|
|
319
347
|
out.write("\x1b[H" + screen.map((l) => l + "\x1b[K").join("\n") + "\x1b[J");
|
|
320
348
|
return { left: left.length, top };
|
|
321
349
|
}
|
|
@@ -354,7 +382,7 @@ function pick(saved, timeout) {
|
|
|
354
382
|
const render = () => {
|
|
355
383
|
const rows = out.rows || 24;
|
|
356
384
|
const list = visible();
|
|
357
|
-
const height = Math.max(3, rows -
|
|
385
|
+
const height = Math.max(3, rows - 19);
|
|
358
386
|
cursor = Math.max(0, Math.min(cursor, list.length - 1));
|
|
359
387
|
if (cursor < top) top = cursor;
|
|
360
388
|
if (cursor >= top + height) top = cursor - height + 1;
|
|
@@ -403,6 +431,7 @@ function pick(saved, timeout) {
|
|
|
403
431
|
];
|
|
404
432
|
lines.splice(head.length + table.length + 1, 0, centerIn(buttonLine, width), "");
|
|
405
433
|
if (message) lines.push(centerIn(yellow(message), width));
|
|
434
|
+
lines.push("", centerIn(dim(repoLink), width));
|
|
406
435
|
const at = draw(lines);
|
|
407
436
|
|
|
408
437
|
// click map, in screen coordinates
|
|
@@ -554,7 +583,7 @@ async function scan(countries, protocols, timeout, tty) {
|
|
|
554
583
|
].join(dim(" │ "));
|
|
555
584
|
const minW = Math.max(64, vw(stats) + 6); // panel and table share one width
|
|
556
585
|
// results table gets whatever height the status panel and footer leave
|
|
557
|
-
const room = Math.max(1, rows -
|
|
586
|
+
const room = Math.max(1, rows - 22);
|
|
558
587
|
const tried = results.some((r) => !["no such location", "interrupted"].includes(r.status));
|
|
559
588
|
const table = view === "availability"
|
|
560
589
|
? (tried ? matrix(results, protocols, { minWidth: minW, limit: room }) : [])
|
|
@@ -592,7 +621,7 @@ async function scan(countries, protocols, timeout, tty) {
|
|
|
592
621
|
while (vw(txt) > width && failed.length) txt = txt.slice(0, -8) + "…"; // ponytail: crude trim, fine for a status line
|
|
593
622
|
lines.push("", centerIn(txt, width));
|
|
594
623
|
}
|
|
595
|
-
lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · q stop · q twice force quit`), width));
|
|
624
|
+
lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · q stop · q twice force quit`), width), "", centerIn(dim(repoLink), width));
|
|
596
625
|
draw(lines);
|
|
597
626
|
};
|
|
598
627
|
|
|
@@ -721,7 +750,10 @@ async function main() {
|
|
|
721
750
|
const timeout = Number(a.timeout);
|
|
722
751
|
if (!(timeout > 0)) throw new Error("--timeout must be a positive number");
|
|
723
752
|
|
|
724
|
-
if
|
|
753
|
+
// the first CLI call launches the app if it isn't running; give it a moment to start and restore the session
|
|
754
|
+
let status = await cli("status");
|
|
755
|
+
for (let i = 0; i < 15 && !status.includes("Logged in"); i++) await sleep(1000), status = await cli("status");
|
|
756
|
+
if (!status.includes("Logged in"))
|
|
725
757
|
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
758
|
+ `\nDon't have it? ${DOWNLOAD_URL}`);
|
|
727
759
|
|