windscout 0.7.0 → 0.9.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 +89 -16
- package/dist/background.js +131 -0
- package/dist/cli.js +180 -121
- package/dist/config.js +44 -5
- package/dist/docker.js +123 -25
- package/dist/measure.js +12 -0
- package/dist/probe.js +50 -24
- package/dist/scan.js +8 -16
- package/dist/time.js +5 -0
- package/dist/tui/connect.js +29 -0
- package/dist/tui/picker.js +80 -63
- package/dist/tui/progress.js +6 -4
- package/dist/tui/report.js +14 -4
- package/dist/tui/results.js +454 -0
- package/dist/tui/text.js +19 -0
- package/dist/watch.js +454 -0
- package/dist/windscribe.js +58 -10
- package/dist/worker.js +8 -6
- package/package.json +1 -1
package/dist/watch.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// Keeping you connected, for the results screen. It holds the list of working connections, best first. You can connect to one
|
|
2
|
+
// (when it doesn't connect: back to the one you had, else the next ones), disconnect, or test them all again, which ranks them
|
|
3
|
+
// by what it finds. The watch checks the connection every few seconds, and when it's down, connects again: to the same one or
|
|
4
|
+
// the next best. While watching, a checker (background.ts) can try the others now and then without touching this computer's
|
|
5
|
+
// VPN. One whose check fails goes to the end of the list, since it may work again later, unless they all fail: that's your
|
|
6
|
+
// network, not them, so the list stays as it was. No terminal here: tui/results.ts shows it, and the tests drive it with a
|
|
7
|
+
// fake app and clock (KeepIo).
|
|
8
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
9
|
+
import { latencyMs } from "./measure.js";
|
|
10
|
+
import { attempt, disconnect, probe, target } from "./probe.js";
|
|
11
|
+
import { duration, now } from "./time.js";
|
|
12
|
+
import { appLogSize, connectStateOf, place, publicIpOf, toldToDisconnect, vpnIpOf, windscribe } from "./windscribe.js";
|
|
13
|
+
export const WATCH_GAP = 3; // seconds between the checks
|
|
14
|
+
export const WATCH_MISSES = 3; // checks in a row that find it down (not connected, or no internet through it) before acting
|
|
15
|
+
export const WATCH_KEEP = 300; // seconds: one that lasted this long is tried again first when it drops; one that didn't, last
|
|
16
|
+
export const WATCH_RETRY = 30; // seconds to wait when none connects, before trying them all again from the best
|
|
17
|
+
export const CHECK_EVERY = 300; // seconds between the background checks, while watching
|
|
18
|
+
const TRIPS = 20; // round trips kept for the view
|
|
19
|
+
// what says a connection doesn't work from here; a container's key limit or missing port, or a stopped attempt, says nothing
|
|
20
|
+
const FAILURES = ["timeout", "failed", "no internet", "no such location", "no such port", "key limit", "unreliable"];
|
|
21
|
+
const CHECK_FAILURES = ["timeout", "failed", "no internet", "no such location", "unreliable"];
|
|
22
|
+
/** "Frankfurt - Castle over stealth:443" */
|
|
23
|
+
export const label = (r) => `${r.location || r.query} over ${r.protocol}`;
|
|
24
|
+
const key = (r) => `${r.query} ${r.protocol}`;
|
|
25
|
+
/** The working ones first, best first; then the ones whose last check failed, in the order they went there */
|
|
26
|
+
export function ranked(entries, sort) {
|
|
27
|
+
const latency = (x, y) => (x.latencyMs ?? 1e9) - (y.latencyMs ?? 1e9);
|
|
28
|
+
const speed = (x, y) => (y.speedMbps ?? -1) - (x.speedMbps ?? -1);
|
|
29
|
+
const by = sort === "speed" ? (x, y) => speed(x, y) || latency(x, y) : (x, y) => latency(x, y) || speed(x, y);
|
|
30
|
+
return [...entries.filter((e) => !e.failed).sort(by), ...entries.filter((e) => e.failed)];
|
|
31
|
+
}
|
|
32
|
+
/** The app's side of KeepIo, connecting the way the scan does; save keeps the list */
|
|
33
|
+
export const appIo = (timeout, save) => ({
|
|
34
|
+
status: async () => {
|
|
35
|
+
const s = await windscribe("status"), state = connectStateOf(s);
|
|
36
|
+
return { state, ip: state.startsWith("Connected") ? vpnIpOf(s) : publicIpOf(s) };
|
|
37
|
+
},
|
|
38
|
+
ping: () => latencyMs(1),
|
|
39
|
+
// nickname: the exact datacenter the list shows, as the connect command in the report does
|
|
40
|
+
attempt: (e, { stopped, onStep }) => attempt(target(e), e.protocol, { timeout, speedTest: false, stopped, onStep }),
|
|
41
|
+
probe: (e, speedTest, { stopped, onStep }) => probe(target(e), e.protocol, { timeout, speedTest, stopped, onStep }),
|
|
42
|
+
disconnect,
|
|
43
|
+
logSize: appLogSize,
|
|
44
|
+
told: toldToDisconnect,
|
|
45
|
+
sleep: (seconds, signal) => wait(seconds * 1000, undefined, { signal }),
|
|
46
|
+
now,
|
|
47
|
+
save,
|
|
48
|
+
});
|
|
49
|
+
const marks = () => ({ was: new Map() });
|
|
50
|
+
/** Owns the VPN connection while the results screen is open; the view reads the fields and calls the methods */
|
|
51
|
+
export class Keeper {
|
|
52
|
+
entries;
|
|
53
|
+
sort;
|
|
54
|
+
watch;
|
|
55
|
+
io;
|
|
56
|
+
phase = "idle";
|
|
57
|
+
current = null; // the one it connected you to, while it's yours
|
|
58
|
+
app = { state: "", ip: "" }; // what the app said at the last check
|
|
59
|
+
since = 0; // when current came up
|
|
60
|
+
ms = null; // the last round trip through it
|
|
61
|
+
trips = []; // the last round trips, oldest first; null: it didn't get through
|
|
62
|
+
misses = 0; // checks in a row that found it down
|
|
63
|
+
drops = 0; // times it connected you again
|
|
64
|
+
lost = null; // the one that dropped, while connecting again
|
|
65
|
+
trying = null; // the attempt running now
|
|
66
|
+
// the test running now; had: the connection it puts back afterwards
|
|
67
|
+
test = null;
|
|
68
|
+
retryAt = 0; // none connected: when it tries them all again
|
|
69
|
+
events = []; // what happened, last ones last
|
|
70
|
+
checker = null; // the background checks, while they run
|
|
71
|
+
checkAt = 0; // when the next background check is due
|
|
72
|
+
lastCheck = null; // the last background check's outcome
|
|
73
|
+
changed = false; // the list learned something since the screen opened
|
|
74
|
+
closed = false;
|
|
75
|
+
queued = null; // a command waiting for what's running to stop
|
|
76
|
+
action = new AbortController(); // the running action: a new command, or close(), ends it
|
|
77
|
+
nap = new AbortController(); // the running wait: anything new ends it early
|
|
78
|
+
napBackground = new AbortController();
|
|
79
|
+
backgroundRun = 0; // the background loop that's on: an older one ends
|
|
80
|
+
backgroundStop = Promise.resolve(); // the checker stopping
|
|
81
|
+
cancelled = false; // esc: stops what's running
|
|
82
|
+
finishing = false; // an attempt that close(true) lets finish
|
|
83
|
+
finishOnClose = false;
|
|
84
|
+
dirty = false; // the list changed since it was last saved
|
|
85
|
+
where = ""; // where the app said it connected to
|
|
86
|
+
logAt = 0; // the app log's size at the last check that found it up: a disconnect it's told to make lands after it
|
|
87
|
+
constructor(entries, sort, watch, io) {
|
|
88
|
+
this.entries = entries;
|
|
89
|
+
this.sort = sort;
|
|
90
|
+
this.watch = watch;
|
|
91
|
+
this.io = io;
|
|
92
|
+
}
|
|
93
|
+
ranked() {
|
|
94
|
+
return ranked(this.entries, this.sort);
|
|
95
|
+
}
|
|
96
|
+
connect(entry) {
|
|
97
|
+
this.command({ kind: "connect", entry });
|
|
98
|
+
}
|
|
99
|
+
disconnect() {
|
|
100
|
+
this.command({ kind: "disconnect" });
|
|
101
|
+
}
|
|
102
|
+
/** Tests them all again, one at a time on this computer, and ranks them by what it finds; then puts the VPN back */
|
|
103
|
+
retest(speed) {
|
|
104
|
+
this.command({ kind: "test", speed });
|
|
105
|
+
}
|
|
106
|
+
/** esc: stops a test (the VPN then goes back as it was), or the connecting */
|
|
107
|
+
cancel() {
|
|
108
|
+
this.cancelled = true;
|
|
109
|
+
this.nap.abort();
|
|
110
|
+
}
|
|
111
|
+
setWatch(on) {
|
|
112
|
+
this.watch = on;
|
|
113
|
+
if (!on && (this.phase === "reconnecting" || this.phase === "none"))
|
|
114
|
+
this.cancel(); // no connecting again by itself now
|
|
115
|
+
this.nap.abort();
|
|
116
|
+
this.napBackground.abort();
|
|
117
|
+
}
|
|
118
|
+
/** Ends run() and the background checks. finish (quitting): the connection you asked for still gets its attempt, and a test
|
|
119
|
+
* still puts back the connection you had; else (a new scan) everything stops right away */
|
|
120
|
+
close(finish = false) {
|
|
121
|
+
this.closed = true;
|
|
122
|
+
this.finishOnClose = finish;
|
|
123
|
+
if (!finish)
|
|
124
|
+
this.action.abort();
|
|
125
|
+
this.nap.abort();
|
|
126
|
+
this.napBackground.abort();
|
|
127
|
+
}
|
|
128
|
+
/** Until close(): runs the commands, one at a time, and checks the connection every WATCH_GAP s in between */
|
|
129
|
+
async run() {
|
|
130
|
+
if (!this.queued)
|
|
131
|
+
this.app = await this.io.status();
|
|
132
|
+
while (!this.closed) {
|
|
133
|
+
const c = this.queued;
|
|
134
|
+
this.queued = null;
|
|
135
|
+
this.action = new AbortController();
|
|
136
|
+
this.cancelled = false;
|
|
137
|
+
if (c)
|
|
138
|
+
await this.act(c, this.action.signal);
|
|
139
|
+
else {
|
|
140
|
+
await this.pause(WATCH_GAP);
|
|
141
|
+
if (!this.queued && !this.closed)
|
|
142
|
+
await this.check(this.action.signal);
|
|
143
|
+
}
|
|
144
|
+
if (this.dirty)
|
|
145
|
+
this.save();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** While watching: every CHECK_EVERY s (the first in firstIn s) the checker tries the others in the list (not the one you're
|
|
149
|
+
* on: the watch checks that one), and what it finds goes in the list, as a test's does. Until stopBackground() or close() */
|
|
150
|
+
async background(checker, firstIn = 0) {
|
|
151
|
+
const run = ++this.backgroundRun;
|
|
152
|
+
const on = () => !this.closed && this.backgroundRun === run;
|
|
153
|
+
const stopped = () => !on() || !this.watch;
|
|
154
|
+
const others = () => this.ranked().filter((e) => e !== this.current);
|
|
155
|
+
this.checker = checker;
|
|
156
|
+
this.checkAt = this.io.now() + firstIn;
|
|
157
|
+
if (checker.prepare)
|
|
158
|
+
await checker.prepare(others(), stopped);
|
|
159
|
+
while (on()) {
|
|
160
|
+
if (this.watch && !this.test && this.io.now() >= this.checkAt) {
|
|
161
|
+
const list = others();
|
|
162
|
+
const results = list.length ? await checker.check(list, stopped) : null;
|
|
163
|
+
if (results && !stopped() && !this.test)
|
|
164
|
+
this.found(list, results); // a test that started meanwhile finds out itself
|
|
165
|
+
this.checkAt = this.io.now() + CHECK_EVERY;
|
|
166
|
+
}
|
|
167
|
+
this.napBackground = new AbortController();
|
|
168
|
+
await this.io.sleep(this.watch ? Math.max(1, this.checkAt - this.io.now()) : 3600, this.napBackground.signal).catch(() => { });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Ends the background checks; resolves once the checker has stopped what it was doing */
|
|
172
|
+
stopBackground() {
|
|
173
|
+
const checker = this.checker;
|
|
174
|
+
this.backgroundRun++;
|
|
175
|
+
this.checker = null;
|
|
176
|
+
this.napBackground.abort();
|
|
177
|
+
if (checker)
|
|
178
|
+
this.backgroundStop = this.backgroundStop.then(() => checker.stop()).catch(() => { });
|
|
179
|
+
return this.backgroundStop;
|
|
180
|
+
}
|
|
181
|
+
command(c) {
|
|
182
|
+
this.queued = c;
|
|
183
|
+
this.action.abort();
|
|
184
|
+
this.nap.abort();
|
|
185
|
+
}
|
|
186
|
+
stopped(signal) {
|
|
187
|
+
return signal.aborted || this.cancelled || (this.closed && !this.finishing);
|
|
188
|
+
}
|
|
189
|
+
/** An attempt that quitting lets finish: so you end up connected, as you asked */
|
|
190
|
+
async tryToFinish(e, signal, m) {
|
|
191
|
+
this.finishing = true;
|
|
192
|
+
try {
|
|
193
|
+
return await this.tryOne(e, signal, m);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
this.finishing = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async act(c, signal) {
|
|
200
|
+
if (c.kind === "disconnect") {
|
|
201
|
+
Object.assign(this, { current: null, lost: null, phase: "idle" });
|
|
202
|
+
await this.io.disconnect();
|
|
203
|
+
this.app = await this.io.status();
|
|
204
|
+
this.event("disconnected");
|
|
205
|
+
}
|
|
206
|
+
else if (c.kind === "connect")
|
|
207
|
+
await this.switchTo(c.entry, signal);
|
|
208
|
+
else
|
|
209
|
+
await this.retestAll(c.speed, signal);
|
|
210
|
+
}
|
|
211
|
+
/** Connects to e. When it doesn't connect: back to the one you had, else the next ones after it, to get you online */
|
|
212
|
+
async switchTo(e, signal) {
|
|
213
|
+
const had = this.phase === "connected" ? this.current : null; // a working connection to go back to
|
|
214
|
+
const list = this.ranked(), at = list.indexOf(e);
|
|
215
|
+
const after = [...list.slice(at + 1), ...list.slice(0, Math.max(0, at))]; // around to the one before it
|
|
216
|
+
const m = marks();
|
|
217
|
+
Object.assign(this, { phase: "connecting", current: null, lost: null });
|
|
218
|
+
await this.io.disconnect(); // one still up would read as this one connecting
|
|
219
|
+
if (await this.tryToFinish(e, signal, m))
|
|
220
|
+
return;
|
|
221
|
+
if (this.stopped(signal))
|
|
222
|
+
return this.halt();
|
|
223
|
+
if (had && had !== e) {
|
|
224
|
+
if (await this.tryOne(had, signal, m))
|
|
225
|
+
return this.event(`${label(e)} didn't connect: back on ${label(had)}`);
|
|
226
|
+
if (this.stopped(signal))
|
|
227
|
+
return this.halt();
|
|
228
|
+
}
|
|
229
|
+
const next = after.filter((x) => x !== had);
|
|
230
|
+
if (next.length)
|
|
231
|
+
this.event(`${label(e)} didn't connect: trying the next ones`);
|
|
232
|
+
await this.tryAll(next, signal, m);
|
|
233
|
+
}
|
|
234
|
+
/** Tries each in turn until one connects. When none does, they all failed, which says more about your network than about
|
|
235
|
+
* them: the list goes back as it was (m). While watching, it then tries them all again from the best every WATCH_RETRY s */
|
|
236
|
+
async tryAll(order, signal, m) {
|
|
237
|
+
for (;;) {
|
|
238
|
+
for (const [k, e] of order.entries()) {
|
|
239
|
+
if (this.stopped(signal))
|
|
240
|
+
return this.halt(), false;
|
|
241
|
+
if (await this.tryOne(e, signal, m, k + 1, order.length))
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (this.stopped(signal))
|
|
245
|
+
return this.halt(), false;
|
|
246
|
+
this.undo(m);
|
|
247
|
+
this.phase = "none";
|
|
248
|
+
if (!this.watch) {
|
|
249
|
+
this.event(this.entries.length > 1 ? `none of the ${this.entries.length} connects now` : "it doesn't connect now");
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
this.event(`none of them connects now: trying them all again in ${duration(WATCH_RETRY)}`);
|
|
253
|
+
if (!(await this.waitRetry(signal)))
|
|
254
|
+
return this.halt(), false;
|
|
255
|
+
order = this.ranked();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** WATCH_RETRY s, watching the app: false when it's stopped meanwhile, or you connected in the app yourself */
|
|
259
|
+
async waitRetry(signal) {
|
|
260
|
+
this.retryAt = this.io.now() + WATCH_RETRY;
|
|
261
|
+
while (this.io.now() < this.retryAt) {
|
|
262
|
+
await this.pause(Math.min(WATCH_GAP, this.retryAt - this.io.now()));
|
|
263
|
+
if (this.stopped(signal))
|
|
264
|
+
return false;
|
|
265
|
+
this.app = await this.io.status();
|
|
266
|
+
if (/^Connect(ed|ing)/.test(this.app.state)) {
|
|
267
|
+
this.event("you're connecting in the app: windscout leaves it to you");
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
/** One attempt at e: true once it's up; a failure goes to the end of the list (m keeps what it was) */
|
|
274
|
+
async tryOne(e, signal, m, n = 1, of = 1) {
|
|
275
|
+
this.trying = { entry: e, step: "connecting", start: this.io.now(), n, of };
|
|
276
|
+
const r = await this.io.attempt(e, { stopped: () => this.stopped(signal), onStep: (s) => this.trying && (this.trying.step = s) });
|
|
277
|
+
this.trying = null;
|
|
278
|
+
if (r.status === "ok") {
|
|
279
|
+
this.learn(e, r, false);
|
|
280
|
+
Object.assign(this, { current: e, phase: "connected", lost: null, since: this.io.now(), misses: 0, ms: r.latencyMs,
|
|
281
|
+
trips: [r.latencyMs], where: r.location, logAt: this.io.logSize() });
|
|
282
|
+
this.app = await this.io.status(); // the VPN's IP now, not the one from before
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
await this.io.disconnect(); // so the next connect isn't swallowed
|
|
286
|
+
if (FAILURES.includes(r.status))
|
|
287
|
+
this.demote(e, r.status, m);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
/** Stopped while connecting: disconnected, unless it got through */
|
|
291
|
+
halt() {
|
|
292
|
+
this.trying = null;
|
|
293
|
+
if (!this.current)
|
|
294
|
+
this.phase = "idle";
|
|
295
|
+
}
|
|
296
|
+
/** A check of the connection: still where it connected, with internet through it. When WATCH_MISSES checks in a row find it
|
|
297
|
+
* down, and watching, connects again. When the app was told to disconnect (or restarted), or is connected somewhere else,
|
|
298
|
+
* you're using it yourself: windscout lets it be */
|
|
299
|
+
async check(signal) {
|
|
300
|
+
const logAt = this.io.logSize();
|
|
301
|
+
this.app = await this.io.status();
|
|
302
|
+
const { state } = this.app, connected = state.startsWith("Connected"), there = place(state);
|
|
303
|
+
if (!this.current) {
|
|
304
|
+
if (this.phase === "none" && /^Connect(ed|ing)/.test(state))
|
|
305
|
+
this.phase = "idle"; // you connected in the app
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (connected && there && this.where && there !== this.where) {
|
|
309
|
+
this.event(`you connected to ${there} in the app: windscout leaves it to you`);
|
|
310
|
+
return void Object.assign(this, { current: null, phase: "idle" });
|
|
311
|
+
}
|
|
312
|
+
this.ms = connected ? await this.io.ping() : null;
|
|
313
|
+
this.trips = [...this.trips, this.ms].slice(-TRIPS);
|
|
314
|
+
if (this.ms !== null) {
|
|
315
|
+
Object.assign(this, { misses: 0, logAt, phase: "connected" });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// you disconnected (or a script did, or the app was quit): yours to do, not a drop
|
|
319
|
+
if (this.io.told(this.logAt)) {
|
|
320
|
+
this.event("you disconnected in the app: windscout leaves it to you");
|
|
321
|
+
return void Object.assign(this, { current: null, phase: "idle" });
|
|
322
|
+
}
|
|
323
|
+
if (++this.misses < WATCH_MISSES || this.closed)
|
|
324
|
+
return;
|
|
325
|
+
if (this.watch)
|
|
326
|
+
await this.failover(connected ? "no internet through it" : state || "not connected", signal);
|
|
327
|
+
else
|
|
328
|
+
this.phase = "down"; // shows it; the app may still bring it back
|
|
329
|
+
}
|
|
330
|
+
/** It dropped: connects again, to the same one first if it lasted WATCH_KEEP s, else to the next ones first */
|
|
331
|
+
async failover(why, signal) {
|
|
332
|
+
const dropped = this.current, lasted = this.io.now() - this.since, m = marks();
|
|
333
|
+
this.event(`✗ ${label(dropped)}: ${why} · it lasted ${duration(lasted)}`);
|
|
334
|
+
Object.assign(this, { lost: { entry: dropped, why, lasted }, phase: "reconnecting", current: null });
|
|
335
|
+
await this.io.disconnect();
|
|
336
|
+
// a hiccup in one that lasted: the same again; one that dropped soon is likely blocked, so it goes to the end
|
|
337
|
+
if (lasted < WATCH_KEEP)
|
|
338
|
+
this.demote(dropped, `dropped after ${duration(lasted)}`, m);
|
|
339
|
+
const order = lasted >= WATCH_KEEP ? [dropped, ...this.ranked().filter((e) => e !== dropped)] : this.ranked();
|
|
340
|
+
if (await this.tryAll(order, signal, m)) {
|
|
341
|
+
this.drops++;
|
|
342
|
+
this.event(`✓ connected again: ${label(this.current)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** Each in the list, in its order, on this computer: connects, measures, disconnects. Then ranks them by what it found,
|
|
346
|
+
* the failed ones last, and connects you again: to the one you had if it still works, else the best */
|
|
347
|
+
async retestAll(speed, signal) {
|
|
348
|
+
const had = this.phase === "connected" || this.phase === "down" ? this.current : null;
|
|
349
|
+
const list = this.ranked(), m = marks();
|
|
350
|
+
let what = speed ? "speed test" : "latency test";
|
|
351
|
+
Object.assign(this, { phase: "testing", current: null, lost: null });
|
|
352
|
+
const test = this.test = { speed, entry: null, step: "", start: this.io.now(), done: 0, total: list.length, had };
|
|
353
|
+
await this.io.disconnect();
|
|
354
|
+
let worked = 0, failed = 0;
|
|
355
|
+
for (const e of list) {
|
|
356
|
+
if (this.stopped(signal))
|
|
357
|
+
break;
|
|
358
|
+
Object.assign(test, { entry: e, step: "connecting" });
|
|
359
|
+
const r = await this.io.probe(e, speed, { stopped: () => this.stopped(signal), onStep: (s) => (test.step = s) });
|
|
360
|
+
if (r.status === "interrupted")
|
|
361
|
+
break;
|
|
362
|
+
if (r.status === "ok")
|
|
363
|
+
worked++, this.learn(e, r, speed);
|
|
364
|
+
else if (FAILURES.includes(r.status))
|
|
365
|
+
failed++, this.demote(e, r.status, m);
|
|
366
|
+
test.done++;
|
|
367
|
+
}
|
|
368
|
+
Object.assign(this, { test: null, phase: "idle" });
|
|
369
|
+
if (test.done < list.length)
|
|
370
|
+
what += ` (stopped after ${test.done} of ${list.length})`;
|
|
371
|
+
if (!worked && failed > 1) {
|
|
372
|
+
this.undo(m);
|
|
373
|
+
this.event(`${what}: none of them connected · is your network down? The list stays as it was`);
|
|
374
|
+
}
|
|
375
|
+
else if (worked + failed) {
|
|
376
|
+
this.event(`${what}: ${worked} of ${worked + failed} work${failed ? ` · ${failed === 1 ? "the one that didn't" : `the ${failed} that didn't`} went to the end` : ""}`);
|
|
377
|
+
}
|
|
378
|
+
if (signal.aborted || !had)
|
|
379
|
+
return; // another command takes over, or there's nothing to go back to
|
|
380
|
+
this.phase = "connecting";
|
|
381
|
+
if (this.closed) { // quitting: back on the one you had (else the best), one attempt
|
|
382
|
+
const back = had.failed ? this.ranked().find((e) => !e.failed) : had;
|
|
383
|
+
if (!this.finishOnClose || !back)
|
|
384
|
+
return this.halt();
|
|
385
|
+
this.cancelled = false;
|
|
386
|
+
return void (await this.tryToFinish(back, signal, marks()), this.halt());
|
|
387
|
+
}
|
|
388
|
+
this.cancelled = false; // esc stopped the test: the VPN still goes back
|
|
389
|
+
await this.tryAll(had.failed ? this.ranked() : [had, ...this.ranked().filter((e) => e !== had)], signal, marks());
|
|
390
|
+
}
|
|
391
|
+
/** What the checker found: the ones that worked get its latency, the others go to the end, unless none worked. The one
|
|
392
|
+
* you're on gets the best of the watch's last round trips, as a scan measures the best of a few */
|
|
393
|
+
found(list, results) {
|
|
394
|
+
const by = new Map(results.map((r) => [key(r), r])), m = marks();
|
|
395
|
+
let worked = 0, failed = 0;
|
|
396
|
+
for (const e of list) {
|
|
397
|
+
const r = by.get(`${target(e)} ${e.protocol}`);
|
|
398
|
+
if (!r || e === this.current)
|
|
399
|
+
continue;
|
|
400
|
+
if (r.status === "ok")
|
|
401
|
+
worked++, this.learn(e, r, false);
|
|
402
|
+
else if (CHECK_FAILURES.includes(r.status))
|
|
403
|
+
failed++, this.demote(e, r.status, m);
|
|
404
|
+
}
|
|
405
|
+
if (!worked && failed > 1)
|
|
406
|
+
this.undo(m); // the containers couldn't get anywhere: that's no news about the list
|
|
407
|
+
const trips = this.trips.filter((t) => t !== null);
|
|
408
|
+
if (this.current && this.phase === "connected" && trips.length)
|
|
409
|
+
this.learn(this.current, { ...this.current, latencyMs: Math.min(...trips) }, false);
|
|
410
|
+
this.lastCheck = { at: Date.now(), worked, of: worked + failed };
|
|
411
|
+
if (this.dirty)
|
|
412
|
+
this.save();
|
|
413
|
+
}
|
|
414
|
+
/** It worked: what it measured (the speed only from a speed test) */
|
|
415
|
+
learn(e, r, speed) {
|
|
416
|
+
Object.assign(e, { location: r.location || e.location, connectS: r.connectS ?? e.connectS, latencyMs: r.latencyMs ?? e.latencyMs, checked: Date.now() });
|
|
417
|
+
if (speed)
|
|
418
|
+
e.speedMbps = r.speedMbps;
|
|
419
|
+
delete e.failed;
|
|
420
|
+
delete e.failedAt;
|
|
421
|
+
this.changed = this.dirty = true;
|
|
422
|
+
}
|
|
423
|
+
/** It didn't get through: to the end of the list, since it may work again later */
|
|
424
|
+
demote(e, why, m) {
|
|
425
|
+
m.order ??= [...this.entries];
|
|
426
|
+
if (!m.was.has(e))
|
|
427
|
+
m.was.set(e, { failed: e.failed, failedAt: e.failedAt });
|
|
428
|
+
Object.assign(e, { failed: why, failedAt: Date.now() });
|
|
429
|
+
this.entries = [...this.entries.filter((x) => x !== e), e];
|
|
430
|
+
this.changed = this.dirty = true;
|
|
431
|
+
}
|
|
432
|
+
/** Back as they were before a pass where everything failed */
|
|
433
|
+
undo(m) {
|
|
434
|
+
for (const [e, was] of m.was) {
|
|
435
|
+
if (was.failed)
|
|
436
|
+
Object.assign(e, was);
|
|
437
|
+
else
|
|
438
|
+
delete e.failed, delete e.failedAt;
|
|
439
|
+
}
|
|
440
|
+
if (m.order)
|
|
441
|
+
this.entries = m.order;
|
|
442
|
+
}
|
|
443
|
+
save() {
|
|
444
|
+
this.dirty = false;
|
|
445
|
+
this.io.save(this.ranked());
|
|
446
|
+
}
|
|
447
|
+
event(text) {
|
|
448
|
+
this.events = [...this.events, { at: new Date(), text }].slice(-20);
|
|
449
|
+
}
|
|
450
|
+
async pause(seconds) {
|
|
451
|
+
this.nap = new AbortController();
|
|
452
|
+
await this.io.sleep(seconds, this.nap.signal).catch(() => { }); // aborted: something new to do
|
|
453
|
+
}
|
|
454
|
+
}
|
package/dist/windscribe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Windscribe desktop app, driven through the windscribe-cli it ships with.
|
|
2
2
|
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { sleep } from "./time.js";
|
|
@@ -65,20 +65,38 @@ export async function windscribe(...args) {
|
|
|
65
65
|
// the CLI mixes JSON log lines into stdout
|
|
66
66
|
return out.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n");
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
export
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
// "Connected: Miami - Vice" → "Miami - Vice"
|
|
69
|
+
export const place = (state) => state.split(":").slice(1).join(":").trim();
|
|
70
|
+
// windscribe-cli status prints a "Label: value" line per item. The app's 2.25 renamed three of them, and older apps are still
|
|
71
|
+
// out there, so either wording goes: 2.25 and newer say "Login", "Connection", "Firewall", older ones "Login state",
|
|
72
|
+
// "Connect state", "Firewall state" (and mark the connect state with a "*" while they check the connection)
|
|
73
|
+
const LABELS = { login: "Login(?: state)?", connection: "(?:Connection|Connect state)", firewall: "Firewall(?: state)?" };
|
|
74
|
+
/** An item of windscribe-cli status, by its label in either wording; "" when it isn't there */
|
|
75
|
+
export const statusValue = (status, item) => status.match(new RegExp(`^\\*?${LABELS[item]}:[ \\t]*(.*)$`, "m"))?.[1].trim() ?? "";
|
|
76
|
+
/** The connect state in a status, as the apps before 2.25 word it, from either: "Connected: Miami - Vice", "Connecting: …",
|
|
77
|
+
* "Disconnecting", "Disconnected", "Error: …". 2.25 and newer say "Connected to Miami - Vice", and add "(checking the
|
|
78
|
+
* connection)" while they check it, or "(network interference detected)"; older ones add "[Network interference]" */
|
|
79
|
+
export const connectStateOf = (status) => statusValue(status, "connection")
|
|
80
|
+
.replace(/\s*(\((checking the connection|network interference detected)\)|\[Network interference\])$/i, "")
|
|
81
|
+
.replace(/^(Connected|Connecting) to /, "$1: ");
|
|
82
|
+
/** "Connected: Miami - Vice", "Connecting: …", "Disconnected", … whichever app version */
|
|
83
|
+
export const connectState = async () => connectStateOf(await windscribe("status"));
|
|
84
|
+
/** "Logged in", "Logging in", "Logged out" or "Error: …", whichever app version */
|
|
85
|
+
export const loginState = (status) => statusValue(status, "login");
|
|
86
|
+
/** The VPN's IP while connected (once the app has checked the connection): 2.25 and newer say "IP: 1.2.3.4 (VPN)", older
|
|
87
|
+
* ones "VPN IP: 1.2.3.4"; "" otherwise */
|
|
88
|
+
export const vpnIpOf = (status) => status.match(/^(?:VPN IP:[ \t]*(\S+)|IP:[ \t]*(\S+)[ \t]+\(VPN\))/m)?.slice(1).find(Boolean) ?? "";
|
|
89
|
+
/** This computer's own IP while disconnected: "IP: 1.2.3.4 (public)", or "Public IP: 1.2.3.4" before 2.25; "" otherwise */
|
|
90
|
+
export const publicIpOf = (status) => status.match(/^(?:Public IP:[ \t]*(\S+)|IP:[ \t]*(\S+)[ \t]+\(public\))/m)?.slice(1).find(Boolean) ?? "";
|
|
73
91
|
/** Waits for the app to run and be logged in; resolves to its status output */
|
|
74
92
|
export async function waitForLogin() {
|
|
75
93
|
// the first CLI call launches the app if it isn't running; give it a moment to start and restore the session
|
|
76
94
|
let status = await windscribe("status");
|
|
77
|
-
for (let i = 0; i < 15 &&
|
|
95
|
+
for (let i = 0; i < 15 && loginState(status) !== "Logged in"; i++) {
|
|
78
96
|
await sleep(1000);
|
|
79
97
|
status = await windscribe("status");
|
|
80
98
|
}
|
|
81
|
-
if (
|
|
99
|
+
if (loginState(status) !== "Logged in")
|
|
82
100
|
throw new Error("Windscribe is not logged in, or the app is not running. Open the Windscribe app and log in, then try again."
|
|
83
101
|
+ `\nDon't have it? ${DOWNLOAD_URL}`);
|
|
84
102
|
return status;
|
|
@@ -128,11 +146,41 @@ export function session() {
|
|
|
128
146
|
const authHash = setting("authHash");
|
|
129
147
|
return authHash ? { userId: setting("userId"), authHash } : null;
|
|
130
148
|
}
|
|
131
|
-
|
|
149
|
+
// The app's own log, in Qt's app data folder. It notes each disconnect the app is told to make (its disconnect button, a switch
|
|
150
|
+
// to another location, windscribe-cli disconnect) with this line, but not the one it makes itself after failing to reconnect
|
|
151
|
+
const APP_LOG = join(process.platform === "darwin" ? join(homedir(), "Library", "Application Support")
|
|
152
|
+
: process.platform === "win32" ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
|
|
153
|
+
: process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "Windscribe", "Windscribe2", "client.log");
|
|
154
|
+
const TOLD = "ConnectionManager::clickDisconnect()";
|
|
155
|
+
const STARTED = "=== Started ==="; // the first line of each log the app starts when it starts
|
|
156
|
+
/** The app log's size now, for toldToDisconnect(); 0 when there's no log */
|
|
157
|
+
export function appLogSize() {
|
|
158
|
+
try {
|
|
159
|
+
return statSync(APP_LOG).size;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Whether the app was told to disconnect since its log was size bytes long, or restarted (quit, which disconnects, then
|
|
166
|
+
* started again: by windscribe-cli, which starts it when it isn't running); false when it can't tell (no log, or another app
|
|
167
|
+
* version that words it differently) */
|
|
168
|
+
export function toldToDisconnect(size) {
|
|
169
|
+
try {
|
|
170
|
+
const log = readFileSync(APP_LOG);
|
|
171
|
+
// shorter: a new log, which the app starts when it starts (or when one grows too long, without STARTED)
|
|
172
|
+
return log.length < size ? log.includes(TOLD) || log.includes(STARTED) : log.subarray(size).includes(TOLD);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// "On", "Off" or "Always On"
|
|
179
|
+
export const isFirewallOn = (status) => /^(Always )?On$/.test(statusValue(status, "firewall"));
|
|
132
180
|
let firewallChanged = false; // blockInternet() turned the firewall on, so restoreFirewall() turns it back off
|
|
133
181
|
/** Turns on the app's firewall, so nothing goes online outside the VPN. False if it was on already (it then stays on) */
|
|
134
182
|
export async function blockInternet() {
|
|
135
|
-
if (
|
|
183
|
+
if (statusValue(await windscribe("status"), "firewall") !== "Off")
|
|
136
184
|
return false;
|
|
137
185
|
await windscribe("firewall", "on");
|
|
138
186
|
return (firewallChanged = true);
|
package/dist/worker.js
CHANGED
|
@@ -4,9 +4,10 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
|
+
import { outsideIp } from "./measure.js";
|
|
7
8
|
import { probe } from "./probe.js";
|
|
8
9
|
import { now, sleep } from "./time.js";
|
|
9
|
-
import { SETTINGS, windscribe, withSession } from "./windscribe.js";
|
|
10
|
+
import { loginState, SETTINGS, windscribe, withSession } from "./windscribe.js";
|
|
10
11
|
const APP = "/opt/windscribe/Windscribe"; // as the image installs it
|
|
11
12
|
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
12
13
|
export async function worker() {
|
|
@@ -43,15 +44,16 @@ export async function worker() {
|
|
|
43
44
|
spawn(APP, { stdio: "ignore" }).on("error", () => { }); // not there: its CLI never answers below
|
|
44
45
|
// its CLI answers once it's up, and says logged in once Windscribe accepts the session, or the error it got instead.
|
|
45
46
|
// One container took 37 s in a test, 16 at once 38-75 s
|
|
46
|
-
let
|
|
47
|
-
for (const t = now(); now() - t < 180 &&
|
|
47
|
+
let login = "";
|
|
48
|
+
for (const t = now(); now() - t < 180 && !/^(Logged in|Error)/.test(login = loginState(await windscribe("status")));)
|
|
48
49
|
await sleep(1000);
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
return void console.error(state ? `Windscribe's app didn't log in with this computer's session (${state})` : "Windscribe's app didn't start in the container");
|
|
50
|
+
if (login !== "Logged in") {
|
|
51
|
+
return void console.error(login ? `Windscribe's app didn't log in with this computer's session (${login})` : "Windscribe's app didn't start in the container");
|
|
52
52
|
}
|
|
53
53
|
send({ ready: true, wireguard: process.env.WS_WIREGUARD !== "0" }); // the image's CMD checked the kernel
|
|
54
54
|
for (let m; (m = await next());) {
|
|
55
|
+
if (m.whoami)
|
|
56
|
+
send({ ip: await outsideIp() }); // its app is disconnected now: this is the container's own way out
|
|
55
57
|
if (!m.probe)
|
|
56
58
|
continue;
|
|
57
59
|
stop = false;
|