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.
@@ -1,8 +1,8 @@
1
- import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
1
+ import { loadDockerWireGuard, MIN_HOLD, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
2
2
  import { dockerState, loginSeconds, notInContainers, workerCount } from "../docker.js";
3
- import { STEADY_SECONDS } from "../probe.js";
3
+ import { steadySeconds } from "../probe.js";
4
4
  import { attemptSeconds, estimate, ORDERS, plan } from "../scan.js";
5
- import { duration } from "../time.js";
5
+ import { ago, duration } from "../time.js";
6
6
  import { canUpdate, checkUpdate, versionLines } from "../update.js";
7
7
  import { COUNTRIES, protocolPorts, PROTOCOLS, withPorts } from "../windscribe.js";
8
8
  import { countryName, flag } from "./country.js";
@@ -20,17 +20,13 @@ const needsLast = (o) => o === "last-first" || o === "last-only";
20
20
  // only the last results: the countries, protocols and ports are greyed out, and their keys and clicks say why
21
21
  const SKIPPED = "skipped with only the last results: o changes the scanning type";
22
22
  const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
23
- // "5m ago", "3h ago", "2 days ago"
24
- const ago = (ms) => {
25
- const m = Math.max(0, Math.round((Date.now() - ms) / 60000)), h = Math.round(m / 60), d = Math.round(h / 24);
26
- return m < 1 ? "just now" : m < 60 ? `${m}m ago` : h < 48 ? `${h}h ago` : `${d} days ago`;
27
- };
28
23
  /** Resolves to the choice (also saved for next time), null when the user quits, or "update" for u */
29
- export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst, allPorts, docker, last }) {
24
+ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst, hold, allPorts, docker, last }) {
30
25
  const selected = new Set(saved?.countries ?? []);
31
26
  const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
32
27
  let cursor = 0, top = 0, message = "", query = "", searching = false;
33
- let seconds = String(timeout), editing = false; // the timeout, as typed
28
+ const typed = { timeout: String(timeout), hold: String(hold) }; // the settings in seconds, as typed
29
+ let editing = null; // the one being typed
34
30
  let dockerAt = null, inDialog = false; // docker's state once checked; a docker dialog has the screen
35
31
  let portLists = null; // each protocol's ports, once the app said
36
32
  let starting = -1; // once start is pressed: the start button's spinner frame, until the next screen shows
@@ -41,12 +37,15 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
41
37
  };
42
38
  const ports = (p) => (allPorts && portLists?.get(p)?.length) || 1; // attempts per country for the protocol
43
39
  const perCountry = (ps) => [...ps].reduce((n, p) => n + ports(p), 0);
44
- // what start scans, in its order: as the CLI plans it, so the estimate counts the same attempts
40
+ const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
41
+ // what start scans, in its order: as the CLI plans it, so the estimate counts the same attempts. A parallel scan is the
42
+ // containers' alone: the protocols they can't run are skipped
45
43
  const todo = () => {
46
44
  const ps = PROTOCOLS.filter((p) => protos.has(p));
47
- return plan(order, COUNTRIES.filter((c) => selected.has(c)), allPorts && portLists ? withPorts(ps, portLists) : ps, last?.results, portLists ?? undefined);
45
+ const all = plan(order, COUNTRIES.filter((c) => selected.has(c)), allPorts && portLists ? withPorts(ps, portLists) : ps, last?.results, portLists ?? undefined);
46
+ const cant = notInContainers(docker ? all.map((a) => a.protocol) : [], wireguard);
47
+ return { list: all.filter((a) => !cant.has(a.protocol)), skipped: [...new Set([...cant].map((p) => p.split(":")[0]))] };
48
48
  };
49
- const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
50
49
  enterScreen();
