windscout 0.6.5 → 0.7.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 +93 -4
- package/dist/cli.js +97 -34
- package/dist/config.js +29 -2
- package/dist/docker.js +2 -2
- package/dist/measure.js +1 -1
- package/dist/probe.js +83 -45
- package/dist/scan.js +36 -7
- package/dist/tui/docker.js +2 -2
- package/dist/tui/picker.js +118 -53
- package/dist/tui/progress.js +14 -8
- package/dist/tui/tables.js +1 -1
- package/dist/tui/terminal.js +21 -0
- package/dist/tui/widgets.js +2 -0
- package/dist/worker.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,57 @@ npm install -g windscout # or run once with: npx windscout
|
|
|
23
23
|
|
|
24
24
|
On startup, WindScout checks npm for a newer version. Above the repository link at the bottom of the screen, it shows the installed version in grey (`windscout 0.5.6 · latest` once npm confirms nothing newer is out), or, when a newer one is out, an update notice in yellow. In the picker, press `u` to update and restart on the new version. The update runs `npm install -g windscout@latest`, with `sudo` when your global npm folder belongs to root (sudo then asks for your password). The update command is also printed after the final report.
|
|
25
25
|
|
|
26
|
+
### In a sandbox (Docker)
|
|
27
|
+
|
|
28
|
+
To run WindScout without installing it or Node on your computer, run it in a container. The container has its own Windscribe app (Windscribe's Linux CLI app) and, for [parallel scans](#parallel-scan-with-docker), its own docker daemon inside it (Docker-in-Docker). You need Docker: on Linux, docker itself; on macOS and Windows, [Docker Desktop](https://docs.docker.com/desktop/) (on Windows with its WSL 2 engine, set to Linux containers). Build the image once, the same way on every system. Docker fetches this repository from GitHub itself, so there's nothing to clone (add `#` and a branch, tag or commit to the URL to build that one, as in `windscout.git#main`; in a clone, use `.` in place of the URL to build your copy):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
docker build -t windscout-sandbox -f sandbox/Dockerfile https://github.com/Diercohen/windscout.git
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then run it with the command for your system, below. If the Windscribe app on your computer is logged in, the container's app logs in with that app's session, as the [parallel scan's containers](#parallel-scan-with-docker) do: the command hands it to the container in a file mounted read-only, and the container copies only the session (`userId` and `authHash`) into its own app's settings. Without the app, or when its session doesn't work, the container asks for your Windscribe username and password (and 2FA code, if you use one), and the `windscout-home` volume keeps that login for the next runs. Then the picker opens as usual. Options after the image name go to WindScout: `windscout-sandbox -c "DE,NL" -d`. The `windscout-docker` volume keeps the inner docker's `windscout-worker` image and its containers' volumes, so only the first parallel scan builds them.
|
|
35
|
+
|
|
36
|
+
**Linux:** the app's settings file is mounted as it is (read-only, never changed). Without the Windscribe app, leave out the `Windscribe2.conf` line: otherwise docker creates an empty folder in its place.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
docker run -it --rm --privileged \
|
|
40
|
+
-v ~/.config/Windscribe/Windscribe2.conf:/host/Windscribe2.conf:ro \
|
|
41
|
+
-v windscout-home:/home/ws -v windscout-docker:/var/lib/docker \
|
|
42
|
+
windscout-sandbox
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**macOS** (Terminal): the app keeps its session in its defaults, so the first line writes it to a private temporary file, which the last line deletes after the run.
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
s=$(mktemp) && printf '[General]\nuserId=%s\nauthHash=%s\n' "$(defaults read com.windscribe.Windscribe2 userId)" "$(defaults read com.windscribe.Windscribe2 authHash)" > "$s"
|
|
49
|
+
docker run -it --rm --privileged \
|
|
50
|
+
-v "$s:/host/Windscribe2.conf:ro" \
|
|
51
|
+
-v windscout-home:/home/ws -v windscout-docker:/var/lib/docker \
|
|
52
|
+
windscout-sandbox
|
|
53
|
+
rm -f "$s"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Windows** (PowerShell): the app keeps its session in the registry, so the first lines write it to a private temporary file, which the last line deletes after the run.
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
$k = Get-ItemProperty HKCU:\Software\Windscribe\Windscribe2
|
|
60
|
+
$s = New-TemporaryFile
|
|
61
|
+
Set-Content $s "[General]`nuserId=$($k.userId)`nauthHash=$($k.authHash)"
|
|
62
|
+
docker run -it --rm --privileged `
|
|
63
|
+
-v "$($s.FullName):/host/Windscribe2.conf:ro" `
|
|
64
|
+
-v windscout-home:/home/ws -v windscout-docker:/var/lib/docker `
|
|
65
|
+
windscout-sandbox
|
|
66
|
+
Remove-Item $s
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Without the Windscribe app on macOS or Windows, run only the `docker run` command, without its `Windscribe2.conf` line.
|
|
70
|
+
|
|
71
|
+
- `--privileged` is needed: the inner docker, and the Windscribe app's tunnel and firewall, need it. It gives the container broad control of docker's Linux kernel (on Linux, your computer's), so this isolates files and installs, not security. VPN connections stay inside the container: your computer's own connection doesn't change.
|
|
72
|
+
- Docker Desktop's own Linux VM (macOS, Windows without WSL 2) can't run Windscribe's WireGuard (see the kernel check under [parallel scans](#parallel-scan-with-docker)), and in the sandbox that goes for its own app too. Windscribe's Linux app has no IKEv2, so the sandbox doesn't try it.
|
|
73
|
+
- Where Docker Hub refuses (Iran), take the base image from a mirror: `docker build --build-arg BASE=registry.docker.ir/library/node:22-bookworm-slim -t windscout-sandbox -f sandbox/Dockerfile https://github.com/Diercohen/windscout.git`
|
|
74
|
+
- Updating from inside (`u`) doesn't last, since each run starts fresh from the image: run the build command again instead, for the latest WindScout; add `--no-cache` to get the latest Windscribe app too.
|
|
75
|
+
- To remove everything: `docker rmi windscout-sandbox` and `docker volume rm windscout-home windscout-docker`.
|
|
76
|
+
|
|
26
77
|
## Usage
|
|
27
78
|
|
|
28
79
|
Run `windscout` with no arguments to open the interactive picker:
|
|
@@ -54,17 +105,18 @@ Run `windscout` with no arguments to open the interactive picker:
|
|
|
54
105
|
| `p` | toggle [all ports](#all-ports): each protocol on every port it has, not just its default |
|
|
55
106
|
| `s` | toggle the speed test (off = connect-only mode) |
|
|
56
107
|
| `f` | toggle "block internet while scanning" (firewall) |
|
|
57
|
-
| `r` | toggle random order: scan the selected countries shuffled, a new order each run |
|
|
58
108
|
| `d` | toggle the [parallel scan in docker containers](#parallel-scan-with-docker) (installs or starts docker first, after asking) |
|
|
109
|
+
| `o` | change the [scanning type](#scanning-type): default, by random country, by last success result first, only the last results |
|
|
110
|
+
| `w` | toggle [stop at the first working connection](#stop-at-the-first-working-connection) that holds up |
|
|
59
111
|
| `u` | update to the newer version and restart (only when the picker shows one is out) |
|
|
60
112
|
| `enter` | start scanning |
|
|
61
113
|
| `q` | quit (during a scan: stop and show partial results; press again to force quit) |
|
|
62
114
|
|
|
63
|
-
**Mouse:** click a country to select or unselect it, click a protocol or
|
|
115
|
+
**Mouse:** click a country to select or unselect it, click a protocol or a setting's line to toggle it (the scanning type's line changes the type), 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; the shortcut key is underlined on screen (on the buttons, the protocol numbers and `s`). This needs a terminal with mouse support, which most have. On Windows, use Windows Terminal; if clicks do nothing there, the keyboard does everything.
|
|
64
116
|
|
|
65
117
|
Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
|
|
66
118
|
|
|
67
|
-
While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting, measuring latency or measuring speed). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command. At the prompt, press enter to connect to the best one, type another one's number to connect to that, or `q` to quit.
|
|
119
|
+
After you start, the Start button spins (`[ ⠋ Starting… ]`) until the scan's screen shows, which can take a moment when it turns the firewall on or starts docker. While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting, measuring latency or measuring speed). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command. At the prompt, press enter to connect to the best one, type another one's number to connect to that, or `q` to quit.
|
|
68
120
|
|
|
69
121
|
### Non-interactive
|
|
70
122
|
|
|
@@ -86,10 +138,47 @@ windscout -t 40 -a # longer connect timeout, also list
|
|
|
86
138
|
| `-a, --all` | include failed attempts in the final table | off |
|
|
87
139
|
| `-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 |
|
|
88
140
|
| `-f, --firewall` | block internet while scanning, except through the VPN | picker: saved, or off; with `-c`: off |
|
|
89
|
-
| `-
|
|
141
|
+
| `-o, --order` | [scanning type](#scanning-type): `default`, `random`, `last-first` or `last-only` (`-r` still works: the same as `-o random`) | picker: saved, or `default`; with `-c`: `default` |
|
|
142
|
+
| `-w, --stop-at-first` | [stop at the first working connection](#stop-at-the-first-working-connection) that holds up | picker: saved, or off; with `-c`: off |
|
|
90
143
|
| `-d, --docker` | [scan in parallel, in docker containers](#parallel-scan-with-docker) | picker: saved, or off; with `-c`: off |
|
|
91
144
|
| `-v, --version` | print the installed version; if a newer one is on npm, show the update command (with `sudo` when needed) and ask (y/n) to run it | |
|
|
92
145
|
|
|
146
|
+
## Last result: rescan and connect
|
|
147
|
+
|
|
148
|
+
After each scan, WindScout saves its working connections, best first, to `~/.config/windscout/last.json`. That includes a partial scan you stopped, and a scan started with `-c`. A scan's working connections replace the saved ones it tried, so the ones that stopped working drop out. The saved connections it didn't try stay, after its own: those of countries it didn't scan, and what a stopped scan didn't get to. A scan where nothing worked keeps the previous result as it is, since the network may just be down.
|
|
149
|
+
|
|
150
|
+
- **Rescan:** the [scanning types](#scanning-type) *by last success result first* and *only the last results* use the saved connections: the first tries the ones in your selection before the rest of it, the second tries only them.
|
|
151
|
+
- **Connect:** `windscout connect` connects to the best saved connection, without a scan. It checks each as the scan does: when one doesn't connect within the timeout (`-t`, or the saved one), or has no internet through it, it tries the next, until one connects. When none does, or there's no saved scan, it says so and suggests a rescan or a new scan.
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
windscout connect # the best saved connection that still works
|
|
155
|
+
windscout connect -t 30 # wait longer for each
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Scanning type
|
|
159
|
+
|
|
160
|
+
Press `o` in the picker, or pass `-o`, to choose what a scan tries and in which order. The picker's line shows the type, and the estimate under the table counts what that type scans. The type is remembered with your other settings.
|
|
161
|
+
|
|
162
|
+
| Type | `-o` | What it scans |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| default | `default` | the selected countries × protocols, the countries in list order |
|
|
165
|
+
| by random country | `random` | the same, with the countries shuffled, a new order each run |
|
|
166
|
+
| by last success result first | `last-first` | the same, but the [last result](#last-result-rescan-and-connect)'s working connections among them go first, best first and on their own port, then the rest. With one country selected, only that country is scanned, its saved connections first. The line shows how many saved connections are in your selection (`1 of 7 saved selected · 3h ago`) |
|
|
167
|
+
| only the last results | `last-only` | only the last result's working connections, each on its own protocol and port. The countries, protocols and all ports are skipped, so the picker greys them out and locks them (your selection stays for next time). With `-o last-only`, `-c` isn't needed |
|
|
168
|
+
|
|
169
|
+
The two types by the last result need a saved scan. Until there is one, choosing either turns `[ ▶ Start ]` off (greyed out), and a warning takes the estimate's place: `no saved scan yet, so start is off: press o for another scanning type`. Without the picker, `-o last-first` and `-o last-only` stop with an error instead. The estimate counts each saved connection once: `tcp` and `tcp:443` are the same attempt when 443 is tcp's default port.
|
|
170
|
+
|
|
171
|
+
### Stop at the first working connection
|
|
172
|
+
|
|
173
|
+
Press `w` in the picker, or pass `-w`, to end the scan at the first connection that works and holds up: the attempts still connecting give up, and no more start. The scan stops at one connection, so that one has to be good for long use. A tunnel can connect and then be cut or throttled once traffic flows, so each connection that connects is checked first:
|
|
174
|
+
|
|
175
|
+
1. A speed test, even with the speed test off (`s`, `-n`). It must reach at least 1 Mbps.
|
|
176
|
+
2. Then it has to stay connected, with internet through it, for 5 round trips 2 s apart (about 10 s), each round trip allowed one retry.
|
|
177
|
+
|
|
178
|
+
A connection that drops during the check gets the status `unreliable` and doesn't count as working: it isn't saved, or offered to connect to. One that stays up but is slower than 1 Mbps still counts as working, but the scan doesn't stop there and keeps looking. If nothing passes, the scan runs to the end, and the report shows whatever worked.
|
|
179
|
+
|
|
180
|
+
The check adds up to about 30 s to each connection that connects (the speed test alone takes up to 15 s). The estimate reads `up to N attempts · at most ≈ …`, for when none holds up. With a type by the last result, the best saved connection goes first, and the line shows about how long the scan takes if it still holds up (`≈ 33s if DE stealth still holds up`). It's the quickest way back online: *only the last results* or *by last success result first*, with `w`.
|
|
181
|
+
|
|
93
182
|
## Connect-only mode
|
|
94
183
|
|
|
95
184
|
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. Connect-only is the default on the first run, and the choice is remembered.
|
package/dist/cli.js
CHANGED
|
@@ -5,24 +5,27 @@
|
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import { CONFIG, DEFAULT_TIMEOUT, forgetLogin, loadConfig, loadDockerWireGuard, MIN_TIMEOUT, saveDockerWireGuard, SPEED_TEST } from "./config.js";
|
|
8
|
+
import { CONFIG, DEFAULT_TIMEOUT, forgetLogin, LAST_FILE, loadConfig, loadDockerWireGuard, loadLast, MIN_TIMEOUT, saveDockerWireGuard, saveLast, SPEED_TEST } from "./config.js";
|
|
9
9
|
import { notInContainers, stopWorkers } from "./docker.js";
|
|
10
|
-
import { probe } from "./probe.js";
|
|
11
|
-
import { Scan } from "./scan.js";
|
|
10
|
+
import { attempt, disconnect, MIN_MBPS, probe } from "./probe.js";
|
|
11
|
+
import { ORDERS, plan, Scan } from "./scan.js";
|
|
12
12
|
import { startParallel } from "./tui/docker.js";
|
|
13
13
|
import { pick } from "./tui/picker.js";
|
|
14
14
|
import { logProgress, showProgress } from "./tui/progress.js";
|
|
15
15
|
import { command, report, target } from "./tui/report.js";
|
|
16
16
|
import { leaveScreen, out } from "./tui/terminal.js";
|
|
17
17
|
import { cyan, green, yellow } from "./tui/text.js";
|
|
18
|
-
import {
|
|
18
|
+
import { duration, now } from "./time.js";
|
|
19
19
|
import { canUpdate, checkUpdate, runUpdate, updateNotice, versionLines } from "./update.js";
|
|
20
|
-
import { blockInternet, CLI,
|
|
20
|
+
import { blockInternet, CLI, isFirewallOn, protocolPorts, PROTOCOLS, restoreFirewall, waitForLogin, windscribe, withPorts } from "./windscribe.js";
|
|
21
21
|
import { worker } from "./worker.js";
|
|
22
22
|
const HELP = `Usage: windscout [options]
|
|
23
|
+
windscout connect [-t SEC]
|
|
23
24
|
|
|
24
25
|
Find which Windscribe locations and protocols work from your network, ranked by ${SPEED_TEST ? "speed" : "latency"}.
|
|
25
26
|
Without -c it opens an interactive country picker (your selection is remembered).
|
|
27
|
+
Each scan's working connections are saved: the scanning type (-o, or o in the picker) can try
|
|
28
|
+
them first or only them, and windscout connect connects to the best of them that still works.
|
|
26
29
|
|
|
27
30
|
Options:
|
|
28
31
|
-c, --countries LIST comma-separated ISO codes, city names or nicknames (skips the picker)
|
|
@@ -31,14 +34,19 @@ Options:
|
|
|
31
34
|
-t, --timeout SEC seconds to wait for each connection, at least ${MIN_TIMEOUT} (default: saved, or ${DEFAULT_TIMEOUT})
|
|
32
35
|
-a, --all also show failed attempts in the final table
|
|
33
36
|
${SPEED_TEST ? " -n, --no-speed connect-only: skip the download test, rank by latency (faster scan)\n" : ""} -f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
|
|
34
|
-
-
|
|
37
|
+
-o, --order TYPE scanning type: default (list order), random (countries shuffled), last-first (the last
|
|
38
|
+
result's working connections among them first, then the rest) or last-only (only those,
|
|
39
|
+
whatever -c and -p say; needs no -c)
|
|
40
|
+
-w, --stop-at-first stop at the first working connection that holds up: a speed test (even with -n) of at least
|
|
41
|
+
${MIN_MBPS} Mbps, then about 10 more seconds connected, with internet through it
|
|
35
42
|
-d, --docker scan in parallel, in docker containers (one per attempt, up to 16, as docker's CPUs and memory allow)
|
|
36
43
|
-v, --version show the installed version (and offer to update if a newer one is out)
|
|
37
44
|
-h, --help show this help
|
|
38
45
|
|
|
39
|
-
Selection is stored in ${CONFIG}
|
|
46
|
+
Selection is stored in ${CONFIG}, the last result in ${LAST_FILE}
|
|
40
47
|
Examples: windscout -c DE,NL,TR -p stealth,tcp:443
|
|
41
|
-
windscout -c DE -p stealth,tcp -P
|
|
48
|
+
windscout -c DE -p stealth,tcp -P
|
|
49
|
+
windscout connect`;
|
|
42
50
|
// "DE, NL,,TR" → ["DE", "NL", "TR"]
|
|
43
51
|
const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
44
52
|
const ask = (question) => new Promise((resolve) => {
|
|
@@ -47,6 +55,17 @@ const ask = (question) => new Promise((resolve) => {
|
|
|
47
55
|
rl.on("close", () => resolve(null));
|
|
48
56
|
rl.question(question, (a) => (resolve(a.trim()), rl.close()));
|
|
49
57
|
});
|
|
58
|
+
/** Runs a result's connect command and checks it as the scan does: connected, on its port, with internet through it.
|
|
59
|
+
* True once it is; otherwise disconnects (and waits until the app has), so the next connect isn't swallowed */
|
|
60
|
+
async function connectTo(r, timeout) {
|
|
61
|
+
console.log(cyan(command(r)));
|
|
62
|
+
const t = now(), { status, location } = await attempt(target(r), r.protocol, { timeout, speedTest: false });
|
|
63
|
+
if (status === "ok")
|
|
64
|
+
return console.log(green(`Connected: ${location}`)), true;
|
|
65
|
+
console.log(yellow(`${status} after ${duration(now() - t)}`));
|
|
66
|
+
await disconnect();
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
50
69
|
/** Asks which numbered connection to make (enter: the best), then runs its command and waits for it to connect */
|
|
51
70
|
async function offerConnect(choices, timeout) {
|
|
52
71
|
const others = choices.length > 2 ? `, 1-${choices.length - 1} = other` : choices.length > 1 ? ", 1 = other" : "";
|
|
@@ -59,15 +78,29 @@ async function offerConnect(choices, timeout) {
|
|
|
59
78
|
console.log(yellow(`no #${a} in the tables`));
|
|
60
79
|
continue;
|
|
61
80
|
}
|
|
62
|
-
|
|
63
|
-
await windscribe("connect", target(r), r.protocol);
|
|
64
|
-
let state = "";
|
|
65
|
-
for (const t = now(); now() - t < timeout && !(state = await connectState()).startsWith("Connected");)
|
|
66
|
-
await sleep(1000);
|
|
67
|
-
console.log(state.startsWith("Connected") ? green(state) : yellow(`${state || "not connected"} after ${timeout} s`));
|
|
81
|
+
await connectTo(r, timeout);
|
|
68
82
|
return;
|
|
69
83
|
}
|
|
70
84
|
}
|
|
85
|
+
const RESCAN_HINT = "rescan them with windscout -o last-only, or start a new scan";
|
|
86
|
+
/** windscout connect: the last result's connections, best first, until one connects */
|
|
87
|
+
async function connectLast(timeout) {
|
|
88
|
+
const last = loadLast();
|
|
89
|
+
if (!last) {
|
|
90
|
+
console.log(yellow("no saved scan yet: run windscout to scan first"));
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await waitForLogin();
|
|
95
|
+
await disconnect(); // a connection already up would read as the first one connecting
|
|
96
|
+
for (const [i, r] of last.results.entries()) {
|
|
97
|
+
console.log(`\n${i + 1}/${last.results.length} ${r.location || r.query} over ${r.protocol}`);
|
|
98
|
+
if (await connectTo(r, timeout))
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
console.log(yellow(`\nnone of the last result's ${last.results.length} connection${last.results.length === 1 ? "" : "s"} works now: ${RESCAN_HINT}`));
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
71
104
|
/** u in the picker: update, then run the new version in this one's place, with the same arguments */
|
|
72
105
|
function updateAndRestart() {
|
|
73
106
|
const status = runUpdate();
|
|
@@ -80,7 +113,8 @@ function updateAndRestart() {
|
|
|
80
113
|
process.exitCode = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], { stdio: "inherit" }).status ?? 1;
|
|
81
114
|
}
|
|
82
115
|
async function main() {
|
|
83
|
-
const { values: a } = parseArgs({
|
|
116
|
+
const { values: a, positionals } = parseArgs({
|
|
117
|
+
allowPositionals: true,
|
|
84
118
|
options: {
|
|
85
119
|
countries: { type: "string", short: "c" },
|
|
86
120
|
protocols: { type: "string", short: "p" },
|
|
@@ -91,7 +125,9 @@ async function main() {
|
|
|
91
125
|
version: { type: "boolean", short: "v", default: false },
|
|
92
126
|
"no-speed": { type: "boolean", short: "n", default: false },
|
|
93
127
|
firewall: { type: "boolean", short: "f", default: false },
|
|
94
|
-
|
|
128
|
+
order: { type: "string", short: "o" },
|
|
129
|
+
"stop-at-first": { type: "boolean", short: "w", default: false },
|
|
130
|
+
random: { type: "boolean", short: "r", default: false }, // from before scanning types: -o random
|
|
95
131
|
docker: { type: "boolean", short: "d", default: false },
|
|
96
132
|
worker: { type: "boolean", default: false }, // in a parallel scan's container
|
|
97
133
|
},
|
|
@@ -103,6 +139,11 @@ async function main() {
|
|
|
103
139
|
forgetLogin();
|
|
104
140
|
if (a.help)
|
|
105
141
|
return console.log(HELP);
|
|
142
|
+
const [subcommand, ...extra] = positionals;
|
|
143
|
+
if (subcommand !== undefined && (subcommand !== "connect" || extra.length))
|
|
144
|
+
throw new Error(`unknown command '${positionals.join(" ")}': see --help`);
|
|
145
|
+
if (subcommand === "connect" && Object.entries(a).some(([k, v]) => k !== "timeout" && v))
|
|
146
|
+
throw new Error("windscout connect takes only -t: see --help");
|
|
106
147
|
checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
|
|
107
148
|
if (a.version) {
|
|
108
149
|
await checkUpdate();
|
|
@@ -114,16 +155,23 @@ async function main() {
|
|
|
114
155
|
let timeout = a.timeout ? Number(a.timeout) : undefined;
|
|
115
156
|
if (timeout !== undefined && !(timeout >= MIN_TIMEOUT))
|
|
116
157
|
throw new Error(`--timeout must be at least ${MIN_TIMEOUT} seconds`);
|
|
158
|
+
if (a.order !== undefined && !ORDERS.includes(a.order))
|
|
159
|
+
throw new Error(`--order must be one of: ${ORDERS.join(", ")}`);
|
|
160
|
+
if (subcommand === "connect")
|
|
161
|
+
return connectLast(timeout ?? loadConfig()?.timeout ?? DEFAULT_TIMEOUT);
|
|
117
162
|
const status = await waitForLogin();
|
|
118
163
|
const tty = Boolean(process.stdin.isTTY && out.isTTY);
|
|
119
164
|
let countries = a.countries ? list(a.countries) : undefined;
|
|
120
165
|
let protocols = a.protocols ? list(a.protocols) : undefined;
|
|
121
166
|
let speedTest = SPEED_TEST && !a["no-speed"];
|
|
122
167
|
let firewall = a.firewall;
|
|
123
|
-
let
|
|
168
|
+
let order = (a.order ?? (a.random ? "random" : undefined));
|
|
169
|
+
let stopAtFirst = a["stop-at-first"];
|
|
124
170
|
let allPorts = a["all-ports"];
|
|
125
171
|
let docker = a.docker;
|
|
126
|
-
|
|
172
|
+
const last = loadLast();
|
|
173
|
+
// only the last results needs no countries: without a terminal, it goes without the picker
|
|
174
|
+
if (!countries && !(order === "last-only" && !tty)) {
|
|
127
175
|
if (!tty)
|
|
128
176
|
throw new Error("no terminal for the picker: pass countries with -c");
|
|
129
177
|
const saved = loadConfig();
|
|
@@ -134,9 +182,11 @@ async function main() {
|
|
|
134
182
|
speedTest: speedTest && (saved?.speed ?? false), // first run: connect-only
|
|
135
183
|
// already on in the app: show it on, since the scan won't turn it off
|
|
136
184
|
firewall: firewall || isFirewallOn(status) || (saved?.firewall ?? false),
|
|
137
|
-
|
|
185
|
+
order: order ?? saved?.order ?? "default",
|
|
186
|
+
stopAtFirst: stopAtFirst || (saved?.stopAtFirst ?? false),
|
|
138
187
|
allPorts: allPorts || (saved?.allPorts ?? false),
|
|
139
188
|
docker: docker || (saved?.docker ?? false),
|
|
189
|
+
last,
|
|
140
190
|
});
|
|
141
191
|
if (!choice)
|
|
142
192
|
return;
|
|
@@ -146,34 +196,41 @@ async function main() {
|
|
|
146
196
|
protocols ??= choice.protocols;
|
|
147
197
|
speedTest = choice.speed;
|
|
148
198
|
firewall = choice.firewall;
|
|
149
|
-
|
|
199
|
+
order = choice.order;
|
|
200
|
+
stopAtFirst = choice.stopAtFirst;
|
|
150
201
|
allPorts = choice.allPorts;
|
|
151
202
|
timeout = choice.timeout;
|
|
152
203
|
docker = choice.docker;
|
|
153
204
|
}
|
|
205
|
+
countries ??= [];
|
|
154
206
|
protocols ??= PROTOCOLS;
|
|
155
207
|
timeout ??= DEFAULT_TIMEOUT;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
208
|
+
order ??= "default";
|
|
209
|
+
const byLast = order === "last-first" || order === "last-only"; // a type by the last result: it needs a saved scan
|
|
210
|
+
if (byLast && !last)
|
|
211
|
+
throw new Error(`no saved scan yet: -o ${order} needs one, so run a scan with another scanning type first`);
|
|
212
|
+
const fromLast = byLast ? last : null; // the saved connections this scan uses
|
|
213
|
+
// the picker asked for the ports already: waiting for that keeps windscribe-cli free for the scan.
|
|
214
|
+
// The saved connections need them too, to tell a saved "tcp:443" from tcp on its default port
|
|
215
|
+
const lists = allPorts || !a.countries || fromLast ? await protocolPorts() : undefined;
|
|
216
|
+
if (allPorts && lists)
|
|
217
|
+
protocols = withPorts(protocols, lists);
|
|
218
|
+
const attempts = plan(order, countries, protocols, fromLast?.results, lists);
|
|
219
|
+
// the tables' columns: the protocols asked for, and the saved connections' own (only those, when only they are scanned)
|
|
220
|
+
const scanned = [...new Set([...(order === "last-only" ? [] : protocols), ...attempts.map((x) => x.protocol)])];
|
|
164
221
|
let probers = [probe], after; // after: what runs here once the containers are done
|
|
165
|
-
const later = notInContainers(
|
|
166
|
-
const inContainers =
|
|
222
|
+
const later = notInContainers(scanned, loadDockerWireGuard()); // as far as the last parallel scan knows
|
|
223
|
+
const inContainers = attempts.filter((x) => !later.has(x.protocol)).length;
|
|
167
224
|
if (docker && inContainers) {
|
|
168
225
|
const started = await startParallel(inContainers);
|
|
169
226
|
if (!started)
|
|
170
227
|
return leaveScreen();
|
|
171
228
|
saveDockerWireGuard(started.wireguard);
|
|
172
229
|
probers = started.probers;
|
|
173
|
-
after = { prober: probe, protocols: notInContainers(
|
|
230
|
+
after = { prober: probe, protocols: notInContainers(scanned, started.wireguard), name: "this computer" };
|
|
174
231
|
await windscribe("disconnect"); // a VPN connection here would carry the containers' traffic too
|
|
175
232
|
}
|
|
176
|
-
const scan = new Scan(countries,
|
|
233
|
+
const scan = new Scan(countries, scanned, { timeout, speedTest, only: attempts, stopAtFirst }, probers, after);
|
|
177
234
|
// not with containers: their traffic goes out through this computer, and the firewall would block it
|
|
178
235
|
const blocking = firewall && !after && (await blockInternet());
|
|
179
236
|
const view = tty ? showProgress(scan, blocking) : logProgress(scan);
|
|
@@ -184,9 +241,15 @@ async function main() {
|
|
|
184
241
|
view.close();
|
|
185
242
|
for (const e of new Set(scan.errors))
|
|
186
243
|
console.error(yellow(e));
|
|
187
|
-
if (scan.
|
|
244
|
+
if (scan.found)
|
|
245
|
+
console.log(green("stopped at the first connection that held up (a speed test, then staying up)"));
|
|
246
|
+
else if (scan.stopping)
|
|
188
247
|
console.log(yellow("interrupted, showing partial results"));
|
|
189
|
-
const choices = report(scan.results,
|
|
248
|
+
const choices = report(scan.results, scanned, { all: a.all, speedTest: speedTest || stopAtFirst }); // stop at the first measures speed
|
|
249
|
+
// its working connections replace the saved ones it tried, so what stopped working drops out; the saved ones it didn't try
|
|
250
|
+
// (other countries, or what a stopped scan didn't get to) stay, after its own; a scan where nothing worked keeps the last
|
|
251
|
+
const tried = new Set(scan.results.filter((r) => r.status !== "interrupted").map((r) => `${r.query} ${r.protocol}`));
|
|
252
|
+
saveLast(choices, undefined, tried);
|
|
190
253
|
await checkUpdate();
|
|
191
254
|
if (updateNotice())
|
|
192
255
|
console.error("\n" + versionLines().join("\n"));
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// The picker's selection,
|
|
1
|
+
// The picker's selection, what the last parallel scan found its containers can do, and the last scan's working results, remembered between runs.
|
|
2
2
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { ORDERS } from "./scan.js";
|
|
5
6
|
import { COUNTRIES, PROTOCOLS } from "./windscribe.js";
|
|
6
7
|
// false hides the speed test: every scan is connect-only, and a saved "on" reads as off
|
|
7
8
|
export const SPEED_TEST = true;
|
|
@@ -9,6 +10,7 @@ export const DEFAULT_TIMEOUT = 15;
|
|
|
9
10
|
export const MIN_TIMEOUT = 10; // seconds; shorter cuts off connections that would have worked
|
|
10
11
|
export const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
|
|
11
12
|
const DOCKER_FILE = join(dirname(CONFIG), "docker.json"); // what the last parallel scan found its containers can do
|
|
13
|
+
export const LAST_FILE = join(dirname(CONFIG), "last.json"); // the last scan's working connections
|
|
12
14
|
/** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
|
|
13
15
|
export function loadConfig() {
|
|
14
16
|
try {
|
|
@@ -18,7 +20,8 @@ export function loadConfig() {
|
|
|
18
20
|
protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
|
|
19
21
|
speed: SPEED_TEST && c.speed !== false,
|
|
20
22
|
firewall: c.firewall === true,
|
|
21
|
-
|
|
23
|
+
order: ORDERS.includes(c.order) ? c.order : c.random === true ? "random" : "default", // before scanning types: random on or off
|
|
24
|
+
stopAtFirst: c.stopAtFirst === true,
|
|
22
25
|
allPorts: c.allPorts === true,
|
|
23
26
|
timeout: c.timeout >= MIN_TIMEOUT ? c.timeout : DEFAULT_TIMEOUT,
|
|
24
27
|
docker: c.docker === true,
|
|
@@ -51,6 +54,30 @@ export function saveDockerWireGuard(wireguard) {
|
|
|
51
54
|
}
|
|
52
55
|
catch { } // not being able to remember isn't worth failing the scan
|
|
53
56
|
}
|
|
57
|
+
/** The last scan that found something; null when there's none or the file is unreadable */
|
|
58
|
+
export function loadLast() {
|
|
59
|
+
try {
|
|
60
|
+
const l = JSON.parse(readFileSync(LAST_FILE, "utf8"));
|
|
61
|
+
const results = (l.results ?? []).filter((r) => r?.status === "ok" && typeof r.query === "string" && typeof r.protocol === "string");
|
|
62
|
+
return results.length && typeof l.at === "number" ? { at: l.at, results } : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Saves a scan's working connections; a scan where nothing worked keeps the last one that found something (the network may
|
|
69
|
+
* just be down). With tried (the "query protocol" pairs the scan tried), the saved connections it didn't try stay, after its own */
|
|
70
|
+
export function saveLast(results, at = Date.now(), tried) {
|
|
71
|
+
if (!results.length)
|
|
72
|
+
return;
|
|
73
|
+
if (tried)
|
|
74
|
+
results = [...results, ...(loadLast()?.results ?? []).filter((r) => !tried.has(`${r.query} ${r.protocol}`))];
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dirname(LAST_FILE), { recursive: true });
|
|
77
|
+
writeFileSync(LAST_FILE, JSON.stringify({ at, results }, null, 2) + "\n");
|
|
78
|
+
}
|
|
79
|
+
catch { } // not being able to remember isn't worth failing the scan
|
|
80
|
+
}
|
|
54
81
|
/** Deletes the Windscribe username and password that 0.6.0 saved for the docker containers: they log in with this computer's session now */
|
|
55
82
|
export function forgetLogin() {
|
|
56
83
|
try {
|
package/dist/docker.js
CHANGED
|
@@ -288,7 +288,7 @@ export async function startWorkers(n, session, onStatus) {
|
|
|
288
288
|
};
|
|
289
289
|
p.on("error", (e) => gone(e.message));
|
|
290
290
|
p.on("close", (code) => gone(lastLine(err) || `exited (${code})`));
|
|
291
|
-
const prober = (query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { } }) => new Promise((done, reject) => {
|
|
291
|
+
const prober = (query, protocol, { timeout, speedTest, steady = false, stopped = () => false, onStep = () => { } }) => new Promise((done, reject) => {
|
|
292
292
|
if (dead)
|
|
293
293
|
return reject(new Error(`container ${i + 1} stopped: ${dead}`));
|
|
294
294
|
let leave = null, over = false;
|
|
@@ -311,7 +311,7 @@ export async function startWorkers(n, session, onStatus) {
|
|
|
311
311
|
else if (m.result)
|
|
312
312
|
end(), done(m.result);
|
|
313
313
|
};
|
|
314
|
-
send({ probe: { query, protocol, timeout, speedTest } });
|
|
314
|
+
send({ probe: { query, protocol, timeout, speedTest, steady } });
|
|
315
315
|
});
|
|
316
316
|
fail = (why) => (onStatus(`container ${i + 1}: ${why}`), resolve(null));
|
|
317
317
|
on = (m) => {
|
package/dist/measure.js
CHANGED
|
@@ -9,7 +9,7 @@ setDefaultAutoSelectFamily(false);
|
|
|
9
9
|
setDefaultResultOrder("ipv4first");
|
|
10
10
|
const LATENCY_URL = "https://www.gstatic.com/generate_204";
|
|
11
11
|
const SPEED_URL = "https://speed.cloudflare.com/__down?bytes=10000000";
|
|
12
|
-
const SPEED_SECONDS = 15;
|
|
12
|
+
export const SPEED_SECONDS = 15;
|
|
13
13
|
/** Best round trip of a few tries, in ms; null if none got through */
|
|
14
14
|
export async function latencyMs(tries = 3) {
|
|
15
15
|
let best = null;
|
package/dist/probe.js
CHANGED
|
@@ -1,62 +1,100 @@
|
|
|
1
1
|
// One attempt: connect to a location over a protocol, measure through the tunnel, disconnect.
|
|
2
|
-
|
|
2
|
+
// windscout connect makes the same attempt and, when it works, stays connected.
|
|
3
|
+
import { latencyMs, SPEED_SECONDS, speedMbps } from "./measure.js";
|
|
3
4
|
import { now, sleep } from "./time.js";
|
|
4
5
|
import { connectState, windscribe } from "./windscribe.js";
|
|
6
|
+
// Stop at the first ends the scan at one connection, so that one has to last: a tunnel can connect and then be cut or throttled
|
|
7
|
+
// once traffic flows. It counts once it downloads at MIN_MBPS or more, then stays up with internet for STEADY_CHECKS round trips
|
|
8
|
+
export const MIN_MBPS = 1;
|
|
9
|
+
const STEADY_CHECKS = 5, STEADY_GAP = 2; // seconds between the round trips
|
|
10
|
+
// shortcut: guesses ~5 s to connect and the whole speed test; refine from real runs
|
|
11
|
+
export const STEADY_SECONDS = 5 + SPEED_SECONDS + STEADY_CHECKS * (STEADY_GAP + 0.5);
|
|
12
|
+
/** Whether stop at the first can stop at this result: it worked, held up, and is fast enough for real use */
|
|
13
|
+
export const reliable = (r) => r.status === "ok" && (r.speedMbps ?? 0) >= MIN_MBPS;
|
|
5
14
|
// "Connected: Miami - Vice" → "Miami - Vice"
|
|
6
15
|
const place = (state) => state.split(":").slice(1).join(":").trim();
|
|
7
|
-
export async function probe(query, protocol,
|
|
16
|
+
export async function probe(query, protocol, options) {
|
|
17
|
+
try {
|
|
18
|
+
return await attempt(query, protocol, options);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
options.onStep?.("disconnecting");
|
|
22
|
+
await disconnect();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Disconnects, and waits until the app has */
|
|
26
|
+
export async function disconnect() {
|
|
27
|
+
await windscribe("disconnect");
|
|
28
|
+
// the CLI's disconnect can return while the app is still tearing down (it does after a timed-out connect);
|
|
29
|
+
// a connect sent then is swallowed and the next attempt reads as an instant "failed", so wait it out
|
|
30
|
+
for (let i = 0; i < 30 && (await connectState()) === "Disconnecting"; i++)
|
|
31
|
+
await sleep(1000);
|
|
32
|
+
}
|
|
33
|
+
/** Connects and measures through the tunnel, as probe does, but leaves the connection as it is: up when it worked */
|
|
34
|
+
export async function attempt(query, protocol, { timeout, speedTest, steady = false, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
|
|
8
35
|
const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null };
|
|
9
36
|
const end = (status) => ({ ...r, status });
|
|
10
37
|
onStep("connecting");
|
|
11
38
|
await windscribe("connect", "-n", query, protocol);
|
|
12
39
|
const t = now();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (stopped())
|
|
42
|
+
return end("interrupted");
|
|
43
|
+
if (now() - t >= timeout)
|
|
44
|
+
return end("timeout");
|
|
45
|
+
const s = await connectState();
|
|
46
|
+
if (s.startsWith("Connecting:"))
|
|
47
|
+
r.location = place(s);
|
|
48
|
+
else if (s.startsWith("Connected")) {
|
|
49
|
+
if (s.includes(":"))
|
|
21
50
|
r.location = place(s);
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
// Windscribe refused this app another WireGuard key for the account: says nothing about this network
|
|
31
|
-
else if (s.includes("WireGuard key limit") && now() - t > 3)
|
|
32
|
-
return end("key limit");
|
|
33
|
-
else if (s === "Disconnected" && now() - t > 3)
|
|
34
|
-
return end("failed");
|
|
35
|
-
onStep(`connecting ${r.location}`.trim());
|
|
36
|
-
await sleep(1000);
|
|
51
|
+
r.connectS = now() - t;
|
|
52
|
+
break;
|
|
37
53
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
r.speedMbps = await speedMbps();
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
return end(r.latencyMs === null ? "no internet" : "ok");
|
|
54
|
+
else if (s.includes("does not exist"))
|
|
55
|
+
return end("no such location");
|
|
56
|
+
// Windscribe refused this app another WireGuard key for the account: says nothing about this network
|
|
57
|
+
else if (s.includes("WireGuard key limit") && now() - t > 3)
|
|
58
|
+
return end("key limit");
|
|
59
|
+
else if (s === "Disconnected" && now() - t > 3)
|
|
60
|
+
return end("failed");
|
|
61
|
+
onStep(`connecting ${r.location}`.trim());
|
|
62
|
+
await sleep(1000);
|
|
51
63
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
// a port the app doesn't offer for the protocol: it connects on the protocol's default instead, so this port wasn't tried
|
|
65
|
+
const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol: .*:(\d+)\s*$/m)?.[1];
|
|
66
|
+
if (port && shown && shown !== port)
|
|
67
|
+
return end("no such port");
|
|
68
|
+
let measured = false, holds = true;
|
|
69
|
+
await measure(async () => {
|
|
70
|
+
// a stopping scan waits for no more measurements: a parallel scan's containers measure one at a time, so they'd queue up
|
|
71
|
+
if (stopped())
|
|
72
|
+
return;
|
|
73
|
+
measured = true;
|
|
74
|
+
onStep(`measuring latency · ${r.location}`);
|
|
75
|
+
r.latencyMs = await latencyMs();
|
|
76
|
+
if (r.latencyMs === null)
|
|
77
|
+
return;
|
|
78
|
+
if (speedTest || steady) {
|
|
79
|
+
onStep(`measuring speed · ${r.location}`);
|
|
80
|
+
r.speedMbps = await speedMbps();
|
|
81
|
+
}
|
|
82
|
+
if (steady)
|
|
83
|
+
holds = await holdsUp(r.location, onStep);
|
|
84
|
+
});
|
|
85
|
+
if (!measured)
|
|
86
|
+
return end("interrupted");
|
|
87
|
+
return end(r.latencyMs === null ? "no internet" : holds ? "ok" : "unreliable");
|
|
88
|
+
}
|
|
89
|
+
/** Whether the connection stays up with internet through it: STEADY_CHECKS round trips, STEADY_GAP seconds apart, each with a retry */
|
|
90
|
+
async function holdsUp(location, onStep) {
|
|
91
|
+
for (let i = 1; i <= STEADY_CHECKS; i++) {
|
|
92
|
+
onStep(`checking it holds up ${i}/${STEADY_CHECKS} · ${location}`);
|
|
93
|
+
await sleep(STEADY_GAP * 1000);
|
|
94
|
+
if (!(await connectState()).startsWith("Connected") || (await latencyMs(2)) === null)
|
|
95
|
+
return false;
|
|
59
96
|
}
|
|
97
|
+
return true;
|
|
60
98
|
}
|
|
61
99
|
/** The attempts that worked, best first: fastest, then lowest latency */
|
|
62
100
|
export const sortOk = (results) => results.filter((r) => r.status === "ok")
|
package/dist/scan.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
// A scan:
|
|
1
|
+
// A scan: the attempts its scanning type plans (plan()), tried in order, with pause and stop.
|
|
2
2
|
// Each slot runs one attempt at a time: one slot here, or one per docker container in a parallel scan.
|
|
3
|
-
import { probe } from "./probe.js";
|
|
3
|
+
import { probe, reliable } from "./probe.js";
|
|
4
4
|
import { now } from "./time.js";
|
|
5
5
|
// ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
|
|
6
6
|
export const attemptSeconds = (timeout) => timeout + 5;
|
|
7
7
|
export const estimate = (attempts, timeout, slots = 1) => Math.ceil(attempts / slots) * attemptSeconds(timeout);
|
|
8
|
+
export const ORDERS = ["default", "random", "last-first", "last-only"];
|
|
9
|
+
const base = (protocol) => protocol.split(":")[0]; // "tcp:443" → "tcp"
|
|
10
|
+
/** What a scan of these countries and protocols tries, in the order of the scanning type, each once. saved: the last result's
|
|
11
|
+
* connections, best first. ports: each protocol's ports, its default first, so "tcp" and "tcp:443" are one attempt */
|
|
12
|
+
export function plan(order, countries, protocols, saved = [], ports = new Map()) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const once = ({ country, protocol }) => {
|
|
15
|
+
const [p, port] = protocol.split(":"), key = `${country} ${p}:${port ?? ports.get(p)?.[0] ?? ""}`;
|
|
16
|
+
return !seen.has(key) && !!seen.add(key);
|
|
17
|
+
};
|
|
18
|
+
const last = saved.map((r) => ({ country: r.query, protocol: r.protocol, saved: true }));
|
|
19
|
+
if (order === "last-only")
|
|
20
|
+
return last.filter(once);
|
|
21
|
+
const bases = new Set(protocols.map(base));
|
|
22
|
+
const first = order === "last-first" ? last.filter((a) => countries.includes(a.country) && bases.has(base(a.protocol))) : [];
|
|
23
|
+
const list = order === "random" ? countries.map((c) => [Math.random(), c]).sort(([x], [y]) => x - y).map(([, c]) => c) : countries;
|
|
24
|
+
return [...first, ...list.flatMap((country) => protocols.map((protocol) => ({ country, protocol })))].filter(once);
|
|
25
|
+
}
|
|
8
26
|
/** Progress views read the fields; their keys call togglePause() and stop() */
|
|
9
27
|
export class Scan {
|
|
10
28
|
countries;
|
|
@@ -20,6 +38,7 @@ export class Scan {
|
|
|
20
38
|
started = now();
|
|
21
39
|
done = 0;
|
|
22
40
|
stopping = false; // stop after the current attempts
|
|
41
|
+
found = false; // stopAtFirst: a connection held up, so the scan is stopping
|
|
23
42
|
// a pause lands between attempts, so the VPN is disconnected while waiting
|
|
24
43
|
paused = false;
|
|
25
44
|
waiting = false; // paused, and no attempt running
|
|
@@ -33,7 +52,7 @@ export class Scan {
|
|
|
33
52
|
this.options = options;
|
|
34
53
|
this.probers = probers;
|
|
35
54
|
this.after = after;
|
|
36
|
-
this.total =
|
|
55
|
+
this.total = this.attempts().length;
|
|
37
56
|
this.slots = probers.map(() => ({ current: null, step: "", attemptStart: now() }));
|
|
38
57
|
if (after)
|
|
39
58
|
this.slots.push({ current: null, step: "", attemptStart: now(), name: after.name });
|
|
@@ -59,9 +78,15 @@ export class Scan {
|
|
|
59
78
|
this.waiting = false;
|
|
60
79
|
this.resume();
|
|
61
80
|
}
|
|
81
|
+
attempts() {
|
|
82
|
+
return this.options.only?.map((a) => ({ ...a }))
|
|
83
|
+
?? this.countries.flatMap((country) => this.protocols.map((protocol) => ({ country, protocol })));
|
|
84
|
+
}
|
|
62
85
|
async run(hooks = {}) {
|
|
63
|
-
const queue = this.
|
|
64
|
-
const left = new Map(
|
|
86
|
+
const queue = this.attempts();
|
|
87
|
+
const left = new Map(); // attempts to go, per country
|
|
88
|
+
for (const { country } of queue)
|
|
89
|
+
left.set(country, (left.get(country) ?? 0) + 1);
|
|
65
90
|
const later = this.after?.protocols ?? new Set();
|
|
66
91
|
await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], (p) => !later.has(p), queue, left, hooks)));
|
|
67
92
|
// then the rest, also what slots whose container stopped left over
|
|
@@ -71,7 +96,7 @@ export class Scan {
|
|
|
71
96
|
}
|
|
72
97
|
/** One slot: takes the attempts it can make from the queue, one at a time, until there are none */
|
|
73
98
|
async work(prober, slot, can, queue, left, hooks) {
|
|
74
|
-
const { timeout, speedTest } = this.options;
|
|
99
|
+
const { timeout, speedTest, stopAtFirst = false } = this.options;
|
|
75
100
|
for (;;) {
|
|
76
101
|
while (this.paused && !this.stopping) {
|
|
77
102
|
if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
|
|
@@ -89,7 +114,7 @@ export class Scan {
|
|
|
89
114
|
hooks.attempt?.(country, protocol);
|
|
90
115
|
let r;
|
|
91
116
|
try {
|
|
92
|
-
r = await prober(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
|
|
117
|
+
r = await prober(country, protocol, { timeout, speedTest, steady: stopAtFirst, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
|
|
93
118
|
}
|
|
94
119
|
catch (e) { // this slot is done for (its container stopped): the others take over its attempt
|
|
95
120
|
queue.unshift(next);
|
|
@@ -109,6 +134,10 @@ export class Scan {
|
|
|
109
134
|
if (!left.get(country))
|
|
110
135
|
this.finished.push(country);
|
|
111
136
|
hooks.result?.(r);
|
|
137
|
+
if (stopAtFirst && reliable(r) && !this.stopping) {
|
|
138
|
+
this.found = true;
|
|
139
|
+
this.stop();
|
|
140
|
+
}
|
|
112
141
|
}
|
|
113
142
|
}
|
|
114
143
|
}
|
package/dist/tui/docker.js
CHANGED
|
@@ -3,7 +3,7 @@ import { duration, now } from "../time.js";
|
|
|
3
3
|
import { session } from "../windscribe.js";
|
|
4
4
|
import { draw, enterScreen, input, leaveScreen, out } from "./terminal.js";
|
|
5
5
|
import { bold, cyan, dim, vw, yellow } from "./text.js";
|
|
6
|
-
import { box, centerIn } from "./widgets.js";
|
|
6
|
+
import { box, centerIn, spin } from "./widgets.js";
|
|
7
7
|
const tty = () => Boolean(process.stdin.isTTY && out.isTTY);
|
|
8
8
|
const clip = (s, n = 76) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
9
9
|
// lines of at most n columns, broken at spaces: an error can be longer than the screen is wide
|
|
@@ -75,7 +75,7 @@ async function busy(status, work) {
|
|
|
75
75
|
}
|
|
76
76
|
const t = now();
|
|
77
77
|
let frame = 0;
|
|
78
|
-
const tick = () => paint({ lines: [`${cyan(
|
|
78
|
+
const tick = () => paint({ lines: [`${cyan(spin(frame++))} ${status.label} ${dim("· " + duration(now() - t))}`,
|
|
79
79
|
dim(clip(status.line)), "", dim("esc quits")] });
|
|
80
80
|
// the exit handlers remove containers already started
|
|
81
81
|
const quit = (_, key = {}) => (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
|
package/dist/tui/picker.js
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
1
|
import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
|
|
2
2
|
import { dockerState, loginSeconds, notInContainers, workerCount } from "../docker.js";
|
|
3
|
-
import {
|
|
3
|
+
import { STEADY_SECONDS } from "../probe.js";
|
|
4
|
+
import { attemptSeconds, estimate, ORDERS, plan } from "../scan.js";
|
|
4
5
|
import { duration } from "../time.js";
|
|
5
6
|
import { canUpdate, checkUpdate, versionLines } from "../update.js";
|
|
6
|
-
import { COUNTRIES, protocolPorts, PROTOCOLS } from "../windscribe.js";
|
|
7
|
+
import { COUNTRIES, protocolPorts, PROTOCOLS, withPorts } from "../windscribe.js";
|
|
7
8
|
import { countryName, flag } from "./country.js";
|
|
8
9
|
import { setupDocker } from "./docker.js";
|
|
9
|
-
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
10
|
+
import { draw, drawUntilNext, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
10
11
|
import { bold, cyan, dim, green, under, vw, yellow } from "./text.js";
|
|
11
|
-
import { box, centerIn, grid } from "./widgets.js";
|
|
12
|
+
import { box, centerIn, grid, spin } from "./widgets.js";
|
|
13
|
+
const ORDER_NAMES = {
|
|
14
|
+
default: "default",
|
|
15
|
+
random: "by random country",
|
|
16
|
+
"last-first": "by last success result first",
|
|
17
|
+
"last-only": "only the last results",
|
|
18
|
+
};
|
|
19
|
+
const needsLast = (o) => o === "last-first" || o === "last-only";
|
|
20
|
+
// only the last results: the countries, protocols and ports are greyed out, and their keys and clicks say why
|
|
21
|
+
const SKIPPED = "skipped with only the last results: o changes the scanning type";
|
|
12
22
|
const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
|
|
23
|
+
// "5m ago", "3h ago", "2 days ago"
|
|
24
|
+
const ago = (ms) => {
|
|
25
|
+
const m = Math.max(0, Math.round((Date.now() - ms) / 60000)), h = Math.round(m / 60), d = Math.round(h / 24);
|
|
26
|
+
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : h < 48 ? `${h}h ago` : `${d} days ago`;
|
|
27
|
+
};
|
|
13
28
|
/** Resolves to the choice (also saved for next time), null when the user quits, or "update" for u */
|
|
14
|
-
export function pick(saved, { timeout, speedTest, firewall,
|
|
29
|
+
export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst, allPorts, docker, last }) {
|
|
15
30
|
const selected = new Set(saved?.countries ?? []);
|
|
16
31
|
const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
|
|
17
32
|
let cursor = 0, top = 0, message = "", query = "", searching = false;
|
|
18
33
|
let seconds = String(timeout), editing = false; // the timeout, as typed
|
|
19
34
|
let dockerAt = null, inDialog = false; // docker's state once checked; a docker dialog has the screen
|
|
20
35
|
let portLists = null; // each protocol's ports, once the app said
|
|
36
|
+
let starting = -1; // once start is pressed: the start button's spinner frame, until the next screen shows
|
|
21
37
|
let hit;
|
|
22
38
|
const visible = () => {
|
|
23
39
|
const q = query.toLowerCase();
|
|
@@ -25,7 +41,11 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
25
41
|
};
|
|
26
42
|
const ports = (p) => (allPorts && portLists?.get(p)?.length) || 1; // attempts per country for the protocol
|
|
27
43
|
const perCountry = (ps) => [...ps].reduce((n, p) => n + ports(p), 0);
|
|
28
|
-
|
|
44
|
+
// what start scans, in its order: as the CLI plans it, so the estimate counts the same attempts
|
|
45
|
+
const todo = () => {
|
|
46
|
+
const ps = PROTOCOLS.filter((p) => protos.has(p));
|
|
47
|
+
return plan(order, COUNTRIES.filter((c) => selected.has(c)), allPorts && portLists ? withPorts(ps, portLists) : ps, last?.results, portLists ?? undefined);
|
|
48
|
+
};
|
|
29
49
|
const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
|
|
30
50
|
enterScreen();
|
|
31
51
|
return new Promise((resolve) => {
|
|
@@ -35,88 +55,112 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
35
55
|
const rows = out.rows || 24;
|
|
36
56
|
const version = versionLines(true);
|
|
37
57
|
const matches = visible();
|
|
38
|
-
const height = Math.max(3, rows -
|
|
58
|
+
const height = Math.max(3, rows - 24 - version.length);
|
|
39
59
|
cursor = Math.max(0, Math.min(cursor, matches.length - 1));
|
|
40
60
|
if (cursor < top)
|
|
41
61
|
top = cursor;
|
|
42
62
|
if (cursor >= top + height)
|
|
43
63
|
top = cursor - height + 1;
|
|
44
64
|
top = Math.max(0, Math.min(top, matches.length - height));
|
|
45
|
-
const
|
|
65
|
+
const off = order === "last-only"; // only the saved connections: the countries, protocols and ports are skipped
|
|
66
|
+
const missing = needsLast(order) && !last; // a type by the last result, and no saved scan: start is off
|
|
67
|
+
timeout = Number(seconds);
|
|
68
|
+
const list = todo(), n = list.length;
|
|
69
|
+
const base = (p) => p.split(":")[0]; // "wireguard:443" → "wireguard"
|
|
70
|
+
const scanned = new Set(list.map((a) => base(a.protocol)));
|
|
71
|
+
const here = PROTOCOLS.filter((p) => docker && scanned.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
|
|
72
|
+
const inContainers = list.filter((a) => !here.includes(base(a.protocol))).length;
|
|
73
|
+
const parallel = docker && dockerAt === "ready" && inContainers > 0;
|
|
74
|
+
const slots = parallel ? workerCount(inContainers) : 1;
|
|
75
|
+
const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
|
|
76
|
+
const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? (off ? dim : green)("● " + p) : dim("○ " + p)}`).join(" ");
|
|
46
77
|
const listed = portLists && [...portLists.values()].some((l) => l.length), each = perCountry(protos);
|
|
47
|
-
const portsLine = `${dim(under("p"))} ${allPorts ? green("● all ports") : dim("○ all ports")} ${dim(
|
|
48
|
-
? "
|
|
49
|
-
: !
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
const portsLine = `${dim(under("p"))} ${allPorts ? (off ? dim : green)("● all ports") : dim("○ all ports")} ${dim(off
|
|
79
|
+
? "not used: each saved connection on its own protocol and port"
|
|
80
|
+
: !allPorts
|
|
81
|
+
? "off: each protocol on its default port"
|
|
82
|
+
: !portLists ? "checking the ports…"
|
|
83
|
+
: !listed ? "this Windscribe app can't list its ports: 2.22 and newer can"
|
|
84
|
+
: `every port of each protocol: ${each} attempt${each === 1 ? "" : "s"} per country${each > protos.size ? `, not ${protos.size}` : ""}`)}`;
|
|
52
85
|
const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
|
|
53
86
|
? "ranks by download speed · adds ~15s per working connection"
|
|
54
|
-
: "off
|
|
87
|
+
: stopAtFirst ? "off · stop at the first still runs it on each connection"
|
|
88
|
+
: "off: connect-only, ranks by latency")}`;
|
|
55
89
|
const fwLabel = "block internet while scanning (firewall)";
|
|
56
90
|
const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(docker
|
|
57
91
|
? "not used with docker"
|
|
58
92
|
: firewall ? "only VPN traffic gets through" : "off: apps may leak in between")}`;
|
|
59
|
-
const randomLine = `${dim(under("r"))} ${random ? green("● random order") : dim("○ random order")} ${dim(random
|
|
60
|
-
? "scans the countries shuffled, a new order each run"
|
|
61
|
-
: "off: scans in list order")}`;
|
|
62
|
-
timeout = Number(seconds);
|
|
63
93
|
const short = timeout < MIN_TIMEOUT;
|
|
64
94
|
const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
|
|
65
|
-
const n = attempts();
|
|
66
|
-
const here = PROTOCOLS.filter((p) => docker && protos.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
|
|
67
|
-
const inContainers = selected.size * perCountry([...protos].filter((p) => !here.includes(p)));
|
|
68
|
-
const parallel = docker && dockerAt === "ready" && inContainers > 0;
|
|
69
|
-
const slots = parallel ? workerCount(inContainers) : 1;
|
|
70
|
-
const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
|
|
71
95
|
const dockerLabel = "parallel scan in docker containers";
|
|
72
96
|
const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
|
|
73
97
|
? "off: one attempt at a time"
|
|
74
98
|
: dockerAt === "ready" ? `${slots} at a time${here.length ? ` · ${here.join(" and ")} here, after them` : ""}`
|
|
75
99
|
: dockerAt ? "docker isn't ready: start sets it up" : "checking docker…")}`;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
const saves = last ? plan("last-only", [], [], last.results, portLists ?? undefined).length : 0; // the saved connections, each once
|
|
101
|
+
const firsts = list.filter((a) => a.saved).length; // those this scan tries first
|
|
102
|
+
const orderLine = `${dim(under("o"))} scanning type ${bold(ORDER_NAMES[order])} ${dim(missing
|
|
103
|
+
? "no saved scan yet"
|
|
104
|
+
: order === "default" ? "countries in list order"
|
|
105
|
+
: order === "random" ? "countries shuffled, a new order each run"
|
|
106
|
+
: order === "last-first" ? `${firsts} of ${saves} saved selected · ${ago(last.at)}`
|
|
107
|
+
: `${saves} saved working connection${saves === 1 ? "" : "s"} · ${ago(last.at)}`)}`;
|
|
108
|
+
const stopLabel = "stop at the first working connection";
|
|
109
|
+
// the best saved connection goes first (unless it waits for the containers): when it still holds up, the scan ends there
|
|
110
|
+
const lead = list[0]?.saved && !here.includes(base(list[0].protocol)) ? list[0] : null;
|
|
111
|
+
const stopLine = `${dim(under("w"))} ${stopAtFirst ? green("● " + stopLabel) : dim("○ " + stopLabel)} ${dim(!stopAtFirst
|
|
112
|
+
? "off: tries every attempt"
|
|
113
|
+
: lead ? `≈ ${duration(login + STEADY_SECONDS)} if ${lead.country} ${lead.protocol} still holds up`
|
|
114
|
+
: "after a speed and stability check")}`;
|
|
115
|
+
const search = off
|
|
116
|
+
? yellow("countries skipped: only the last results are scanned")
|
|
117
|
+
: searching ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
118
|
+
: query ? `🔍 ${query}` : dim("🔍 press / to search");
|
|
79
119
|
const shown = matches.slice(top, top + height);
|
|
80
120
|
const range = matches.length > height ? `${top + 1}–${top + shown.length} of ${matches.length}` : `${matches.length} countries`;
|
|
81
121
|
const body = shown.map((code, j) => {
|
|
82
|
-
const
|
|
83
|
-
const mark = selected.has(code) ? green("●") : dim("○");
|
|
122
|
+
const current = !off && top + j === cursor;
|
|
123
|
+
const mark = selected.has(code) ? (off ? dim : green)("●") : dim("○");
|
|
84
124
|
const name = `${flag(code)} ${countryName(code)}`;
|
|
85
|
-
return [`${
|
|
125
|
+
return [`${current ? cyan("❯") : " "} ${mark}`, current ? bold(code) : dim(code), current ? bold(cyan(name)) : off ? dim(name) : name];
|
|
86
126
|
});
|
|
87
127
|
if (!body.length)
|
|
88
128
|
body.push([" ", "", dim("no country matches")]);
|
|
89
129
|
const buttons = [
|
|
90
|
-
["start", "▶ Start"],
|
|
130
|
+
["start", starting < 0 ? "▶ Start" : `${spin(starting++)} Starting…`],
|
|
91
131
|
["all", under("A") + (query ? "ll shown" : "ll")],
|
|
92
132
|
["none", under("N") + (query ? "one shown" : "one")],
|
|
93
133
|
["quit", under("Q") + "uit"],
|
|
94
134
|
];
|
|
95
|
-
const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
96
|
-
const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine,
|
|
135
|
+
const buttonLine = buttons.map(([id, label]) => (id === "start" && n && !missing ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
136
|
+
const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine];
|
|
97
137
|
const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...settings.map(vw)) + 4 });
|
|
98
138
|
const width = vw(table[0]);
|
|
99
139
|
const head = box(settings, {
|
|
100
140
|
title: bold(cyan("WindScout")),
|
|
101
|
-
right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
141
|
+
right: `${(off ? dim : bold)(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
|
|
102
142
|
width: width - 4,
|
|
103
143
|
});
|
|
144
|
+
const total = login + estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout);
|
|
145
|
+
const costs = dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""}${login ? `, + ~${duration(login)} login` : ""})`);
|
|
104
146
|
const lines = [
|
|
105
147
|
...head,
|
|
106
148
|
...table,
|
|
107
149
|
"",
|
|
108
150
|
centerIn(buttonLine, width),
|
|
109
151
|
"",
|
|
110
|
-
centerIn(
|
|
111
|
-
?
|
|
112
|
-
: yellow(
|
|
152
|
+
centerIn(missing
|
|
153
|
+
? yellow("no saved scan yet, so start is off: press o for another scanning type")
|
|
154
|
+
: !n ? yellow(`select at least one country and one protocol${last ? ", or o for only the last results" : ""}`)
|
|
155
|
+
: stopAtFirst // it can end at any attempt: this is when none holds up
|
|
156
|
+
? `up to ${bold(String(n))} attempts · at most ${bold("≈ " + duration(total))} ${costs}`
|
|
157
|
+
: `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(total))} ${costs}`, width),
|
|
113
158
|
centerIn(dim(editing
|
|
114
159
|
? "type the seconds to wait for each connection · enter/esc done"
|
|
115
160
|
: searching
|
|
116
161
|
? "type to filter · ↑↓ move · enter/esc done"
|
|
117
|
-
// both lines fit 80 columns
|
|
118
162
|
: `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
|
|
119
|
-
centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall ·
|
|
163
|
+
centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall · t timeout · d docker · o type · w first`), width),
|
|
120
164
|
];
|
|
121
165
|
if (message)
|
|
122
166
|
lines.push(centerIn(yellow(message), width));
|
|
@@ -140,7 +184,7 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
140
184
|
});
|
|
141
185
|
hit = {
|
|
142
186
|
protoRow: row(protoLine), protoHits, portsRow: row(portsLine), speedRow: SPEED_TEST ? row(speedLine) : -1,
|
|
143
|
-
firewallRow: row(fwLine),
|
|
187
|
+
firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine),
|
|
144
188
|
searchRow: at.top + head.length + 1,
|
|
145
189
|
firstRow: at.top + head.length + 5, shown: shown.length,
|
|
146
190
|
buttonRow: at.top + head.length + table.length + 1, buttonHits,
|
|
@@ -161,7 +205,9 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
161
205
|
resolve(value);
|
|
162
206
|
};
|
|
163
207
|
const start = () => {
|
|
164
|
-
if (
|
|
208
|
+
if (needsLast(order) && !last)
|
|
209
|
+
return false; // start is off: the warning under the table says why
|
|
210
|
+
if (!todo().length) {
|
|
165
211
|
message = "nothing to scan yet";
|
|
166
212
|
return false;
|
|
167
213
|
}
|
|
@@ -175,15 +221,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
175
221
|
protocols: PROTOCOLS.filter((p) => protos.has(p)),
|
|
176
222
|
speed: speedTest,
|
|
177
223
|
firewall,
|
|
178
|
-
|
|
224
|
+
order,
|
|
225
|
+
stopAtFirst,
|
|
179
226
|
allPorts,
|
|
180
227
|
timeout,
|
|
181
228
|
docker,
|
|
182
229
|
};
|
|
183
230
|
saveConfig(choice);
|
|
184
231
|
finish(choice);
|
|
232
|
+
// the scan can take a moment to show (the firewall, docker): the button spins until its screen, or a docker one, draws
|
|
233
|
+
starting = 0;
|
|
234
|
+
drawUntilNext(render);
|
|
185
235
|
return true;
|
|
186
236
|
};
|
|
237
|
+
const cycleOrder = () => (order = ORDERS[(ORDERS.indexOf(order) + 1) % ORDERS.length]); // o: the next scanning type
|
|
187
238
|
// turning it on gets docker going first: installs or starts it (after asking), unless it answers already
|
|
188
239
|
const toggleDocker = async () => {
|
|
189
240
|
if (!docker && (dockerAt = await dockerState()) !== "ready") {
|
|
@@ -205,14 +256,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
205
256
|
if (!m.press)
|
|
206
257
|
return;
|
|
207
258
|
message = "";
|
|
208
|
-
const matches = visible();
|
|
259
|
+
const matches = visible(), off = order === "last-only";
|
|
209
260
|
const { x, y } = m;
|
|
261
|
+
const button = y === hit.buttonRow ? hit.buttonHits.find(([, a, b]) => x >= a && x < b)?.[0] : undefined;
|
|
262
|
+
const onCountries = y === hit.searchRow || (y >= hit.firstRow && y < hit.firstRow + hit.shown) || button === "all" || button === "none";
|
|
263
|
+
if (off && (m.button === 64 || m.button === 65))
|
|
264
|
+
return; // the skipped countries don't scroll
|
|
210
265
|
if (m.button === 64)
|
|
211
266
|
cursor = Math.max(0, cursor - 3); // wheel up
|
|
212
267
|
else if (m.button === 65)
|
|
213
268
|
cursor = Math.min(matches.length - 1, cursor + 3); // wheel down
|
|
214
269
|
else if (m.button !== 0)
|
|
215
270
|
return; // left button only
|
|
271
|
+
else if (off && (onCountries || y === hit.protoRow || y === hit.portsRow))
|
|
272
|
+
message = SKIPPED;
|
|
216
273
|
else if (y === hit.searchRow)
|
|
217
274
|
searching = true, editing = false;
|
|
218
275
|
else if (y === hit.timeoutRow)
|
|
@@ -236,19 +293,20 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
236
293
|
speedTest = !speedTest;
|
|
237
294
|
else if (y === hit.firewallRow)
|
|
238
295
|
firewall = !firewall;
|
|
239
|
-
else if (y === hit.randomRow)
|
|
240
|
-
random = !random;
|
|
241
296
|
else if (y === hit.dockerRow)
|
|
242
297
|
return void toggleDocker();
|
|
298
|
+
else if (y === hit.orderRow)
|
|
299
|
+
cycleOrder();
|
|
300
|
+
else if (y === hit.stopRow)
|
|
301
|
+
stopAtFirst = !stopAtFirst;
|
|
243
302
|
else if (y === hit.buttonRow) {
|
|
244
|
-
|
|
245
|
-
if (id === "start" && start())
|
|
303
|
+
if (button === "start" && start())
|
|
246
304
|
return;
|
|
247
|
-
if (
|
|
305
|
+
if (button === "quit")
|
|
248
306
|
return finish(null);
|
|
249
|
-
if (
|
|
307
|
+
if (button === "all")
|
|
250
308
|
matches.forEach((c) => selected.add(c));
|
|
251
|
-
if (
|
|
309
|
+
if (button === "none")
|
|
252
310
|
matches.forEach((c) => selected.delete(c));
|
|
253
311
|
}
|
|
254
312
|
}
|
|
@@ -267,9 +325,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
267
325
|
flip(selected, c);
|
|
268
326
|
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
269
327
|
};
|
|
328
|
+
// only the last results: the keys for the countries, protocols and ports change nothing
|
|
329
|
+
const skipped = order === "last-only" && !editing && !searching
|
|
330
|
+
&& (["up", "down", "pageup", "pagedown", "home", "end", "j", "k", "space", "a", "n", "p"].includes(k ?? "") || str === "/" || /^[1-9]$/.test(str ?? ""));
|
|
270
331
|
if (key.ctrl && k === "c")
|
|
271
332
|
return finish(null);
|
|
272
|
-
if (
|
|
333
|
+
if (skipped)
|
|
334
|
+
message = SKIPPED;
|
|
335
|
+
else if (k === "up")
|
|
273
336
|
cursor = Math.max(0, cursor - 1);
|
|
274
337
|
else if (k === "down")
|
|
275
338
|
cursor = Math.min(matches.length - 1, cursor + 1);
|
|
@@ -318,12 +381,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, do
|
|
|
318
381
|
speedTest = !speedTest;
|
|
319
382
|
else if (k === "f")
|
|
320
383
|
firewall = !firewall;
|
|
321
|
-
else if (k === "r")
|
|
322
|
-
random = !random;
|
|
323
384
|
else if (k === "t")
|
|
324
385
|
editing = true, seconds = "";
|
|
325
386
|
else if (k === "d")
|
|
326
387
|
return void toggleDocker();
|
|
388
|
+
else if (k === "o")
|
|
389
|
+
cycleOrder();
|
|
390
|
+
else if (k === "w")
|
|
391
|
+
stopAtFirst = !stopAtFirst;
|
|
327
392
|
else if (k === "u" && canUpdate())
|
|
328
393
|
return finish("update");
|
|
329
394
|
else if (/^[1-9]$/.test(str ?? "") && PROTOCOLS[Number(str) - 1])
|
package/dist/tui/progress.js
CHANGED
|
@@ -7,11 +7,13 @@ import { countryName, flag } from "./country.js";
|
|
|
7
7
|
import { matrix, resultTable } from "./tables.js";
|
|
8
8
|
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
9
9
|
import { bold, cyan, dim, green, red, vw, yellow } from "./text.js";
|
|
10
|
-
import { bar, box, centerIn } from "./widgets.js";
|
|
10
|
+
import { bar, box, centerIn, spin } from "./widgets.js";
|
|
11
11
|
/** Progress bar, current attempt, results so far. tab switches the table, p pauses, q stops (twice: quits).
|
|
12
12
|
* blocking: we turned the firewall on for the scan */
|
|
13
13
|
export function showProgress(scan, blocking) {
|
|
14
|
-
const { protocols, options: { timeout
|
|
14
|
+
const { protocols, options: { timeout } } = scan;
|
|
15
|
+
const speedTest = scan.options.speedTest || Boolean(scan.options.stopAtFirst); // stop at the first measures speed too
|
|
16
|
+
let quitting = false; // q pressed: a second one quits right away
|
|
15
17
|
let view = speedTest ? "ranking" : "availability"; // tab switches
|
|
16
18
|
let frame = 0;
|
|
17
19
|
const render = () => {
|
|
@@ -22,7 +24,7 @@ export function showProgress(scan, blocking) {
|
|
|
22
24
|
const partial = slots.reduce((sum, s) => sum + (s.current ? Math.min(0.95, (now() - s.attemptStart) / attemptSeconds(timeout)) : 0), 0);
|
|
23
25
|
const fraction = (done + (done < total ? partial : 0)) / total;
|
|
24
26
|
const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout) / slots.length);
|
|
25
|
-
const spinner =
|
|
27
|
+
const spinner = spin(frame++);
|
|
26
28
|
const ok = sortOk(results);
|
|
27
29
|
// blocked: nothing worked, and something was really tried (a key limit or a port the app doesn't offer tries nothing)
|
|
28
30
|
const failed = finished.filter((c) => !results.some((r) => r.query === c && r.status === "ok")
|
|
@@ -51,9 +53,10 @@ export function showProgress(scan, blocking) {
|
|
|
51
53
|
: [];
|
|
52
54
|
const width = Math.max(minW, table.length ? vw(table[0]) : 0);
|
|
53
55
|
const inner = width - 4;
|
|
54
|
-
const status =
|
|
55
|
-
:
|
|
56
|
-
: ""
|
|
56
|
+
const status = scan.found ? green(" ✓ a connection held up · stopping the other attempts…")
|
|
57
|
+
: stopping ? yellow(` stopping after the current attempt${many ? "s" : ""}…`)
|
|
58
|
+
: waiting ? yellow(` paused · VPN disconnected${blocking ? " · firewall blocking internet" : ""} · press p to resume`)
|
|
59
|
+
: "";
|
|
57
60
|
const pausing = paused && !waiting && frame % 8 < 5;
|
|
58
61
|
const slotLine = (s) => {
|
|
59
62
|
if (!s.current)
|
|
@@ -102,12 +105,13 @@ export function showProgress(scan, blocking) {
|
|
|
102
105
|
render();
|
|
103
106
|
}
|
|
104
107
|
else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
105
|
-
if (
|
|
108
|
+
if (quitting) { // second press: bail out now
|
|
106
109
|
leaveScreen();
|
|
107
110
|
console.error(`stopped. the VPN may still be connecting: run '${CLI} disconnect'`);
|
|
108
111
|
await restoreFirewall();
|
|
109
112
|
process.exit(130);
|
|
110
113
|
}
|
|
114
|
+
quitting = true;
|
|
111
115
|
scan.stop();
|
|
112
116
|
render();
|
|
113
117
|
}
|
|
@@ -126,11 +130,13 @@ const clip = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" :
|
|
|
126
130
|
/** A line per attempt. Ctrl-C stops after the current attempt, a second one quits */
|
|
127
131
|
export function logProgress(scan) {
|
|
128
132
|
const many = scan.slots.length > 1; // attempts overlap: a whole line once each is done
|
|
133
|
+
let quitting = false; // Ctrl-C pressed: a second one quits right away
|
|
129
134
|
process.on("SIGINT", async () => {
|
|
130
|
-
if (
|
|
135
|
+
if (quitting) {
|
|
131
136
|
await restoreFirewall();
|
|
132
137
|
process.exit(130);
|
|
133
138
|
}
|
|
139
|
+
quitting = true;
|
|
134
140
|
scan.stop();
|
|
135
141
|
});
|
|
136
142
|
return {
|
package/dist/tui/tables.js
CHANGED
|
@@ -14,7 +14,7 @@ const resultRow = (r, i) => [
|
|
|
14
14
|
r.connectS === null ? "-" : `${fmt(r.connectS, 1)} s`,
|
|
15
15
|
r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`,
|
|
16
16
|
r.speedMbps === null ? "-" : bold(`${fmt(r.speedMbps, 1)} Mbps`),
|
|
17
|
-
r.status === "ok" ? green("✓ ok") :
|
|
17
|
+
r.status === "ok" ? green("✓ ok") : ["key limit", "no such port", "unreliable"].includes(r.status) ? yellow(r.status) : red(r.status),
|
|
18
18
|
];
|
|
19
19
|
/** Numbered attempts, in the given order; the Speed column only when the speed test ran */
|
|
20
20
|
export function resultTable(results, speedTest, options) {
|
package/dist/tui/terminal.js
CHANGED
|
@@ -13,6 +13,8 @@ export const input = new PassThrough();
|
|
|
13
13
|
emitKeypressEvents(input);
|
|
14
14
|
let fullscreen = false;
|
|
15
15
|
let link = null; // where draw() last put the repo link
|
|
16
|
+
let waiting; // drawUntilNext()'s timer
|
|
17
|
+
let redrawing = false; // drawUntilNext()'s own redraw is drawing
|
|
16
18
|
const openUrl = (url) => spawn({ darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open", [url], { stdio: "ignore", detached: true })
|
|
17
19
|
.on("error", () => { }).unref();
|
|
18
20
|
// SGR mouse report: ESC [ < button ; col ; row (M = press, m = release)
|
|
@@ -40,6 +42,7 @@ export function enterScreen() {
|
|
|
40
42
|
}
|
|
41
43
|
/** Back to the normal screen; drops every key and mouse listener */
|
|
42
44
|
export function leaveScreen() {
|
|
45
|
+
clearInterval(waiting);
|
|
43
46
|
if (!fullscreen)
|
|
44
47
|
return;
|
|
45
48
|
fullscreen = false;
|
|
@@ -51,8 +54,26 @@ export function leaveScreen() {
|
|
|
51
54
|
out.write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
52
55
|
}
|
|
53
56
|
process.on("exit", leaveScreen);
|
|
57
|
+
/** Calls redraw every 120 ms (for a spinner) while the screen waits for the next one: until another screen draws, or leaveScreen() */
|
|
58
|
+
export function drawUntilNext(redraw) {
|
|
59
|
+
const tick = () => {
|
|
60
|
+
redrawing = true;
|
|
61
|
+
try {
|
|
62
|
+
redraw();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
redrawing = false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
clearInterval(waiting);
|
|
69
|
+
waiting = setInterval(tick, 120);
|
|
70
|
+
waiting.unref(); // never what keeps the process alive
|
|
71
|
+
tick();
|
|
72
|
+
}
|
|
54
73
|
/** Redraws in place, centered: home, each line cleared to its end, then clear below (no flicker). Returns the top-left corner */
|
|
55
74
|
export function draw(lines) {
|
|
75
|
+
if (!redrawing)
|
|
76
|
+
clearInterval(waiting); // another screen: it takes over from drawUntilNext()
|
|
56
77
|
const cols = out.columns || 80, rows = out.rows || 24;
|
|
57
78
|
const w = Math.max(...lines.map(vw));
|
|
58
79
|
const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
|
package/dist/tui/widgets.js
CHANGED
|
@@ -32,6 +32,8 @@ export function grid(cols, rows, { caption, captionRight = "", minWidth = 0, str
|
|
|
32
32
|
out.push(row(cols.map((c) => bold(c.title))), rule("├", "┼", "┤"), ...rows.map(row), rule("╰", "┴", "╯"));
|
|
33
33
|
return out;
|
|
34
34
|
}
|
|
35
|
+
/** A spinner's character for this frame */
|
|
36
|
+
export const spin = (frame) => "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame % 10];
|
|
35
37
|
/** s, indented to sit centered in width columns */
|
|
36
38
|
export const centerIn = (s, width) => " ".repeat(Math.max(0, Math.floor((width - vw(s)) / 2))) + s;
|
|
37
39
|
/** Gradient bar (cyan → green) with eighth-block precision on the leading edge */
|
package/dist/worker.js
CHANGED
|
@@ -55,9 +55,9 @@ export async function worker() {
|
|
|
55
55
|
if (!m.probe)
|
|
56
56
|
continue;
|
|
57
57
|
stop = false;
|
|
58
|
-
const { query, protocol, timeout, speedTest } = m.probe;
|
|
58
|
+
const { query, protocol, timeout, speedTest, steady } = m.probe;
|
|
59
59
|
const result = await probe(query, protocol, {
|
|
60
|
-
timeout, speedTest,
|
|
60
|
+
timeout, speedTest, steady,
|
|
61
61
|
stopped: () => stop,
|
|
62
62
|
onStep: (step) => send({ step }),
|
|
63
63
|
measure: async (fn) => {
|