roxxie-proxy-u-prod 0.2.3 → 0.2.5

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.
Files changed (39) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/index-CSCDo1rk.js +3669 -0
  3. package/dist/assets/inject-CDw4wJ3e.js +43 -0
  4. package/embed.js +1 -1
  5. package/examples/single-page.html +2 -2
  6. package/package.json +1 -1
  7. package/runtime/index.html +3 -3
  8. package/scripts/verify.mjs +2 -2
  9. package/source/packages/chrome/src/Tab/History.test.ts +137 -0
  10. package/source/packages/chrome/src/Tab/History.ts +8 -3
  11. package/source/packages/chrome/src/Tab/Tab.tsx +4 -1
  12. package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
  13. package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
  14. package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
  15. package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
  16. package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
  17. package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
  18. package/source/packages/chrome/src/pages/nodes.ts +108 -0
  19. package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
  20. package/source/packages/chrome/src/services/SettingsService.ts +8 -1
  21. package/source/packages/roxxie-transport/package.json +1 -1
  22. package/source/packages/roxxie-transport/src/transport.ts +51 -7
  23. package/source/packages/scramjet/packages/core/dist/scramjet.js +14 -13582
  24. package/source/packages/scramjet/packages/core/dist/scramjet.js.map +1 -1
  25. package/source/packages/scramjet/packages/core/dist/scramjet.mjs +14 -13632
  26. package/source/packages/scramjet/packages/core/dist/scramjet.mjs.map +1 -1
  27. package/source/packages/scramjet/packages/core/dist/scramjet_bundled.js +14 -13584
  28. package/source/packages/scramjet/packages/core/dist/scramjet_bundled.js.map +1 -1
  29. package/source/packages/scramjet/packages/core/dist/scramjet_bundled.mjs +14 -13634
  30. package/source/packages/scramjet/packages/core/dist/scramjet_bundled.mjs.map +1 -1
  31. package/source/packages/scramjet/packages/core/dist/types/fetch/user-agent.d.ts +31 -0
  32. package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
  33. package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
  34. package/source/packages/tracker-protocol/package.json +1 -1
  35. package/source/packages/tracker-protocol/src/index.ts +22 -0
  36. package/source/pnpm-lock.yaml +1 -1
  37. package/standalone.html +2 -2
  38. package/dist/assets/index-BQkXFDet.js +0 -3613
  39. package/dist/assets/inject-xtH4w5z9.js +0 -43
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ AUTOMATIC_NODE,
5
+ type CandidateNode,
6
+ buildNodeRows,
7
+ nodeSummary,
8
+ resolvePreferredNode,
9
+ toNodeRow,
10
+ versionLabel,
11
+ } from "./nodes";
12
+
13
+ function candidate(overrides: Partial<CandidateNode> = {}): CandidateNode {
14
+ return {
15
+ nodeId: "018f1f5e-7b2a-7c35-8b1d-2a4d9f8e6c10",
16
+ name: "Roxxie community node",
17
+ activeSessions: 1,
18
+ maxSessions: 8,
19
+ utilization: 0.125,
20
+ protocolVersion: "6.0",
21
+ packageVersion: "0.5.1",
22
+ platform: "linux",
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe("advanced settings node list", () => {
28
+ it("surfaces the node software version", () => {
29
+ const row = toNodeRow(candidate());
30
+ expect(row.version).toBe("0.5.1");
31
+ expect(versionLabel(row)).toBe("v0.5.1");
32
+ });
33
+
34
+ it("says so plainly when a tracker does not report a version", () => {
35
+ // Trackers older than the version field omit it; showing "v undefined"
36
+ // would be worse than admitting we do not know.
37
+ const row = toNodeRow(candidate({ packageVersion: undefined }));
38
+ expect(row.version).toBeNull();
39
+ expect(versionLabel(row)).toBe("version unknown");
40
+ });
41
+
42
+ it("marks which node is currently connected", () => {
43
+ const row = toNodeRow(candidate(), candidate().nodeId);
44
+ expect(row.connected).toBe(true);
45
+ expect(toNodeRow(candidate(), "someone-else").connected).toBe(false);
46
+ });
47
+
48
+ it("renders load as a clamped percentage", () => {
49
+ expect(toNodeRow(candidate({ utilization: 0 })).loadPercent).toBe(0);
50
+ expect(toNodeRow(candidate({ utilization: 0.5 })).loadPercent).toBe(50);
51
+ expect(toNodeRow(candidate({ utilization: 1 })).loadPercent).toBe(100);
52
+ expect(toNodeRow(candidate({ utilization: 2 })).loadPercent).toBe(100);
53
+ expect(toNodeRow(candidate({ utilization: -1 })).loadPercent).toBe(0);
54
+ expect(toNodeRow(candidate({ utilization: Number.NaN })).loadPercent).toBe(
55
+ 0
56
+ );
57
+ });
58
+
59
+ it("lists the least loaded node first", () => {
60
+ const rows = buildNodeRows([
61
+ candidate({ nodeId: "c", name: "C", utilization: 0.9 }),
62
+ candidate({ nodeId: "a", name: "A", utilization: 0.1 }),
63
+ candidate({ nodeId: "b", name: "B", utilization: 0.5 }),
64
+ ]);
65
+ expect(rows.map((row) => row.nodeId)).toEqual(["a", "b", "c"]);
66
+ });
67
+
68
+ it("orders ties stably so the list does not reshuffle", () => {
69
+ const rows = buildNodeRows([
70
+ candidate({ nodeId: "z", name: "Same", utilization: 0.2 }),
71
+ candidate({ nodeId: "a", name: "Same", utilization: 0.2 }),
72
+ ]);
73
+ expect(rows.map((row) => row.nodeId)).toEqual(["a", "z"]);
74
+ });
75
+
76
+ it("summarises a node in one line", () => {
77
+ expect(nodeSummary(toNodeRow(candidate()))).toBe(
78
+ "v0.5.1 · linux · 1/8 sessions · 13% load"
79
+ );
80
+ });
81
+
82
+ it("keeps a pin that still matches an offered node", () => {
83
+ const rows = buildNodeRows([candidate({ nodeId: "keep" })]);
84
+ expect(resolvePreferredNode("keep", rows)).toBe("keep");
85
+ });
86
+
87
+ it("falls back to automatic when the pinned node is gone", () => {
88
+ // Otherwise the user would be stuck pointing at a node that no longer
89
+ // exists with no obvious way back.
90
+ const rows = buildNodeRows([candidate({ nodeId: "present" })]);
91
+ expect(resolvePreferredNode("vanished", rows)).toBe(AUTOMATIC_NODE);
92
+ expect(resolvePreferredNode("", rows)).toBe(AUTOMATIC_NODE);
93
+ });
94
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The parts of a tracker candidate this page renders. Declared structurally so
3
+ * the settings page does not need a direct dependency on the tracker protocol
4
+ * package.
5
+ */
6
+ export type CandidateNode = {
7
+ nodeId: string;
8
+ name: string;
9
+ activeSessions: number;
10
+ maxSessions: number;
11
+ utilization: number;
12
+ protocolVersion: string;
13
+ packageVersion?: string | undefined;
14
+ platform?: string | undefined;
15
+ };
16
+
17
+ /** A node as presented in Advanced settings. */
18
+ export type NodeRow = {
19
+ nodeId: string;
20
+ name: string;
21
+ /** Node agent release, or null when the tracker does not report one. */
22
+ version: string | null;
23
+ platform: string | null;
24
+ activeSessions: number;
25
+ maxSessions: number;
26
+ /** 0-100, rounded, clamped. */
27
+ loadPercent: number;
28
+ protocolVersion: string;
29
+ connected: boolean;
30
+ };
31
+
32
+ /** Value used in the picker to mean "let the browser choose". */
33
+ export const AUTOMATIC_NODE = "";
34
+
35
+ function clampPercent(value: number): number {
36
+ if (!Number.isFinite(value)) return 0;
37
+ return Math.min(100, Math.max(0, Math.round(value * 100)));
38
+ }
39
+
40
+ export function toNodeRow(
41
+ candidate: CandidateNode,
42
+ connectedNodeId?: string
43
+ ): NodeRow {
44
+ return {
45
+ nodeId: candidate.nodeId,
46
+ name: candidate.name,
47
+ // Trackers predating version reporting simply omit it.
48
+ version: candidate.packageVersion ?? null,
49
+ platform: candidate.platform ?? null,
50
+ activeSessions: candidate.activeSessions,
51
+ maxSessions: candidate.maxSessions,
52
+ loadPercent: clampPercent(candidate.utilization),
53
+ protocolVersion: candidate.protocolVersion,
54
+ connected: candidate.nodeId === connectedNodeId,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Least loaded first, so the ordering matches how a node would be picked
60
+ * automatically. Ties fall back to name then id to keep the list from
61
+ * reshuffling between refreshes.
62
+ */
63
+ export function sortNodeRows(rows: readonly NodeRow[]): NodeRow[] {
64
+ return [...rows].sort(
65
+ (left, right) =>
66
+ left.loadPercent - right.loadPercent ||
67
+ left.activeSessions - right.activeSessions ||
68
+ left.name.localeCompare(right.name) ||
69
+ left.nodeId.localeCompare(right.nodeId)
70
+ );
71
+ }
72
+
73
+ export function buildNodeRows(
74
+ candidates: readonly CandidateNode[],
75
+ connectedNodeId?: string
76
+ ): NodeRow[] {
77
+ return sortNodeRows(
78
+ candidates.map((candidate) => toNodeRow(candidate, connectedNodeId))
79
+ );
80
+ }
81
+
82
+ /** Version text for display, distinguishing "unknown" from a real version. */
83
+ export function versionLabel(row: NodeRow): string {
84
+ return row.version === null ? "version unknown" : `v${row.version}`;
85
+ }
86
+
87
+ /** Short summary line shown under each node's name. */
88
+ export function nodeSummary(row: NodeRow): string {
89
+ const parts = [versionLabel(row)];
90
+ if (row.platform) parts.push(row.platform);
91
+ parts.push(`${row.activeSessions}/${row.maxSessions} sessions`);
92
+ parts.push(`${row.loadPercent}% load`);
93
+ return parts.join(" · ");
94
+ }
95
+
96
+ /**
97
+ * Whether a saved preference still refers to a node the tracker is offering.
98
+ * A stale pin is treated as automatic rather than leaving the user stuck.
99
+ */
100
+ export function resolvePreferredNode(
101
+ preferredNodeId: string,
102
+ rows: readonly NodeRow[]
103
+ ): string {
104
+ if (!preferredNodeId) return AUTOMATIC_NODE;
105
+ return rows.some((row) => row.nodeId === preferredNodeId)
106
+ ? preferredNodeId
107
+ : AUTOMATIC_NODE;
108
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ applyUserAgentHeader,
5
+ browserUserAgent,
6
+ resetUserAgentCache,
7
+ } from "../../../scramjet/packages/core/src/fetch/user-agent";
8
+
9
+ /** Minimal stand-in for ScramjetHeaders' case-insensitive storage. */
10
+ function headers(initial: Record<string, string> = {}) {
11
+ const store = new Map<string, string>();
12
+ for (const [name, value] of Object.entries(initial)) {
13
+ store.set(name.toLowerCase(), value);
14
+ }
15
+ return {
16
+ get: (key: string) => store.get(key.toLowerCase()) ?? null,
17
+ set: (key: string, value: string) => {
18
+ store.set(key.toLowerCase(), value);
19
+ },
20
+ raw: store,
21
+ };
22
+ }
23
+
24
+ const FIREFOX =
25
+ "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0";
26
+
27
+ afterEach(() => {
28
+ resetUserAgentCache();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ describe("User-Agent forwarding", () => {
33
+ it("sends the browser's own User-Agent when the request carries none", () => {
34
+ // Without this an origin that content-negotiates on User-Agent serves its
35
+ // non-browser representation: ip.me returns a text/plain IP, not its page.
36
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
37
+ const h = headers();
38
+
39
+ applyUserAgentHeader(h);
40
+
41
+ expect(h.get("user-agent")).toBe(FIREFOX);
42
+ });
43
+
44
+ it("matches the header case-insensitively", () => {
45
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
46
+ const h = headers({ "User-Agent": "PageChosen/1.0" });
47
+
48
+ applyUserAgentHeader(h);
49
+
50
+ expect(h.get("user-agent")).toBe("PageChosen/1.0");
51
+ });
52
+
53
+ it("never overwrites a User-Agent the page set deliberately", () => {
54
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
55
+ const h = headers({ "user-agent": "CustomBot/2.0" });
56
+
57
+ applyUserAgentHeader(h);
58
+
59
+ expect(h.get("user-agent")).toBe("CustomBot/2.0");
60
+ });
61
+
62
+ it("leaves the header absent rather than inventing one", () => {
63
+ vi.stubGlobal("navigator", { userAgent: "" });
64
+ const h = headers();
65
+
66
+ applyUserAgentHeader(h);
67
+
68
+ expect(h.get("user-agent")).toBeNull();
69
+ });
70
+
71
+ it("tolerates environments with no navigator at all", () => {
72
+ vi.stubGlobal("navigator", undefined);
73
+ const h = headers();
74
+
75
+ expect(() => applyUserAgentHeader(h)).not.toThrow();
76
+ expect(browserUserAgent()).toBeNull();
77
+ });
78
+ });
@@ -24,6 +24,8 @@ export type Settings = {
24
24
  clearHistoryOnExit: boolean;
25
25
  doNotTrack: boolean;
26
26
  extensionsDevMode: boolean;
27
+ /** Node id the user pinned in Advanced settings; empty means automatic. */
28
+ preferredNodeId: string;
27
29
  };
28
30
 
29
31
  export type TabLayoutMode = Settings["tabLayout"];
@@ -44,6 +46,7 @@ const DEFAULT_SETTINGS: Settings = {
44
46
  clearHistoryOnExit: false,
45
47
  doNotTrack: true,
46
48
  extensionsDevMode: false,
49
+ preferredNodeId: "",
47
50
  };
48
51
 
49
52
  export type SettingsServiceState = {
@@ -63,6 +66,7 @@ export type SettingsServiceState = {
63
66
  clearHistoryOnExit: boolean;
64
67
  doNotTrack: boolean;
65
68
  extensionsDevMode: boolean;
69
+ preferredNodeId: string;
66
70
  };
67
71
  };
68
72
 
@@ -72,7 +76,9 @@ export class SettingsService extends Service {
72
76
  constructor(data: SettingsServiceState | null) {
73
77
  super();
74
78
  if (data) {
75
- this.settings = createState(data.settings);
79
+ // Profiles saved before a setting existed omit it entirely, so fill in
80
+ // defaults rather than leaving the state with undefined values.
81
+ this.settings = createState({ ...DEFAULT_SETTINGS, ...data.settings });
76
82
  } else {
77
83
  this.settings = createState(DEFAULT_SETTINGS);
78
84
  }
@@ -102,6 +108,7 @@ export class SettingsService extends Service {
102
108
  clearHistoryOnExit: this.settings.clearHistoryOnExit,
103
109
  doNotTrack: this.settings.doNotTrack,
104
110
  extensionsDevMode: this.settings.extensionsDevMode,
111
+ preferredNodeId: this.settings.preferredNodeId ?? "",
105
112
  },
106
113
  };
107
114
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports",
3
- "version": "6.0.2",
3
+ "version": "6.1.0",
4
4
  "description": "Tracker-discovered WebRTC transport for Roxxie Proxy U",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -78,6 +78,9 @@ export class RoxxieProxyTransport implements ProxyTransport {
78
78
  private readonly nodeCooldowns = new Map<string, number>();
79
79
  private readonly dependencies: RoxxieTransportDependencies;
80
80
  private closed = false;
81
+ /** Node the user pinned in advanced settings, if any. */
82
+ private preferredNodeId: string | undefined;
83
+ private lastCandidates: readonly CandidateNode[] = [];
81
84
 
82
85
  constructor(
83
86
  private readonly source: TrackerNetworkSource,
@@ -243,16 +246,57 @@ export class RoxxieProxyTransport implements ProxyTransport {
243
246
  }
244
247
  }
245
248
 
249
+ /**
250
+ * Nodes the tracker offered on the most recent connection attempt. Used by
251
+ * the settings UI to show what is available without forcing a reconnect.
252
+ */
253
+ get availableNodes(): readonly CandidateNode[] {
254
+ return this.lastCandidates;
255
+ }
256
+
257
+ /** Ask the tracker for the current node list without connecting. */
258
+ async listNodes(): Promise<readonly CandidateNode[]> {
259
+ const document = await this.getDocument();
260
+ const tracker = this.getTracker(document);
261
+ const candidates = [...(await tracker.requestCandidates())];
262
+ this.lastCandidates = candidates;
263
+ return candidates;
264
+ }
265
+
266
+ get preferredNode(): string | undefined {
267
+ return this.preferredNodeId;
268
+ }
269
+
270
+ /**
271
+ * Pin future connections to one node, or pass undefined to go back to
272
+ * automatic selection. Takes effect on the next connection.
273
+ */
274
+ setPreferredNode(nodeId: string | undefined): void {
275
+ this.preferredNodeId = nodeId || undefined;
276
+ }
277
+
246
278
  private async establishConnection(): Promise<RoxxieConnection> {
247
279
  const document = await this.getDocument();
248
280
  const tracker = this.getTracker(document);
249
- const discovered = [...(await tracker.requestCandidates())]
250
- .slice(0, 3)
251
- .sort(
252
- (left, right) =>
253
- left.activeSessions - right.activeSessions ||
254
- left.utilization - right.utilization
255
- );
281
+ const offered = [...(await tracker.requestCandidates())];
282
+ // Keep the full list for the settings UI; only the probe set is trimmed.
283
+ this.lastCandidates = offered;
284
+ const ranked = offered.sort(
285
+ (left, right) =>
286
+ left.activeSessions - right.activeSessions ||
287
+ left.utilization - right.utilization
288
+ );
289
+ // A pinned node has to be selected before the list is trimmed, or picking
290
+ // a lightly-used node outside the top few would silently do nothing.
291
+ const pinned =
292
+ this.preferredNodeId === undefined
293
+ ? []
294
+ : ranked.filter(
295
+ (candidate) => candidate.nodeId === this.preferredNodeId
296
+ );
297
+ // Fall back to automatic selection rather than stranding the user on a node
298
+ // that has gone away since they picked it.
299
+ const discovered = pinned.length > 0 ? pinned : ranked.slice(0, 3);
256
300
  const compatible = discovered.filter(
257
301
  (candidate) => candidate.protocolVersion === PROTOCOL_VERSION
258
302
  );