windscout 0.7.1 → 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/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
+ }
@@ -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
- /** "Connected: Miami - Vice", "Connecting: …", "Disconnected", … */
69
- export async function connectState() {
70
- // macOS marks the line with a leading "*" once connected: "*Connect state: Connected: Miami - Vice"
71
- return (await windscribe("status")).match(/^\*?Connect state: (.*)$/m)?.[1].trim() ?? "";
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 && !status.includes("Logged in"); i++) {
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 (!status.includes("Logged in"))
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
- export const isFirewallOn = (status) => /^Firewall state: On/m.test(status);
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 (!/^Firewall state: Off/m.test(await windscribe("status")))
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 status = "";
47
- for (const t = now(); now() - t < 180 && !/Login state: (Logged in|Error)/.test(status = await windscribe("status"));)
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 (!status.includes("Logged in")) {
50
- const state = status.match(/^Login state: (.*)$/m)?.[1].trim();
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windscout",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
5
5
  "type": "module",
6
6
  "bin": {