windscout 0.4.6 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/dist/cli.js +108 -0
- package/dist/config.js +31 -0
- package/dist/measure.js +41 -0
- package/dist/probe.js +56 -0
- package/dist/scan.js +75 -0
- package/dist/time.js +10 -0
- package/dist/tui/country.js +15 -0
- package/dist/tui/picker.js +274 -0
- package/dist/tui/progress.js +124 -0
- package/dist/tui/report.js +44 -0
- package/dist/tui/tables.js +62 -0
- package/dist/tui/terminal.js +67 -0
- package/dist/tui/text.js +22 -0
- package/dist/tui/widgets.js +53 -0
- package/dist/update.js +28 -0
- package/dist/windscribe.js +85 -0
- package/package.json +12 -4
- package/windscout.js +0 -866
package/README.md
CHANGED
|
@@ -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 |
|
|
@@ -143,6 +143,17 @@ At the end, the results, a per-protocol summary and the best pick are printed to
|
|
|
143
143
|
- **No flags in macOS Terminal.** Terminal.app draws flag emoji wider than their space, which pushes table borders out of line, so WindScout leaves them out there (as on Windows). iTerm2 and other terminals show them.
|
|
144
144
|
- **Your connection changes during the scan.** WindScout connects and disconnects the VPN repeatedly, so don't run it while you need a stable connection.
|
|
145
145
|
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
The source is TypeScript in `src/`. It compiles to `dist/`, which is what gets published.
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
npm install
|
|
152
|
+
npm run build # compile src/ to dist/ (the windscout command is dist/cli.js)
|
|
153
|
+
node dist/cli.js # run your build; takes the same options as windscout
|
|
154
|
+
./publish.sh # build, install the packed tarball to check it runs, publish to npm (bump the version first)
|
|
155
|
+
```
|
|
156
|
+
|
|
146
157
|
## License
|
|
147
158
|
|
|
148
159
|
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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 { parseArgs } from "node:util";
|
|
6
|
+
import { CONFIG, DEFAULT_TIMEOUT, loadConfig, MIN_TIMEOUT } from "./config.js";
|
|
7
|
+
import { Scan } from "./scan.js";
|
|
8
|
+
import { pick } from "./tui/picker.js";
|
|
9
|
+
import { logProgress, showProgress } from "./tui/progress.js";
|
|
10
|
+
import { report } from "./tui/report.js";
|
|
11
|
+
import { leaveScreen, out } from "./tui/terminal.js";
|
|
12
|
+
import { yellow } from "./tui/text.js";
|
|
13
|
+
import { checkUpdate, updateNotice, VERSION } from "./update.js";
|
|
14
|
+
import { blockInternet, CLI, isFirewallOn, PROTOCOLS, restoreFirewall, waitForLogin } from "./windscribe.js";
|
|
15
|
+
const HELP = `Usage: windscout [options]
|
|
16
|
+
|
|
17
|
+
Find which Windscribe locations and protocols work from your network, ranked by speed.
|
|
18
|
+
Without -c it opens an interactive country picker (your selection is remembered).
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
-c, --countries LIST comma-separated ISO codes, city names or nicknames (skips the picker)
|
|
22
|
+
-p, --protocols LIST comma-separated protocols, optional :port (default: ${PROTOCOLS.join(",")})
|
|
23
|
+
-t, --timeout SEC seconds to wait for each connection, at least ${MIN_TIMEOUT} (default: saved, or ${DEFAULT_TIMEOUT})
|
|
24
|
+
-a, --all also show failed attempts in the final table
|
|
25
|
+
-n, --no-speed connect-only: skip the download test, rank by latency (faster scan)
|
|
26
|
+
-f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
|
|
27
|
+
-v, --version show the installed version (and whether a newer one is out)
|
|
28
|
+
-h, --help show this help
|
|
29
|
+
|
|
30
|
+
Selection is stored in ${CONFIG}
|
|
31
|
+
Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
|
|
32
|
+
// "DE, NL,,TR" → ["DE", "NL", "TR"]
|
|
33
|
+
const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
34
|
+
async function main() {
|
|
35
|
+
const { values: a } = parseArgs({
|
|
36
|
+
options: {
|
|
37
|
+
countries: { type: "string", short: "c" },
|
|
38
|
+
protocols: { type: "string", short: "p" },
|
|
39
|
+
timeout: { type: "string", short: "t" },
|
|
40
|
+
all: { type: "boolean", short: "a", default: false },
|
|
41
|
+
help: { type: "boolean", short: "h", default: false },
|
|
42
|
+
version: { type: "boolean", short: "v", default: false },
|
|
43
|
+
"no-speed": { type: "boolean", short: "n", default: false },
|
|
44
|
+
firewall: { type: "boolean", short: "f", default: false },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
if (a.help)
|
|
48
|
+
return console.log(HELP);
|
|
49
|
+
checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
|
|
50
|
+
if (a.version) {
|
|
51
|
+
console.log(`windscout ${VERSION}`);
|
|
52
|
+
await checkUpdate();
|
|
53
|
+
const notice = updateNotice();
|
|
54
|
+
if (notice)
|
|
55
|
+
console.error(yellow(notice));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let timeout = a.timeout ? Number(a.timeout) : undefined;
|
|
59
|
+
if (timeout !== undefined && !(timeout >= MIN_TIMEOUT))
|
|
60
|
+
throw new Error(`--timeout must be at least ${MIN_TIMEOUT} seconds`);
|
|
61
|
+
const status = await waitForLogin();
|
|
62
|
+
const tty = Boolean(process.stdin.isTTY && out.isTTY);
|
|
63
|
+
let countries = a.countries ? list(a.countries) : undefined;
|
|
64
|
+
let protocols = a.protocols ? list(a.protocols) : undefined;
|
|
65
|
+
let speedTest = !a["no-speed"];
|
|
66
|
+
let firewall = a.firewall;
|
|
67
|
+
if (!countries) {
|
|
68
|
+
if (!tty)
|
|
69
|
+
throw new Error("no terminal for the picker: pass countries with -c");
|
|
70
|
+
const saved = loadConfig();
|
|
71
|
+
if (protocols && saved)
|
|
72
|
+
saved.protocols = protocols.filter((p) => PROTOCOLS.includes(p));
|
|
73
|
+
const choice = await pick(saved, {
|
|
74
|
+
timeout: timeout ?? saved?.timeout ?? DEFAULT_TIMEOUT,
|
|
75
|
+
speedTest: speedTest && (saved?.speed ?? false), // first run: connect-only
|
|
76
|
+
// already on in the app: show it on, since the scan won't turn it off
|
|
77
|
+
firewall: firewall || isFirewallOn(status) || (saved?.firewall ?? false),
|
|
78
|
+
});
|
|
79
|
+
if (!choice)
|
|
80
|
+
return;
|
|
81
|
+
countries = choice.countries;
|
|
82
|
+
protocols ??= choice.protocols;
|
|
83
|
+
speedTest = choice.speed;
|
|
84
|
+
firewall = choice.firewall;
|
|
85
|
+
timeout = choice.timeout;
|
|
86
|
+
}
|
|
87
|
+
protocols ??= PROTOCOLS;
|
|
88
|
+
timeout ??= DEFAULT_TIMEOUT;
|
|
89
|
+
const scan = new Scan(countries, protocols, { timeout, speedTest });
|
|
90
|
+
const blocking = firewall && (await blockInternet());
|
|
91
|
+
const view = tty ? showProgress(scan, blocking) : logProgress(scan);
|
|
92
|
+
await scan.run(view);
|
|
93
|
+
await restoreFirewall();
|
|
94
|
+
view.close();
|
|
95
|
+
if (scan.stopping)
|
|
96
|
+
console.log(yellow("interrupted, showing partial results"));
|
|
97
|
+
report(scan.results, protocols, { all: a.all, speedTest });
|
|
98
|
+
await checkUpdate();
|
|
99
|
+
const notice = updateNotice();
|
|
100
|
+
if (notice)
|
|
101
|
+
console.error("\n" + yellow(notice));
|
|
102
|
+
}
|
|
103
|
+
main().catch(async (e) => {
|
|
104
|
+
leaveScreen();
|
|
105
|
+
console.error(`windscout: ${e.message}`);
|
|
106
|
+
await restoreFirewall().catch(() => console.error(`the firewall may still be on: run '${CLI} firewall off'`));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The picker's selection, remembered between runs.
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
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
|
|
8
|
+
export const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
|
|
9
|
+
/** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
const c = JSON.parse(readFileSync(CONFIG, "utf8"));
|
|
13
|
+
return {
|
|
14
|
+
countries: (c.countries ?? []).filter((x) => COUNTRIES.includes(x)),
|
|
15
|
+
protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
|
|
16
|
+
speed: c.speed !== false,
|
|
17
|
+
firewall: c.firewall === true,
|
|
18
|
+
timeout: c.timeout >= MIN_TIMEOUT ? c.timeout : DEFAULT_TIMEOUT,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function saveConfig(c) {
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(dirname(CONFIG), { recursive: true });
|
|
28
|
+
writeFileSync(CONFIG, JSON.stringify(c, null, 2) + "\n");
|
|
29
|
+
}
|
|
30
|
+
catch { } // not being able to remember isn't worth failing the scan
|
|
31
|
+
}
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Latency and download speed over whatever connection is up (the VPN tunnel, during a scan).
|
|
2
|
+
import { now } from "./time.js";
|
|
3
|
+
const LATENCY_URL = "https://www.gstatic.com/generate_204";
|
|
4
|
+
const SPEED_URL = "https://speed.cloudflare.com/__down?bytes=10000000";
|
|
5
|
+
const SPEED_SECONDS = 15;
|
|
6
|
+
/** Best round trip of a few tries, in ms; null if none got through */
|
|
7
|
+
export async function latencyMs(tries = 3) {
|
|
8
|
+
let best = null;
|
|
9
|
+
for (let i = 0; i < tries; i++) {
|
|
10
|
+
const t = now();
|
|
11
|
+
try {
|
|
12
|
+
await (await fetch(LATENCY_URL, { signal: AbortSignal.timeout(5000) })).arrayBuffer();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const ms = (now() - t) * 1000;
|
|
18
|
+
best = best === null ? ms : Math.min(best, ms);
|
|
19
|
+
}
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
/** Download speed in Mbps over at most SPEED_SECONDS; null if nothing arrived */
|
|
23
|
+
export async function speedMbps() {
|
|
24
|
+
const t = now();
|
|
25
|
+
let n = 0;
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(SPEED_URL, {
|
|
28
|
+
headers: { "User-Agent": "curl/8" }, // Cloudflare 403s unknown clients
|
|
29
|
+
signal: AbortSignal.timeout(SPEED_SECONDS * 1000),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok || !res.body)
|
|
32
|
+
return null;
|
|
33
|
+
for await (const chunk of res.body)
|
|
34
|
+
n += chunk.length;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
if (!n)
|
|
38
|
+
return null; // timeout mid-download is fine: we measured what arrived
|
|
39
|
+
}
|
|
40
|
+
return (n * 8) / (now() - t) / 1e6;
|
|
41
|
+
}
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// One attempt: connect to a location over a protocol, measure through the tunnel, disconnect.
|
|
2
|
+
import { latencyMs, speedMbps } from "./measure.js";
|
|
3
|
+
import { now, sleep } from "./time.js";
|
|
4
|
+
import { connectState, windscribe } from "./windscribe.js";
|
|
5
|
+
// "Connected: Miami - Vice" → "Miami - Vice"
|
|
6
|
+
const place = (state) => state.split(":").slice(1).join(":").trim();
|
|
7
|
+
export async function probe(query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { } }) {
|
|
8
|
+
const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null };
|
|
9
|
+
const end = (status) => ({ ...r, status });
|
|
10
|
+
onStep("connecting");
|
|
11
|
+
await windscribe("connect", "-n", query, protocol);
|
|
12
|
+
const t = now();
|
|
13
|
+
try {
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (stopped())
|
|
16
|
+
return end("interrupted");
|
|
17
|
+
if (now() - t >= timeout)
|
|
18
|
+
return end("timeout");
|
|
19
|
+
const s = await connectState();
|
|
20
|
+
if (s.startsWith("Connecting:"))
|
|
21
|
+
r.location = place(s);
|
|
22
|
+
else if (s.startsWith("Connected")) {
|
|
23
|
+
if (s.includes(":"))
|
|
24
|
+
r.location = place(s);
|
|
25
|
+
r.connectS = now() - t;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
else if (s.includes("does not exist"))
|
|
29
|
+
return end("no such location");
|
|
30
|
+
else if (s === "Disconnected" && now() - t > 3)
|
|
31
|
+
return end("failed");
|
|
32
|
+
onStep(`connecting ${r.location}`.trim());
|
|
33
|
+
await sleep(1000);
|
|
34
|
+
}
|
|
35
|
+
onStep(`measuring latency · ${r.location}`);
|
|
36
|
+
r.latencyMs = await latencyMs();
|
|
37
|
+
if (r.latencyMs === null)
|
|
38
|
+
return end("no internet");
|
|
39
|
+
if (speedTest) {
|
|
40
|
+
onStep(`measuring speed · ${r.location}`);
|
|
41
|
+
r.speedMbps = await speedMbps();
|
|
42
|
+
}
|
|
43
|
+
return end("ok");
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
onStep("disconnecting");
|
|
47
|
+
await windscribe("disconnect");
|
|
48
|
+
// the CLI's disconnect can return while the app is still tearing down (it does after a timed-out connect);
|
|
49
|
+
// a connect sent then is swallowed and the next attempt reads as an instant "failed", so wait it out
|
|
50
|
+
for (let i = 0; i < 30 && (await connectState()) === "Disconnecting"; i++)
|
|
51
|
+
await sleep(1000);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The attempts that worked, best first: fastest, then lowest latency */
|
|
55
|
+
export const sortOk = (results) => results.filter((r) => r.status === "ok")
|
|
56
|
+
.sort((x, y) => (y.speedMbps ?? 0) - (x.speedMbps ?? 0) || (x.latencyMs ?? 0) - (y.latencyMs ?? 0));
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A scan: every country × protocol pair, tried in order, with pause and stop.
|
|
2
|
+
import { probe } from "./probe.js";
|
|
3
|
+
import { now } from "./time.js";
|
|
4
|
+
// ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
|
|
5
|
+
export const attemptSeconds = (timeout) => timeout + 5;
|
|
6
|
+
export const estimate = (countries, protocols, timeout) => countries * protocols * attemptSeconds(timeout);
|
|
7
|
+
/** Progress views read the fields; their keys call togglePause() and stop() */
|
|
8
|
+
export class Scan {
|
|
9
|
+
countries;
|
|
10
|
+
protocols;
|
|
11
|
+
options;
|
|
12
|
+
results = [];
|
|
13
|
+
finished = []; // countries with every attempt done: the views refresh their tables per country
|
|
14
|
+
total;
|
|
15
|
+
started = now();
|
|
16
|
+
done = 0;
|
|
17
|
+
attemptStart = now();
|
|
18
|
+
current = null;
|
|
19
|
+
step = ""; // what the running attempt is doing
|
|
20
|
+
stopping = false; // stop after the current attempt
|
|
21
|
+
// a pause lands between attempts, so the VPN is disconnected while waiting
|
|
22
|
+
paused = false;
|
|
23
|
+
waiting = false;
|
|
24
|
+
waitStart = 0;
|
|
25
|
+
idle = 0; // seconds spent paused
|
|
26
|
+
resume = () => { };
|
|
27
|
+
constructor(countries, protocols, options) {
|
|
28
|
+
this.countries = countries;
|
|
29
|
+
this.protocols = protocols;
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.total = countries.length * protocols.length;
|
|
32
|
+
}
|
|
33
|
+
/** Seconds spent scanning, pauses not counted */
|
|
34
|
+
elapsed() {
|
|
35
|
+
return now() - this.started - this.idle - (this.waiting ? now() - this.waitStart : 0);
|
|
36
|
+
}
|
|
37
|
+
togglePause() {
|
|
38
|
+
this.paused = !this.paused;
|
|
39
|
+
if (!this.paused)
|
|
40
|
+
this.resume();
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
this.stopping = true;
|
|
44
|
+
this.resume(); // a paused scan stops right away
|
|
45
|
+
}
|
|
46
|
+
async run(hooks = {}) {
|
|
47
|
+
const { timeout, speedTest } = this.options;
|
|
48
|
+
outer: for (const country of this.countries) {
|
|
49
|
+
for (const protocol of this.protocols) {
|
|
50
|
+
if (this.paused && !this.stopping) {
|
|
51
|
+
this.waiting = true;
|
|
52
|
+
this.waitStart = now();
|
|
53
|
+
await new Promise((resolve) => (this.resume = resolve));
|
|
54
|
+
this.waiting = false;
|
|
55
|
+
this.idle += now() - this.waitStart;
|
|
56
|
+
}
|
|
57
|
+
if (this.stopping)
|
|
58
|
+
break outer;
|
|
59
|
+
this.current = { country, protocol };
|
|
60
|
+
this.attemptStart = now();
|
|
61
|
+
hooks.attempt?.(country, protocol);
|
|
62
|
+
const r = await probe(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (this.step = s) });
|
|
63
|
+
this.results.push(r);
|
|
64
|
+
this.done++;
|
|
65
|
+
hooks.result?.(r);
|
|
66
|
+
if (r.status === "no such location") { // its other protocols won't connect either
|
|
67
|
+
this.done += this.protocols.length - this.protocols.indexOf(protocol) - 1;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.finished.push(country);
|
|
72
|
+
}
|
|
73
|
+
return this.results;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/time.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Clock helpers shared by the scan and the screens.
|
|
2
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
/** Seconds on a monotonic clock */
|
|
4
|
+
export const now = () => performance.now() / 1000;
|
|
5
|
+
/** "9s", "1m 05s", "1h 01m" */
|
|
6
|
+
export function duration(sec) {
|
|
7
|
+
sec = Math.max(0, Math.round(sec));
|
|
8
|
+
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
9
|
+
return h ? `${h}h ${String(m).padStart(2, "0")}m` : m ? `${m}m ${String(s).padStart(2, "0")}s` : `${s}s`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Country codes for display: "DE" → "🇩🇪 Germany".
|
|
2
|
+
const regionName = new Intl.DisplayNames(["en"], { type: "region" });
|
|
3
|
+
export function countryName(code) {
|
|
4
|
+
try {
|
|
5
|
+
return regionName.of(code) ?? code;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return code; // city names / nicknames aren't region codes
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// two regional-indicator letters render as the country's flag emoji (Windows has no flag glyphs: it would show
|
|
12
|
+
// two boxed letters of unpredictable width and break the table borders, so leave the slot blank there).
|
|
13
|
+
// macOS Terminal draws flags ~half a column wider than the 2 it advances, nudging table borders right: blank there too
|
|
14
|
+
const noFlags = process.platform === "win32" || process.env.TERM_PROGRAM === "Apple_Terminal";
|
|
15
|
+
export const flag = (code) => !noFlags && /^[A-Z]{2}$/.test(code) ? String.fromCodePoint(...[...code].map((c) => 0x1f1a5 + c.charCodeAt(0))) : " ";
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { MIN_TIMEOUT, saveConfig } from "../config.js";
|
|
2
|
+
import { attemptSeconds, estimate } from "../scan.js";
|
|
3
|
+
import { duration } from "../time.js";
|
|
4
|
+
import { checkUpdate, updateNotice } from "../update.js";
|
|
5
|
+
import { COUNTRIES, PROTOCOLS } from "../windscribe.js";
|
|
6
|
+
import { countryName, flag } from "./country.js";
|
|
7
|
+
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
8
|
+
import { bold, cyan, dim, green, under, vw, yellow } from "./text.js";
|
|
9
|
+
import { box, centerIn, grid } from "./widgets.js";
|
|
10
|
+
const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
|
|
11
|
+
/** Resolves to the choice (also saved for next time), or null when the user quits */
|
|
12
|
+
export function pick(saved, { timeout, speedTest, firewall }) {
|
|
13
|
+
const selected = new Set(saved?.countries ?? []);
|
|
14
|
+
const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
|
|
15
|
+
let cursor = 0, top = 0, message = "", query = "", searching = false;
|
|
16
|
+
let seconds = String(timeout), editing = false; // the timeout, as typed
|
|
17
|
+
let hit;
|
|
18
|
+
const visible = () => {
|
|
19
|
+
const q = query.toLowerCase();
|
|
20
|
+
return q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q) || countryName(c).toLowerCase().includes(q)) : COUNTRIES;
|
|
21
|
+
};
|
|
22
|
+
enterScreen();
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const render = () => {
|
|
25
|
+
const rows = out.rows || 24;
|
|
26
|
+
const notice = updateNotice();
|
|
27
|
+
const matches = visible();
|
|
28
|
+
const height = Math.max(3, rows - 21 - (notice ? 1 : 0));
|
|
29
|
+
cursor = Math.max(0, Math.min(cursor, matches.length - 1));
|
|
30
|
+
if (cursor < top)
|
|
31
|
+
top = cursor;
|
|
32
|
+
if (cursor >= top + height)
|
|
33
|
+
top = cursor - height + 1;
|
|
34
|
+
top = Math.max(0, Math.min(top, matches.length - height));
|
|
35
|
+
const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? green("● " + p) : dim("○ " + p)}`).join(" ");
|
|
36
|
+
const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
|
|
37
|
+
? "ranks by download speed · adds ~15s per working connection"
|
|
38
|
+
: "off: connect-only, ranks by latency")}`;
|
|
39
|
+
const fwLabel = "block internet while scanning (firewall)";
|
|
40
|
+
const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(firewall
|
|
41
|
+
? "only VPN traffic gets through"
|
|
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}`)}`;
|
|
46
|
+
const search = searching
|
|
47
|
+
? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
48
|
+
: query ? `🔍 ${query}` : dim("🔍 press / to search");
|
|
49
|
+
const shown = matches.slice(top, top + height);
|
|
50
|
+
const range = matches.length > height ? `${top + 1}–${top + shown.length} of ${matches.length}` : `${matches.length} countries`;
|
|
51
|
+
const body = shown.map((code, j) => {
|
|
52
|
+
const here = top + j === cursor;
|
|
53
|
+
const mark = selected.has(code) ? green("●") : dim("○");
|
|
54
|
+
const name = `${flag(code)} ${countryName(code)}`;
|
|
55
|
+
return [`${here ? cyan("❯") : " "} ${mark}`, here ? bold(code) : dim(code), here ? bold(cyan(name)) : name];
|
|
56
|
+
});
|
|
57
|
+
if (!body.length)
|
|
58
|
+
body.push([" ", "", dim("no country matches")]);
|
|
59
|
+
const buttons = [
|
|
60
|
+
["start", "▶ Start"],
|
|
61
|
+
["all", under("A") + (query ? "ll shown" : "ll")],
|
|
62
|
+
["none", under("N") + (query ? "one shown" : "one")],
|
|
63
|
+
["quit", under("Q") + "uit"],
|
|
64
|
+
];
|
|
65
|
+
const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
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 });
|
|
67
|
+
const width = vw(table[0]);
|
|
68
|
+
const head = box([protoLine, speedLine, fwLine, timeoutLine], {
|
|
69
|
+
title: bold(cyan("WindScout")),
|
|
70
|
+
right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
71
|
+
width: width - 4,
|
|
72
|
+
});
|
|
73
|
+
const n = selected.size * protos.size;
|
|
74
|
+
const lines = [
|
|
75
|
+
...head,
|
|
76
|
+
...table,
|
|
77
|
+
"",
|
|
78
|
+
centerIn(buttonLine, width),
|
|
79
|
+
"",
|
|
80
|
+
centerIn(n
|
|
81
|
+
? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(selected.size, protos.size, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each)`)}`
|
|
82
|
+
: yellow("select at least one country and one protocol"), 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),
|
|
89
|
+
];
|
|
90
|
+
if (message)
|
|
91
|
+
lines.push(centerIn(yellow(message), width));
|
|
92
|
+
lines.push("", centerIn(dim(repoLink), width));
|
|
93
|
+
if (notice)
|
|
94
|
+
lines.push(centerIn(yellow(notice), width));
|
|
95
|
+
const at = draw(lines);
|
|
96
|
+
// click map, in screen coordinates
|
|
97
|
+
let px = at.left + 2; // "│ " before the protocol line
|
|
98
|
+
const protoHits = PROTOCOLS.map((p, i) => {
|
|
99
|
+
const len = vw(`${i + 1} ● ${p}`);
|
|
100
|
+
const range = [px, px + len];
|
|
101
|
+
px += len + 3;
|
|
102
|
+
return range;
|
|
103
|
+
});
|
|
104
|
+
let bx = at.left + Math.floor((width - vw(buttonLine)) / 2);
|
|
105
|
+
const buttonHits = buttons.map(([id, label]) => {
|
|
106
|
+
const len = vw(`[ ${label} ]`);
|
|
107
|
+
const range = [id, bx, bx + len];
|
|
108
|
+
bx += len + 2;
|
|
109
|
+
return range;
|
|
110
|
+
});
|
|
111
|
+
hit = {
|
|
112
|
+
protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3, timeoutRow: at.top + 4,
|
|
113
|
+
searchRow: at.top + head.length + 1,
|
|
114
|
+
firstRow: at.top + head.length + 5, shown: shown.length,
|
|
115
|
+
buttonRow: at.top + head.length + table.length + 1, buttonHits,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
let closed = false;
|
|
119
|
+
checkUpdate().then(() => updateNotice() && !closed && render()); // the check usually lands after the first draw
|
|
120
|
+
const finish = (value) => {
|
|
121
|
+
closed = true;
|
|
122
|
+
out.off("resize", render);
|
|
123
|
+
input.removeAllListeners("keypress");
|
|
124
|
+
input.removeAllListeners("mouse");
|
|
125
|
+
if (!value)
|
|
126
|
+
leaveScreen();
|
|
127
|
+
resolve(value);
|
|
128
|
+
};
|
|
129
|
+
const start = () => {
|
|
130
|
+
if (!selected.size || !protos.size) {
|
|
131
|
+
message = "nothing to scan yet";
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (!(timeout >= MIN_TIMEOUT)) {
|
|
135
|
+
message = `the timeout must be at least ${MIN_TIMEOUT} seconds`;
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// keep the user's order of preference stable: list order
|
|
139
|
+
const choice = {
|
|
140
|
+
countries: COUNTRIES.filter((c) => selected.has(c)),
|
|
141
|
+
protocols: PROTOCOLS.filter((p) => protos.has(p)),
|
|
142
|
+
speed: speedTest,
|
|
143
|
+
firewall,
|
|
144
|
+
timeout,
|
|
145
|
+
};
|
|
146
|
+
saveConfig(choice);
|
|
147
|
+
finish(choice);
|
|
148
|
+
return true;
|
|
149
|
+
};
|
|
150
|
+
input.on("mouse", (m) => {
|
|
151
|
+
if (!m.press)
|
|
152
|
+
return;
|
|
153
|
+
message = "";
|
|
154
|
+
const matches = visible();
|
|
155
|
+
const { x, y } = m;
|
|
156
|
+
if (m.button === 64)
|
|
157
|
+
cursor = Math.max(0, cursor - 3); // wheel up
|
|
158
|
+
else if (m.button === 65)
|
|
159
|
+
cursor = Math.min(matches.length - 1, cursor + 3); // wheel down
|
|
160
|
+
else if (m.button !== 0)
|
|
161
|
+
return; // left button only
|
|
162
|
+
else if (y === hit.searchRow)
|
|
163
|
+
searching = true, editing = false;
|
|
164
|
+
else if (y === hit.timeoutRow)
|
|
165
|
+
editing = true, searching = false;
|
|
166
|
+
else {
|
|
167
|
+
searching = editing = false; // clicking anywhere else ends typing, like leaving a text field
|
|
168
|
+
if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
|
|
169
|
+
cursor = top + (y - hit.firstRow);
|
|
170
|
+
const c = matches[cursor];
|
|
171
|
+
if (c)
|
|
172
|
+
flip(selected, c);
|
|
173
|
+
}
|
|
174
|
+
else if (y === hit.protoRow) {
|
|
175
|
+
const i = hit.protoHits.findIndex(([a, b]) => x >= a && x < b);
|
|
176
|
+
if (i >= 0)
|
|
177
|
+
flip(protos, PROTOCOLS[i]);
|
|
178
|
+
}
|
|
179
|
+
else if (y === hit.speedRow)
|
|
180
|
+
speedTest = !speedTest;
|
|
181
|
+
else if (y === hit.firewallRow)
|
|
182
|
+
firewall = !firewall;
|
|
183
|
+
else if (y === hit.buttonRow) {
|
|
184
|
+
const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
|
|
185
|
+
if (id === "start" && start())
|
|
186
|
+
return;
|
|
187
|
+
if (id === "quit")
|
|
188
|
+
return finish(null);
|
|
189
|
+
if (id === "all")
|
|
190
|
+
matches.forEach((c) => selected.add(c));
|
|
191
|
+
if (id === "none")
|
|
192
|
+
matches.forEach((c) => selected.delete(c));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
render();
|
|
196
|
+
});
|
|
197
|
+
out.on("resize", render);
|
|
198
|
+
input.on("keypress", (str, key = {}) => {
|
|
199
|
+
message = "";
|
|
200
|
+
const k = key.name;
|
|
201
|
+
const matches = visible();
|
|
202
|
+
const toggle = () => {
|
|
203
|
+
const c = matches[cursor];
|
|
204
|
+
if (!c)
|
|
205
|
+
return;
|
|
206
|
+
flip(selected, c);
|
|
207
|
+
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
208
|
+
};
|
|
209
|
+
if (key.ctrl && k === "c")
|
|
210
|
+
return finish(null);
|
|
211
|
+
if (k === "up")
|
|
212
|
+
cursor = Math.max(0, cursor - 1);
|
|
213
|
+
else if (k === "down")
|
|
214
|
+
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
215
|
+
else if (k === "pageup")
|
|
216
|
+
cursor = Math.max(0, cursor - 10);
|
|
217
|
+
else if (k === "pagedown")
|
|
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
|
+
}
|
|
227
|
+
else if (searching) {
|
|
228
|
+
// typing goes into the search box; letters are not shortcuts here
|
|
229
|
+
if (k === "return" || k === "escape")
|
|
230
|
+
searching = false;
|
|
231
|
+
else if (k === "backspace")
|
|
232
|
+
query = query.slice(0, -1), cursor = 0;
|
|
233
|
+
else if (k === "space" && !query)
|
|
234
|
+
toggle();
|
|
235
|
+
else if (str && !key.ctrl && !key.meta && str >= " ")
|
|
236
|
+
query += str, cursor = 0;
|
|
237
|
+
}
|
|
238
|
+
else if (k === "home")
|
|
239
|
+
cursor = 0;
|
|
240
|
+
else if (k === "end")
|
|
241
|
+
cursor = matches.length - 1;
|
|
242
|
+
else if (k === "k")
|
|
243
|
+
cursor = Math.max(0, cursor - 1);
|
|
244
|
+
else if (k === "j")
|
|
245
|
+
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
246
|
+
else if (str === "/")
|
|
247
|
+
searching = true;
|
|
248
|
+
else if (k === "space")
|
|
249
|
+
toggle();
|
|
250
|
+
else if (k === "a")
|
|
251
|
+
matches.forEach((c) => selected.add(c));
|
|
252
|
+
else if (k === "n")
|
|
253
|
+
matches.forEach((c) => selected.delete(c));
|
|
254
|
+
else if (k === "s")
|
|
255
|
+
speedTest = !speedTest;
|
|
256
|
+
else if (k === "f")
|
|
257
|
+
firewall = !firewall;
|
|
258
|
+
else if (k === "t")
|
|
259
|
+
editing = true, seconds = "";
|
|
260
|
+
else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
|
|
261
|
+
flip(protos, PROTOCOLS[Number(str) - 1]);
|
|
262
|
+
else if (k === "escape" && query)
|
|
263
|
+
query = "", cursor = 0;
|
|
264
|
+
else if (k === "return") {
|
|
265
|
+
if (start())
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
else if (k === "q" || k === "escape")
|
|
269
|
+
return finish(null);
|
|
270
|
+
render();
|
|
271
|
+
});
|
|
272
|
+
render();
|
|
273
|
+
});
|
|
274
|
+
}
|