roxxie-proxy-u-prod 0.2.2 → 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 (41) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/{index-CttvagWK.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 +42 -43
  6. package/runtime/index.html +3 -3
  7. package/scripts/verify.mjs +2 -2
  8. package/source/packages/adrift-protocol/package.json +2 -2
  9. package/source/packages/adrift-protocol/src/index.ts +11 -0
  10. package/source/packages/chrome/src/Tab/History.test.ts +137 -0
  11. package/source/packages/chrome/src/Tab/History.ts +8 -3
  12. package/source/packages/chrome/src/Tab/Tab.tsx +13 -2
  13. package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
  14. package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
  15. package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
  16. package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
  17. package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
  18. package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
  19. package/source/packages/chrome/src/pages/nodes.ts +108 -0
  20. package/source/packages/chrome/src/proxy/Controller.ts +12 -0
  21. package/source/packages/chrome/src/proxy/ProxyFrame.ts +19 -2
  22. package/source/packages/chrome/src/proxy/cache.test.ts +52 -0
  23. package/source/packages/chrome/src/proxy/cache.ts +55 -11
  24. package/source/packages/chrome/src/proxy/navigation.test.ts +91 -0
  25. package/source/packages/chrome/src/proxy/navigation.ts +97 -0
  26. package/source/packages/chrome/src/proxy/scramjet-config.test.ts +19 -0
  27. package/source/packages/chrome/src/proxy/scramjet-config.ts +26 -0
  28. package/source/packages/chrome/src/proxy/scramjet.ts +5 -18
  29. package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
  30. package/source/packages/chrome/src/services/SettingsService.ts +8 -1
  31. package/source/packages/roxxie-transport/package.json +1 -1
  32. package/source/packages/roxxie-transport/src/connection.ts +18 -1
  33. package/source/packages/roxxie-transport/src/transport.ts +51 -7
  34. package/source/packages/scramjet/packages/core/src/fetch/fetch.ts +7 -0
  35. package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
  36. package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
  37. package/source/packages/scramjet/packages/core/src/shared/rewriters/js.ts +1 -2
  38. package/source/packages/tracker-protocol/package.json +2 -2
  39. package/source/packages/tracker-protocol/src/index.ts +22 -0
  40. package/source/pnpm-lock.yaml +2 -2
  41. 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.2/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.2/embed.js"
22
- integrity="sha384-3QVF5CQiWk0SKqCDSEAoupN0KXK0bxo9qaZeS5Tj8/8mO8J719dv+fvmrqmdthH2"
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,44 +1,43 @@
1
1
  {
2
- "name": "roxxie-proxy-u-prod",
3
- "version": "0.2.2",
4
- "description": "Production CDN assets and embed loader for Roxxie Proxy U",
5
- "license": "AGPL-3.0-only",
6
- "private": false,
7
- "main": "embed.js",
8
- "browser": "embed.js",
9
- "jsdelivr": "embed.js",
10
- "unpkg": "embed.js",
11
- "files": [
12
- "dist",
13
- "runtime",
14
- "examples",
15
- "scripts",
16
- "source",
17
- "third_party",
18
- "embed.js",
19
- "standalone.html",
20
- "LICENSE",
21
- "SOURCE.md",
22
- "THIRD_PARTY_NOTICES.md",
23
- "README.md"
24
- ],
25
- "scripts": {
26
- "build": "node scripts/build.mjs",
27
- "smoke": "node scripts/smoke.mjs",
28
- "verify": "node scripts/verify.mjs",
29
- "prepack": "npm run verify"
30
- },
31
- "publishConfig": {
32
- "access": "public"
33
- },
34
- "devDependencies": {
35
- "playwright": "1.59.1"
36
- },
37
- "keywords": [
38
- "roxxie",
39
- "browser",
40
- "proxy",
41
- "webrtc",
42
- "embed"
43
- ]
44
- }
2
+ "name": "roxxie-proxy-u-prod",
3
+ "version": "0.2.4",
4
+ "description": "Production CDN assets and embed loader for Roxxie Proxy U",
5
+ "license": "AGPL-3.0-only",
6
+ "private": false,
7
+ "main": "embed.js",
8
+ "browser": "embed.js",
9
+ "jsdelivr": "embed.js",
10
+ "unpkg": "embed.js",
11
+ "files": [
12
+ "dist",
13
+ "runtime",
14
+ "examples",
15
+ "scripts",
16
+ "source",
17
+ "third_party",
18
+ "embed.js",
19
+ "standalone.html",
20
+ "LICENSE",
21
+ "SOURCE.md",
22
+ "THIRD_PARTY_NOTICES.md",
23
+ "README.md"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "devDependencies": {
29
+ "playwright": "1.59.1"
30
+ },
31
+ "keywords": [
32
+ "roxxie",
33
+ "browser",
34
+ "proxy",
35
+ "webrtc",
36
+ "embed"
37
+ ],
38
+ "scripts": {
39
+ "build": "node scripts/build.mjs",
40
+ "smoke": "node scripts/smoke.mjs",
41
+ "verify": "node scripts/verify.mjs"
42
+ }
43
+ }
@@ -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.2/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.2/dist/assets/index-CttvagWK.js"></script>
12
- <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.2/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.2") {
122
- throw new Error("This production release must be version 0.2.2");
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"));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports-adrift-protocol",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Browser-safe wire types and codecs for Roxxie Proxy U transports",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -18,7 +18,7 @@
18
18
  "test": "npm run build && node --test dist/test/*.test.js"
19
19
  },
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=18"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
@@ -47,6 +47,17 @@ export const S2CRequestTypes = {
47
47
  WSDataEnd: 8,
48
48
  WSError: 9,
49
49
  Pong: 10,
50
+ /**
51
+ * Terminates a response whose body was cut short after the headers were
52
+ * already delivered. `HTTPResponseEnd` means "the body is complete", so it
53
+ * must never be used for a truncated stream: the client cannot tell a
54
+ * partial body from a whole one and will happily parse and cache it.
55
+ *
56
+ * Purely additive, so `PROTOCOL_VERSION` stays at 6.0. A node that sends
57
+ * this to an older client makes that client fail the session loudly, which
58
+ * is strictly better than the silent corruption it replaces.
59
+ */
60
+ HTTPResponseError: 11,
50
61
  } as const;
51
62
 
52
63
  export type ObjectValue<T> = T[keyof T];
@@ -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) {
@@ -62,7 +62,7 @@ export class Tab extends StatefulClass {
62
62
  this.url ??= new URL(`${INTERNAL_URL_PROTOCOL}//newtab`);
63
63
  this.id ??= uuid("tab-");
64
64
 
65
- this.frame = new ProxyFrame();
65
+ this.frame = new ProxyFrame(() => this.commitExternalNavigation());
66
66
  this.history = new History(this, history);
67
67
  this.own(this.history);
68
68
  this.waitForInit = new Promise((resolve) => {
@@ -135,6 +135,7 @@ export class Tab extends StatefulClass {
135
135
 
136
136
  // only caller should be history.ts for this
137
137
  _directnavigate(url: URL) {
138
+ this.frame.cancelPendingNavigation();
138
139
  this.url = url;
139
140
  this.icon = defaultFaviconUrl;
140
141
  if (url.protocol == INTERNAL_URL_PROTOCOL) {
@@ -188,6 +189,13 @@ export class Tab extends StatefulClass {
188
189
  }
189
190
 
190
191
  initialLoad() {
192
+ this.commitExternalNavigation();
193
+ }
194
+
195
+ private commitExternalNavigation() {
196
+ // A late response from a page we have already left must not hide a newly
197
+ // selected internal page.
198
+ if (this.url.protocol === INTERNAL_URL_PROTOCOL) return;
191
199
  this.initResolve();
192
200
  this.internalpage = null;
193
201
  }
@@ -196,7 +204,10 @@ export class Tab extends StatefulClass {
196
204
  this.history.push(url, null, null, true, false);
197
205
  }
198
206
  replaceNavigate(url: URL) {
199
- 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);
200
211
  }
201
212
 
202
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
+ }