windscout 0.5.0 → 0.5.2

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 CHANGED
@@ -59,7 +59,7 @@ Run `windscout` with no arguments to open the interactive picker:
59
59
 
60
60
  Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
61
61
 
62
- 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.
62
+ 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. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command; type a number at the prompt to connect to it.
63
63
 
64
64
  ### Non-interactive
65
65
 
@@ -75,7 +75,7 @@ windscout -t 40 -a # longer connect timeout, also list
75
75
  |---|---|---|
76
76
  | `-c, --countries` | comma-separated ISO country codes, city names or nicknames | open the picker |
77
77
  | `-p, --protocols` | comma-separated: `wireguard`, `ikev2` (macOS and Windows), `stealth`, `wstunnel`, `udp`, `tcp`, optionally `:port` | saved, or all |
78
- | `-t, --timeout` | seconds to wait for each connection | 20 |
78
+ | `-t, --timeout` | seconds to wait for each connection, at least 10 | picker: saved, or 20; with `-c`: 20 |
79
79
  | `-a, --all` | include failed attempts in the final table | off |
80
80
  | `-n, --no-speed` | connect-only mode: skip the download test, rank by latency | picker: saved, or connect-only on first run; with `-c`: speed test on |
81
81
  | `-f, --firewall` | block internet while scanning, except through the VPN | picker: saved, or off; with `-c`: off |
package/dist/cli.js CHANGED
@@ -2,16 +2,18 @@
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 { createInterface } from "node:readline";
5
6
  import { parseArgs } from "node:util";
6
- import { CONFIG, loadConfig } from "./config.js";
7
+ import { CONFIG, DEFAULT_TIMEOUT, loadConfig, MIN_TIMEOUT } from "./config.js";
7
8
  import { Scan } from "./scan.js";
8
9
  import { pick } from "./tui/picker.js";
9
10
  import { logProgress, showProgress } from "./tui/progress.js";
10
- import { report } from "./tui/report.js";
11
+ import { command, report, target } from "./tui/report.js";
11
12
  import { leaveScreen, out } from "./tui/terminal.js";
12
- import { yellow } from "./tui/text.js";
13
+ import { cyan, green, yellow } from "./tui/text.js";
14
+ import { now, sleep } from "./time.js";
13
15
  import { checkUpdate, updateNotice, VERSION } from "./update.js";
