windscout 0.4.6 → 0.5.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 +12 -1
- package/dist/cli.js +108 -0
- package/dist/config.js +31 -0
- package/dist/measure.js +41 -0
- package/dist/probe.js +56 -0
- package/dist/scan.js +75 -0
- package/dist/time.js +10 -0
- package/dist/tui/country.js +15 -0
- package/dist/tui/picker.js +274 -0
- package/dist/tui/progress.js +124 -0
- package/dist/tui/report.js +44 -0
- package/dist/tui/tables.js +62 -0
- package/dist/tui/terminal.js +67 -0
- package/dist/tui/text.js +22 -0
- package/dist/tui/widgets.js +53 -0
- package/dist/update.js +28 -0
- package/dist/windscribe.js +85 -0
- package/package.json +12 -4
- package/windscout.js +0 -866
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { sortOk } from "../probe.js";
|
|
2
|
+
import { attemptSeconds } from "../scan.js";
|
|
3
|
+
import { duration, now } from "../time.js";
|
|
4
|
+
import { CLI, restoreFirewall } from "../windscribe.js";
|
|
5
|
+
import { countryName, flag } from "./country.js";
|
|
6
|
+
import { matrix, resultTable } from "./tables.js";
|
|
7
|
+
import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
|
|
8
|
+
import { bold, cyan, dim, green, red, vw, yellow } from "./text.js";
|
|
9
|
+
import { bar, box, centerIn } from "./widgets.js";
|
|
10
|
+
/** Progress bar, current attempt, results so far. tab switches the table, p pauses, q stops (twice: quits).
|
|
11
|
+
* blocking: we turned the firewall on for the scan */
|
|
12
|
+
export function showProgress(scan, blocking) {
|
|
13
|
+
const { protocols, options: { timeout, speedTest } } = scan;
|
|
14
|
+
let view = speedTest ? "ranking" : "availability"; // tab switches
|
|
15
|
+
let frame = 0;
|
|
16
|
+
const render = () => {
|
|
17
|
+
const rows = out.rows || 24;
|
|
18
|
+
const { done, total, waiting, paused, stopping, results, finished } = scan;
|
|
19
|
+
const elapsed = scan.elapsed();
|
|
20
|
+
// smooth bar: count the running attempt as partially done
|
|
21
|
+
const partial = waiting ? 0 : Math.min(0.95, (now() - scan.attemptStart) / attemptSeconds(timeout));
|
|
22
|
+
const fraction = (done + (done < total ? partial : 0)) / total;
|
|
23
|
+
const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout));
|
|
24
|
+
const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10];
|
|
25
|
+
const ok = sortOk(results);
|
|
26
|
+
const failed = finished.filter((c) => !results.some((r) => r.query === c && r.status === "ok"));
|
|
27
|
+
const current = scan.current ? `${flag(scan.current.country)} ${countryName(scan.current.country)} ${dim("·")} ${scan.current.protocol}` : "";
|
|
28
|
+
const pct = bold(`${String(Math.floor(fraction * 100)).padStart(3)}%`);
|
|
29
|
+
const stats = [
|
|
30
|
+
`${dim("elapsed")} ${duration(elapsed)}`,
|
|
31
|
+
`${dim("remaining")} ${done < total ? "≈ " + duration(eta) : "-"}`,
|
|
32
|
+
`${green("✓")} ${ok.length} ${dim("working")}`,
|
|
33
|
+
`${red("✗")} ${failed.length} ${dim("blocked")}`,
|
|
34
|
+
].join(dim(" │ "));
|
|
35
|
+
const minW = Math.max(64, vw(stats) + 6); // panel and table share one width
|
|
36
|
+
// results table gets whatever height the status panel and footer leave
|
|
37
|
+
const room = Math.max(1, rows - 22);
|
|
38
|
+
const table = view === "availability"
|
|
39
|
+
? matrix(results, protocols, { minWidth: minW, limit: room })
|
|
40
|
+
: ok.length
|
|
41
|
+
? resultTable(ok.slice(0, room), speedTest, {
|
|
42
|
+
caption: bold("Working so far"),
|
|
43
|
+
captionRight: dim(ok.length > room ? `top ${room} of ${ok.length}` : `${ok.length} found`),
|
|
44
|
+
minWidth: minW,
|
|
45
|
+
})
|
|
46
|
+
: [];
|
|
47
|
+
const width = Math.max(minW, table.length ? vw(table[0]) : 0);
|
|
48
|
+
const inner = width - 4;
|
|
49
|
+
const panel = box([
|
|
50
|
+
"",
|
|
51
|
+
` ${bar(fraction, inner - 8)} ${pct}`,
|
|
52
|
+
"",
|
|
53
|
+
stopping
|
|
54
|
+
? yellow(" stopping after the current attempt…")
|
|
55
|
+
: waiting ? yellow(` paused · VPN disconnected${blocking ? " · firewall blocking internet" : ""} · press p to resume`)
|
|
56
|
+
: ` ${cyan(spinner)} ${bold(current)}`,
|
|
57
|
+
stopping || waiting ? "" : ` ${dim(scan.step)} ${dim("·")} ${duration(now() - scan.attemptStart)}${paused && frame % 8 < 5 ? yellow(" · pausing after this attempt") : ""}`,
|
|
58
|
+
null,
|
|
59
|
+
centerIn(stats, inner),
|
|
60
|
+
], {
|
|
61
|
+
title: bold(cyan("WindScout")),
|
|
62
|
+
right: `${bold(String(done))}${dim(`/${total} attempts`)}`,
|
|
63
|
+
width: inner,
|
|
64
|
+
});
|
|
65
|
+
const lines = [...panel];
|
|
66
|
+
if (table.length)
|
|
67
|
+
lines.push(...table);
|
|
68
|
+
else
|
|
69
|
+
lines.push("", centerIn(dim(finished.length ? "nothing has connected yet" : "results appear here after each country"), width));
|
|
70
|
+
if (failed.length) {
|
|
71
|
+
let txt = `${red("✗")} ${dim("no connection:")} ${failed.map((c) => `${flag(c)} ${c}`).join(" ")}`;
|
|
72
|
+
while (vw(txt) > width && failed.length)
|
|
73
|
+
txt = txt.slice(0, -8) + "…"; // ponytail: crude trim, fine for a status line
|
|
74
|
+
lines.push("", centerIn(txt, width));
|
|
75
|
+
}
|
|
76
|
+
lines.push("", centerIn(dim(`tab ${view === "ranking" ? "availability" : "ranking"} view · p ${paused ? "resume" : "pause"} · q stop · q twice force quit`), width), "", centerIn(dim(repoLink), width));
|
|
77
|
+
draw(lines);
|
|
78
|
+
};
|
|
79
|
+
enterScreen();
|
|
80
|
+
input.on("keypress", async (_str, key = {}) => {
|
|
81
|
+
if (key.name === "tab") {
|
|
82
|
+
view = view === "ranking" ? "availability" : "ranking";
|
|
83
|
+
render();
|
|
84
|
+
}
|
|
85
|
+
else if (key.name === "p" || key.name === "space") {
|
|
86
|
+
scan.togglePause();
|
|
87
|
+
render();
|
|
88
|
+
}
|
|
89
|
+
else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
90
|
+
if (scan.stopping) { // second press: bail out now
|
|
91
|
+
leaveScreen();
|
|
92
|
+
console.error(`stopped. the VPN may still be connecting: run '${CLI} disconnect'`);
|
|
93
|
+
await restoreFirewall();
|
|
94
|
+
process.exit(130);
|
|
95
|
+
}
|
|
96
|
+
scan.stop();
|
|
97
|
+
render();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
out.on("resize", render);
|
|
101
|
+
const timer = setInterval(render, 120);
|
|
102
|
+
return {
|
|
103
|
+
close() {
|
|
104
|
+
clearInterval(timer);
|
|
105
|
+
out.off("resize", render);
|
|
106
|
+
leaveScreen();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** A line per attempt. Ctrl-C stops after the current attempt, a second one quits */
|
|
111
|
+
export function logProgress(scan) {
|
|
112
|
+
process.on("SIGINT", async () => {
|
|
113
|
+
if (scan.stopping) {
|
|
114
|
+
await restoreFirewall();
|
|
115
|
+
process.exit(130);
|
|
116
|
+
}
|
|
117
|
+
scan.stop();
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
attempt: (country, protocol) => process.stdout.write(`[${scan.done + 1}/${scan.total}] ${country} ${protocol} ... `),
|
|
121
|
+
result: (r) => console.log([r.status, r.location, r.speedMbps ? `${r.speedMbps.toFixed(1)} Mbps` : ""].join(" ").trim()),
|
|
122
|
+
close() { },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The final report, printed to the normal screen so it stays in the scrollback.
|
|
2
|
+
import { sortOk } from "../probe.js";
|
|
3
|
+
import { CLI } from "../windscribe.js";
|
|
4
|
+
import { flag } from "./country.js";
|
|
5
|
+
import { matrix, resultTable } from "./tables.js";
|
|
6
|
+
import { bold, cyan, dim, fmt, green, vw, yellow } from "./text.js";
|
|
7
|
+
import { box, grid } from "./widgets.js";
|
|
8
|
+
export function report(results, protocols, { all, speedTest }) {
|
|
9
|
+
const ok = sortOk(results);
|
|
10
|
+
const rows = [...ok, ...(all ? results.filter((r) => r.status !== "ok") : [])];
|
|
11
|
+
// render once to learn the widest table, then again so every box lines up
|
|
12
|
+
const build = (minWidth) => ({
|
|
13
|
+
avail: matrix(results, protocols, { minWidth }),
|
|
14
|
+
main: rows.length
|
|
15
|
+
? resultTable(rows, speedTest, { caption: bold("Results"), captionRight: dim(`${ok.length} working`), minWidth })
|
|
16
|
+
: [],
|
|
17
|
+
});
|
|
18
|
+
const first = build(0);
|
|
19
|
+
const width = Math.max(64, ...[first.avail[0], first.main[0]].filter(Boolean).map(vw));
|
|
20
|
+
const { avail, main } = build(width);
|
|
21
|
+
console.log();
|
|
22
|
+
if (avail.length)
|
|
23
|
+
console.log(avail.join("\n"));
|
|
24
|
+
if (!ok.length) {
|
|
25
|
+
console.log(box([yellow("Nothing connected."),
|
|
26
|
+
dim("Try other countries, ports (e.g. -p stealth:443,tcp:443) or a longer --timeout.")], { title: bold(cyan("WindScout")), width: width - 4 }).join("\n"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
console.log(main.join("\n"));
|
|
30
|
+
const stats = protocols.map((p) => {
|
|
31
|
+
const tried = results.filter((r) => r.protocol === p && r.status !== "no such location");
|
|
32
|
+
const good = tried.filter((r) => r.status === "ok");
|
|
33
|
+
return { p, good: good.length, tried: tried.length, best: sortOk(good).at(0) };
|
|
34
|
+
}).sort((x, y) => y.good - x.good || (y.best?.speedMbps ?? 0) - (x.best?.speedMbps ?? 0) || (x.best?.latencyMs ?? 1e9) - (y.best?.latencyMs ?? 1e9));
|
|
35
|
+
const bestValue = (r) => (speedTest ? `${fmt(r.speedMbps, 1)} Mbps` : `${fmt(r.latencyMs, 0)} ms`);
|
|
36
|
+
console.log(grid([{ title: "Protocol" }, { title: "Worked", align: "right" }, { title: speedTest ? "Best speed" : "Best latency", align: "right" }, { title: "Best location" }], stats.map((s) => [s.p, `${s.good}/${s.tried}`, s.best ? bestValue(s.best) : "-", s.best ? `${flag(s.best.query)} ${s.best.location}` : "-"]), { caption: bold("Per protocol"), minWidth: width }).join("\n"));
|
|
37
|
+
const b = ok[0];
|
|
38
|
+
const target = b.location.split(" - ").at(-1) || b.query; // nickname pins the exact datacenter
|
|
39
|
+
const facts = [speedTest && `${fmt(b.speedMbps, 1)} Mbps`, `${fmt(b.latencyMs, 0)} ms`].filter(Boolean).join(" · ");
|
|
40
|
+
console.log(box([
|
|
41
|
+
`${flag(b.query)} ${bold(b.location)} over ${bold(b.protocol)} ${dim("· " + facts)}`,
|
|
42
|
+
cyan(`${CLI} connect "${target}" ${b.protocol}`),
|
|
43
|
+
], { title: green(speedTest ? "★ Best" : "★ Best (lowest latency)"), width: width - 4 }).join("\n"));
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { countryName, flag } from "./country.js";
|
|
2
|
+
import { bold, dim, fmt, green, red } from "./text.js";
|
|
3
|
+
import { grid } from "./widgets.js";
|
|
4
|
+
const RESULT_COLS = [
|
|
5
|
+
{ title: "#", align: "right" }, { title: "Country" }, { title: "Location" }, { title: "Protocol" },
|
|
6
|
+
{ title: "Connect", align: "right" }, { title: "Latency", align: "right" }, { title: "Speed", align: "right" }, { title: "Status" },
|
|
7
|
+
];
|
|
8
|
+
const SPEED_COL = 6;
|
|
9
|
+
const resultRow = (r, i) => [
|
|
10
|
+
dim(String(i + 1)),
|
|
11
|
+
`${flag(r.query)} ${countryName(r.query)}`,
|
|
12
|
+
r.location || "-",
|
|
13
|
+
r.protocol,
|
|
14
|
+
r.connectS === null ? "-" : `${fmt(r.connectS, 1)} s`,
|
|
15
|
+
r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`,
|
|
16
|
+
r.speedMbps === null ? "-" : bold(`${fmt(r.speedMbps, 1)} Mbps`),
|
|
17
|
+
r.status === "ok" ? green("✓ ok") : red(r.status),
|
|
18
|
+
];
|
|
19
|
+
/** Numbered attempts, in the given order; the Speed column only when the speed test ran */
|
|
20
|
+
export function resultTable(results, speedTest, options) {
|
|
21
|
+
const keep = (_, i) => speedTest || i !== SPEED_COL;
|
|
22
|
+
return grid(RESULT_COLS.filter(keep), results.map((r, i) => resultRow(r, i).filter(keep)), options);
|
|
23
|
+
}
|
|
24
|
+
/** One row per country, ✓/✗ per protocol (· = not tried yet); no lines until something was tried */
|
|
25
|
+
export function matrix(results, protocols, { minWidth = 0, limit = Infinity } = {}) {
|
|
26
|
+
const byCountry = new Map();
|
|
27
|
+
for (const r of results) {
|
|
28
|
+
if (r.status === "no such location" || r.status === "interrupted")
|
|
29
|
+
continue;
|
|
30
|
+
if (!byCountry.has(r.query))
|
|
31
|
+
byCountry.set(r.query, []);
|
|
32
|
+
byCountry.get(r.query).push(r);
|
|
33
|
+
}
|
|
34
|
+
if (!byCountry.size)
|
|
35
|
+
return [];
|
|
36
|
+
const ranked = [...byCountry].map(([q, rs]) => ({ q, rs, good: rs.filter((r) => r.status === "ok").length }))
|
|
37
|
+
.sort((a, b) => b.good - a.good); // stable: ties keep scan order
|
|
38
|
+
const reachable = ranked.filter((c) => c.good).length;
|
|
39
|
+
const rows = ranked.slice(0, limit).flatMap(({ q, rs }) => {
|
|
40
|
+
const seen = rs.find((r) => r.status === "ok") ?? rs.find((r) => r.location);
|
|
41
|
+
// protocols can land on different datacenters (even cities), so list each one that connected, one per line
|
|
42
|
+
const places = [...new Set(rs.filter((r) => r.status === "ok").map((r) => r.location).filter(Boolean))];
|
|
43
|
+
const [first = seen?.location.split(" - ")[0] || dim("-"), ...more] = places;
|
|
44
|
+
return [
|
|
45
|
+
[
|
|
46
|
+
`${flag(q)} ${countryName(q)}`,
|
|
47
|
+
first,
|
|
48
|
+
...protocols.map((p) => {
|
|
49
|
+
const r = rs.find((x) => x.protocol === p);
|
|
50
|
+
return !r ? dim("·") : r.status === "ok" ? green("✓") : red("✗");
|
|
51
|
+
}),
|
|
52
|
+
],
|
|
53
|
+
...more.map((place) => ["", place, ...protocols.map(() => "")]),
|
|
54
|
+
];
|
|
55
|
+
}).slice(0, limit);
|
|
56
|
+
return grid([{ title: "Country" }, { title: "Location(s)" }, ...protocols.map((p) => ({ title: p, align: "center" }))], rows, {
|
|
57
|
+
caption: bold("Availability"),
|
|
58
|
+
captionRight: dim(`${reachable}/${ranked.length} countries reachable${ranked.length > limit ? ` · top ${limit}` : ""}`),
|
|
59
|
+
minWidth,
|
|
60
|
+
stretch: 1,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The terminal: full-screen mode, keyboard and mouse input, drawing frames.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { emitKeypressEvents } from "node:readline";
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
5
|
+
import { vw } from "./text.js";
|
|
6
|
+
const REPO_URL = "https://github.com/Diercohen/windscout";
|
|
7
|
+
// OSC 8 makes the text a link in terminals that support it; clicks we receive ourselves are handled in onData
|
|
8
|
+
export const repoLink = `\x1b]8;;${REPO_URL}\x1b\\${REPO_URL}\x1b]8;;\x1b\\`;
|
|
9
|
+
export const out = process.stdout;
|
|
10
|
+
// stdin minus mouse reports: readline would split those into stray keypresses ("0", ";", "M"…).
|
|
11
|
+
// Emits "keypress" (str, key) like stdin would, and "mouse" (Mouse)
|
|
12
|
+
export const input = new PassThrough();
|
|
13
|
+
emitKeypressEvents(input);
|
|
14
|
+
let fullscreen = false;
|
|
15
|
+
let link = null; // where draw() last put the repo link
|
|
16
|
+
const openUrl = (url) => spawn({ darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open", [url], { stdio: "ignore", detached: true })
|
|
17
|
+
.on("error", () => { }).unref();
|
|
18
|
+
// SGR mouse report: ESC [ < button ; col ; row (M = press, m = release)
|
|
19
|
+
// ponytail: assumes a report never straddles two reads, true for terminals in practice
|
|
20
|
+
const onData = (buf) => {
|
|
21
|
+
const rest = buf.toString().replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, b, x, y, kind) => {
|
|
22
|
+
const m = { button: Number(b), x: Number(x) - 1, y: Number(y) - 1, press: kind === "M" };
|
|
23
|
+
if (link && m.press && m.button === 0 && m.y === link.y && m.x >= link.x && m.x < link.x + REPO_URL.length)
|
|
24
|
+
openUrl(REPO_URL);
|
|
25
|
+
input.emit("mouse", m);
|
|
26
|
+
return "";
|
|
27
|
+
});
|
|
28
|
+
if (rest)
|
|
29
|
+
input.write(rest);
|
|
30
|
+
};
|
|
31
|
+
/** Alternate screen, cursor hidden, mouse reports on, raw keys */
|
|
32
|
+
export function enterScreen() {
|
|
33
|
+
if (fullscreen)
|
|
34
|
+
return;
|
|
35
|
+
fullscreen = true;
|
|
36
|
+
out.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"); // alternate screen, hide cursor, mouse on
|
|
37
|
+
process.stdin.setRawMode(true);
|
|
38
|
+
process.stdin.on("data", onData);
|
|
39
|
+
process.stdin.resume();
|
|
40
|
+
}
|
|
41
|
+
/** Back to the normal screen; drops every key and mouse listener */
|
|
42
|
+
export function leaveScreen() {
|
|
43
|
+
if (!fullscreen)
|
|
44
|
+
return;
|
|
45
|
+
fullscreen = false;
|
|
46
|
+
process.stdin.setRawMode(false);
|
|
47
|
+
process.stdin.off("data", onData);
|
|
48
|
+
process.stdin.pause();
|
|
49
|
+
input.removeAllListeners("keypress");
|
|
50
|
+
input.removeAllListeners("mouse");
|
|
51
|
+
out.write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
52
|
+
}
|
|
53
|
+
process.on("exit", leaveScreen);
|
|
54
|
+
/** Redraws in place, centered: home, each line cleared to its end, then clear below (no flicker). Returns the top-left corner */
|
|
55
|
+
export function draw(lines) {
|
|
56
|
+
const cols = out.columns || 80, rows = out.rows || 24;
|
|
57
|
+
const w = Math.max(...lines.map(vw));
|
|
58
|
+
const left = " ".repeat(Math.max(0, Math.floor((cols - w) / 2)));
|
|
59
|
+
const top = Math.max(0, Math.floor((rows - lines.length) / 2));
|
|
60
|
+
const screen = [...Array(top).fill(""), ...lines.map((l) => left + l)].slice(0, rows);
|
|
61
|
+
const i = screen.findIndex((l) => l.includes(REPO_URL));
|
|
62
|
+
link = i < 0 ? null : { y: i, x: vw(screen[i].slice(0, screen[i].indexOf("\x1b]8;;"))) };
|
|
63
|
+
// a timer tick can land after leaveScreen() (force quit, crash): don't paint over the normal screen
|
|
64
|
+
if (fullscreen)
|
|
65
|
+
out.write("\x1b[H" + screen.map((l) => l + "\x1b[K").join("\n") + "\x1b[J");
|
|
66
|
+
return { left: left.length, top };
|
|
67
|
+
}
|
package/dist/tui/text.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// String helpers for the terminal: ANSI colors, visible width, padding.
|
|
2
|
+
const plain = () => process.env.NO_COLOR || !process.stdout.isTTY;
|
|
3
|
+
export const color = (code, s) => (plain() ? s : `\x1b[${code}m${s}\x1b[0m`);
|
|
4
|
+
export const bold = (s) => color(1, s);
|
|
5
|
+
export const dim = (s) => color(2, s);
|
|
6
|
+
export const red = (s) => color(31, s);
|
|
7
|
+
export const green = (s) => color(32, s);
|
|
8
|
+
export const yellow = (s) => color(33, s);
|
|
9
|
+
export const cyan = (s) => color(36, s);
|
|
10
|
+
// underline off (24) instead of a full reset, so it nests inside dim/bold
|
|
11
|
+
export const under = (s) => (plain() ? s : `\x1b[4m${s}\x1b[24m`);
|
|
12
|
+
/** A number with fixed decimals, "-" when missing */
|
|
13
|
+
export const fmt = (v, digits) => (v === null ? "-" : v.toFixed(digits));
|
|
14
|
+
// visible width: ANSI codes take no space; flags and emoji (🔍) take 2 columns. text symbols like ▶ ★ stay 1
|
|
15
|
+
export const vw = (s) => [...s.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/[\u{1F1E6}-\u{1F1FF}]{2}|[\u{1F300}-\u{1FAFF}]/gu, "xx")].length;
|
|
16
|
+
/** Pads s with spaces to n visible columns */
|
|
17
|
+
export function pad(s, n, align = "left") {
|
|
18
|
+
const gap = Math.max(0, n - vw(s));
|
|
19
|
+
if (align === "center")
|
|
20
|
+
return " ".repeat(Math.floor(gap / 2)) + s + " ".repeat(Math.ceil(gap / 2));
|
|
21
|
+
return align === "right" ? " ".repeat(gap) + s : s + " ".repeat(gap);
|
|
22
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Drawing blocks, with rounded box-drawing borders: box, grid (table), progress bar.
|
|
2
|
+
import { bold, color, dim, pad, vw } from "./text.js";
|
|
3
|
+
const border = dim;
|
|
4
|
+
/** Box around lines; a null line draws a ├──┤ divider. title and right sit in the top border */
|
|
5
|
+
export function box(lines, { title = "", right = "", width = 0 } = {}) {
|
|
6
|
+
const inner = Math.max(width, ...lines.map((l) => (l === null ? 0 : vw(l))), vw(title) + vw(right) + 6);
|
|
7
|
+
const left = title ? border("─ ") + title + " " : "";
|
|
8
|
+
const tail = right ? " " + right + border(" ─") : "";
|
|
9
|
+
const fill = inner + 2 - vw(left) - vw(tail);
|
|
10
|
+
return [
|
|
11
|
+
border("╭") + left + border("─".repeat(fill)) + tail + border("╮"),
|
|
12
|
+
...lines.map((l) => (l === null ? border("├" + "─".repeat(inner + 2) + "┤") : border("│") + " " + pad(l, inner) + " " + border("│"))),
|
|
13
|
+
border("╰" + "─".repeat(inner + 2) + "╯"),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
/** Bordered table */
|
|
17
|
+
export function grid(cols, rows, { caption, captionRight = "", minWidth = 0, stretch = cols.length - 1 } = {}) {
|
|
18
|
+
const w = cols.map((c, i) => Math.max(vw(c.title), ...rows.map((r) => vw(r[i]))));
|
|
19
|
+
const total = () => w.reduce((a, n) => a + n + 3, 1);
|
|
20
|
+
if (caption !== undefined)
|
|
21
|
+
w[stretch] += Math.max(0, vw(caption) + vw(captionRight) + 5 - total());
|
|
22
|
+
w[stretch] += Math.max(0, minWidth - total());
|
|
23
|
+
const rule = (l, m, r) => border(l + w.map((n) => "─".repeat(n + 2)).join(m) + r);
|
|
24
|
+
const row = (cells) => border("│") + cells.map((c, i) => " " + pad(c, w[i], cols[i].align) + " ").join(border("│")) + border("│");
|
|
25
|
+
const out = [];
|
|
26
|
+
if (caption !== undefined) {
|
|
27
|
+
const inner = total() - 4;
|
|
28
|
+
out.push(border("╭" + "─".repeat(total() - 2) + "╮"), border("│") + " " + pad(caption, inner - vw(captionRight)) + captionRight + " " + border("│"), rule("├", "┬", "┤"));
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
out.push(rule("╭", "┬", "╮"));
|
|
32
|
+
out.push(row(cols.map((c) => bold(c.title))), rule("├", "┼", "┤"), ...rows.map(row), rule("╰", "┴", "╯"));
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** s, indented to sit centered in width columns */
|
|
36
|
+
export const centerIn = (s, width) => " ".repeat(Math.max(0, Math.floor((width - vw(s)) / 2))) + s;
|
|
37
|
+
/** Gradient bar (cyan → green) with eighth-block precision on the leading edge */
|
|
38
|
+
export function bar(fraction, width) {
|
|
39
|
+
const parts = " ▏▎▍▌▋▊▉";
|
|
40
|
+
const cells = Math.max(0, Math.min(1, fraction)) * width;
|
|
41
|
+
const full = Math.floor(cells);
|
|
42
|
+
const palette = [45, 44, 43, 42, 41, 47, 48, 49, 83, 82];
|
|
43
|
+
const shade = (i) => palette[Math.floor((i / width) * palette.length)];
|
|
44
|
+
let s = "";
|
|
45
|
+
for (let i = 0; i < full; i++)
|
|
46
|
+
s += color(`38;5;${shade(i)}`, "█");
|
|
47
|
+
if (full < width) {
|
|
48
|
+
const p = parts[Math.floor((cells - full) * 8)];
|
|
49
|
+
s += p === " " ? dim("░") : color(`38;5;${shade(full)}`, p);
|
|
50
|
+
s += dim("░".repeat(width - full - 1));
|
|
51
|
+
}
|
|
52
|
+
return s;
|
|
53
|
+
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The installed version, and whether npm has a newer one.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
4
|
+
let latest = null; // newer version on npm, once known
|
|
5
|
+
let check;
|
|
6
|
+
// numeric x.y.z compare; parseInt drops a prerelease suffix ("0-beta" → 0)
|
|
7
|
+
export function newer(a, b) {
|
|
8
|
+
const x = a.split(".").map((n) => parseInt(n)), y = b.split(".").map((n) => parseInt(n));
|
|
9
|
+
for (let i = 0; i < 3; i++)
|
|
10
|
+
if (x[i] !== y[i])
|
|
11
|
+
return x[i] > y[i];
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
/** Asks npm once; later calls share that request. Best effort: offline or registry down just means no notice */
|
|
15
|
+
export function checkUpdate() {
|
|
16
|
+
check ??= fetch("https://registry.npmjs.org/windscout/latest", { signal: AbortSignal.timeout(3000) })
|
|
17
|
+
.then((r) => r.json())
|
|
18
|
+
.then(({ version }) => { if (version && newer(version, VERSION))
|
|
19
|
+
latest = version; })
|
|
20
|
+
.catch(() => { });
|
|
21
|
+
return check;
|
|
22
|
+
}
|
|
23
|
+
/** "update available: …" with the command to run, once checkUpdate() found a newer version */
|
|
24
|
+
export function updateNotice() {
|
|
25
|
+
if (!latest)
|
|
26
|
+
return null;
|
|
27
|
+
return `update available: ${VERSION} → ${latest} · run: ${process.env.npm_command === "exec" ? "npx windscout@latest" : "npm install -g windscout@latest"}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The Windscribe desktop app, driven through the windscribe-cli it ships with.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { sleep } from "./time.js";
|
|
6
|
+
export const CLI = "windscribe-cli";
|
|
7
|
+
export const DOWNLOAD_URL = "https://windscribe.com/download";
|
|
8
|
+
// the desktop app installs the CLI here and links it into /usr/bin (/usr/local/bin on macOS); use it directly if the link is missing.
|
|
9
|
+
// Windows doesn't put it on PATH at all, so there the fallback is the normal case
|
|
10
|
+
const DESKTOP_CLI = {
|
|
11
|
+
darwin: "/Applications/Windscribe.app/Contents/MacOS/windscribe-cli",
|
|
12
|
+
win32: join(process.env.ProgramFiles || "C:\\Program Files", "Windscribe", "windscribe-cli.exe"),
|
|
13
|
+
}[process.platform] ?? "/opt/windscribe/windscribe-cli";
|
|
14
|
+
let cliBin = CLI;
|
|
15
|
+
// IKEv2 is offered by the macOS/Windows apps only
|
|
16
|
+
export const PROTOCOLS = ["wireguard", ...(process.platform === "linux" ? [] : ["ikev2"]), "stealth", "wstunnel", "udp", "tcp"];
|
|
17
|
+
// ponytail: hardcoded because `windscribe-cli locations` returns nothing on 2.24; refresh when Windscribe adds countries
|
|
18
|
+
export const COUNTRIES = `US CA MX BR AR CL CO PE GB IE FR DE NL BE LU CH AT IT ES PT DK NO SE FI IS
|
|
19
|
+
PL CZ SK HU RO BG GR CY TR RS HR SI BA MK AL MD UA EE LV LT IL AE AZ GE AM KZ IN JP KR
|
|
20
|
+
HK SG TW ID MY TH VN PH AU NZ ZA NG KE EG`.split(/\s+/);
|
|
21
|
+
// spawn, not execFile: if the app isn't running, the CLI launches it and the app inherits our stdout pipe.
|
|
22
|
+
// The pipe then stays open after the CLI exits, and execFile would wait for its timeout. Settle on exit instead
|
|
23
|
+
function run(args) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const p = spawn(cliBin, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
26
|
+
let out = "";
|
|
27
|
+
p.stdout.on("data", (d) => (out += d));
|
|
28
|
+
const done = () => (clearTimeout(kill), p.stdout.destroy(), resolve(out));
|
|
29
|
+
const kill = setTimeout(() => p.kill(), 90_000);
|
|
30
|
+
p.on("error", (e) => (clearTimeout(kill), reject(e)));
|
|
31
|
+
p.on("close", done);
|
|
32
|
+
// the CLI's last output may still be in the pipe when it exits; read for a moment, then stop waiting
|
|
33
|
+
p.on("exit", () => setTimeout(done, 300));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Run a windscribe-cli command; resolves to its output */
|
|
37
|
+
export async function windscribe(...args) {
|
|
38
|
+
let out;
|
|
39
|
+
try {
|
|
40
|
+
out = await run(args);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e.code !== "ENOENT")
|
|
44
|
+
throw e;
|
|
45
|
+
if (cliBin === CLI && existsSync(DESKTOP_CLI)) {
|
|
46
|
+
cliBin = DESKTOP_CLI;
|
|
47
|
+
return windscribe(...args);
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`${CLI} not found. Install the Windscribe desktop app (it includes ${CLI}): ${DOWNLOAD_URL}`);
|
|
50
|
+
}
|
|
51
|
+
// the CLI mixes JSON log lines into stdout
|
|
52
|
+
return out.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n");
|
|
53
|
+
}
|
|
54
|
+
/** "Connected: Miami - Vice", "Connecting: …", "Disconnected", … */
|
|
55
|
+
export async function connectState() {
|
|
56
|
+
// macOS marks the line with a leading "*" once connected: "*Connect state: Connected: Miami - Vice"
|
|
57
|
+
return (await windscribe("status")).match(/^\*?Connect state: (.*)$/m)?.[1].trim() ?? "";
|
|
58
|
+
}
|
|
59
|
+
/** Waits for the app to run and be logged in; resolves to its status output */
|
|
60
|
+
export async function waitForLogin() {
|
|
61
|
+
// the first CLI call launches the app if it isn't running; give it a moment to start and restore the session
|
|
62
|
+
let status = await windscribe("status");
|
|
63
|
+
for (let i = 0; i < 15 && !status.includes("Logged in"); i++) {
|
|
64
|
+
await sleep(1000);
|
|
65
|
+
status = await windscribe("status");
|
|
66
|
+
}
|
|
67
|
+
if (!status.includes("Logged in"))
|
|
68
|
+
throw new Error("Windscribe is not logged in, or the app is not running. Open the Windscribe app and log in, then try again."
|
|
69
|
+
+ `\nDon't have it? ${DOWNLOAD_URL}`);
|
|
70
|
+
return status;
|
|
71
|
+
}
|
|
72
|
+
export const isFirewallOn = (status) => /^Firewall state: On/m.test(status);
|
|
73
|
+
let firewallChanged = false; // blockInternet() turned the firewall on, so restoreFirewall() turns it back off
|
|
74
|
+
/** Turns on the app's firewall, so nothing goes online outside the VPN. False if it was on already (it then stays on) */
|
|
75
|
+
export async function blockInternet() {
|
|
76
|
+
if (!/^Firewall state: Off/m.test(await windscribe("status")))
|
|
77
|
+
return false;
|
|
78
|
+
await windscribe("firewall", "on");
|
|
79
|
+
return (firewallChanged = true);
|
|
80
|
+
}
|
|
81
|
+
/** Puts the firewall back the way the user had it; safe to call any time, also on a crash so the internet isn't left blocked */
|
|
82
|
+
export async function restoreFirewall() {
|
|
83
|
+
if (firewallChanged)
|
|
84
|
+
await windscribe("firewall", "off");
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windscout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"windscout": "
|
|
7
|
+
"windscout": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"
|
|
10
|
+
"dist"
|
|
11
11
|
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc && node -e \"fs.chmodSync('dist/cli.js', 0o755)\"",
|
|
14
|
+
"prepack": "npm run build"
|
|
15
|
+
},
|
|
12
16
|
"engines": {
|
|
13
17
|
"node": ">=20"
|
|
14
18
|
},
|
|
@@ -37,5 +41,9 @@
|
|
|
37
41
|
"url": "git+https://github.com/Diercohen/windscout.git"
|
|
38
42
|
},
|
|
39
43
|
"homepage": "https://github.com/Diercohen/windscout#readme",
|
|
40
|
-
"bugs": "https://github.com/Diercohen/windscout/issues"
|
|
44
|
+
"bugs": "https://github.com/Diercohen/windscout/issues",
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^20.19.43",
|
|
47
|
+
"typescript": "^7.0.2"
|
|
48
|
+
}
|
|
41
49
|
}
|