windscout 0.4.6 → 0.5.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/README.md CHANGED
@@ -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,106 @@
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, loadConfig } 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 (default: 20)
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", default: "20" },
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
+ const timeout = Number(a.timeout);
59
+ if (!(timeout > 0))
60
+ throw new Error("--timeout must be a positive number");
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,
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
+ }
86
+ protocols ??= PROTOCOLS;
87
+ const scan = new Scan(countries, protocols, { timeout, speedTest });
88
+ const blocking = firewall && (await blockInternet());
89
+ const view = tty ? showProgress(scan, blocking) : logProgress(scan);
90
+ await scan.run(view);
91
+ await restoreFirewall();
92
+ view.close();
93
+ if (scan.stopping)
94
+ console.log(yellow("interrupted, showing partial results"));
95
+ report(scan.results, protocols, { all: a.all, speedTest });
96
+ await checkUpdate();
97
+ const notice = updateNotice();
98
+ if (notice)
99
+ console.error("\n" + yellow(notice));
100
+ }
101
+ main().catch(async (e) => {
102
+ leaveScreen();
103
+ console.error(`windscout: ${e.message}`);
104
+ await restoreFirewall().catch(() => console.error(`the firewall may still be on: run '${CLI} firewall off'`));
105
+ process.exit(1);
106
+ });
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
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 CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
7
+ /** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
8
+ export function loadConfig() {
9
+ try {
10
+ const c = JSON.parse(readFileSync(CONFIG, "utf8"));
11
+ return {
12
+ countries: (c.countries ?? []).filter((x) => COUNTRIES.includes(x)),
13
+ protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
14
+ speed: c.speed !== false,
15
+ firewall: c.firewall === true,
16
+ };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function saveConfig(c) {
23
+ try {
24
+ mkdirSync(dirname(CONFIG), { recursive: true });
25
+ writeFileSync(CONFIG, JSON.stringify(c, null, 2) + "\n");
26
+ }
27
+ catch { } // not being able to remember isn't worth failing the scan
28
+ }
@@ -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,251 @@
1
+ import { 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 hit;
17
+ const visible = () => {
18
+ const q = query.toLowerCase();
19
+ return q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q) || countryName(c).toLowerCase().includes(q)) : COUNTRIES;
20
+ };
21
+ enterScreen();
22
+ return new Promise((resolve) => {
23
+ const render = () => {
24
+ const rows = out.rows || 24;
25
+ const notice = updateNotice();
26
+ const matches = visible();
27
+ const height = Math.max(3, rows - 20 - (notice ? 1 : 0));
28
+ cursor = Math.max(0, Math.min(cursor, matches.length - 1));
29
+ if (cursor < top)
30
+ top = cursor;
31
+ if (cursor >= top + height)
32
+ top = cursor - height + 1;
33
+ top = Math.max(0, Math.min(top, matches.length - height));
34
+ const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? green("● " + p) : dim("○ " + p)}`).join(" ");
35
+ const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
36
+ ? "ranks by download speed · adds ~15s per working connection"
37
+ : "off: connect-only, ranks by latency")}`;
38
+ const fwLabel = "block internet while scanning (firewall)";
39
+ const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(firewall
40
+ ? "only VPN traffic gets through"
41
+ : "off: apps may leak in between")}`;
42
+ const search = searching
43
+ ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
44
+ : query ? `🔍 ${query}` : dim("🔍 press / to search");
45
+ const shown = matches.slice(top, top + height);
46
+ const range = matches.length > height ? `${top + 1}–${top + shown.length} of ${matches.length}` : `${matches.length} countries`;
47
+ const body = shown.map((code, j) => {
48
+ const here = top + j === cursor;
49
+ const mark = selected.has(code) ? green("●") : dim("○");
50
+ const name = `${flag(code)} ${countryName(code)}`;
51
+ return [`${here ? cyan("❯") : " "} ${mark}`, here ? bold(code) : dim(code), here ? bold(cyan(name)) : name];
52
+ });
53
+ if (!body.length)
54
+ body.push([" ", "", dim("no country matches")]);
55
+ const buttons = [
56
+ ["start", "▶ Start"],
57
+ ["all", under("A") + (query ? "ll shown" : "ll")],
58
+ ["none", under("N") + (query ? "one shown" : "one")],
59
+ ["quit", under("Q") + "uit"],
60
+ ];
61
+ 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 });
63
+ const width = vw(table[0]);
64
+ const head = box([protoLine, speedLine, fwLine], {
65
+ title: bold(cyan("WindScout")),
66
+ right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
67
+ width: width - 4,
68
+ });
69
+ const n = selected.size * protos.size;
70
+ const lines = [
71
+ ...head,
72
+ ...table,
73
+ "",
74
+ centerIn(buttonLine, width),
75
+ "",
76
+ centerIn(n
77
+ ? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(selected.size, protos.size, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each)`)}`
78
+ : 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
+ ];
84
+ if (message)
85
+ lines.push(centerIn(yellow(message), width));
86
+ lines.push("", centerIn(dim(repoLink), width));
87
+ if (notice)
88
+ lines.push(centerIn(yellow(notice), width));
89
+ const at = draw(lines);
90
+ // click map, in screen coordinates
91
+ let px = at.left + 2; // "│ " before the protocol line
92
+ const protoHits = PROTOCOLS.map((p, i) => {
93
+ const len = vw(`${i + 1} ● ${p}`);
94
+ const range = [px, px + len];
95
+ px += len + 3;
96
+ return range;
97
+ });
98
+ let bx = at.left + Math.floor((width - vw(buttonLine)) / 2);
99
+ const buttonHits = buttons.map(([id, label]) => {
100
+ const len = vw(`[ ${label} ]`);
101
+ const range = [id, bx, bx + len];
102
+ bx += len + 2;
103
+ return range;
104
+ });
105
+ hit = {
106
+ protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3,
107
+ searchRow: at.top + head.length + 1,
108
+ firstRow: at.top + head.length + 5, shown: shown.length,
109
+ buttonRow: at.top + head.length + table.length + 1, buttonHits,
110
+ };
111
+ };
112
+ let closed = false;
113
+ checkUpdate().then(() => updateNotice() && !closed && render()); // the check usually lands after the first draw
114
+ const finish = (value) => {
115
+ closed = true;
116
+ out.off("resize", render);
117
+ input.removeAllListeners("keypress");
118
+ input.removeAllListeners("mouse");
119
+ if (!value)
120
+ leaveScreen();
121
+ resolve(value);
122
+ };
123
+ const start = () => {
124
+ if (!selected.size || !protos.size) {
125
+ message = "nothing to scan yet";
126
+ return false;
127
+ }
128
+ // keep the user's order of preference stable: list order
129
+ const choice = {
130
+ countries: COUNTRIES.filter((c) => selected.has(c)),
131
+ protocols: PROTOCOLS.filter((p) => protos.has(p)),
132
+ speed: speedTest,
133
+ firewall,
134
+ };
135
+ saveConfig(choice);
136
+ finish(choice);
137
+ return true;
138
+ };
139
+ input.on("mouse", (m) => {
140
+ if (!m.press)
141
+ return;
142
+ message = "";
143
+ const matches = visible();
144
+ const { x, y } = m;
145
+ if (m.button === 64)
146
+ cursor = Math.max(0, cursor - 3); // wheel up
147
+ else if (m.button === 65)
148
+ cursor = Math.min(matches.length - 1, cursor + 3); // wheel down
149
+ else if (m.button !== 0)
150
+ return; // left button only
151
+ else if (y === hit.searchRow)
152
+ searching = true;
153
+ else {
154
+ searching = false; // clicking anywhere else ends typing, like leaving a text field
155
+ if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
156
+ cursor = top + (y - hit.firstRow);
157
+ const c = matches[cursor];
158
+ if (c)
159
+ flip(selected, c);
160
+ }
161
+ else if (y === hit.protoRow) {
162
+ const i = hit.protoHits.findIndex(([a, b]) => x >= a && x < b);
163
+ if (i >= 0)
164
+ flip(protos, PROTOCOLS[i]);
165
+ }
166
+ else if (y === hit.speedRow)
167
+ speedTest = !speedTest;
168
+ else if (y === hit.firewallRow)
169
+ firewall = !firewall;
170
+ else if (y === hit.buttonRow) {
171
+ const id = hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0];
172
+ if (id === "start" && start())
173
+ return;
174
+ if (id === "quit")
175
+ return finish(null);
176
+ if (id === "all")
177
+ matches.forEach((c) => selected.add(c));
178
+ if (id === "none")
179
+ matches.forEach((c) => selected.delete(c));
180
+ }
181
+ }
182
+ render();
183
+ });
184
+ out.on("resize", render);
185
+ input.on("keypress", (str, key = {}) => {
186
+ message = "";
187
+ const k = key.name;
188
+ const matches = visible();
189
+ const toggle = () => {
190
+ const c = matches[cursor];
191
+ if (!c)
192
+ return;
193
+ flip(selected, c);
194
+ cursor = Math.min(matches.length - 1, cursor + 1);
195
+ };
196
+ if (key.ctrl && k === "c")
197
+ return finish(null);
198
+ if (k === "up")
199
+ cursor = Math.max(0, cursor - 1);
200
+ else if (k === "down")
201
+ cursor = Math.min(matches.length - 1, cursor + 1);
202
+ else if (k === "pageup")
203
+ cursor = Math.max(0, cursor - 10);
204
+ else if (k === "pagedown")
205
+ cursor = Math.min(matches.length - 1, cursor + 10);
206
+ else if (searching) {
207
+ // typing goes into the search box; letters are not shortcuts here
208
+ if (k === "return" || k === "escape")
209
+ searching = false;
210
+ else if (k === "backspace")
211
+ query = query.slice(0, -1), cursor = 0;
212
+ else if (k === "space" && !query)
213
+ toggle();
214
+ else if (str && !key.ctrl && !key.meta && str >= " ")
215
+ query += str, cursor = 0;
216
+ }
217
+ else if (k === "home")
218
+ cursor = 0;
219
+ else if (k === "end")
220
+ cursor = matches.length - 1;
221
+ else if (k === "k")
222
+ cursor = Math.max(0, cursor - 1);
223
+ else if (k === "j")
224
+ cursor = Math.min(matches.length - 1, cursor + 1);
225
+ else if (str === "/")
226
+ searching = true;
227
+ else if (k === "space")
228
+ toggle();
229
+ else if (k === "a")
230
+ matches.forEach((c) => selected.add(c));
231
+ else if (k === "n")
232
+ matches.forEach((c) => selected.delete(c));
233
+ else if (k === "s")
234
+ speedTest = !speedTest;
235
+ else if (k === "f")
236
+ firewall = !firewall;
237
+ else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
238
+ flip(protos, PROTOCOLS[Number(str) - 1]);
239
+ else if (k === "escape" && query)
240
+ query = "", cursor = 0;
241
+ else if (k === "return") {
242
+ if (start())
243
+ return;
244
+ }
245
+ else if (k === "q" || k === "escape")
246
+ return finish(null);
247
+ render();
248
+ });
249
+ render();
250
+ });
251
+ }