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.
Files changed (3) hide show
  1. package/README.md +6 -6
  2. package/package.json +23 -6
  3. 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 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
 
@@ -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 only), `stealth`, `wstunnel`, `udp`, `tcp`, optionally `:port` | saved, or all |
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.0",
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": { "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");
@@ -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/g, "").replace(/[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]/gu, "xx")].length;
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 - 17);
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 - 20);
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 (!(await cli("status")).includes("Logged in"))
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