51
50
  return new Promise((resolve) => {
52
51
  const render = () => {
@@ -54,24 +53,25 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
54
53
  return;
55
54
  const rows = out.rows || 24;
56
55
  const version = versionLines(true);
57
- const matches = visible();
58
- const height = Math.max(3, rows - 24 - version.length);
59
- cursor = Math.max(0, Math.min(cursor, matches.length - 1));
60
- if (cursor < top)
61
- top = cursor;
62
- if (cursor >= top + height)
63
- top = cursor - height + 1;
64
- top = Math.max(0, Math.min(top, matches.length - height));
65
56
  const off = order === "last-only"; // only the saved connections: the countries, protocols and ports are skipped
66
57
  const missing = needsLast(order) && !last; // a type by the last result, and no saved scan: start is off
67
- timeout = Number(seconds);
68
- const list = todo(), n = list.length;
69
- const base = (p) => p.split(":")[0]; // "wireguard:443" → "wireguard"
70
- const scanned = new Set(list.map((a) => base(a.protocol)));
71
- const here = PROTOCOLS.filter((p) => docker && scanned.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
72
- const inContainers = list.filter((a) => !here.includes(base(a.protocol))).length;
73
- const parallel = docker && dockerAt === "ready" && inContainers > 0;
74
- const slots = parallel ? workerCount(inContainers) : 1;
58
+ timeout = Number(typed.timeout);
59
+ hold = Number(typed.hold);
60
+ const { list, skipped } = todo(), n = list.length;
61
+ // only the last results lists the countries it scans, all selected; the user's own selection is back with the other types
62
+ const matches = off ? [...new Set(list.map((a) => a.country))] : visible();
63
+ const height = Math.max(3, rows - 25 - version.length);
64
+ if (!off) { // so the selection's list keeps its cursor and scroll for then
65
+ cursor = Math.max(0, Math.min(cursor, matches.length - 1));
66
+ if (cursor < top)
67
+ top = cursor;
68
+ if (cursor >= top + height)
69
+ top = cursor - height + 1;
70
+ top = Math.max(0, Math.min(top, matches.length - height));
71
+ }
72
+ const from = off ? 0 : top; // the first row shown
73
+ const parallel = docker && dockerAt === "ready" && n > 0;
74
+ const slots = parallel ? workerCount(n) : 1;
75
75
  const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
76
76
  const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? (off ? dim : green)("● " + p) : dim("○ " + p)}`).join(" ");
77
77
  const listed = portLists && [...portLists.values()].some((l) => l.length), each = perCountry(protos);
@@ -90,13 +90,14 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
90
90
  const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(docker
91
91
  ? "not used with docker"
92
92
  : firewall ? "only VPN traffic gets through" : "off: apps may leak in between")}`;
93
- const short = timeout < MIN_TIMEOUT;
94
- const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
93
+ // a setting in seconds: as typed, with the cursor while typing, yellow when it's under its least
94
+ const field = (k, least) => editing === k ? bold(typed[k]) + cyan("▏") : (Number(typed[k]) < least ? yellow : bold)(typed[k] || "?");
95
+ const timeoutLine = `${dim(under("t"))} timeout ${field("timeout", MIN_TIMEOUT)} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
95
96
  const dockerLabel = "parallel scan in docker containers";
96
97
  const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
97
98
  ? "off: one attempt at a time"
98
- : dockerAt === "ready" ? `${slots} at a time${here.length ? ` · ${here.join(" and ")} here, after them` : ""}`
99
- : dockerAt ? "docker isn't ready: start sets it up" : "checking docker…")}`;
99
+ : (dockerAt === "ready" ? `${slots} at a time` : dockerAt ? "docker isn't ready: start sets it up" : "checking docker…")
100
+ + (skipped.length ? ` · ${skipped.join(" and ")} skipped` : ""))}`;
100
101
  const saves = last ? plan("last-only", [], [], last.results, portLists ?? undefined).length : 0; // the saved connections, each once
101
102
  const firsts = list.filter((a) => a.saved).length; // those this scan tries first
102
103
  const orderLine = `${dim(under("o"))} scanning type ${bold(ORDER_NAMES[order])} ${dim(missing
@@ -106,26 +107,29 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
106
107
  : order === "last-first" ? `${firsts} of ${saves} saved selected · ${ago(last.at)}`
107
108
  : `${saves} saved working connection${saves === 1 ? "" : "s"} · ${ago(last.at)}`)}`;
108
109
  const stopLabel = "stop at the first working connection";
109
- // the best saved connection goes first (unless it waits for the containers): when it still holds up, the scan ends there
110
- const lead = list[0]?.saved && !here.includes(base(list[0].protocol)) ? list[0] : null;
110
+ // the best saved connection goes first: when it still holds up, the scan ends there
111
+ const lead = list[0]?.saved ? list[0] : null;
111
112
  const stopLine = `${dim(under("w"))} ${stopAtFirst ? green("● " + stopLabel) : dim("○ " + stopLabel)} ${dim(!stopAtFirst
112
113
  ? "off: tries every attempt"
113
- : lead ? `≈ ${duration(login + STEADY_SECONDS)} if ${lead.country} ${lead.protocol} still holds up`
114
- : "after a speed and stability check")}`;
114
+ : lead ? `≈ ${duration(login + steadySeconds(hold))} if ${lead.country} ${lead.protocol} still holds up`
115
+ : `≈ ${duration(steadySeconds(hold))} of checks per working one`)}`;
116
+ const holdLine = `${dim(under("h"))} hold ${field("hold", MIN_HOLD)} s ${dim(stopAtFirst
117
+ ? `each working one stays connected this long · at least ${MIN_HOLD}`
118
+ : "not used: stop at the first is off")}`;
115
119
  const search = off
116
- ? yellow("countries skipped: only the last results are scanned")
120
+ ? yellow("the last results' countries: only these are scanned")
117
121
  : searching ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
118
122
  : query ? `🔍 ${query}` : dim("🔍 press / to search");
119
- const shown = matches.slice(top, top + height);
120
- const range = matches.length > height ? `${top + 1}–${top + shown.length} of ${matches.length}` : `${matches.length} countries`;
123
+ const shown = matches.slice(from, from + height);
124
+ const range = matches.length > height ? `${from + 1}–${from + shown.length} of ${matches.length}` : `${matches.length} countries`;
121
125
  const body = shown.map((code, j) => {
122
126
  const current = !off && top + j === cursor;
123
- const mark = selected.has(code) ? (off ? dim : green)("●") : dim("○");
127
+ const mark = off || selected.has(code) ? (off ? dim : green)("●") : dim("○");
124
128
  const name = `${flag(code)} ${countryName(code)}`;
125
129
  return [`${current ? cyan("❯") : " "} ${mark}`, current ? bold(code) : dim(code), current ? bold(cyan(name)) : off ? dim(name) : name];
126
130
  });
127
131
  if (!body.length)
128
- body.push([" ", "", dim("no country matches")]);
132
+ body.push([" ", "", dim(off ? "no saved scan yet" : "no country matches")]);
129
133
  const buttons = [
130
134
  ["start", starting < 0 ? "▶ Start" : `${spin(starting++)} Starting…`],
131
135
  ["all", under("A") + (query ? "ll shown" : "ll")],
@@ -133,15 +137,15 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
133
137
  ["quit", under("Q") + "uit"],
134
138
  ];
135
139
  const buttonLine = buttons.map(([id, label]) => (id === "start" && n && !missing ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
136
- const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine];
140
+ const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine, holdLine];
137
141
  const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...settings.map(vw)) + 4 });