14
- import { blockInternet, CLI, isFirewallOn, PROTOCOLS, restoreFirewall, waitForLogin } from "./windscribe.js";
16
+ import { blockInternet, CLI, connectState, isFirewallOn, PROTOCOLS, restoreFirewall, waitForLogin, windscribe } from "./windscribe.js";
15
17
  const HELP = `Usage: windscout [options]
16
18
 
17
19
  Find which Windscribe locations and protocols work from your network, ranked by speed.
@@ -20,7 +22,7 @@ Without -c it opens an interactive country picker (your selection is remembered)
20
22
  Options:
21
23
  -c, --countries LIST comma-separated ISO codes, city names or nicknames (skips the picker)
22
24
  -p, --protocols LIST comma-separated protocols, optional :port (default: ${PROTOCOLS.join(",")})
23
- -t, --timeout SEC seconds to wait for each connection (default: 20)
25
+ -t, --timeout SEC seconds to wait for each connection, at least ${MIN_TIMEOUT} (default: saved, or ${DEFAULT_TIMEOUT})
24
26
  -a, --all also show failed attempts in the final table
25
27
  -n, --no-speed connect-only: skip the download test, rank by latency (faster scan)
26
28
  -f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
@@ -31,12 +33,39 @@ Selection is stored in ${CONFIG}
31
33
  Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
32
34
  // "DE, NL,,TR" → ["DE", "NL", "TR"]
33
35
  const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
36
+ const ask = (question) => new Promise((resolve) => {
37
+ const rl = createInterface({ input: process.stdin, output: out });
38
+ rl.on("SIGINT", () => rl.close()); // Ctrl-C / Ctrl-D: same as enter
39
+ rl.on("close", () => resolve(""));
40
+ rl.question(question, (a) => (resolve(a.trim()), rl.close()));
41
+ });
42
+ /** Asks which numbered connection to make, then runs its command and waits for it to connect */
43
+ async function offerConnect(choices, timeout) {
44
+ const others = choices.length > 1 ? `, 1-${choices.length - 1} = other` : "";
45
+ for (;;) {
46
+ const a = await ask(`\nconnect to # (0 = best${others}, enter to quit): `);
47
+ if (!a)
48
+ return;
49
+ const r = /^\d+$/.test(a) ? choices[Number(a)] : undefined;
50
+ if (!r) {
51
+ console.log(yellow(`no #${a} in the tables`));
52
+ continue;
53
+ }
54
+ console.log(cyan(command(r)));
55
+ await windscribe("connect", target(r), r.protocol);
56
+ let state = "";
57
+ for (const t = now(); now() - t < timeout && !(state = await connectState()).startsWith("Connected");)
58
+ await sleep(1000);
59
+ console.log(state.startsWith("Connected") ? green(state) : yellow(`${state || "not connected"} after ${timeout} s`));
60
+ return;
61
+ }
62
+ }
34
63
  async function main() {
35
64
  const { values: a } = parseArgs({
36
65
  options: {
37
66
  countries: { type: "string", short: "c" },
38
67
  protocols: { type: "string", short: "p" },
39
- timeout: { type: "string", short: "t", default: "20" },
68
+ timeout: { type: "string", short: "t" },
40
69
  all: { type: "boolean", short: "a", default: false },
41
70
  help: { type: "boolean", short: "h", default: false },
42
71
  version: { type: "boolean", short: "v", default: false },
@@ -55,9 +84,9 @@ async function main() {
55
84
  console.error(yellow(notice));
56
85
  return;
57
86
  }
58
- const timeout = Number(a.timeout);
59
- if (!(timeout > 0))
60
- throw new Error("--timeout must be a positive number");
87
+ let timeout = a.timeout ? Number(a.timeout) : undefined;
88
+ if (timeout !== undefined && !(timeout >= MIN_TIMEOUT))
89
+ throw new Error(`--timeout must be at least ${MIN_TIMEOUT} seconds`);
61
90
  const status = await waitForLogin();
62
91
  const tty = Boolean(process.stdin.isTTY && out.isTTY);
63
92
  let countries = a.countries ? list(a.countries) : undefined;
@@ -71,7 +100,7 @@ async function main() {
71
100
  if (protocols && saved)
72
101
  saved.protocols = protocols.filter((p) => PROTOCOLS.includes(p));
73
102
  const choice = await pick(saved, {
74
- timeout,
103
+ timeout: timeout ?? saved?.timeout ?? DEFAULT_TIMEOUT,
75
104
  speedTest: speedTest && (saved?.speed ?? false), // first run: connect-only
76
105
  // already on in the app: show it on, since the scan won't turn it off
77
106
  firewall: firewall || isFirewallOn(status) || (saved?.firewall ?? false),
@@ -82,8 +111,10 @@ async function main() {
82
111
  protocols ??= choice.protocols;
83
112
  speedTest = choice.speed;
84
113
  firewall = choice.firewall;
114
+ timeout = choice.timeout;
85
115
  }
86
116
  protocols ??= PROTOCOLS;
117
+ timeout ??= DEFAULT_TIMEOUT;
87
118
  const scan = new Scan(countries, protocols, { timeout, speedTest });
88
119
  const blocking = firewall && (await blockInternet());
89
120
  const view = tty ? showProgress(scan, blocking) : logProgress(scan);
@@ -92,11 +123,13 @@ async function main() {
92
123
  view.close();
93
124
  if (scan.stopping)
94
125
  console.log(yellow("interrupted, showing partial results"));
95
- report(scan.results, protocols, { all: a.all, speedTest });
126
+ const choices = report(scan.results, protocols, { all: a.all, speedTest });
96
127
  await checkUpdate();
97
128
  const notice = updateNotice();
98
129
  if (notice)
99
130
  console.error("\n" + yellow(notice));
131
+ if (tty && choices.length)
132
+ await offerConnect(choices, timeout);
100
133
  }
101
134
  main().catch(async (e) => {
102
135
  leaveScreen();
package/dist/config.js CHANGED
@@ -3,6 +3,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { COUNTRIES, PROTOCOLS } from "./windscribe.js";
6
+ export const DEFAULT_TIMEOUT = 20;
7
+ export const MIN_TIMEOUT = 10; // seconds; shorter cuts off connections that would have worked
6
8
  export const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
7
9
  /** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
8
10
  export function loadConfig() {
@@ -13,6 +15,7 @@ export function loadConfig() {
13
15
  protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
14
16
  speed: c.speed !== false,
15
17
  firewall: c.firewall === true,
18
+ timeout: c.timeout >= MIN_TIMEOUT ? c.timeout : DEFAULT_TIMEOUT,
16
19
  };
17
20
  }
18
21
  catch {
@@ -1,4 +1,4 @@
1
- import { saveConfig } from "../config.js";
1
+ import { MIN_TIMEOUT, saveConfig } from "../config.js";
2
2
  import { attemptSeconds, estimate } from "../scan.js";
3
3
  import { duration } from "../time.js";
4
4
  import { checkUpdate, updateNotice } from "../update.js";
@@ -13,6 +13,7 @@ export function pick(saved, { timeout, speedTest, firewall }) {
13
13
  const selected = new Set(saved?.countries ?? []);
14
14
  const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
15
15
  let cursor = 0, top = 0, message = "", query = "", searching = false;
16
+ let seconds = String(timeout), editing = false; // the timeout, as typed
16
17
  let hit;
17
18
  const visible = () => {
18
19
  const q = query.toLowerCase();
@@ -24,7 +25,7 @@ export function pick(saved, { timeout, speedTest, firewall }) {
24
25
  const rows = out.rows || 24;
25
26
  const notice = updateNotice();
26
27
  const matches = visible();
27
- const height = Math.max(3, rows - 20 - (notice ? 1 : 0));
28
+ const height = Math.max(3, rows - 21 - (notice ? 1 : 0));
28
29
  cursor = Math.max(0, Math.min(cursor, matches.length - 1));
29
30
  if (cursor < top)
30
31
  top = cursor;
@@ -39,6 +40,9 @@ export function pick(saved, { timeout, speedTest, firewall }) {
39
40
  const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(firewall
40
41
  ? "only VPN traffic gets through"
41
42
  : "off: apps may leak in between")}`;
43
+ timeout = Number(seconds);
44
+ const short = timeout < MIN_TIMEOUT;
45
+ const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
42
46
  const search = searching
43
47
  ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
44
48
  : query ? `🔍 ${query}` : dim("🔍 press / to search");
@@ -59,9 +63,9 @@ export function pick(saved, { timeout, speedTest, firewall }) {
59
63
  ["quit", under("Q") + "uit"],
60
64
  ];
61
65
  const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
62
- const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(vw(protoLine), vw(speedLine), vw(fwLine)) + 4 });
66
+ const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(vw(protoLine), vw(speedLine), vw(fwLine), vw(timeoutLine)) + 4 });
63
67
  const width = vw(table[0]);
64
- const head = box([protoLine, speedLine, fwLine], {
68
+ const head = box([protoLine, speedLine, fwLine, timeoutLine], {
65
69
  title: bold(cyan("WindScout")),
66
70
  right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
67
71
  width: width - 4,
@@ -76,10 +80,12 @@ export function pick(saved, { timeout, speedTest, firewall }) {
76
80
  centerIn(n
77
81
  ? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(selected.size, protos.size, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each)`)}`
78
82
  : yellow("select at least one country and one protocol"), width),
79
- centerIn(dim(searching
80
- ? "type to filter · ↑↓ move · enter/esc done"
81
- : `↑↓ move · space select · a/n ${query ? "all/none shown" : "all/none"} · / search`), width),
82
- centerIn(dim(searching ? "" : `1-${PROTOCOLS.length} protocols · s speed test · f firewall · enter start · ${query ? "esc clear · " : ""}q quit`), width),
83
+ centerIn(dim(editing
84
+ ? "type the seconds to wait for each connection · enter/esc done"
85
+ : searching
86
+ ? "type to filter · ↑↓ move · enter/esc done"
87
+ : `↑↓ move · space select · a/n ${query ? "all/none shown" : "all/none"} · / search`), width),
88
+ centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · s speed test · f firewall · t timeout · enter start · ${query ? "esc clear · " : ""}q quit`), width),
83
89
  ];
84
90
  if (message)
85
91
  lines.push(centerIn(yellow(message), width));
@@ -103,7 +109,7 @@ export function pick(saved, { timeout, speedTest, firewall }) {
103
109
  return range;
104
110
  });
105
111
  hit = {
106
- protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3,
112
+ protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3, timeoutRow: at.top + 4,
107
113
  searchRow: at.top + head.length + 1,
108
114
  firstRow: at.top + head.length + 5, shown: shown.length,
109
115
  buttonRow: at.top + head.length + table.length + 1, buttonHits,
@@ -125,12 +131,17 @@ export function pick(saved, { timeout, speedTest, firewall }) {
125
131
  message = "nothing to scan yet";
126
132
  return false;
127
133
  }
134
+ if (!(timeout >= MIN_TIMEOUT)) {
135
+ message = `the timeout must be at least ${MIN_TIMEOUT} seconds`;
136
+ return false;
137
+ }
128
138
  // keep the user's order of preference stable: list order
129
139
  const choice = {
130
140
  countries: COUNTRIES.filter((c) => selected.has(c)),
131
141
  protocols: PROTOCOLS.filter((p) => protos.has(p)),
132
142
  speed: speedTest,
133
143
  firewall,
144
+ timeout,
134
145
  };
135
146
  saveConfig(choice);
136
147
  finish(choice);
@@ -149,9 +160,11 @@ export function pick(saved, { timeout, speedTest, firewall }) {
149
160
  else if (m.button !== 0)
150
161
  return; // left button only
151
162
  else if (y === hit.searchRow)
152
- searching = true;
163
+ searching = true, editing = false;
164
+ else if (y === hit.timeoutRow)
165
+ editing = true, searching = false;
153
166
  else {
154
- searching = false; // clicking anywhere else ends typing, like leaving a text field
167
+ searching = editing = false; // clicking anywhere else ends typing, like leaving a text field
155
168
  if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
156
169
  cursor = top + (y - hit.firstRow);
157
170
  const c = matches[cursor];
@@ -203,6 +216,14 @@ export function pick(saved, { timeout, speedTest, firewall }) {
203
216
  cursor = Math.max(0, cursor - 10);
204
217
  else if (k === "pagedown")
205
218
  cursor = Math.min(matches.length - 1, cursor + 10);
219
+ else if (editing) {
220
+ if (k === "return" || k === "escape")
221
+ editing = false;
222
+ else if (k === "backspace")
223
+ seconds = seconds.slice(0, -1);
224
+ else if (/^[0-9]$/.test(str ?? "") && seconds.length < 4)
225
+ seconds = seconds === "0" ? str : seconds + str;
226
+ }
206
227
  else if (searching) {
207
228
  // typing goes into the search box; letters are not shortcuts here
208
229
  if (k === "return" || k === "escape")
@@ -234,6 +255,8 @@ export function pick(saved, { timeout, speedTest, firewall }) {
234
255
  speedTest = !speedTest;
235
256
  else if (k === "f")
236
257
  firewall = !firewall;
258
+ else if (k === "t")
259
+ editing = true, seconds = "";
237
260
  else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
238
261
  flip(protos, PROTOCOLS[Number(str) - 1]);
239
262
  else if (k === "escape" && query)
@@ -5,26 +5,35 @@ import { flag } from "./country.js";
5
5
  import { matrix, resultTable } from "./tables.js";
6
6
  import { bold, cyan, dim, fmt, green, vw, yellow } from "./text.js";
7
7
  import { box, grid } from "./widgets.js";
8
+ // nickname pins the exact datacenter
9
+ export const target = (r) => r.location.split(" - ").at(-1) || r.query;
10
+ export const command = (r) => `${CLI} connect "${target(r)}" ${r.protocol}`;
11
+ /** Prints the report; returns the connections it numbered: the best (#0), then the others (#1…) */
8
12
  export function report(results, protocols, { all, speedTest }) {
9
13
  const ok = sortOk(results);
10
14
  const rows = [...ok, ...(all ? results.filter((r) => r.status !== "ok") : [])];
15
+ const others = ok.slice(1).sort((x, y) => (x.latencyMs ?? Infinity) - (y.latencyMs ?? Infinity));
11
16
  // render once to learn the widest table, then again so every box lines up
12
17
  const build = (minWidth) => ({
13
18
  avail: matrix(results, protocols, { minWidth }),
14
19
  main: rows.length
15
20
  ? resultTable(rows, speedTest, { caption: bold("Results"), captionRight: dim(`${ok.length} working`), minWidth })
16
21
  : [],
22
+ others: others.length
23
+ ? grid([{ title: "#", align: "right" }, { title: "Location" }, { title: "Protocol" }, { title: "Latency", align: "right" }, { title: "Connect with" }], others.map((r, i) => [dim(String(i + 1)), `${flag(r.query)} ${r.location}`, r.protocol,
24
+ r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`, cyan(command(r))]), { caption: bold("Other working locations"), captionRight: dim("by latency"), minWidth })
25
+ : [],
17
26
  });
18
27
  const first = build(0);
19
- const width = Math.max(64, ...[first.avail[0], first.main[0]].filter(Boolean).map(vw));
20
- const { avail, main } = build(width);
28
+ const width = Math.max(64, ...[first.avail[0], first.main[0], first.others[0]].filter(Boolean).map(vw));
29
+ const { avail, main, others: rest } = build(width);
21
30
  console.log();
22
31
  if (avail.length)
23
32
  console.log(avail.join("\n"));
24
33
  if (!ok.length) {
25
34
  console.log(box([yellow("Nothing connected."),
26
35
  dim("Try other countries, ports (e.g. -p stealth:443,tcp:443) or a longer --timeout.")], { title: bold(cyan("WindScout")), width: width - 4 }).join("\n"));
27
- return;
36
+ return [];
28
37
  }
29
38
  console.log(main.join("\n"));
30
39
  const stats = protocols.map((p) => {
@@ -35,10 +44,12 @@ export function report(results, protocols, { all, speedTest }) {
35
44
  const bestValue = (r) => (speedTest ? `${fmt(r.speedMbps, 1)} Mbps` : `${fmt(r.latencyMs, 0)} ms`);
36
45
  console.log(grid([{ title: "Protocol" }, { title: "Worked", align: "right" }, { title: speedTest ? "Best speed" : "Best latency", align: "right" }, { title: "Best location" }], stats.map((s) => [s.p, `${s.good}/${s.tried}`, s.best ? bestValue(s.best) : "-", s.best ? `${flag(s.best.query)} ${s.best.location}` : "-"]), { caption: bold("Per protocol"), minWidth: width }).join("\n"));
37
46
  const b = ok[0];
38
- const target = b.location.split(" - ").at(-1) || b.query; // nickname pins the exact datacenter
39
47
  const facts = [speedTest && `${fmt(b.speedMbps, 1)} Mbps`, `${fmt(b.latencyMs, 0)} ms`].filter(Boolean).join(" · ");
40
48
  console.log(box([
41
49
  `${flag(b.query)} ${bold(b.location)} over ${bold(b.protocol)} ${dim("· " + facts)}`,
42
- cyan(`${CLI} connect "${target}" ${b.protocol}`),
50
+ cyan(command(b)),
43
51
  ], { title: green(speedTest ? "★ Best" : "★ Best (lowest latency)"), width: width - 4 }).join("\n"));
52
+ if (rest.length)
53
+ console.log(rest.join("\n"));
54
+ return [b, ...others];
44
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windscout",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
5
5
  "type": "module",
6
6
  "bin": {