Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions app/src/main/services/navigation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion app/src/renderer/shell/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions app/src/shared/addressInput.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
18 changes: 18 additions & 0 deletions app/tests/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
writeFileSync(join(fresh, "settings.json"), "{}");
const first = await electron.launch({ args: ["."], env: { ...process.env, JOYCHROMIUM_DATA: fresh } });
try {
await expect.poll(() => first.evaluate(() => globalThis.joy.tabs()[0]?.url), { timeout: 30_000 }).toBe("joychromium://welcome");

Check failure on line 110 in app/tests/e2e/shell.spec.ts

View workflow job for this annotation

GitHub Actions / electron (ubuntu-latest)

tests/e2e/shell.spec.ts:103:5 › first run shows onboarding

1) tests/e2e/shell.spec.ts:103:5 › first run shows onboarding ──────────────────────────────────── Error: electronApplication.evaluate: Target page, context or browser has been closed 108 | const first = await electron.launch({ args: ["."], env: { ...process.env, JOYCHROMIUM_DATA: fresh } }); 109 | try { > 110 | await expect.poll(() => first.evaluate(() => globalThis.joy.tabs()[0]?.url), { timeout: 30_000 }).toBe("joychromium://welcome"); | ^ 111 | } finally { 112 | await first.close(); 113 | rmSync(fresh, { recursive: true, force: true }); at _test.expect.poll.timeout (/home/runner/work/joychromium/joychromium/app/tests/e2e/shell.spec.ts:110:35) at /home/runner/work/joychromium/joychromium/app/tests/e2e/shell.spec.ts:110:5
} finally {
await first.close();
rmSync(fresh, { recursive: true, force: true });
Expand All @@ -127,3 +127,21 @@
await command({ type: "navigate", url: "joychromium://newtab" });
await expect.poll(() => pageEval<string>("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("<title>Site</title>", { headers: { "content-type": "text/html" } }));
});
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(); })()`);
};
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");
});
44 changes: 44 additions & 0 deletions app/tests/unit/addressInput.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading