roxxie-proxy-u-prod 0.2.3 → 0.2.4

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 (27) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/{index-BQkXFDet.js → index-MRhaNu_Q.js} +195 -135
  3. package/embed.js +1 -1
  4. package/examples/single-page.html +2 -2
  5. package/package.json +1 -1
  6. package/runtime/index.html +3 -3
  7. package/scripts/verify.mjs +2 -2
  8. package/source/packages/chrome/src/Tab/History.test.ts +137 -0
  9. package/source/packages/chrome/src/Tab/History.ts +8 -3
  10. package/source/packages/chrome/src/Tab/Tab.tsx +4 -1
  11. package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
  12. package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
  13. package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
  14. package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
  15. package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
  16. package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
  17. package/source/packages/chrome/src/pages/nodes.ts +108 -0
  18. package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
  19. package/source/packages/chrome/src/services/SettingsService.ts +8 -1
  20. package/source/packages/roxxie-transport/package.json +1 -1
  21. package/source/packages/roxxie-transport/src/transport.ts +51 -7
  22. package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
  23. package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
  24. package/source/packages/tracker-protocol/package.json +1 -1
  25. package/source/packages/tracker-protocol/src/index.ts +22 -0
  26. package/source/pnpm-lock.yaml +1 -1
  27. package/standalone.html +2 -2
package/embed.js CHANGED
@@ -3,7 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  var DEFAULT_SHELL_URL =
6
- "https://unpkg.com/roxxie-proxy-u-prod@0.2.3/runtime/index.html";
6
+ "https://unpkg.com/roxxie-proxy-u-prod@0.2.4/runtime/index.html";
7
7
  var loadingScript = document.currentScript;
8
8
 
9
9
  function resolveContainer(target) {
@@ -18,8 +18,8 @@
18
18
  <div id="roxxie-browser"></div>
19
19
  <script
20
20
  defer
21
- src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/embed.js"
22
- integrity="sha384-yu0TlvCJ9LcKoOG66anQWT/iHEz3uC7rUGLmaDZV9otcFcWsQ6EvX8y2GPuxFsOH"
21
+ src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/embed.js"
22
+ integrity="sha384-o6wwoyCvQbj3evNh2USZSUAOc625OxxX2yUnWs7o2i4NmKGhII4Y9SxvCG03OjAk"
23
23
  crossorigin="anonymous"
24
24
  data-roxxie-target="#roxxie-browser"
25
25
  ></script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-proxy-u-prod",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Production CDN assets and embed loader for Roxxie Proxy U",
5
5
  "license": "AGPL-3.0-only",
6
6
  "private": false,
@@ -2,14 +2,14 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/favicon.png" />
5
+ <link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/dist/favicon.png" />
6
6
  <meta name="theme-color" content="#222230" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>Roxxie Proxy U</title>
9
9
 
10
10
  <!--ssr-head-->
11
- <script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/assets/index-BQkXFDet.js"></script>
12
- <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/assets/index-EeRhLAof.css">
11
+ <script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/dist/assets/index-MRhaNu_Q.js"></script>
12
+ <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/dist/assets/index-EeRhLAof.css">
13
13
  </head>
14
14
 
15
15
  <body>
@@ -118,8 +118,8 @@ if (
118
118
  throw new Error("Start screen must remain flat and glow-free");
119
119
  }
120
120
 
121
- if (manifest.version !== "0.2.3") {
122
- throw new Error("This production release must be version 0.2.3");
121
+ if (manifest.version !== "0.2.4") {
122
+ throw new Error("This production release must be version 0.2.4");
123
123
  }
124
124
 
125
125
  const embedBytes = await readFile(join(packageRoot, "embed.js"));
@@ -0,0 +1,137 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // History records visited pages into the global profile; stub it so the model
4
+ // can be exercised on its own.
5
+ vi.mock("..", () => ({
6
+ profileService: { globalhistory: [] as unknown[] },
7
+ }));
8
+
9
+ import { History } from "./History";
10
+
11
+ /** Only the surface History touches on its owning tab. */
12
+ function fakeTab() {
13
+ return {
14
+ url: null as URL | null,
15
+ title: null as string | null,
16
+ icon: null as string | null,
17
+ canGoBack: false,
18
+ canGoForward: false,
19
+ navigated: [] as string[],
20
+ _directnavigate(url: URL) {
21
+ this.navigated.push(url.href);
22
+ this.url = url;
23
+ },
24
+ };
25
+ }
26
+
27
+ function makeHistory() {
28
+ const tab = fakeTab();
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ const history = new History(tab as any);
31
+ return { tab, history };
32
+ }
33
+
34
+ const A = new URL("https://a.example/");
35
+ const B = new URL("https://b.example/");
36
+ const C = new URL("https://c.example/");
37
+ const D = new URL("https://d.example/");
38
+
39
+ describe("tab history", () => {
40
+ let tab: ReturnType<typeof fakeTab>;
41
+ let history: History;
42
+
43
+ beforeEach(() => {
44
+ ({ tab, history } = makeHistory());
45
+ });
46
+
47
+ it("tracks a linear trail and walks back through it", () => {
48
+ history.push(A);
49
+ history.push(B);
50
+ history.push(C);
51
+
52
+ expect(history.current().url.href).toBe(C.href);
53
+ expect(history.canGoBack()).toBe(true);
54
+ expect(history.canGoForward()).toBe(false);
55
+
56
+ expect(history.go(-1).url.href).toBe(B.href);
57
+ expect(history.go(-1).url.href).toBe(A.href);
58
+ expect(history.canGoBack()).toBe(false);
59
+ });
60
+
61
+ it("keeps the current entry when a new navigation forks the trail", () => {
62
+ // Regression: the fork used to splice from the current index rather than
63
+ // after it, deleting the entry the user was on. index then pointed past the
64
+ // end of the array, so states[index] was undefined and going back walked
65
+ // into that hole.
66
+ history.push(A);
67
+ history.push(B);
68
+ history.push(C);
69
+ history.go(-2);
70
+ expect(history.current().url.href).toBe(A.href);
71
+
72
+ history.push(D);
73
+
74
+ expect(history.states.map((s) => s.url.href)).toEqual([A.href, D.href]);
75
+ expect(history.index).toBe(1);
76
+ expect(history.current().url.href).toBe(D.href);
77
+ expect(history.states[history.index]).toBeDefined();
78
+ });
79
+
80
+ it("goes back to a real page after forking rather than an empty state", () => {
81
+ history.push(A);
82
+ history.push(B);
83
+ history.push(C);
84
+ history.go(-2);
85
+ history.push(D);
86
+
87
+ expect(history.canGoBack()).toBe(true);
88
+ const back = history.go(-1);
89
+
90
+ expect(back).toBeDefined();
91
+ expect(back.url).toBeInstanceOf(URL);
92
+ expect(back.url.href).toBe(A.href);
93
+ // The URL actually handed to the tab must be a real page, never "undefined".
94
+ expect(tab.navigated.at(-1)).toBe(A.href);
95
+ expect(tab.navigated.some((href) => href.includes("undefined"))).toBe(false);
96
+ });
97
+
98
+ it("never reports canGoBack while sitting on the first entry", () => {
99
+ history.push(A);
100
+ history.push(B);
101
+ history.go(-1);
102
+
103
+ expect(history.index).toBe(0);
104
+ expect(history.canGoBack()).toBe(false);
105
+ });
106
+
107
+ it("clamps overshooting travel to the ends of the trail", () => {
108
+ history.push(A);
109
+ history.push(B);
110
+
111
+ expect(history.go(-99).url.href).toBe(A.href);
112
+ expect(history.go(99).url.href).toBe(B.href);
113
+ });
114
+
115
+ it("replaces the current entry without growing the trail", () => {
116
+ history.push(A);
117
+ history.push(B);
118
+ history.replace(C, null, null, false);
119
+
120
+ expect(history.states.map((s) => s.url.href)).toEqual([A.href, C.href]);
121
+ expect(history.index).toBe(1);
122
+ });
123
+
124
+ it("survives a round trip through serialization", () => {
125
+ history.push(A);
126
+ history.push(B);
127
+ history.go(-1);
128
+
129
+ const restoredTab = fakeTab();
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ const restored = new History(restoredTab as any, history.serialize());
132
+
133
+ expect(restored.index).toBe(history.index);
134
+ expect(restored.current().url.href).toBe(A.href);
135
+ expect(restored.canGoForward()).toBe(true);
136
+ });
137
+ });
@@ -94,8 +94,11 @@ export class History extends StatefulClass {
94
94
  // disown states we're removing
95
95
  this.states.slice(this.index + 1).forEach((state) => this.disown(state));
96
96
 
97
- // "fork" history tree, creating a new timeline
98
- this.states.splice(this.index, this.states.length - this.index);
97
+ // "fork" history tree, creating a new timeline. Only the entries *after*
98
+ // the current one are dropped: removing the current entry as well left
99
+ // index pointing past the end of the array, so states[index] was
100
+ // undefined and going back navigated into that hole.
101
+ this.states.splice(this.index + 1);
99
102
  }
100
103
  const hstate = new HistoryState({ url, state, title });
101
104
  if (virtual) hstate.virtual = true;
@@ -129,7 +132,9 @@ export class History extends StatefulClass {
129
132
  this.current().title = title;
130
133
  this.current().favicon = null;
131
134
  } else {
132
- return this.push(url, state);
135
+ // push takes (url, title, state, navigate); passing state positionally
136
+ // as the title corrupted the new entry.
137
+ return this.push(url, title, state, navigate);
133
138
  }
134
139
 
135
140
  if (navigate) {
@@ -204,7 +204,10 @@ export class Tab extends StatefulClass {
204
204
  this.history.push(url, null, null, true, false);
205
205
  }
206
206
  replaceNavigate(url: URL) {
207
- this.history.replace(url, null, true);
207
+ // replace takes (url, title, state, navigate); `true` was landing in the
208
+ // state slot, so the replacement carried a bogus state and never navigated
209
+ // explicitly.
210
+ this.history.replace(url, null, null, true);
208
211
  }
209
212
 
210
213
  back() {
@@ -9,6 +9,7 @@ import {
9
9
  type OmniboxResult,
10
10
  } from "./suggestions";
11
11
  import { trimUrl } from "./utils";
12
+ import { inlineCompletion } from "./autocomplete";
12
13
  import { UrlInput } from "@components/Omnibar/UrlInput";
13
14
  import { Suggestion } from "@components/Omnibar/Suggestion";
14
15
  import { requestUnfocusFrames } from "@components/Shell";
@@ -97,32 +98,19 @@ export function Omnibox(
97
98
  // if the user is actually trying to search something we can kill the trending suggestions
98
99
  this.trendingSuggestions = [];
99
100
 
100
- fetchSuggestions(this.realvalue, this.suggestionDenied, (results) => {
101
+ // Capture the flag for this run. It is cleared synchronously below, well
102
+ // before fetchSuggestions calls back, so the callback cannot read it.
103
+ const justDeleted = this.suggestionDenied;
104
+
105
+ fetchSuggestions(this.realvalue, justDeleted, (results) => {
101
106
  this.searchSuggestions = results;
102
107
 
103
108
  const firstResult = results[0];
104
109
  if (!firstResult) return;
105
- if (firstResult.kind === "search") {
106
- if (!firstResult.title) return;
107
- if (this.realvalue.length >= firstResult.title.length) return;
108
- if (
109
- !firstResult.title
110
- .toLowerCase()
111
- .startsWith(this.realvalue.toLowerCase())
112
- )
113
- return;
114
110
 
115
- let currentCursor = this.input.selectionStart || 0;
116
-
117
- this.input.setSelectionRange(
118
- currentCursor,
119
- currentCursor + firstResult.title.length
120
- );
121
- this.value = firstResult.title;
122
- this.input.setSelectionRange(
123
- currentCursor,
124
- currentCursor + firstResult.title.length
125
- );
111
+ let candidate: string | null | undefined;
112
+ if (firstResult.kind === "search") {
113
+ candidate = firstResult.title;
126
114
  } else {
127
115
  if (!firstResult.url) return;
128
116
 
@@ -136,24 +124,26 @@ export function Omnibox(
136
124
  if (normalizedUrl.endsWith("/") && !this.realvalue.endsWith("/")) {
137
125
  normalizedUrl = normalizedUrl.slice(0, -1);
138
126
  }
139
- if (this.realvalue.length >= normalizedUrl.length) return;
140
- if (
141
- !normalizedUrl.toLowerCase().startsWith(this.realvalue.toLowerCase())
142
- )
143
- return;
144
-
145
- let currentCursor = this.input.selectionStart || 0;
146
-
147
- this.input.setSelectionRange(
148
- currentCursor,
149
- currentCursor + normalizedUrl.length
150
- );
151
- this.value = normalizedUrl;
152
- this.input.setSelectionRange(
153
- currentCursor,
154
- currentCursor + normalizedUrl.length
155
- );
127
+ candidate = normalizedUrl;
156
128
  }
129
+
130
+ const completion = inlineCompletion(
131
+ this.realvalue,
132
+ candidate,
133
+ justDeleted
134
+ );
135
+ if (completion === null) return;
136
+
137
+ const currentCursor = this.input.selectionStart || 0;
138
+ this.input.setSelectionRange(
139
+ currentCursor,
140
+ currentCursor + completion.length
141
+ );
142
+ this.value = completion;
143
+ this.input.setSelectionRange(
144
+ currentCursor,
145
+ currentCursor + completion.length
146
+ );
157
147
  });
158
148
  this.suggestionDenied = false;
159
149
  });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { inlineCompletion } from "./autocomplete";
4
+
5
+ describe("omnibox inline completion", () => {
6
+ it("completes a prefix while the user is typing", () => {
7
+ expect(inlineCompletion("ama", "amazon.com", false)).toBe("amazon.com");
8
+ });
9
+
10
+ it("never re-adds text right after a deletion", () => {
11
+ // The completion is written back into the box with its tail selected, so
12
+ // re-adding it after a backspace means the next backspace only removes the
13
+ // completion again and the final character can never be deleted.
14
+ expect(inlineCompletion("a", "amazon.com", true)).toBeNull();
15
+ });
16
+
17
+ it("leaves an emptied box empty", () => {
18
+ expect(inlineCompletion("", "amazon.com", true)).toBeNull();
19
+ expect(inlineCompletion("", "amazon.com", false)).toBeNull();
20
+ });
21
+
22
+ it("ignores candidates that are not a continuation of the input", () => {
23
+ expect(inlineCompletion("xyz", "amazon.com", false)).toBeNull();
24
+ });
25
+
26
+ it("matches case-insensitively but preserves the candidate's casing", () => {
27
+ expect(inlineCompletion("AmA", "amazon.com", false)).toBe("amazon.com");
28
+ });
29
+
30
+ it("adds nothing when the candidate is not longer than the input", () => {
31
+ expect(inlineCompletion("amazon.com", "amazon.com", false)).toBeNull();
32
+ expect(inlineCompletion("amazon.com/x", "amazon.com", false)).toBeNull();
33
+ });
34
+
35
+ it("tolerates a missing candidate", () => {
36
+ expect(inlineCompletion("ama", undefined, false)).toBeNull();
37
+ expect(inlineCompletion("ama", null, false)).toBeNull();
38
+ expect(inlineCompletion("ama", "", false)).toBeNull();
39
+ });
40
+
41
+ it("lets a full deletion sequence reach an empty box", () => {
42
+ // Walk "ama" down to "" the way repeated backspaces would, asserting the
43
+ // box is never refilled at any step.
44
+ let typed = "ama";
45
+ while (typed.length > 0) {
46
+ typed = typed.slice(0, -1);
47
+ expect(inlineCompletion(typed, "amazon.com", true)).toBeNull();
48
+ }
49
+ expect(typed).toBe("");
50
+ });
51
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Inline omnibox autocompletion.
3
+ *
4
+ * The omnibox completes what you type by writing the candidate into the input
5
+ * and selecting the added tail, so the next keystroke replaces it. That is only
6
+ * correct while text is being *added*. Applying it after a deletion makes the
7
+ * next backspace consume the re-added completion instead of the character the
8
+ * user meant to remove, which strands the final character permanently.
9
+ */
10
+
11
+ /**
12
+ * The text to place in the omnibox, or null to leave what the user typed alone.
13
+ *
14
+ * @param typed what the user actually has in the box
15
+ * @param candidate the top suggestion, if any
16
+ * @param justDeleted whether the last edit removed characters
17
+ */
18
+ export function inlineCompletion(
19
+ typed: string,
20
+ candidate: string | undefined | null,
21
+ justDeleted: boolean
22
+ ): string | null {
23
+ // Never re-add text the user is in the middle of removing.
24
+ if (justDeleted) return null;
25
+ if (!typed || !candidate) return null;
26
+ // A candidate that is not strictly longer adds nothing to complete.
27
+ if (candidate.length <= typed.length) return null;
28
+ if (!candidate.toLowerCase().startsWith(typed.toLowerCase())) return null;
29
+ return candidate;
30
+ }
@@ -0,0 +1,225 @@
1
+ import { css, type FC } from "dreamland/core";
2
+
3
+ import { Button } from "@components/Button";
4
+ import { roxxieTransport } from "../proxy/roxxie";
5
+ import { settingsService } from "..";
6
+ import {
7
+ AUTOMATIC_NODE,
8
+ buildNodeRows,
9
+ nodeSummary,
10
+ resolvePreferredNode,
11
+ type NodeRow,
12
+ } from "./nodes";
13
+
14
+ /**
15
+ * Exit-node picker. Lists the nodes the tracker is currently offering, what
16
+ * version of the node software each is running, and lets the user pin one
17
+ * instead of letting the browser choose by load and latency.
18
+ */
19
+ export function AdvancedSettings(
20
+ this: FC<
21
+ Record<string, never>,
22
+ {
23
+ rows: NodeRow[];
24
+ loading: boolean;
25
+ error: string | null;
26
+ selected: string;
27
+ }
28
+ >
29
+ ) {
30
+ this.rows = [];
31
+ this.loading = false;
32
+ this.error = null;
33
+ this.selected = settingsService.settings.preferredNodeId || AUTOMATIC_NODE;
34
+
35
+ const refresh = async () => {
36
+ if (!roxxieTransport) {
37
+ this.error = "The proxy transport is not running yet.";
38
+ return;
39
+ }
40
+ this.loading = true;
41
+ this.error = null;
42
+ try {
43
+ const candidates = await roxxieTransport.listNodes();
44
+ this.rows = buildNodeRows(
45
+ candidates,
46
+ roxxieTransport.connectedNode?.node.nodeId
47
+ );
48
+ // A pin left over from a node that has since disappeared reads as
49
+ // automatic rather than showing a selection that cannot be honoured.
50
+ this.selected = resolvePreferredNode(
51
+ settingsService.settings.preferredNodeId,
52
+ this.rows
53
+ );
54
+ } catch (error) {
55
+ this.error =
56
+ error instanceof Error ? error.message : "Could not reach the tracker.";
57
+ } finally {
58
+ this.loading = false;
59
+ }
60
+ };
61
+
62
+ const choose = (nodeId: string) => {
63
+ this.selected = nodeId;
64
+ settingsService.settings.preferredNodeId = nodeId;
65
+ roxxieTransport?.setPreferredNode(nodeId || undefined);
66
+ };
67
+
68
+ void refresh();
69
+
70
+ return (
71
+ <section class="setting-section">
72
+ <div class="section-header">
73
+ <h2>Exit Nodes</h2>
74
+ </div>
75
+ <div class="section-content">
76
+ <div class="setting-group">
77
+ <h4>Node Selection</h4>
78
+ <p class="setting-description">
79
+ Traffic leaves through one of these community nodes. Automatic picks
80
+ the least loaded node with the best latency. Choosing a node applies
81
+ to the next connection.
82
+ </p>
83
+
84
+ <div class="node-actions">
85
+ <Button
86
+ variant="secondary"
87
+ on:click={() => void refresh()}
88
+ disabled={use(this.loading)}
89
+ >
90
+ {use(this.loading).map((l) => (l ? "Refreshing…" : "Refresh"))}
91
+ </Button>
92
+ {use(this.selected).map(
93
+ (selected) =>
94
+ selected !== AUTOMATIC_NODE && (
95
+ <Button
96
+ variant="secondary"
97
+ on:click={() => choose(AUTOMATIC_NODE)}
98
+ >
99
+ Use automatic
100
+ </Button>
101
+ )
102
+ )}
103
+ </div>
104
+
105
+ {use(this.error).map(
106
+ (error) => error && <p class="node-error">{error}</p>
107
+ )}
108
+
109
+ <div class="node-list">
110
+ <label class="node-option" class:selected={use(this.selected).map(
111
+ (s) => s === AUTOMATIC_NODE
112
+ )}>
113
+ <input
114
+ type="radio"
115
+ name="roxxie-node"
116
+ checked={use(this.selected).map((s) => s === AUTOMATIC_NODE)}
117
+ on:change={() => choose(AUTOMATIC_NODE)}
118
+ />
119
+ <span class="node-text">
120
+ <span class="node-name">Automatic</span>
121
+ <span class="node-meta">
122
+ Pick the fastest available node for me
123
+ </span>
124
+ </span>
125
+ </label>
126
+
127
+ {use(this.rows).map((rows) =>
128
+ rows.length === 0 && !this.loading ? (
129
+ <p class="node-empty">No nodes are available right now.</p>
130
+ ) : (
131
+ rows.map((row) => (
132
+ <label
133
+ class="node-option"
134
+ class:selected={use(this.selected).map(
135
+ (s) => s === row.nodeId
136
+ )}
137
+ >
138
+ <input
139
+ type="radio"
140
+ name="roxxie-node"
141
+ checked={use(this.selected).map((s) => s === row.nodeId)}
142
+ on:change={() => choose(row.nodeId)}
143
+ />
144
+ <span class="node-text">
145
+ <span class="node-name">
146
+ {row.name}
147
+ {row.connected && (
148
+ <span class="node-badge">connected</span>
149
+ )}
150
+ </span>
151
+ <span class="node-meta">{nodeSummary(row)}</span>
152
+ <span class="node-id">{row.nodeId}</span>
153
+ </span>
154
+ </label>
155
+ ))
156
+ )
157
+ )}
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </section>
162
+ );
163
+ }
164
+
165
+ AdvancedSettings.style = css`
166
+ :scope .node-actions {
167
+ display: flex;
168
+ gap: var(--space-sm);
169
+ margin-block: var(--space-sm);
170
+ }
171
+ :scope .node-list {
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: var(--space-xs);
175
+ }
176
+ :scope .node-option {
177
+ display: flex;
178
+ align-items: flex-start;
179
+ gap: var(--space-sm);
180
+ padding: var(--space-sm);
181
+ border: 1px solid var(--popup_border);
182
+ border-radius: var(--radius-md, 8px);
183
+ cursor: pointer;
184
+ }
185
+ :scope .node-option.selected {
186
+ border-color: var(--tab_line);
187
+ }
188
+ :scope .node-text {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 2px;
192
+ min-width: 0;
193
+ }
194
+ :scope .node-name {
195
+ font-weight: 600;
196
+ display: flex;
197
+ align-items: center;
198
+ gap: var(--space-xs);
199
+ }
200
+ :scope .node-badge {
201
+ font-size: 0.75em;
202
+ font-weight: 500;
203
+ padding: 1px 6px;
204
+ border-radius: 999px;
205
+ background: var(--tab_line);
206
+ color: var(--toolbar);
207
+ }
208
+ :scope .node-meta {
209
+ opacity: 0.8;
210
+ font-size: 0.9em;
211
+ }
212
+ :scope .node-id {
213
+ opacity: 0.5;
214
+ font-size: 0.8em;
215
+ font-family: monospace;
216
+ overflow-wrap: anywhere;
217
+ }
218
+ :scope .node-error {
219
+ color: var(--error, #e57373);
220
+ }
221
+ :scope .node-empty,
222
+ :scope .setting-description {
223
+ opacity: 0.8;
224
+ }
225
+ `;
@@ -26,6 +26,7 @@ import {
26
26
  iconRefresh,
27
27
  } from "../icons";
28
28
  import { settingsService } from "..";
29
+ import { AdvancedSettings } from "./AdvancedSettings";
29
30
 
30
31
  function ThemePreview(this: FC<{ theme: (typeof THEMES)[number] }>) {
31
32
  const theme = this.theme;
@@ -701,6 +702,7 @@ export function SettingsPage(
701
702
  {button("appearance", iconBrush, "Appearance")}
702
703
  {button("search", iconSearch, "Search")}
703
704
  {button("privacy", iconPrivacy, "Privacy & Security")}
705
+ {button("advanced", iconOptions, "Advanced Settings")}
704
706
  {/* {button("extensions", iconExtension, "Extensions")} */}
705
707
  {button("about", iconAbout, "About")}
706
708
  </nav>
@@ -1303,6 +1305,15 @@ export function SettingsPage(
1303
1305
  ) : null
1304
1306
  )}
1305
1307
 
1308
+ {/* Advanced Tab */}
1309
+ {use(this.selected).map((selected) =>
1310
+ selected === "advanced" ? (
1311
+ <div class="settings-tab">
1312
+ <AdvancedSettings />
1313
+ </div>
1314
+ ) : null
1315
+ )}
1316
+
1306
1317
  {/* About Tab */}
1307
1318
  {use(this.selected).map((selected) =>
1308
1319
  selected === "about" ? (