138
142
  const width = vw(table[0]);
139
143
  const head = box(settings, {
140
144
  title: bold(cyan("WindScout")),
141
- right: `${(off ? dim : bold)(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
145
+ right: `${(off ? dim : bold)(String(off ? matches.length : selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
142
146
  width: width - 4,
143
147
  });
144
- const total = login + estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout);
148
+ const total = login + estimate(n, timeout, slots);
145
149
  const costs = dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""}${login ? `, + ~${duration(login)} login` : ""})`);
146
150
  const lines = [
147
151
  ...head,
@@ -151,15 +155,18 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
151
155
  "",
152
156
  centerIn(missing
153
157
  ? yellow("no saved scan yet, so start is off: press o for another scanning type")
154
- : !n ? yellow(`select at least one country and one protocol${last ? ", or o for only the last results" : ""}`)
155
- : stopAtFirst // it can end at any attempt: this is when none holds up
156
- ? `up to ${bold(String(n))} attempts · at most ${bold("≈ " + duration(total))} ${costs}`
158
+ : !n ? yellow(skipped.length ? `the docker containers can't run ${skipped.join(" or ")}: d turns docker off`
159
+ : `select at least one country and one protocol${last ? ", or o for only the last results" : ""}`)
160
+ : stopAtFirst // it can end at any attempt, and each working one adds its checks (the w line)
161
+ ? `up to ${bold(String(n))} attempts · ${bold("≈ " + duration(total))} if none connects ${costs}`
157
162
  : `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(total))} ${costs}`, width),
