From c56748e655ce33efeefa4ab992b2829047f94eeb Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 15:27:46 -0300 Subject: [PATCH 1/2] fix(search): open addresses typed without a scheme; keyboard no longer blurs the bar - Both boxes (the bar and the new tab) only opened what began with https://, so "youtube.com" was sent to Google. resolveAddressInput now tells an address from a search: domains, localhost and IPs open (https, or http for local ones), anything with a space or a single word is searched, and file-looking endings such as config.json are searched too. - The controller keyboard's full-screen layer dimmed and blurred the address bar, hiding the text being typed. The layer is now transparent and only the keyboard panel takes the pointer. Co-Authored-By: Claude Sonnet 5.5 --- app/src/main/services/navigation.ts | 12 ++++---- app/src/renderer/shell/shell.css | 5 +++- app/src/shared/addressInput.ts | 35 +++++++++++++++++++++++ app/tests/e2e/shell.spec.ts | 16 +++++++++++ app/tests/unit/addressInput.test.ts | 44 +++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 app/src/shared/addressInput.ts create mode 100644 app/tests/unit/addressInput.test.ts diff --git a/app/src/main/services/navigation.ts b/app/src/main/services/navigation.ts index afde1a9..3bb0d5a 100644 --- a/app/src/main/services/navigation.ts +++ b/app/src/main/services/navigation.ts @@ -1,4 +1,5 @@ -import { Pages, isInternal, isWebUrl } from "../../shared/pages"; +import { resolveAddressInput } from "../../shared/addressInput"; +import { Pages } from "../../shared/pages"; import { hostOf } from "../../shared/security"; import { buildSearchUrl, newTabTarget, type AppSettings } from "../../shared/settings"; import type { TabManager } from "../tabs"; @@ -28,12 +29,11 @@ export class Navigation { void this.host.tabs.load(tab, url); } - /** A bar entry: a URL or an internal page goes as-is, anything else becomes a search. */ + /** A bar entry: an address or an internal page opens, anything else becomes a search (see resolveAddressInput). */ searchOrGo(text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - const url = isWebUrl(trimmed) || isInternal(trimmed) ? trimmed : buildSearchUrl(this.host.settings().searchEngine, trimmed); - this.go(url); + const intent = resolveAddressInput(text); + if (!intent) return; + this.go(intent.kind === "open" ? intent.url : buildSearchUrl(this.host.settings().searchEngine, intent.text)); } newTab(isPrivate = false): void { diff --git a/app/src/renderer/shell/shell.css b/app/src/renderer/shell/shell.css index 74581d2..45ce37d 100644 --- a/app/src/renderer/shell/shell.css +++ b/app/src/renderer/shell/shell.css @@ -328,7 +328,10 @@ body { } .overlay.center { align-items: center; justify-content: center; } -.overlay.bottom { align-items: flex-end; justify-content: center; } +/* The controller keyboard is not a modal: what is being typed shows in the address bar above it, or in the page, and + neither may be dimmed or blurred. The layer is transparent and only the keyboard panel takes the pointer. */ +.overlay.bottom { align-items: flex-end; justify-content: center; background: none; backdrop-filter: none; pointer-events: none; } +.overlay.bottom > * { pointer-events: auto; } .dialog, .menu { diff --git a/app/src/shared/addressInput.ts b/app/src/shared/addressInput.ts new file mode 100644 index 0000000..cd366b8 --- /dev/null +++ b/app/src/shared/addressInput.ts @@ -0,0 +1,35 @@ +import { isInternal, isWebUrl } from "./pages"; + +/** What a typed entry means: an address to open, or words to search for. */ +export type AddressIntent = { kind: "open"; url: string } | { kind: "search"; text: string }; + +/** Endings that look like a TLD but are files (`config.json`, `logo.png`): those are searched, not opened. */ +const FILE_ENDINGS = new Set([ + "json", "txt", "png", "jpg", "jpeg", "gif", "webp", "svg", "pdf", "exe", "zip", "rar", "html", "htm", "css", "js", + "ts", "mp3", "mp4", "mkv", "docx", "xlsx", "pptx", "log", "md", +]); + +const HOST = String.raw`(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+([a-z]{2,24}|xn--[a-z0-9-]{2,59})`; +const DOMAIN = new RegExp(String.raw`^${HOST}(?::\d{1,5})?(?:[/?#]\S*)?$`, "i"); +const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?(?:[/?#]\S*)?$/; +const LOCALHOST = /^localhost(?::\d{1,5})?(?:[/?#]\S*)?$/i; + +/** + * Tells an address from a search, the way a browser's one box does: `youtube.com`, `www.site.com.br/tv`, `localhost:3000` + * and `192.168.0.10` are addresses; anything with a space, or a single word, is a search. + * + * shortcut: no public-suffix list, so any `word.letters` ending that is not a known file type opens as a site; add the + * list if a real search such as "node.sh" keeps being taken for an address. + */ +export function resolveAddressInput(input: string): AddressIntent | undefined { + const text = input.trim(); + if (!text) return undefined; + if (isWebUrl(text) || isInternal(text)) return { kind: "open", url: text }; + if (!/\s/.test(text)) { + // A site on this machine or network has no certificate to speak of: http, like every browser does for these. + if (LOCALHOST.test(text) || IPV4.test(text)) return { kind: "open", url: `http://${text}` }; + const match = DOMAIN.exec(text); + if (match && !FILE_ENDINGS.has((match[1] ?? "").toLowerCase())) return { kind: "open", url: `https://${text}` }; + } + return { kind: "search", text }; +} diff --git a/app/tests/e2e/shell.spec.ts b/app/tests/e2e/shell.spec.ts index 73aa669..0f89433 100644 --- a/app/tests/e2e/shell.spec.ts +++ b/app/tests/e2e/shell.spec.ts @@ -127,3 +127,19 @@ test("settings change the new tab: backdrop and quick links", async () => { await command({ type: "navigate", url: "joychromium://newtab" }); await expect.poll(() => pageEval("document.body.dataset.bg"), { timeout: 15_000 }).toBe("Solid"); }); + +test("the new tab box opens an address and searches the rest", async () => { + const pageEval = (code: string) => app.evaluate(({}, c) => globalThis.joy.pageEval(c), code); + await app.evaluate(({ protocol }) => { + protocol.handle("https", () => new Response("Site", { headers: { "content-type": "text/html" } })); + }); + const submit = async (text: string) => { + await command({ type: "navigate", url: "joychromium://newtab" }); + await expect.poll(() => pageEval("Boolean(document.getElementById('q'))"), { timeout: 15_000 }).toBe(true); + await pageEval(`(() => { document.getElementById('q').value = ${JSON.stringify(text)}; document.getElementById('search').requestSubmit(); })()`); + }; + await submit("youtube.com"); + await expect.poll(async () => (await activeTab())?.url, { timeout: 15_000 }).toBe("https://youtube.com/"); + await submit("filmes de terror"); + await expect.poll(async () => (await activeTab())?.url, { timeout: 15_000 }).toContain("/search?q=filmes%20de%20terror"); +}); diff --git a/app/tests/unit/addressInput.test.ts b/app/tests/unit/addressInput.test.ts new file mode 100644 index 0000000..5f1fae8 --- /dev/null +++ b/app/tests/unit/addressInput.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressInput } from "../../src/shared/addressInput"; + +const open = (url: string) => ({ kind: "open", url }); +const search = (text: string) => ({ kind: "search", text }); + +describe("address or search", () => { + it("opens what looks like an address, with https when no scheme is typed", () => { + expect(resolveAddressInput("youtube.com")).toEqual(open("https://youtube.com")); + expect(resolveAddressInput(" www.youtube.com/watch?v=abc ")).toEqual(open("https://www.youtube.com/watch?v=abc")); + expect(resolveAddressInput("g1.globo.com")).toEqual(open("https://g1.globo.com")); + expect(resolveAddressInput("site.com.br/tv#x")).toEqual(open("https://site.com.br/tv#x")); + expect(resolveAddressInput("YouTube.COM")).toEqual(open("https://YouTube.COM")); + expect(resolveAddressInput("example.com:8080/a")).toEqual(open("https://example.com:8080/a")); + }); + + it("keeps a typed scheme and the internal pages as they are", () => { + expect(resolveAddressInput("https://a.example/x")).toEqual(open("https://a.example/x")); + expect(resolveAddressInput("http://a.example")).toEqual(open("http://a.example")); + expect(resolveAddressInput("joychromium://history")).toEqual(open("joychromium://history")); + }); + + it("uses http for this machine and the local network", () => { + expect(resolveAddressInput("localhost:3000")).toEqual(open("http://localhost:3000")); + expect(resolveAddressInput("localhost")).toEqual(open("http://localhost")); + expect(resolveAddressInput("192.168.0.10:8096/web")).toEqual(open("http://192.168.0.10:8096/web")); + }); + + it("searches everything else", () => { + expect(resolveAddressInput("youtube")).toEqual(search("youtube")); + expect(resolveAddressInput("filmes de terror 2026")).toEqual(search("filmes de terror 2026")); + expect(resolveAddressInput("youtube.com tutorial")).toEqual(search("youtube.com tutorial")); + expect(resolveAddressInput("ok.")).toEqual(search("ok.")); + expect(resolveAddressInput("e.g")).toEqual(search("e.g")); + expect(resolveAddressInput("config.json")).toEqual(search("config.json")); + // Not a navigable scheme: searched, never run. + expect(resolveAddressInput("javascript:alert(1)")).toEqual(search("javascript:alert(1)")); + expect(resolveAddressInput("file:///C:/x.html")).toEqual(search("file:///C:/x.html")); + }); + + it("ignores an empty entry", () => { + expect(resolveAddressInput(" ")).toBeUndefined(); + }); +}); From a32b3ebd0eeda3d83b2884849a03011d7b050c4b Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 16:01:32 -0300 Subject: [PATCH 2/2] test(shell): let the new tab settle before reading it A script run in a page that is still navigating may never answer, so the new test timed out on the CI runners. Co-Authored-By: Claude Opus 5.5 --- app/tests/e2e/shell.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/tests/e2e/shell.spec.ts b/app/tests/e2e/shell.spec.ts index 0f89433..c415b64 100644 --- a/app/tests/e2e/shell.spec.ts +++ b/app/tests/e2e/shell.spec.ts @@ -135,6 +135,8 @@ test("the new tab box opens an address and searches the rest", async () => { }); const submit = async (text: string) => { await command({ type: "navigate", url: "joychromium://newtab" }); + // A script run in a page still on its way in may never answer, so the new tab settles first. + await expect.poll(async () => { const tab = await activeTab(); return tab?.url === "joychromium://newtab" && !tab.isLoading; }, { timeout: 30_000 }).toBe(true); await expect.poll(() => pageEval("Boolean(document.getElementById('q'))"), { timeout: 15_000 }).toBe(true); await pageEval(`(() => { document.getElementById('q').value = ${JSON.stringify(text)}; document.getElementById('search').requestSubmit(); })()`); };