windscout 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/package.json +13 -0
- package/windscout.js +753 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dier Cohen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# WindScout
|
|
2
|
+
|
|
3
|
+
Find which Windscribe locations and protocols actually work from your network, ranked by speed.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
> Unofficial tool, not affiliated with Windscribe.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Linux or macOS
|
|
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`.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g windscout # or run once with: npx windscout
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Run `windscout` with no arguments to open the interactive picker:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
╭─ WindScout ──────────────────────────────────── 2/68 selected ─╮
|
|
27
|
+
│ 1 ● wireguard 2 ● stealth 3 ○ wstunnel 4 ● udp 5 ● tcp │
|
|
28
|
+
╰────────────────────────────────────────────────────────────────╯
|
|
29
|
+
╭────────────────────────────────────────────────────────────────╮
|
|
30
|
+
│ 🔍 an▏ 1–16 of 18 │
|
|
31
|
+
├─────┬──────┬───────────────────────────────────────────────────┤
|
|
32
|
+
│ │ Code │ Country │
|
|
33
|
+
├─────┼──────┼───────────────────────────────────────────────────┤
|
|
34
|
+
│ ❯ ● │ CA │ 🇨🇦 Canada │
|
|
35
|
+
│ ○ │ IE │ 🇮🇪 Ireland │
|
|
36
|
+
│ ● │ DE │ 🇩🇪 Germany │
|
|
37
|
+
╰─────┴──────┴───────────────────────────────────────────────────╯
|
|
38
|
+
8 attempts · estimated ≈ 3m 20s (~25s each)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Key | Action |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `↑` `↓` / `j` `k`, PgUp/PgDn, Home/End | move |
|
|
44
|
+
| `space` | select / unselect the country |
|
|
45
|
+
| `/` | search by country name or code (enter/esc to finish, esc again to clear) |
|
|
46
|
+
| `a` / `n` | select all / unselect all (only the matching countries while a search is active) |
|
|
47
|
+
| `1`–`6` | toggle a protocol (`1`–`5` on Linux, which has no IKEv2) |
|
|
48
|
+
| `s` | toggle the speed test (off = connect-only mode) |
|
|
49
|
+
| `enter` | start scanning |
|
|
50
|
+
| `q` | quit (during a scan: stop and show partial results; press again to force quit) |
|
|
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.
|
|
53
|
+
|
|
54
|
+
Your selection is saved to `~/.config/windscout/config.json` and preselected next time.
|
|
55
|
+
|
|
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
|
+
|
|
58
|
+
### Non-interactive
|
|
59
|
+
|
|
60
|
+
Pass `-c` to skip the picker, for scripts or when there is no terminal:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
windscout -c DE,NL,TR -p stealth,tcp:443 # chosen countries and protocols (port optional)
|
|
64
|
+
windscout -c Frankfurt,Wurstchen # cities or datacenter nicknames work too
|
|
65
|
+
windscout -t 40 -a # longer connect timeout, also list failed attempts
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Option | Meaning | Default |
|
|
69
|
+
|---|---|---|
|
|
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 |
|
|
72
|
+
| `-t, --timeout` | seconds to wait for each connection | 20 |
|
|
73
|
+
| `-a, --all` | include failed attempts in the final table | off |
|
|
74
|
+
| `-n, --no-speed` | connect-only mode: skip the download test, rank by latency | saved, or off |
|
|
75
|
+
|
|
76
|
+
## Connect-only mode
|
|
77
|
+
|
|
78
|
+
Often you only need to know **what connects**, not how fast it is. Turn the speed test off with `s` in the picker or with `--no-speed`. Each working connection is then checked with a quick latency test only. That saves about 15 s per working connection, and results are ranked by latency. The choice is remembered.
|
|
79
|
+
|
|
80
|
+
Both modes show an **availability grid**: one row per country, and ✓ / ✗ for each protocol (`·` = not tried yet):
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
╭────────────────────────────────────────────────────────────────────────╮
|
|
84
|
+
│ Availability 2/3 countries reachable │
|
|
85
|
+
├─────────────────────────┬──────────────────┬─────────┬───────────┬─────┤
|
|
86
|
+
│ Country │ City │ stealth │ wireguard │ tcp │
|
|
87
|
+
├─────────────────────────┼──────────────────┼─────────┼───────────┼─────┤
|
|
88
|
+
│ 🇩🇪 Germany │ Frankfurt │ ✓ │ ✓ │ ✓ │
|
|
89
|
+
│ 🇳🇱 Netherlands │ Amsterdam │ ✓ │ ✓ │ · │
|
|
90
|
+
│ 🇦🇪 United Arab Emirates │ Dubai │ ✗ │ ✗ │ ✗ │
|
|
91
|
+
╰─────────────────────────┴──────────────────┴─────────┴───────────┴─────╯
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
While scanning, press `tab` to switch between the availability grid and the speed or latency ranking. In connect-only mode the scan starts on the availability grid.
|
|
95
|
+
|
|
96
|
+
## Output
|
|
97
|
+
|
|
98
|
+
While scanning:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
╭─ WindScout ─────────────────────────────────────────── 4/6 attempts ─╮
|
|
102
|
+
│ │
|
|
103
|
+
│ ██████████████████████████████████████████▌░░░░░░░░░░░░░░░░ 72% │
|
|
104
|
+
│ │
|
|
105
|
+
│ ⠏ 🇳🇱 Netherlands · stealth │
|
|
106
|
+
│ measuring speed · Amsterdam - Tulip · 3s │
|
|
107
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
108
|
+
│ elapsed 25s │ remaining ≈ 13s │ ✓ 2 working │ ✗ 1 blocked │
|
|
109
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
110
|
+
╭──────────────────────────────────────────────────────────────────────╮
|
|
111
|
+
│ Working so far 2 found │
|
|
112
|
+
├───┬────────────┬───────────────────────┬───────────┬───────────┬─────┤
|
|
113
|
+
│ # │ Country │ Location │ Protocol │ Speed │ ... │
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
At the end, the results, a per-protocol summary and the best pick are printed to your normal terminal:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
╭─ ★ Best ─────────────────────────────────────────────────╮
|
|
120
|
+
│ 🇳🇱 Amsterdam - Tulip over wireguard · 27.0 Mbps · 109 ms │
|
|
121
|
+
│ windscribe-cli connect "Tulip" wireguard │
|
|
122
|
+
╰──────────────────────────────────────────────────────────╯
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Notes
|
|
126
|
+
|
|
127
|
+
- **Connecting by country picks a random datacenter.** A single run tests one per country. Pass city names or nicknames with `-c` to test specific datacenters.
|
|
128
|
+
- **The country list is built in.** On v2.24, `windscribe-cli locations` returns nothing, so WindScout uses a fixed list of country codes. Codes Windscribe doesn't serve are skipped quickly.
|
|
129
|
+
- **A full scan takes a while.** The worst case is countries × protocols × timeout. Narrow it with `-c` and `-p`.
|
|
130
|
+
- **Your connection changes during the scan.** WindScout connects and disconnects the VPN repeatedly, so don't run it while you need a stable connection.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "windscout",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
|
|
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"],
|
|
11
|
+
"author": "Dier Cohen <i.am.dier.cohen@gmail.com>",
|
|
12
|
+
"license": "MIT"
|
|
13
|
+
}
|
package/windscout.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// WindScout: find which Windscribe locations and protocols actually work from here.
|
|
3
|
+
// Drives the official windscribe-cli: connects to each country/protocol pair,
|
|
4
|
+
// measures latency and download speed through the tunnel, prints a ranked table.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import readline from "node:readline";
|
|
10
|
+
import { PassThrough } from "node:stream";
|
|
11
|
+
import { parseArgs, promisify } from "node:util";
|
|
12
|
+
|
|
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";
|
|
18
|
+
const DOWNLOAD_URL = "https://windscribe.com/download";
|
|
19
|
+
let cliBin = CLI;
|
|
20
|
+
// IKEv2 is offered by the macOS/Windows apps only
|
|
21
|
+
const PROTOCOLS = ["wireguard", ...(process.platform === "linux" ? [] : ["ikev2"]), "stealth", "wstunnel", "udp", "tcp"];
|
|
22
|
+
// ponytail: hardcoded because `windscribe-cli locations` returns nothing on 2.24; refresh when Windscribe adds countries
|
|
23
|
+
const COUNTRIES = `US CA MX BR AR CL CO PE GB IE FR DE NL BE LU CH AT IT ES PT DK NO SE FI IS
|
|
24
|
+
PL CZ SK HU RO BG GR CY TR RS HR SI BA MK AL MD UA EE LV LT IL AE AZ GE AM KZ IN JP KR
|
|
25
|
+
HK SG TW ID MY TH VN PH AU NZ ZA NG KE EG`.split(/\s+/);
|
|
26
|
+
|
|
27
|
+
const LATENCY_URL = "https://www.gstatic.com/generate_204";
|
|
28
|
+
const SPEED_URL = "https://speed.cloudflare.com/__down?bytes=10000000";
|
|
29
|
+
const SPEED_SECONDS = 15;
|
|
30
|
+
const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
|
|
31
|
+
|
|
32
|
+
const run = promisify(execFile);
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
const now = () => performance.now() / 1000;
|
|
35
|
+
const regionName = new Intl.DisplayNames(["en"], { type: "region" });
|
|
36
|
+
const countryName = (code) => {
|
|
37
|
+
try { return regionName.of(code); } catch { return code; } // city names / nicknames aren't region codes
|
|
38
|
+
};
|
|
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))) : " ";
|
|
41
|
+
let stop = 0;
|
|
42
|
+
let speedTest = true; // false = connect-only mode: skip the download test, rank by latency
|
|
43
|
+
|
|
44
|
+
// ---------- windscribe + measurements ----------
|
|
45
|
+
|
|
46
|
+
async function cli(...args) {
|
|
47
|
+
let out = "";
|
|
48
|
+
try {
|
|
49
|
+
({ stdout: out } = await run(cliBin, args, { timeout: 90_000 }));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.code === "ENOENT") {
|
|
52
|
+
if (cliBin === CLI && existsSync(DESKTOP_CLI)) return (cliBin = DESKTOP_CLI), cli(...args);
|
|
53
|
+
throw new Error(`${CLI} not found. Install the Windscribe desktop app (it includes ${CLI}): ${DOWNLOAD_URL}`);
|
|
54
|
+
}
|
|
55
|
+
out = e.stdout ?? "";
|
|
56
|
+
}
|
|
57
|
+
// the CLI mixes JSON log lines into stdout
|
|
58
|
+
return out.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function state() {
|
|
62
|
+
// macOS marks the line with a leading "*" once connected: "*Connect state: Connected: Miami - Vice"
|
|
63
|
+
return (await cli("status")).match(/^\*?Connect state: (.*)$/m)?.[1].trim() ?? "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function latencyMs(tries = 3) {
|
|
67
|
+
let best = null;
|
|
68
|
+
for (let i = 0; i < tries; i++) {
|
|
69
|
+
const t = now();
|
|
70
|
+
try {
|
|
71
|
+
await (await fetch(LATENCY_URL, { signal: AbortSignal.timeout(5000) })).arrayBuffer();
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const ms = (now() - t) * 1000;
|
|
76
|
+
best = best === null ? ms : Math.min(best, ms);
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function speedMbps() {
|
|
82
|
+
const t = now();
|
|
83
|
+
let n = 0;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(SPEED_URL, {
|
|
86
|
+
headers: { "User-Agent": "curl/8" }, // Cloudflare 403s unknown clients
|
|
87
|
+
signal: AbortSignal.timeout(SPEED_SECONDS * 1000),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) return null;
|
|
90
|
+
for await (const chunk of res.body) n += chunk.length;
|
|
91
|
+
} catch {
|
|
92
|
+
if (!n) return null; // timeout mid-download is fine: we measured what arrived
|
|
93
|
+
}
|
|
94
|
+
return (n * 8) / (now() - t) / 1e6;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// onStep(text) reports what the probe is doing, for the live view
|
|
98
|
+
async function probe(query, protocol, timeout, onStep = () => {}) {
|
|
99
|
+
const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null, status: "" };
|
|
100
|
+
onStep("connecting");
|
|
101
|
+
await cli("connect", "-n", query, protocol);
|
|
102
|
+
const t = now();
|
|
103
|
+
try {
|
|
104
|
+
for (;;) {
|
|
105
|
+
if (stop) return (r.status = "interrupted"), r;
|
|
106
|
+
if (now() - t >= timeout) return (r.status = "timeout"), r;
|
|
107
|
+
const s = await state();
|
|
108
|
+
if (s.startsWith("Connecting:")) r.location = s.split(":").slice(1).join(":").trim();
|
|
109
|
+
else if (s.startsWith("Connected")) {
|
|
110
|
+
if (s.includes(":")) r.location = s.split(":").slice(1).join(":").trim();
|
|
111
|
+
r.connectS = now() - t;
|
|
112
|
+
break;
|
|
113
|
+
} else if (s.includes("does not exist")) return (r.status = "no such location"), r;
|
|
114
|
+
else if (s === "Disconnected" && now() - t > 3) return (r.status = "failed"), r;
|
|
115
|
+
onStep(`connecting ${r.location}`.trim());
|
|
116
|
+
await sleep(1000);
|
|
117
|
+
}
|
|
118
|
+
onStep(`measuring latency · ${r.location}`);
|
|
119
|
+
r.latencyMs = await latencyMs();
|
|
120
|
+
if (r.latencyMs === null) return (r.status = "no internet"), r;
|
|
121
|
+
if (speedTest) {
|
|
122
|
+
onStep(`measuring speed · ${r.location}`);
|
|
123
|
+
r.speedMbps = await speedMbps();
|
|
124
|
+
}
|
|
125
|
+
r.status = "ok";
|
|
126
|
+
return r;
|
|
127
|
+
} finally {
|
|
128
|
+
onStep("disconnecting");
|
|
129
|
+
await cli("disconnect");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------- formatting ----------
|
|
134
|
+
|
|
135
|
+
const color = (code, s) => (process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[${code}m${s}\x1b[0m`);
|
|
136
|
+
const bold = (s) => color(1, s), dim = (s) => color(2, s), green = (s) => color(32, s);
|
|
137
|
+
const cyan = (s) => color(36, s), yellow = (s) => color(33, s), red = (s) => color(31, s);
|
|
138
|
+
const fmt = (v, digits) => (v === null || v === undefined ? "-" : v.toFixed(digits));
|
|
139
|
+
const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
140
|
+
|
|
141
|
+
function duration(sec) {
|
|
142
|
+
sec = Math.max(0, Math.round(sec));
|
|
143
|
+
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
144
|
+
return h ? `${h}h ${String(m).padStart(2, "0")}m` : m ? `${m}m ${String(s).padStart(2, "0")}s` : `${s}s`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
|
|
148
|
+
const attemptSeconds = (timeout) => timeout + 5;
|
|
149
|
+
const estimate = (nCountries, nProtocols, timeout) => nCountries * nProtocols * attemptSeconds(timeout);
|
|
150
|
+
|
|
151
|
+
// ---------- boxes (rounded box-drawing) ----------
|
|
152
|
+
|
|
153
|
+
// visible width: ANSI codes take no space; flags and emoji (🔍) take 2 columns. text symbols like ▶ ★ stay 1
|
|
154
|
+
const vw = (s) =>
|
|
155
|
+
[...s.replace(/\x1b\[[0-9;]*m/g, "").replace(/[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]/gu, "xx")].length;
|
|
156
|
+
// align: falsy = left, true = right, "center"
|
|
157
|
+
const pad = (s, n, align = false) => {
|
|
158
|
+
const gap = Math.max(0, n - vw(s));
|
|
159
|
+
if (align === "center") return " ".repeat(Math.floor(gap / 2)) + s + " ".repeat(Math.ceil(gap / 2));
|
|
160
|
+
return align ? " ".repeat(gap) + s : s + " ".repeat(gap);
|
|
161
|
+
};
|
|
162
|
+
const line = (s) => dim(s);
|
|
163
|
+
|
|
164
|
+
// box around content lines; null in lines draws a ├──┤ divider. title/right sit in the top border
|
|
165
|
+
function box(lines, { title = "", right = "", width = 0 } = {}) {
|
|
166
|
+
const inner = Math.max(width, ...lines.map((l) => (l === null ? 0 : vw(l))), vw(title) + vw(right) + 6);
|
|
167
|
+
const left = title ? line("─ ") + title + " " : "";
|
|
168
|
+
const tail = right ? " " + right + line(" ─") : "";
|
|
169
|
+
const fill = inner + 2 - vw(left) - vw(tail);
|
|
170
|
+
return [
|
|
171
|
+
line("╭") + left + line("─".repeat(fill)) + tail + line("╮"),
|
|
172
|
+
...lines.map((l) => (l === null ? line("├" + "─".repeat(inner + 2) + "┤") : line("│") + " " + pad(l, inner) + " " + line("│"))),
|
|
173
|
+
line("╰" + "─".repeat(inner + 2) + "╯"),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// bordered table; optional caption row (spanning all columns) sits on top inside the same border
|
|
178
|
+
// cols: { title, align }; stretch = index of the column that absorbs extra width
|
|
179
|
+
function grid(cols, rows, { caption, captionRight = "", minWidth = 0, stretch = cols.length - 1 } = {}) {
|
|
180
|
+
const w = cols.map((c, i) => Math.max(vw(c.title), ...rows.map((r) => vw(r[i]))));
|
|
181
|
+
const total = () => w.reduce((a, n) => a + n + 3, 1);
|
|
182
|
+
if (caption !== undefined) w[stretch] += Math.max(0, vw(caption) + vw(captionRight) + 5 - total());
|
|
183
|
+
w[stretch] += Math.max(0, minWidth - total());
|
|
184
|
+
const rule = (l, m, r) => line(l + w.map((n) => "─".repeat(n + 2)).join(m) + r);
|
|
185
|
+
const row = (cells) => line("│") + cells.map((c, i) => " " + pad(c, w[i], cols[i].align) + " ").join(line("│")) + line("│");
|
|
186
|
+
const out = [];
|
|
187
|
+
if (caption !== undefined) {
|
|
188
|
+
const inner = total() - 4;
|
|
189
|
+
out.push(line("╭" + "─".repeat(total() - 2) + "╮"),
|
|
190
|
+
line("│") + " " + pad(caption, inner - vw(captionRight)) + captionRight + " " + line("│"),
|
|
191
|
+
rule("├", "┬", "┤"));
|
|
192
|
+
} else out.push(rule("╭", "┬", "╮"));
|
|
193
|
+
out.push(row(cols.map((c) => bold(c.title))), rule("├", "┼", "┤"), ...rows.map(row), rule("╰", "┴", "╯"));
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const centerIn = (s, width) => " ".repeat(Math.max(0, Math.floor((width - vw(s)) / 2))) + s;
|
|
198
|
+
|
|
199
|
+
const sortOk = (results) =>
|
|
200
|
+
results.filter((r) => r.status === "ok")
|
|
201
|
+
.sort((x, y) => (y.speedMbps ?? 0) - (x.speedMbps ?? 0) || x.latencyMs - y.latencyMs);
|
|
202
|
+
|
|
203
|
+
const ALL_RESULT_COLS = [
|
|
204
|
+
{ title: "#", align: true }, { title: "Country" }, { title: "Location" }, { title: "Protocol" },
|
|
205
|
+
{ title: "Connect", align: true }, { title: "Latency", align: true }, { title: "Speed", align: true }, { title: "Status" },
|
|
206
|
+
];
|
|
207
|
+
const SPEED_COL = 6;
|
|
208
|
+
const resultCols = () => ALL_RESULT_COLS.filter((_, i) => speedTest || i !== SPEED_COL);
|
|
209
|
+
const resultRow = (r, i) => [
|
|
210
|
+
dim(String(i + 1)),
|
|
211
|
+
`${flag(r.query)} ${countryName(r.query)}`,
|
|
212
|
+
r.location || "-",
|
|
213
|
+
r.protocol,
|
|
214
|
+
r.connectS === null ? "-" : `${fmt(r.connectS, 1)} s`,
|
|
215
|
+
r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`,
|
|
216
|
+
r.speedMbps === null ? "-" : bold(`${fmt(r.speedMbps, 1)} Mbps`),
|
|
217
|
+
r.status === "ok" ? green("✓ ok") : red(r.status),
|
|
218
|
+
].filter((_, i) => speedTest || i !== SPEED_COL);
|
|
219
|
+
|
|
220
|
+
// availability grid: one row per country, ✓/✗ per protocol (· = not tried yet)
|
|
221
|
+
function matrix(results, protocols, { minWidth = 0, limit = Infinity } = {}) {
|
|
222
|
+
const byCountry = new Map();
|
|
223
|
+
for (const r of results) {
|
|
224
|
+
if (r.status === "no such location" || r.status === "interrupted") continue;
|
|
225
|
+
if (!byCountry.has(r.query)) byCountry.set(r.query, []);
|
|
226
|
+
byCountry.get(r.query).push(r);
|
|
227
|
+
}
|
|
228
|
+
const ranked = [...byCountry].map(([q, rs]) => ({ q, rs, good: rs.filter((r) => r.status === "ok").length }))
|
|
229
|
+
.sort((a, b) => b.good - a.good); // stable: ties keep scan order
|
|
230
|
+
const reachable = ranked.filter((c) => c.good).length;
|
|
231
|
+
const rows = ranked.slice(0, limit).map(({ q, rs }) => {
|
|
232
|
+
const seen = rs.find((r) => r.status === "ok") ?? rs.find((r) => r.location);
|
|
233
|
+
return [
|
|
234
|
+
`${flag(q)} ${countryName(q)}`,
|
|
235
|
+
seen?.location?.split(" - ")[0] || dim("-"),
|
|
236
|
+
...protocols.map((p) => {
|
|
237
|
+
const r = rs.find((x) => x.protocol === p);
|
|
238
|
+
return !r ? dim("·") : r.status === "ok" ? green("✓") : red("✗");
|
|
239
|
+
}),
|
|
240
|
+
];
|
|
241
|
+
});
|
|
242
|
+
return grid(
|
|
243
|
+
[{ title: "Country" }, { title: "City" }, ...protocols.map((p) => ({ title: p, align: "center" }))],
|
|
244
|
+
rows,
|
|
245
|
+
{
|
|
246
|
+
caption: bold("Availability"),
|
|
247
|
+
captionRight: dim(`${reachable}/${ranked.length} countries reachable${ranked.length > limit ? ` · top ${limit}` : ""}`),
|
|
248
|
+
minWidth,
|
|
249
|
+
stretch: 1,
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------- config ----------
|
|
255
|
+
|
|
256
|
+
function loadConfig() {
|
|
257
|
+
try {
|
|
258
|
+
const c = JSON.parse(readFileSync(CONFIG, "utf8"));
|
|
259
|
+
return {
|
|
260
|
+
countries: (c.countries ?? []).filter((x) => COUNTRIES.includes(x)),
|
|
261
|
+
protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
|
|
262
|
+
speed: c.speed !== false,
|
|
263
|
+
};
|
|
264
|
+
} catch {
|
|
265
|
+
return null; // first run or unreadable file
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function saveConfig(c) {
|
|
270
|
+
try {
|
|
271
|
+
mkdirSync(join(CONFIG, ".."), { recursive: true });
|
|
272
|
+
writeFileSync(CONFIG, JSON.stringify(c, null, 2) + "\n");
|
|
273
|
+
} catch {} // not being able to remember isn't worth failing the scan
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------- terminal ----------
|
|
277
|
+
|
|
278
|
+
const out = process.stdout;
|
|
279
|
+
// stdin minus mouse reports: readline would split those into stray keypresses ("0", ";", "M"…)
|
|
280
|
+
const input = new PassThrough();
|
|
281
|
+
readline.emitKeypressEvents(input);
|
|
282
|
+
// SGR mouse report: ESC [ < button ; col ; row (M = press, m = release); 64/65 = wheel up/down
|
|
283
|
+
// ponytail: assumes a report never straddles two reads, true for terminals in practice
|
|
284
|
+
const onData = (buf) => {
|
|
285
|
+
const rest = buf.toString().replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, b, x, y, kind) => {
|
|
286
|
+
input.emit("mouse", { button: Number(b), x: Number(x) - 1, y: Number(y) - 1, press: kind === "M" });
|
|
287
|
+
return "";
|
|
288
|
+
});
|
|
289
|
+
if (rest) input.write(rest);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
let fullscreen = false;
|
|
293
|
+
function enterScreen() {
|
|
294
|
+
fullscreen = true;
|
|
295
|
+
out.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"); // alternate screen, hide cursor, mouse on
|
|
296
|
+
process.stdin.setRawMode(true);
|
|
297
|
+
process.stdin.on("data", onData);
|
|
298
|
+
process.stdin.resume();
|
|
299
|
+
}
|
|
300
|
+
function leaveScreen() {
|
|
301
|
+
if (!fullscreen) return;
|
|
302
|
+
fullscreen = false;
|
|
303
|
+
process.stdin.setRawMode(false);
|
|
304
|
+
process.stdin.off("data", onData);
|
|
305
|
+
process.stdin.pause();
|
|
306
|
+
input.removeAllListeners("keypress");
|
|
307
|
+
input.removeAllListeners("mouse");
|
|
308
|
+
out.write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
309
|
+
}
|
|
310
|
+
process.on("exit", leaveScreen);
|
|
311
|
+
|
|
312
|
+
// redraw in place, centered: home, each line cleared to its end, then clear below (no flicker)
|
|
313
|
+
function draw(lines) {
|
|
314
|
+
const cols = out.columns || 80, rows = out.rows || 24;
|
|
315
|
+
const w = Math.max(...lines.map(vw));
|
|
316
|
+
const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
|
|
317
|
+
const top = Math.max(0, Math.floor((rows - lines.length) / 2));
|
|
318
|
+
const screen = [...Array(top).fill(""), ...lines.map((l) => left + l)].slice(0, rows);
|
|
319
|
+
out.write("\x1b[H" + screen.map((l) => l + "\x1b[K").join("\n") + "\x1b[J");
|
|
320
|
+
return { left: left.length, top };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// gradient bar (cyan → green) with eighth-block precision on the leading edge
|
|
324
|
+
function bar(fraction, width) {
|
|
325
|
+
const parts = " ▏▎▍▌▋▊▉";
|
|
326
|
+
const cells = Math.max(0, Math.min(1, fraction)) * width;
|
|
327
|
+
const full = Math.floor(cells);
|
|
328
|
+
const palette = [45, 44, 43, 42, 41, 47, 48, 49, 83, 82];
|
|
329
|
+
const shade = (i) => palette[Math.floor((i / width) * palette.length)];
|
|
330
|
+
let s = "";
|
|
331
|
+
for (let i = 0; i < full; i++) s += color(`38;5;${shade(i)}`, "█");
|
|
332
|
+
if (full < width) {
|
|
333
|
+
const p = parts[Math.floor((cells - full) * 8)];
|
|
334
|
+
s += p === " " ? dim("░") : color(`38;5;${shade(full)}`, p);
|
|
335
|
+
s += dim("░".repeat(width - full - 1));
|
|
336
|
+
}
|
|
337
|
+
return s;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------- screen 1: picker ----------
|
|
341
|
+
|
|
342
|
+
function pick(saved, timeout) {
|
|
343
|
+
const selected = new Set(saved?.countries ?? []);
|
|
344
|
+
const protos = new Set(saved?.protocols?.length ? saved.protocols : PROTOCOLS);
|
|
345
|
+
let cursor = 0, top = 0, message = "", query = "", searching = false;
|
|
346
|
+
let hit = {}; // where things landed on screen last render, for mouse clicks
|
|
347
|
+
const visible = () => {
|
|
348
|
+
const q = query.toLowerCase();
|
|
349
|
+
return q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q) || countryName(c).toLowerCase().includes(q)) : COUNTRIES;
|
|
350
|
+
};
|
|
351
|
+
enterScreen();
|
|
352
|
+
|
|
353
|
+
return new Promise((resolve) => {
|
|
354
|
+
const render = () => {
|
|
355
|
+
const rows = out.rows || 24;
|
|
356
|
+
const list = visible();
|
|
357
|
+
const height = Math.max(3, rows - 17);
|
|
358
|
+
cursor = Math.max(0, Math.min(cursor, list.length - 1));
|
|
359
|
+
if (cursor < top) top = cursor;
|
|
360
|
+
if (cursor >= top + height) top = cursor - height + 1;
|
|
361
|
+
top = Math.max(0, Math.min(top, list.length - height));
|
|
362
|
+
|
|
363
|
+
const protoLine = PROTOCOLS.map((p, i) =>
|
|
364
|
+
`${dim(String(i + 1))} ${protos.has(p) ? green("● " + p) : dim("○ " + p)}`).join(" ");
|
|
365
|
+
const speedLine = `${dim("s")} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
|
|
366
|
+
? "ranks by download speed · adds ~15s per working connection"
|
|
367
|
+
: "off: connect-only, ranks by latency")}`;
|
|
368
|
+
const search = searching
|
|
369
|
+
? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
370
|
+
: query ? `🔍 ${query}` : dim("🔍 press / to search");
|
|
371
|
+
const shown = list.slice(top, top + height);
|
|
372
|
+
const range = list.length > height ? `${top + 1}–${top + shown.length} of ${list.length}` : `${list.length} countries`;
|
|
373
|
+
const body = shown.map((code, j) => {
|
|
374
|
+
const here = top + j === cursor;
|
|
375
|
+
const mark = selected.has(code) ? green("●") : dim("○");
|
|
376
|
+
const name = `${flag(code)} ${countryName(code)}`;
|
|
377
|
+
return [`${here ? cyan("❯") : " "} ${mark}`, here ? bold(code) : dim(code), here ? bold(cyan(name)) : name];
|
|
378
|
+
});
|
|
379
|
+
if (!body.length) body.push([" ", "", dim("no country matches")]);
|
|
380
|
+
const buttons = [["start", "▶ Start"], ["all", query ? "All shown" : "All"], ["none", query ? "None shown" : "None"], ["quit", "Quit"]];
|
|
381
|
+
const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
382
|
+
|
|
383
|
+
const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body,
|
|
384
|
+
{ caption: search, captionRight: dim(range), minWidth: Math.max(vw(protoLine), vw(speedLine)) + 4 });
|
|
385
|
+
const width = vw(table[0]);
|
|
386
|
+
const head = box([protoLine, speedLine], {
|
|
387
|
+
title: bold(cyan("WindScout")),
|
|
388
|
+
right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
389
|
+
width: width - 4,
|
|
390
|
+
});
|
|
391
|
+
const n = selected.size * protos.size;
|
|
392
|
+
const lines = [
|
|
393
|
+
...head,
|
|
394
|
+
...table,
|
|
395
|
+
"",
|
|
396
|
+
centerIn(n
|
|
397
|
+
? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(selected.size, protos.size, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each)`)}`
|
|
398
|
+
: yellow("select at least one country and one protocol"), width),
|
|
399
|
+
centerIn(dim(searching
|
|
400
|
+
? "type to filter · ↑↓ move · enter/esc done"
|
|
401
|
+
: `↑↓ move · space select · a/n ${query ? "all/none shown" : "all/none"} · / search`), width),
|
|
402
|
+
centerIn(dim(searching ? "" : `1-${PROTOCOLS.length} protocols · s speed test · enter start · ${query ? "esc clear · " : ""}q quit`), width),
|
|
403
|
+
];
|
|
404
|
+
lines.splice(head.length + table.length + 1, 0, centerIn(buttonLine, width), "");
|
|
405
|
+
if (message) lines.push(centerIn(yellow(message), width));
|
|
406
|
+
const at = draw(lines);
|
|
407
|
+
|
|
408
|
+
// click map, in screen coordinates
|
|
409
|
+
const x0 = at.left, y0 = at.top;
|
|
410
|
+
let px = x0 + 2; // "│ " before the protocol line
|
|
411
|
+
const protoHits = PROTOCOLS.map((p, i) => {
|
|
412
|
+
const len = vw(`${i + 1} ● ${p}`);
|
|
413
|
+
const range = [px, px + len];
|
|
414
|
+
px += len + 3;
|
|
415
|
+
return range;
|
|
416
|
+
});
|
|
417
|
+
let bx = x0 + Math.floor((width - vw(buttonLine)) / 2);
|
|
418
|
+
const buttonHits = buttons.map(([id, label]) => {
|
|
419
|
+
const len = vw(`[ ${label} ]`);
|
|
420
|
+
const range = [id, bx, bx + len];
|
|
421
|
+
bx += len + 2;
|
|
422
|
+
return range;
|
|
423
|
+
});
|
|
424
|
+
hit = {
|
|
425
|
+
protoRow: y0 + 1, speedRow: y0 + 2, protoHits,
|
|
426
|
+
searchRow: y0 + head.length + 1,
|
|
427
|
+
firstRow: y0 + head.length + 5, shown: shown.length,
|
|
428
|
+
buttonRow: y0 + head.length + table.length + 1, buttonHits,
|
|
429
|
+
};
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const finish = (value) => {
|
|
433
|
+
out.off("resize", render);
|
|
434
|
+
input.removeAllListeners("keypress");
|
|
435
|
+
input.removeAllListeners("mouse");
|
|
436
|
+
if (!value) leaveScreen();
|
|
437
|
+
resolve(value);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const start = () => {
|
|
441
|
+
if (!selected.size || !protos.size) return void (message = "nothing to scan yet");
|
|
442
|
+
// keep the user's order of preference stable: list order
|
|
443
|
+
const choice = {
|
|
444
|
+
countries: COUNTRIES.filter((c) => selected.has(c)),
|
|
445
|
+
protocols: PROTOCOLS.filter((p) => protos.has(p)),
|
|
446
|
+
speed: speedTest,
|
|
447
|
+
};
|
|
448
|
+
saveConfig(choice);
|
|
449
|
+
finish(choice);
|
|
450
|
+
return true;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
input.on("mouse", (m) => {
|
|
454
|
+
if (!m.press) return;
|
|
455
|
+
message = "";
|
|
456
|
+
const list = visible();
|
|
457
|
+
const { x, y } = m;
|
|
458
|
+
if (m.button === 64) cursor = Math.max(0, cursor - 3); // wheel up
|
|
459
|
+
else if (m.button === 65) cursor = Math.min(list.length - 1, cursor + 3); // wheel down
|
|
460
|
+
else if (m.button !== 0) return; // left button only
|
|
461
|
+
else if (y === hit.searchRow) searching = true;
|
|
462
|
+
else {
|
|
463
|
+
searching = false; // clicking anywhere else ends typing, like leaving a text field
|
|
464
|
+
if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
|
|
465
|
+
cursor = top + (y - hit.firstRow);
|
|
466
|
+
const c = list[cursor];
|
|
467
|
+
if (c) selected.has(c) ? selected.delete(c) : selected.add(c);
|
|
468
|
+
} else if (y === hit.protoRow) {
|
|
469
|
+
const i = hit.protoHits.findIndex(([a, b]) => x >= a && x < b);
|
|
470
|
+
if (i >= 0) protos.has(PROTOCOLS[i]) ? protos.delete(PROTOCOLS[i]) : protos.add(PROTOCOLS[i]);
|
|
471
|
+
} else if (y === hit.speedRow) speedTest = !speedTest;
|
|
472
|
+
else if (y === hit.buttonRow) {
|
|
473
|
+
const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
|
|
474
|
+
if (id === "start" && start()) return;
|
|
475
|
+
if (id === "quit") return finish(null);
|
|
476
|
+
if (id === "all") list.forEach((c) => selected.add(c));
|
|
477
|
+
if (id === "none") list.forEach((c) => selected.delete(c));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
render();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
out.on("resize", render);
|
|
484
|
+
input.on("keypress", (str, key = {}) => {
|
|
485
|
+
message = "";
|
|
486
|
+
const k = key.name;
|
|
487
|
+
const list = visible();
|
|
488
|
+
const toggle = () => {
|
|
489
|
+
const c = list[cursor];
|
|
490
|
+
if (!c) return;
|
|
491
|
+
selected.has(c) ? selected.delete(c) : selected.add(c);
|
|
492
|
+
cursor = Math.min(list.length - 1, cursor + 1);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
if (key.ctrl && k === "c") return finish(null);
|
|
496
|
+
if (k === "up") cursor = Math.max(0, cursor - 1);
|
|
497
|
+
else if (k === "down") cursor = Math.min(list.length - 1, cursor + 1);
|
|
498
|
+
else if (k === "pageup") cursor = Math.max(0, cursor - 10);
|
|
499
|
+
else if (k === "pagedown") cursor = Math.min(list.length - 1, cursor + 10);
|
|
500
|
+
else if (searching) {
|
|
501
|
+
// typing goes into the search box; letters are not shortcuts here
|
|
502
|
+
if (k === "return" || k === "escape") searching = false;
|
|
503
|
+
else if (k === "backspace") query = query.slice(0, -1), cursor = 0;
|
|
504
|
+
else if (k === "space" && !query) toggle();
|
|
505
|
+
else if (str && !key.ctrl && !key.meta && str >= " ") query += str, cursor = 0;
|
|
506
|
+
} else if (k === "home") cursor = 0;
|
|
507
|
+
else if (k === "end") cursor = list.length - 1;
|
|
508
|
+
else if (k === "k") cursor = Math.max(0, cursor - 1);
|
|
509
|
+
else if (k === "j") cursor = Math.min(list.length - 1, cursor + 1);
|
|
510
|
+
else if (str === "/") searching = true;
|
|
511
|
+
else if (k === "space") toggle();
|
|
512
|
+
else if (k === "a") list.forEach((c) => selected.add(c));
|
|
513
|
+
else if (k === "n") list.forEach((c) => selected.delete(c));
|
|
514
|
+
else if (k === "s") speedTest = !speedTest;
|
|
515
|
+
else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1]) {
|
|
516
|
+
const p = PROTOCOLS[Number(str) - 1];
|
|
517
|
+
protos.has(p) ? protos.delete(p) : protos.add(p);
|
|
518
|
+
} else if (k === "escape" && query) query = "", cursor = 0;
|
|
519
|
+
else if (k === "return") {
|
|
520
|
+
if (start()) return;
|
|
521
|
+
} else if (k === "q" || k === "escape") return finish(null);
|
|
522
|
+
render();
|
|
523
|
+
});
|
|
524
|
+
render();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------- screen 2: scan ----------
|
|
529
|
+
|
|
530
|
+
async function scan(countries, protocols, timeout, tty) {
|
|
531
|
+
const results = [];
|
|
532
|
+
const total = countries.length * protocols.length;
|
|
533
|
+
const started = now();
|
|
534
|
+
let done = 0, attemptStart = now(), current = "", step = "", frame = 0, finishedCountries = [];
|
|
535
|
+
let view = speedTest ? "ranking" : "availability"; // tab switches
|
|
536
|
+
|
|
537
|
+
const render = () => {
|
|
538
|
+
const rows = out.rows || 24;
|
|
539
|
+
const elapsed = now() - started;
|
|
540
|
+
// smooth bar: count the running attempt as partially done
|
|
541
|
+
const partial = Math.min(0.95, (now() - attemptStart) / attemptSeconds(timeout));
|
|
542
|
+
const fraction = (done + (done < total ? partial : 0)) / total;
|
|
543
|
+
const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout));
|
|
544
|
+
const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10];
|
|
545
|
+
const ok = sortOk(results);
|
|
546
|
+
const failed = finishedCountries.filter((c) => !results.some((r) => r.query === c && r.status === "ok"));
|
|
547
|
+
|
|
548
|
+
const pct = bold(`${String(Math.floor(fraction * 100)).padStart(3)}%`);
|
|
549
|
+
const stats = [
|
|
550
|
+
`${dim("elapsed")} ${duration(elapsed)}`,
|
|
551
|
+
`${dim("remaining")} ${done < total ? "≈ " + duration(eta) : "-"}`,
|
|
552
|
+
`${green("✓")} ${ok.length} ${dim("working")}`,
|
|
553
|
+
`${red("✗")} ${failed.length} ${dim("blocked")}`,
|
|
554
|
+
].join(dim(" │ "));
|
|
555
|
+
const minW = Math.max(64, vw(stats) + 6); // panel and table share one width
|
|
556
|
+
// results table gets whatever height the status panel and footer leave
|
|
557
|
+
const room = Math.max(1, rows - 20);
|
|
558
|
+
const tried = results.some((r) => !["no such location", "interrupted"].includes(r.status));
|
|
559
|
+
const table = view === "availability"
|
|
560
|
+
? (tried ? matrix(results, protocols, { minWidth: minW, limit: room }) : [])
|
|
561
|
+
: ok.length
|
|
562
|
+
? grid(resultCols(), ok.slice(0, room).map(resultRow), {
|
|
563
|
+
caption: bold("Working so far"),
|
|
564
|
+
captionRight: dim(ok.length > room ? `top ${room} of ${ok.length}` : `${ok.length} found`),
|
|
565
|
+
minWidth: minW,
|
|
566
|
+
})
|
|
567
|
+
: [];
|
|
568
|
+
const width = Math.max(minW, table.length ? vw(table[0]) : 0);
|
|
569
|
+
const inner = width - 4;
|
|
570
|
+
|
|
571
|
+
const panel = box([
|
|
572
|
+
"",
|
|
573
|
+
` ${bar(fraction, inner - 8)} ${pct}`,
|
|
574
|
+
"",
|
|
575
|
+
stop
|
|
576
|
+
? yellow(" stopping after the current attempt…")
|
|
577
|
+
: ` ${cyan(spinner)} ${bold(current)}`,
|
|
578
|
+
stop ? "" : ` ${dim(step)} ${dim("·")} ${duration(now() - attemptStart)}`,
|
|
579
|
+
null,
|
|
580
|
+
centerIn(stats, inner),
|
|
581
|
+
], {
|
|
582
|
+
title: bold(cyan("WindScout")),
|
|
583
|
+
right: `${bold(String(done))}${dim(`/${total} attempts`)}`,
|
|
584
|
+
width: inner,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const lines = [...panel];
|
|
588
|
+
if (table.length) lines.push(...table);
|
|
589
|
+
else lines.push("", centerIn(dim(finishedCountries.length ? "nothing has connected yet" : "results appear here after each country"), width));
|
|
590
|
+
if (failed.length) {
|
|
591
|
+
let txt = `${red("✗")} ${dim("no connection:")} ${failed.map((c) => `${flag(c)} ${c}`).join(" ")}`;
|
|
592
|
+
while (vw(txt) > width && failed.length) txt = txt.slice(0, -8) + "…"; // ponytail: crude trim, fine for a status line
|
|
593
|
+
lines.push("", centerIn(txt, width));
|
|
594
|
+
}
|
|
595
|
+
lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · q stop · q twice force quit`), width));
|
|
596
|
+
draw(lines);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
let timer;
|
|
600
|
+
if (tty) {
|
|
601
|
+
if (!fullscreen) enterScreen();
|
|
602
|
+
input.on("keypress", (str, key = {}) => {
|
|
603
|
+
if (key.name === "tab") {
|
|
604
|
+
view = view === "ranking" ? "availability" : "ranking";
|
|
605
|
+
render();
|
|
606
|
+
} else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
607
|
+
if (stop++) { // second press: bail out now
|
|
608
|
+
leaveScreen();
|
|
609
|
+
console.error(`stopped. the VPN may still be connecting: run '${CLI} disconnect'`);
|
|
610
|
+
process.exit(130);
|
|
611
|
+
}
|
|
612
|
+
render();
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
out.on("resize", render);
|
|
616
|
+
timer = setInterval(render, 120);
|
|
617
|
+
} else {
|
|
618
|
+
process.on("SIGINT", () => { if (stop++) process.exit(130); });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
outer: for (const loc of countries) {
|
|
622
|
+
for (const proto of protocols) {
|
|
623
|
+
if (stop) break outer;
|
|
624
|
+
current = `${flag(loc)} ${countryName(loc)} ${dim("·")} ${proto}`;
|
|
625
|
+
attemptStart = now();
|
|
626
|
+
if (!tty) process.stdout.write(`[${done + 1}/${total}] ${loc} ${proto} ... `);
|
|
627
|
+
const r = await probe(loc, proto, timeout, (s) => (step = s));
|
|
628
|
+
results.push(r);
|
|
629
|
+
done++;
|
|
630
|
+
if (!tty) console.log([r.status, r.location, r.speedMbps ? `${r.speedMbps.toFixed(1)} Mbps` : ""].join(" ").trim());
|
|
631
|
+
if (r.status === "no such location") { done += protocols.length - protocols.indexOf(proto) - 1; break; }
|
|
632
|
+
}
|
|
633
|
+
finishedCountries.push(loc); // table/failed list refresh per finished country
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (tty) {
|
|
637
|
+
clearInterval(timer);
|
|
638
|
+
out.off("resize", render);
|
|
639
|
+
leaveScreen();
|
|
640
|
+
}
|
|
641
|
+
return results;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---------- final report (normal screen, stays in scrollback) ----------
|
|
645
|
+
|
|
646
|
+
function report(results, protocols, all) {
|
|
647
|
+
const ok = sortOk(results);
|
|
648
|
+
const rows = [...ok, ...(all ? results.filter((r) => r.status !== "ok") : [])];
|
|
649
|
+
const tried = results.some((r) => !["no such location", "interrupted"].includes(r.status));
|
|
650
|
+
// render once to learn the widest table, then again so every box lines up
|
|
651
|
+
const build = (minWidth) => ({
|
|
652
|
+
avail: tried ? matrix(results, protocols, { minWidth }) : [],
|
|
653
|
+
main: rows.length
|
|
654
|
+
? grid(resultCols(), rows.map(resultRow), { caption: bold("Results"), captionRight: dim(`${ok.length} working`), minWidth })
|
|
655
|
+
: [],
|
|
656
|
+
});
|
|
657
|
+
const first = build(0);
|
|
658
|
+
const width = Math.max(64, ...[first.avail[0], first.main[0]].filter(Boolean).map(vw));
|
|
659
|
+
const { avail, main } = build(width);
|
|
660
|
+
|
|
661
|
+
console.log();
|
|
662
|
+
if (avail.length) console.log(avail.join("\n"));
|
|
663
|
+
if (!ok.length) {
|
|
664
|
+
console.log(box([yellow("Nothing connected."),
|
|
665
|
+
dim("Try other countries, ports (e.g. -p stealth:443,tcp:443) or a longer --timeout.")],
|
|
666
|
+
{ title: bold(cyan("WindScout")), width: width - 4 }).join("\n"));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
console.log(main.join("\n"));
|
|
670
|
+
|
|
671
|
+
const stats = protocols.map((p) => {
|
|
672
|
+
const tried = results.filter((r) => r.protocol === p && r.status !== "no such location");
|
|
673
|
+
const good = tried.filter((r) => r.status === "ok");
|
|
674
|
+
return { p, good: good.length, tried: tried.length, best: sortOk(good)[0] };
|
|
675
|
+
}).sort((x, y) => y.good - x.good || (y.best?.speedMbps ?? 0) - (x.best?.speedMbps ?? 0) || (x.best?.latencyMs ?? 1e9) - (y.best?.latencyMs ?? 1e9));
|
|
676
|
+
const bestValue = (r) => (speedTest ? `${fmt(r.speedMbps, 1)} Mbps` : `${fmt(r.latencyMs, 0)} ms`);
|
|
677
|
+
console.log(grid(
|
|
678
|
+
[{ title: "Protocol" }, { title: "Worked", align: true }, { title: speedTest ? "Best speed" : "Best latency", align: true }, { title: "Best location" }],
|
|
679
|
+
stats.map((s) => [s.p, `${s.good}/${s.tried}`, s.best ? bestValue(s.best) : "-", s.best ? `${flag(s.best.query)} ${s.best.location}` : "-"]),
|
|
680
|
+
{ caption: bold("Per protocol"), minWidth: width },
|
|
681
|
+
).join("\n"));
|
|
682
|
+
const b = ok[0];
|
|
683
|
+
const target = b.location.split(" - ").at(-1) || b.query; // nickname pins the exact datacenter
|
|
684
|
+
const facts = [speedTest && `${fmt(b.speedMbps, 1)} Mbps`, `${fmt(b.latencyMs, 0)} ms`].filter(Boolean).join(" · ");
|
|
685
|
+
console.log(box([
|
|
686
|
+
`${flag(b.query)} ${bold(b.location)} over ${bold(b.protocol)} ${dim("· " + facts)}`,
|
|
687
|
+
cyan(`${CLI} connect "${target}" ${b.protocol}`),
|
|
688
|
+
], { title: green(speedTest ? "★ Best" : "★ Best (lowest latency)"), width: width - 4 }).join("\n"));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ---------- main ----------
|
|
692
|
+
|
|
693
|
+
const HELP = `Usage: windscout [options]
|
|
694
|
+
|
|
695
|
+
Find which Windscribe locations and protocols work from your network, ranked by speed.
|
|
696
|
+
Without -c it opens an interactive country picker (your selection is remembered).
|
|
697
|
+
|
|
698
|
+
Options:
|
|
699
|
+
-c, --countries LIST comma-separated ISO codes, city names or nicknames (skips the picker)
|
|
700
|
+
-p, --protocols LIST comma-separated protocols, optional :port (default: ${PROTOCOLS.join(",")})
|
|
701
|
+
-t, --timeout SEC seconds to wait for each connection (default: 20)
|
|
702
|
+
-a, --all also show failed attempts in the final table
|
|
703
|
+
-n, --no-speed connect-only: skip the download test, rank by latency (faster scan)
|
|
704
|
+
-h, --help show this help
|
|
705
|
+
|
|
706
|
+
Selection is stored in ${CONFIG}
|
|
707
|
+
Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
|
|
708
|
+
|
|
709
|
+
async function main() {
|
|
710
|
+
const { values: a } = parseArgs({
|
|
711
|
+
options: {
|
|
712
|
+
countries: { type: "string", short: "c" },
|
|
713
|
+
protocols: { type: "string", short: "p" },
|
|
714
|
+
timeout: { type: "string", short: "t", default: "20" },
|
|
715
|
+
all: { type: "boolean", short: "a", default: false },
|
|
716
|
+
help: { type: "boolean", short: "h", default: false },
|
|
717
|
+
"no-speed": { type: "boolean", short: "n", default: false },
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
if (a.help) return console.log(HELP);
|
|
721
|
+
const timeout = Number(a.timeout);
|
|
722
|
+
if (!(timeout > 0)) throw new Error("--timeout must be a positive number");
|
|
723
|
+
|
|
724
|
+
if (!(await cli("status")).includes("Logged in"))
|
|
725
|
+
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
|
+
+ `\nDon't have it? ${DOWNLOAD_URL}`);
|
|
727
|
+
|
|
728
|
+
const tty = Boolean(process.stdin.isTTY && out.isTTY);
|
|
729
|
+
let countries = a.countries && list(a.countries);
|
|
730
|
+
let protocols = a.protocols && list(a.protocols);
|
|
731
|
+
if (!countries) {
|
|
732
|
+
if (!tty) throw new Error("no terminal for the picker: pass countries with -c");
|
|
733
|
+
const saved = loadConfig();
|
|
734
|
+
if (protocols && saved) saved.protocols = protocols.filter((p) => PROTOCOLS.includes(p));
|
|
735
|
+
speedTest = !a["no-speed"] && (saved?.speed ?? true);
|
|
736
|
+
const choice = await pick(saved, timeout);
|
|
737
|
+
if (!choice) return;
|
|
738
|
+
countries = choice.countries;
|
|
739
|
+
protocols = protocols ?? choice.protocols;
|
|
740
|
+
}
|
|
741
|
+
protocols ??= PROTOCOLS;
|
|
742
|
+
if (a.countries) speedTest = !a["no-speed"];
|
|
743
|
+
|
|
744
|
+
const results = await scan(countries, protocols, timeout, tty);
|
|
745
|
+
if (stop) console.log(yellow("interrupted, showing partial results"));
|
|
746
|
+
report(results, protocols, a.all);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
main().catch((e) => {
|
|
750
|
+
leaveScreen();
|
|
751
|
+
console.error(`windscout: ${e.message}`);
|
|
752
|
+
process.exit(1);
|
|
753
|
+
});
|