158
- centerIn(dim(editing
159
- ? "type the seconds to wait for each connection · enter/esc done"
160
- : searching
161
- ? "type to filter · ↑↓ move · enter/esc done"
162
- : `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
163
+ centerIn(dim(editing === "hold"
164
+ ? "type the seconds each working connection has to stay connected · enter/esc done"
165
+ : editing
166
+ ? "type the seconds to wait for each connection · enter/esc done"
167
+ : searching
168
+ ? "type to filter · ↑↓ move · enter/esc done"
169
+ : `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
163
170
  centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall · t timeout · d docker · o type · w first`), width),
164
171
  ];
165
172
  if (message)
@@ -184,7 +191,7 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
184
191
  });
185
192
  hit = {
186
193
  protoRow: row(protoLine), protoHits, portsRow: row(portsLine), speedRow: SPEED_TEST ? row(speedLine) : -1,
187
- firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine),
194
+ firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine), holdRow: row(holdLine),
188
195
  searchRow: at.top + head.length + 1,
189
196
  firstRow: at.top + head.length + 5, shown: shown.length,
190
197
  buttonRow: at.top + head.length + table.length + 1, buttonHits,
@@ -207,7 +214,7 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
207
214
  const start = () => {
208
215
  if (needsLast(order) && !last)
209
216
  return false; // start is off: the warning under the table says why
210
- if (!todo().length) {
217
+ if (!todo().list.length) {
211
218
  message = "nothing to scan yet";
212
219
  return false;
213
220
  }
@@ -215,6 +222,10 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
215
222
  message = `the timeout must be at least ${MIN_TIMEOUT} seconds`;
216
223
  return false;
217
224
  }
225
+ if (stopAtFirst && !(hold >= MIN_HOLD)) {
226
+ message = `the hold must be at least ${MIN_HOLD} seconds`;
227
+ return false;
228
+ }
218
229
  // keep the user's order of preference stable: list order
219
230
  const choice = {
220
231
  countries: COUNTRIES.filter((c) => selected.has(c)),
@@ -223,6 +234,7 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
223
234
  firewall,
224
235
  order,
225
236
  stopAtFirst,
237
+ hold,
226
238
  allPorts,
227
239
  timeout,
228
240
  docker,
@@ -271,11 +283,13 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
271
283
  else if (off && (onCountries || y === hit.protoRow || y === hit.portsRow))
272
284
  message = SKIPPED;
273
285
  else if (y === hit.searchRow)
274
- searching = true, editing = false;
286
+ searching = true, editing = null;
275
287
  else if (y === hit.timeoutRow)
276
- editing = true, searching = false;
288
+ editing = "timeout", searching = false;
289
+ else if (y === hit.holdRow)
290
+ editing = "hold", searching = false;
277
291
  else {
278
- searching = editing = false; // clicking anywhere else ends typing, like leaving a text field
292
+ searching = false, editing = null; // clicking anywhere else ends typing, like leaving a text field
279
293
  if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
280
294
  cursor = top + (y - hit.firstRow);
281
295
  const c = matches[cursor];
@@ -341,12 +355,13 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
341
355
  else if (k === "pagedown")
342
356
  cursor = Math.min(matches.length - 1, cursor + 10);
343
357
  else if (editing) {
358
+ const text = typed[editing];
344
359
  if (k === "return" || k === "escape")
345
- editing = false;
360
+ editing = null;
346
361
  else if (k === "backspace")
347
- seconds = seconds.slice(0, -1);
348
- else if (/^[0-9]$/.test(str ?? "") && seconds.length < 4)
349
- seconds = seconds === "0" ? str : seconds + str;
362
+ typed[editing] = text.slice(0, -1);
363
+ else if (/^[0-9]$/.test(str ?? "") && text.length < 4)
364
+ typed[editing] = text === "0" ? str : text + str;
350
365
  }
351
366
  else if (searching) {
352
367
  // typing goes into the search box; letters are not shortcuts here
@@ -382,7 +397,9 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
382
397
  else if (k === "f")
383
398
  firewall = !firewall;
384
399
  else if (k === "t")
385
- editing = true, seconds = "";
400
+ editing = "timeout", typed.timeout = "";
401
+ else if (k === "h")
402
+ editing = "hold", typed.hold = "";
386
403
  else if (k === "d")
387
404
  return void toggleDocker();
388
405
  else if (k === "o")
@@ -1,3 +1,4 @@
1
+ import { loadView, saveView } from "../config.js";
1
2
  import { sortOk } from "../probe.js";
2
3
  import { attemptSeconds } from "../scan.js";
3
4
  import { duration, now } from "../time.js";
@@ -8,13 +9,13 @@ import { matrix, resultTable } from "./tables.js";
8
9
  import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
9
10
  import { bold, cyan, dim, green, red, vw, yellow } from "./text.js";
10
11
  import { bar, box, centerIn, spin } from "./widgets.js";
11
- /** Progress bar, current attempt, results so far. tab switches the table, p pauses, q stops (twice: quits).
12
+ /** Progress bar, current attempt, results so far. tab switches the table (the next scans start on it), p pauses, q stops (twice: quits).
12
13
  * blocking: we turned the firewall on for the scan */
13
14
  export function showProgress(scan, blocking) {
14
15
  const { protocols, options: { timeout } } = scan;
15
16
  const speedTest = scan.options.speedTest || Boolean(scan.options.stopAtFirst); // stop at the first measures speed too
16
17
  let quitting = false; // q pressed: a second one quits right away
17
- let view = speedTest ? "ranking" : "availability"; // tab switches
18
+ let view = loadView(); // tab switches, and saves it for the next scans
18
19
  let frame = 0;
19
20
  const render = () => {
20
21
  const rows = out.rows || 24;
@@ -60,8 +61,8 @@ export function showProgress(scan, blocking) {
60
61
  const pausing = paused && !waiting && frame % 8 < 5;
61
62
  const slotLine = (s) => {
62
63
  if (!s.current)
63
- return s.step ? ` ${red("✗")} ${dim(clip(s.step, inner - 3))}` : dim(s.name ? ` · ${s.name}: after the containers` : " · idle");
64
- const head = ` ${cyan(spinner)} ${s.name ? dim(`${s.name}: `) : ""}${bold(attempt(s))} `, time = ` · ${duration(now() - s.attemptStart)}`;
64
+ return s.step ? ` ${red("✗")} ${dim(clip(s.step, inner - 3))}` : dim(" · idle");
65
+ const head = ` ${cyan(spinner)} ${bold(attempt(s))} `, time = ` · ${duration(now() - s.attemptStart)}`;
65
66
  return head + dim(clip(s.step, inner - vw(head) - time.length) + time);
66
67
  };
67
68
  const panel = box([
@@ -98,6 +99,7 @@ export function showProgress(scan, blocking) {
98
99
  input.on("keypress", async (_str, key = {}) => {
99
100
  if (key.name === "tab") {
100
101
  view = view === "ranking" ? "availability" : "ranking";
102
+ saveView(view);
101
103
  render();
102
104
  }
103
105
  else if (key.name === "p" || key.name === "space") {
@@ -1,12 +1,11 @@
1
1
  // The final report, printed to the normal screen so it stays in the scrollback.
2
- import { sortOk } from "../probe.js";
2
+ import { sortOk, target } from "../probe.js";
3
+ import { ago } from "../time.js";
3
4
  import { CLI } from "../windscribe.js";
4
5
  import { flag } from "./country.js";
5
6
  import { matrix, resultTable } from "./tables.js";
6
- import { bold, cyan, dim, fmt, green, vw, yellow } from "./text.js";
7
+ import { bold, cyan, dim, fmt, green, red, vw, yellow } from "./text.js";
7
8
  import { box, grid } from "./widgets.js";
8
- // nickname pins the exact datacenter
9
- export const target = (r) => r.location.split(" - ").at(-1) || r.query;
10
9
  export const command = (r) => `${CLI} connect "${target(r)}" ${r.protocol}`;
11
10
  /** Prints the report; returns the connections it numbered: the best (#0), then the others (#1…) */
12
11
  export function report(results, protocols, { all, speedTest }) {
@@ -62,3 +61,14 @@ export function report(results, protocols, { all, speedTest }) {
62
61
  console.log(rest.join("\n"));
63
62
  return [b, ...others];
64
63
  }
64
+ /** Prints the list as the results screen left it, best first, as windscout connect will try it: on the way out, when it
65
+ * changed there (a test, a drop, a background check) */
66
+ export function printList(list) {
67
+ if (!list.length)
68
+ return;
69
+ const speeds = list.some((e) => e.speedMbps !== null);
70
+ console.log(grid([{ title: "#", align: "right" }, { title: "Location" }, { title: "Protocol" }, { title: "Latency", align: "right" },
71
+ ...(speeds ? [{ title: "Speed", align: "right" }] : []), { title: "Last check" }], list.map((e, i) => [dim(String(i + 1)), `${flag(e.query)} ${e.location || e.query}`, e.protocol,
72
+ e.latencyMs === null ? "-" : `${fmt(e.latencyMs, 0)} ms`, ...(speeds ? [e.speedMbps === null ? "-" : `${fmt(e.speedMbps, 1)} Mbps`] : []),
73
+ e.failed ? red(`✗ ${e.failed} · ${ago(e.failedAt ?? Date.now())}`) : e.checked ? green(`✓ ${ago(e.checked)}`) : dim("✓ in the scan")]), { caption: `${bold("Your connections now")} ${dim("· saved: windscout connect tries them in this order")}`, minWidth: 64 }).join("\n"));
74
+ }