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/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
- import { latencyMs, speedMbps } from "./measure.js";
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, { timeout, speedTest, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
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
- try {
14
- for (;;) {
15
- if (stopped())
16
- return end("interrupted");
17
- if (now() - t >= timeout)
18
- return end("timeout");
19
- const s = await connectState();
20
- if (s.startsWith("Connecting:"))
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
- else if (s.startsWith("Connected")) {
23
- if (s.includes(":"))
24
- r.location = place(s);
25
- r.connectS = now() - t;
26
- break;
27
- }
28
- else if (s.includes("does not exist"))
29
- return end("no such location");
30
- // 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
- // a port the app doesn't offer for the protocol: it connects on the protocol's default instead, so this port wasn't tried
39
- const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol: .*:(\d+)\s*$/m)?.[1];
40
- if (port && shown && shown !== port)
41
- return end("no such port");
42
- await measure(async () => {
43
- onStep(`measuring latency · ${r.location}`);
44
- r.latencyMs = await latencyMs();
45
- if (r.latencyMs !== null && speedTest) {
46
- onStep(`measuring speed · ${r.location}`);
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
- finally {
53
- onStep("disconnecting");
54
- await windscribe("disconnect");
55
- // the CLI's disconnect can return while the app is still tearing down (it does after a timed-out connect);
56
- // a connect sent then is swallowed and the next attempt reads as an instant "failed", so wait it out
57
- for (let i = 0; i < 30 && (await connectState()) === "Disconnecting"; i++)
58
- await sleep(1000);
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: every country × protocol pair, tried in order, with pause and stop.
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], after) {
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.after = after;
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.countries.flatMap((country) => this.protocols.map((protocol) => ({ country, protocol })));
64
- const left = new Map(this.countries.map((c) => [c, this.protocols.length])); // attempts to go, per country
65
- const later = this.after?.protocols ?? new Set();
66
- await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], (p) => !later.has(p), queue, left, hooks)));
67
- // then the rest, also what slots whose container stopped left over
68
- if (this.after)
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 it can make from the queue, one at a time, until there are none */
73
- async work(prober, slot, can, queue, left, hooks) {
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 i = this.stopping ? -1 : queue.findIndex((a) => can(a.protocol));
84
- if (i < 0)
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
  }
@@ -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("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
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"))