windscout 0.6.5 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -7
- package/dist/cli.js +122 -43
- package/dist/config.js +47 -2
- package/dist/docker.js +5 -5
- package/dist/measure.js +1 -1
- package/dist/probe.js +83 -45
- package/dist/scan.js +43 -22
- package/dist/tui/docker.js +2 -2
- package/dist/tui/picker.js +134 -65
- package/dist/tui/progress.js +20 -12
- 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/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,17 +1,34 @@
|
|
|
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;
|
|
11
29
|
protocols;
|
|
12
30
|
options;
|
|
13
31
|
probers;
|
|
14
|
-
after;
|
|
15
32
|
results = [];
|
|
16
33
|
finished = []; // countries with every attempt done: the views refresh their tables per country
|
|
17
34
|
errors = []; // why slots stopped early
|
|
@@ -20,6 +37,7 @@ export class Scan {
|
|
|
20
37
|
started = now();
|
|
21
38
|
done = 0;
|
|
22
39
|
stopping = false; // stop after the current attempts
|
|
40
|
+
found = false; // stopAtFirst: a connection held up, so the scan is stopping
|
|
23
41
|
// a pause lands between attempts, so the VPN is disconnected while waiting
|
|
24
42
|
paused = false;
|
|
25
43
|
waiting = false; // paused, and no attempt running
|
|
@@ -27,16 +45,13 @@ export class Scan {
|
|
|
27
45
|
idle = 0; // seconds spent paused
|
|
28
46
|
resume = () => { };
|
|
29
47
|
unpaused = Promise.resolve();
|
|
30
|
-
constructor(countries, protocols, options, probers = [probe]
|
|
48
|
+
constructor(countries, protocols, options, probers = [probe]) {
|
|
31
49
|
this.countries = countries;
|
|
32
50
|
this.protocols = protocols;
|
|
33
51
|
this.options = options;
|
|
34
52
|
this.probers = probers;
|
|
35
|
-
this.
|
|
36
|
-
this.total = countries.length * protocols.length;
|
|
53
|
+
this.total = this.attempts().length;
|
|
37
54
|
this.slots = probers.map(() => ({ current: null, step: "", attemptStart: now() }));
|
|
38
|
-
if (after)
|
|
39
|
-
this.slots.push({ current: null, step: "", attemptStart: now(), name: after.name });
|
|
40
55
|
}
|
|
41
56
|
/** Seconds spent scanning, pauses not counted */
|
|
42
57
|
elapsed() {
|
|
@@ -59,19 +74,22 @@ export class Scan {
|
|
|
59
74
|
this.waiting = false;
|
|
60
75
|
this.resume();
|
|
61
76
|
}
|
|
77
|
+
attempts() {
|
|
78
|
+
return this.options.only?.map((a) => ({ ...a }))
|
|
79
|
+
?? this.countries.flatMap((country) => this.protocols.map((protocol) => ({ country, protocol })));
|
|
80
|
+
}
|
|
62
81
|
async run(hooks = {}) {
|
|
63
|
-
const queue = this.
|
|
64
|
-
const left = new Map(
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
await this.work(this.after.prober, this.slots[this.probers.length], () => true, queue, left, hooks);
|
|
82
|
+
const queue = this.attempts();
|
|
83
|
+
const left = new Map(); // attempts to go, per country
|
|
84
|
+
for (const { country } of queue)
|
|
85
|
+
left.set(country, (left.get(country) ?? 0) + 1);
|
|
86
|
+
// when every slot's container stops, what they left isn't tried: scan.errors says why
|
|
87
|
+
await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], queue, left, hooks)));
|
|
70
88
|
return this.results;
|
|
71
89
|
}
|
|
72
|
-
/** One slot: takes the attempts
|
|
73
|
-
async work(prober, slot,
|
|
74
|
-
const { timeout, speedTest } = this.options;
|
|
90
|
+
/** One slot: takes the attempts from the queue, one at a time, until there are none */
|
|
91
|
+
async work(prober, slot, queue, left, hooks) {
|
|
92
|
+
const { timeout, speedTest, stopAtFirst = false } = this.options;
|
|
75
93
|
for (;;) {
|
|
76
94
|
while (this.paused && !this.stopping) {
|
|
77
95
|
if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
|
|
@@ -80,16 +98,15 @@ export class Scan {
|
|
|
80
98
|
}
|
|
81
99
|
await this.unpaused;
|
|
82
100
|
}
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
101
|
+
const next = this.stopping ? undefined : queue.shift();
|
|
102
|
+
if (!next)
|
|
85
103
|
return;
|
|
86
|
-
const [next] = queue.splice(i, 1);
|
|
87
104
|
const { country, protocol } = next;
|
|
88
105
|
Object.assign(slot, { current: next, step: "", attemptStart: now() });
|
|
89
106
|
hooks.attempt?.(country, protocol);
|
|
90
107
|
let r;
|
|
91
108
|
try {
|
|
92
|
-
r = await prober(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
|
|
109
|
+
r = await prober(country, protocol, { timeout, speedTest, steady: stopAtFirst, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
|
|
93
110
|
}
|
|
94
111
|
catch (e) { // this slot is done for (its container stopped): the others take over its attempt
|
|
95
112
|
queue.unshift(next);
|
|
@@ -109,6 +126,10 @@ export class Scan {
|
|
|
109
126
|
if (!left.get(country))
|
|
110
127
|
this.finished.push(country);
|
|
111
128
|
hooks.result?.(r);
|
|
129
|
+
if (stopAtFirst && reliable(r) && !this.stopping) {
|
|
130
|
+
this.found = true;
|
|
131
|
+
this.stop();
|
|
132
|
+
}
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
135
|
}
|
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"))
|