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.
Files changed (3) hide show
  1. package/README.md +8 -6
  2. package/package.json +23 -6
  3. 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 macOS
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 only), `stealth`, `wstunnel`, `udp`, `tcp`, optionally `:port` | saved, or all |
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.1.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": { "windscout": "windscout.js" },
7
- "files": ["windscout.js"],
8
- "engines": { "node": ">=20" },
9
- "os": ["linux", "darwin"],
10
- "keywords": ["windscribe", "vpn", "speedtest", "wireguard", "stealth", "cli"],
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 { execFile } from "node:child_process";
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, promisify } from "node:util";
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
- const DESKTOP_CLI = process.platform === "darwin"
16
- ? "/Applications/Windscribe.app/Contents/MacOS/windscribe-cli"
17
- : "/opt/windscribe/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";
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
- const flag = (code) => /^[A-Z]{2}$/.test(code) ? String.fromCodePoint(...[...code].map((c) => 0x1f1a5 + c.charCodeAt(0))) : " ";
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
- ({ stdout: out } = await run(cliBin, args, { timeout: 90_000 }));
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
- out = e.stdout ?? "";
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/g, "").replace(/[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]/gu, "xx")].length;
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 - 17);
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 - 20);
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 (!(await cli("status")).includes("Logged in"))
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) => {