diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index a7ac04a1a09..285b58f2ac7 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -116,9 +116,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) - expect(external('psl', undefined, false)).toBe(false) + expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) - expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index fac8f7c58d1..685103ba9ba 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -583,8 +583,8 @@ breakaway hands the whole tree its escape. The per-PTY job therefore omits `BREAKAWAY_OK` whenever `msys-2.0.dll` or `cygwin1.dll` sits on the shell's DLL search path — beside the executable, or under `usr/bin` for Git's `bin` launcher. Native shells keep explicit breakaway. Denying it costs Cygwin -nothing, because it *pre-checks* the limit rather than retrying, so no spawn -fails; but a *native* program that passes `CREATE_BREAKAWAY_FROM_JOB` itself +nothing, because it _pre-checks_ the limit rather than retrying, so no spawn +fails; but a _native_ program that passes `CREATE_BREAKAWAY_FROM_JOB` itself inside such a pane now gets `ERROR_ACCESS_DENIED`. `nohup` and `disown` are unaffected — they are Cygwin signal/session concepts, unrelated to job membership. The daemon's host job is unchanged. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eb1e9d1e16c..d900e6cba16 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,7 +10,7 @@ import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', - 'psl', + 'tldts', // Why: Windows NSIS deploys app.asar before external resources; bootstrap must // not race the later resources/node_modules copy. 'zod' diff --git a/package.json b/package.json index ae359661873..9cbae455f2d 100644 --- a/package.json +++ b/package.json @@ -177,12 +177,12 @@ "node-pty": "^1.1.0", "posthog-node": "^5.33.3", "proper-lockfile": "4.1.2", - "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "17.0.13", "serve-sim": "^0.1.40", "sherpa-onnx": "1.12.37", "ssh2": "^1.17.0", + "tldts": "7.4.12", "tweetnacl": "^1.0.3", "ws": "^8.21.3", "yaml": "^2.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbed2db3f37..58b5d255c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,9 +164,6 @@ importers: proper-lockfile: specifier: 4.1.2 version: 4.1.2 - psl: - specifier: 1.15.0 - version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -182,6 +179,9 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + tldts: + specifier: 7.4.12 + version: 7.4.12 tweetnacl: specifier: ^1.0.3 version: 1.0.3 @@ -5951,16 +5951,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -6605,10 +6598,17 @@ packages: tldts-core@7.4.10: resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + tldts@7.4.10: resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -12706,17 +12706,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: {} - pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -13490,11 +13484,17 @@ snapshots: tldts-core@7.4.10: optional: true + tldts-core@7.4.12: {} + tldts@7.4.10: dependencies: tldts-core: 7.4.10 optional: true + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + tmp-promise@3.0.3: dependencies: tmp: 0.2.7 diff --git a/src/main/browser/browser-cookie-clear-preserve.test.ts b/src/main/browser/browser-cookie-clear-preserve.test.ts index 061289b86dd..e082c2eff6f 100644 --- a/src/main/browser/browser-cookie-clear-preserve.test.ts +++ b/src/main/browser/browser-cookie-clear-preserve.test.ts @@ -171,8 +171,9 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' }) it('preserves a family named by an IPv4 literal', async () => { - // Why: psl reads 127.0.0.1 as the dotted DNS name '0.1'. If registrableFamily returned that, - // the live 127.0.0.1 session would not match the preserve set and would be erased. + // Why: an IPv4 literal has no registrable domain, so the family must come from the IP branch. + // If registrableFamily fell through to the suffix parser, the live 127.0.0.1 session would not + // match the preserve set and would be erased. const target = jar([cookie('127.0.0.1', 'loopback-session'), cookie('.other.example', 'stale')]) await removeTransplantableCookies( diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 04a54a47b15..d263af9be23 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,6 +1,6 @@ import { isIP } from 'node:net' import type { Cookie, Cookies } from 'electron' -import { parse as parseDomain } from 'psl' +import { parse as parseDomain } from 'tldts' // Why: type-only, so this does not create a runtime cycle with the clear module. import type { CookieClearIdentity } from './browser-cookie-import-clear' @@ -41,13 +41,24 @@ export function normalizeCookieDomain(domain: string): string | null { } } +// Why allowPrivateDomains: the PSL's PRIVATE section is what keeps one tenant's cookies out of +// another's — without it `foo.github.io` and `bar.github.io` collapse to the same family, and a +// replace-mode import for one would clear the other. tldts defaults this off; cookie scoping needs +// it on. +const PUBLIC_SUFFIX_OPTIONS = { allowPrivateDomains: true } as const + +// psl exposed a single `listed` flag; tldts splits the same question across the two list sections. +function isListedSuffix(parsed: { isIcann: boolean | null; isPrivate: boolean | null }): boolean { + return parsed.isIcann === true || parsed.isPrivate === true +} + // Why (STA-4300): one definition of "family" for every consumer of the partition skip set — the // planner, the per-coordinate removal filter, and the path A domain comparison. Deriving it inline // in several places is what let the removal scope and the write set disagree (STA-4090, STA-4170). // // The IP test MUST run on normalizeCookieDomain's output, never the raw string: Chromium accepts -// many spellings of one address and psl mangles all of them (psl.parse('2130706433').domain is -// null, psl.parse('127.0.0.1').domain is '0.1'). normalizeCookieDomain runs the value through +// many spellings of one address and the suffix parser mangles all of them (tldts.parse('2130706433') +// .domain is null, tldts.parse('127.1').domain is '127.1'). normalizeCookieDomain runs the value through // `new URL()`, which canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 / a trailing dot to a // dotted quad first, so isIP() then recognises every one of them. // @@ -65,12 +76,12 @@ export function registrableFamily(domain: string): string | null { if (host.startsWith('[') && host.endsWith(']') && isIP(host.slice(1, -1)) === 6) { return host } - const parsed = parseDomain(host) - if ('error' in parsed) { + const parsed = parseDomain(host, PUBLIC_SUFFIX_OPTIONS) + if (parsed.hostname === null) { return host } if (parsed.domain === null) { - return parsed.listed ? null : host + return isListedSuffix(parsed) ? null : host } return parsed.domain } @@ -80,11 +91,11 @@ export function normalizeCookieImportDomain(domain: string): string | null { if (!normalized) { return null } - const parsed = parseDomain(normalized) - if ('error' in parsed) { + const parsed = parseDomain(normalized, PUBLIC_SUFFIX_OPTIONS) + if (parsed.hostname === null) { return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null } - if (parsed.domain === null && parsed.listed) { + if (parsed.domain === null && isListedSuffix(parsed)) { return null } return normalized @@ -129,8 +140,8 @@ function domainSuffixes(domain: string): string[] { } function importDomainAncestors(domain: string): string[] { - const parsed = parseDomain(domain) - const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain) + const parsed = parseDomain(domain, PUBLIC_SUFFIX_OPTIONS) + const boundary = parsed.hostname === null ? domain : (parsed.domain ?? domain) const ancestors: string[] = [] for (const suffix of domainSuffixes(domain)) { ancestors.push(suffix) diff --git a/src/main/browser/browser-cookie-public-suffix-scope.test.ts b/src/main/browser/browser-cookie-public-suffix-scope.test.ts new file mode 100644 index 00000000000..8d3fbce62da --- /dev/null +++ b/src/main/browser/browser-cookie-public-suffix-scope.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + domainIsInImportedScope, + importedDomainScope, + normalizeCookieImportDomain, + registrableFamily +} from './browser-cookie-import-policy' + +// Why this file exists: the public-suffix engine decides which cookies share a removal scope, so a +// library swap silently re-partitions the jar. These cases pin the boundaries that moved (or had to +// be held) when this moved off `psl`. +describe('registrable family across public-suffix sections', () => { + it('keeps each PRIVATE-section tenant in its own family', () => { + // psl and tldts disagree here unless allowPrivateDomains is set; without it every + // *.github.io tenant collapses into one family and a replace-mode import clears siblings. + expect(registrableFamily('foo.github.io')).toBe('foo.github.io') + expect(registrableFamily('bar.github.io')).toBe('bar.github.io') + expect(registrableFamily('bar.s3.amazonaws.com')).toBe('bar.s3.amazonaws.com') + expect(registrableFamily('foo.vercel.app')).toBe('foo.vercel.app') + }) + + it('refuses to name a bare public suffix as a family', () => { + expect(registrableFamily('com')).toBeNull() + expect(registrableFamily('co.uk')).toBeNull() + expect(registrableFamily('github.io')).toBeNull() + // Absent from psl 1.15.0's 2024 snapshot; naming it a family would preserve a whole suffix. + expect(registrableFamily('api.br')).toBeNull() + expect(registrableFamily('seg.ar')).toBeNull() + }) + + it('resolves ICANN suffixes to the registrable domain', () => { + expect(registrableFamily('a.b.example.co.uk')).toBe('example.co.uk') + expect(registrableFamily('www.example.com')).toBe('example.com') + expect(registrableFamily('foo.example.api.br')).toBe('example.api.br') + }) + + it('returns the canonicalised address for every IP spelling', () => { + expect(registrableFamily('127.0.0.1')).toBe('127.0.0.1') + expect(registrableFamily('127.1')).toBe('127.0.0.1') + expect(registrableFamily('2130706433')).toBe('127.0.0.1') + expect(registrableFamily('[::1]')).toBe('[::1]') + }) + + it('treats an unlisted suffix as its own boundary', () => { + expect(registrableFamily('example.notaruleatall')).toBe('example.notaruleatall') + }) + + it('rejects a bare suffix as an import domain but keeps real hosts', () => { + expect(normalizeCookieImportDomain('co.uk')).toBeNull() + expect(normalizeCookieImportDomain('api.br')).toBeNull() + expect(normalizeCookieImportDomain('.example.com')).toBe('example.com') + expect(normalizeCookieImportDomain('foo.github.io')).toBe('foo.github.io') + }) +}) + +// Why: `.local` is absent from the PSL, and the two libraries disagreed about what that means. psl +// returned an all-null parse, so every `*.orca.local` host was its own family; tldts applies the +// default single-label rule and stops at `orca.local`, which is what Chromium treats as registrable. +// The widening is deliberate, so it is pinned here rather than left to the next library bump. +describe('unlisted .local suffix', () => { + it('stops at the two-label boundary', () => { + expect(registrableFamily('app.orca.local')).toBe('orca.local') + expect(registrableFamily('orca.local')).toBe('orca.local') + }) + + // The consequence of the boundary move: a replace-mode import of one host now also clears + // non-host-only cookies scoped to `.orca.local`, which every sibling `*.orca.local` host shares. + it('pulls the shared parent into the removal scope', () => { + const scope = importedDomainScope(['app.orca.local']) + + expect(domainIsInImportedScope(scope, 'orca.local', false)).toBe(true) + expect(domainIsInImportedScope(scope, 'orca.local', true)).toBe(false) + }) +}) + +// Why: psl's 2024 snapshot carried `compute.amazonaws.com` as a literal PRIVATE suffix; the current +// list only has the `*.compute.amazonaws.com` wildcard, so the bare host is an ordinary ICANN domain +// now. That moves a real host shape from "no family" to `amazonaws.com`. +describe('suffix entries that changed shape upstream', () => { + it('reads bare compute.amazonaws.com as a registrable domain', () => { + expect(registrableFamily('compute.amazonaws.com')).toBe('amazonaws.com') + expect(normalizeCookieImportDomain('compute.amazonaws.com')).toBe('compute.amazonaws.com') + }) + + it('still refuses the wildcard child and the sibling private suffix', () => { + expect(registrableFamily('foo.compute.amazonaws.com')).toBeNull() + expect(registrableFamily('s3.amazonaws.com')).toBeNull() + }) +}) diff --git a/src/main/browser/browser-cookie-registrable-family.test.ts b/src/main/browser/browser-cookie-registrable-family.test.ts index 00244ce02d9..c1aadc59654 100644 --- a/src/main/browser/browser-cookie-registrable-family.test.ts +++ b/src/main/browser/browser-cookie-registrable-family.test.ts @@ -21,9 +21,10 @@ describe('registrableFamily', () => { expect(registrableFamily(host)).toBe(expected) }) - // Why: psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain is '0.1'. - // These pass only because the IP check runs on normalizeCookieDomain's canonicalised output. - // Moving the check before normalisation reintroduces a wrong, destructive family. + // Why: the suffix parser reads a non-dotted-quad IPv4 spelling as a DNS name — + // tldts.parse('127.1').domain is '127.1' and tldts.parse('2130706433').domain is null. These pass + // only because the IP check runs on normalizeCookieDomain's canonicalised output. Moving the check + // before normalisation reintroduces a wrong, destructive family. it.each([ ['127.0.0.1', '127.0.0.1'], ['192.168.1.1', '192.168.1.1'], @@ -32,15 +33,15 @@ describe('registrableFamily', () => { ['2130706433', '127.0.0.1'], ['0x7f.1', '127.0.0.1'], ['127.0.0.1.', '127.0.0.1'], - // Octal, and 8.0.0.1 is the correct reading — psl would have produced '0.1'. + // Octal, and 8.0.0.1 is the correct reading — unnormalised, this parses as a DNS name. ['010.0.0.1', '8.0.0.1'] ])('recognises the IPv4 literal %s as %s', (host, expected) => { expect(registrableFamily(host)).toBe(expected) }) // Why: isIP('[::1]') is 0, so the bracketed form needs its own branch. Without it these fall - // through to psl, which throws, which happens to return the host — right answer, wrong reason, - // and it stops being right the moment the error branch is touched. + // through to the parser, which strips the brackets and reports no suffix — the unlisted path then + // happens to return the host. Right answer, wrong reason, and only while that path is untouched. it.each([ ['[::1]', '[::1]'], ['[2001:db8::1]', '[2001:db8::1]'] diff --git a/src/main/runtime/relay/relay-control-client-options.ts b/src/main/runtime/relay/relay-control-client-options.ts index 5b98737d11a..93d1efc0fc6 100644 --- a/src/main/runtime/relay/relay-control-client-options.ts +++ b/src/main/runtime/relay/relay-control-client-options.ts @@ -1,9 +1,6 @@ import type WebSocket from 'ws' import type { E2EEKeypair } from '../e2ee-keypair' -import type { - RelayConnectionOpenMessage, - RelayDrainMessage, -} from './relay-control-protocol' +import type { RelayConnectionOpenMessage, RelayDrainMessage } from './relay-control-protocol' export type RelayControlClientOptions = { cellUrl: string diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index ba05d976ed7..b2cc7dd0232 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -76,7 +76,11 @@ export const RelayConnectionOpenMessageSchema = z export const RelayDrainMessageSchema = z .object({ type: z.literal('drain'), - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000), recovery: z.literal('resolve-director') }) .strict() diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index 15abcc4127c..ec8bb2f539d 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -187,19 +187,14 @@ export class RelayOriginPool { } this.deferredAssignment = null if (assignment.cellUrl === origin.cellUrl) { - let rebound = false + let rebound = false try { await origin.rebind(this.relayJwt, assignment) rebound = true } catch { // Why: a restarted cell cannot know the prior process's resume secret; // after rebind fails, a fresh generation is the only recoverable path. - await this.activateTarget( - origin, - assignment, - this.relayJwt, - message.graceMs, - ) + await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) } if (rebound) { this.assertCurrent() @@ -208,12 +203,7 @@ export class RelayOriginPool { this.drainingOrigins.delete(origin) } } else { - await this.activateTarget( - origin, - assignment, - this.relayJwt, - message.graceMs, - ) + await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) } this.options.onStatus('registered') this.drainRetry.reset() diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts new file mode 100644 index 00000000000..3497cef7cac --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { classifyHostUrl } from './tab-create-entry-url-classification' + +// Why this file exists: the suffix check behind `host/path` navigation moved off `psl.isValid`, and +// nothing else in the tree exercises it directly. These cases pin the listed/unlisted split that +// decides whether a typed string navigates or falls through to search. +describe('classifyHostUrl suffix gate', () => { + it('navigates for a listed suffix carrying a path', () => { + expect(classifyHostUrl('example.com/foo')).toEqual({ + kind: 'host-url', + url: 'https://example.com/foo' + }) + expect(classifyHostUrl('example.co.uk/foo')).toEqual({ + kind: 'host-url', + url: 'https://example.co.uk/foo' + }) + }) + + // The PRIVATE section has to stay in: these are navigable hosts, not search terms. + it('navigates for a private-section suffix carrying a path', () => { + expect(classifyHostUrl('foo.github.io/bar')).toEqual({ + kind: 'host-url', + url: 'https://foo.github.io/bar' + }) + expect(classifyHostUrl('foo.vercel.app/bar')).toEqual({ + kind: 'host-url', + url: 'https://foo.vercel.app/bar' + }) + }) + + // Why this is the fix: psl's 2024 snapshot did not know `api.br`, so `isValid` called it a domain + // and a typed `api.br/x` navigated to a bare public suffix instead of searching. + it('refuses a bare public suffix carrying a path', () => { + expect(classifyHostUrl('api.br/foo')).toBeNull() + expect(classifyHostUrl('co.uk/foo')).toBeNull() + expect(classifyHostUrl('github.io/foo')).toBeNull() + }) + + it('keeps localhost and IPv4 on http without consulting the suffix list', () => { + expect(classifyHostUrl('localhost:3000/foo')).toEqual({ + kind: 'host-url', + url: 'http://localhost:3000/foo' + }) + expect(classifyHostUrl('127.0.0.1:8080/foo')).toEqual({ + kind: 'host-url', + url: 'http://127.0.0.1:8080/foo' + }) + }) + + // The gate only applies once an authority ends; a bare host still navigates so unlisted intranet + // names typed on their own are not forced into search. + it('leaves a bare host unfiltered by the suffix list', () => { + expect(classifyHostUrl('api.br')).toEqual({ kind: 'host-url', url: 'https://api.br/' }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index a18f59d1ccf..ef99f648b9c 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { isValid as isListedDomain } from 'psl' +import { parse as parseDomain } from 'tldts' import { classifySchemeLessLocalDevAddress } from '../../../../shared/browser-url' const HOST_FILE_EXTENSIONS = new Set([ @@ -54,6 +54,13 @@ function parseHttpUrl(query: string): ExplicitUrlClassification { } } +// Why: a bare public suffix (`api.br`) is not a navigable host, so the typed string stays a search. +// tldts' PRIVATE section is included so `foo.github.io` still reads as a domain. +function isListedDomain(host: string): boolean { + const parsed = parseDomain(host, { allowPrivateDomains: true }) + return parsed.domain !== null && (parsed.isIcann === true || parsed.isPrivate === true) +} + function splitHostCandidate(query: string): { host: string; port: string | null } | null { if (/[\\\s]/.test(query)) { return null diff --git a/src/types/psl.ts b/src/types/psl.ts deleted file mode 100644 index b34a5329583..00000000000 --- a/src/types/psl.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module 'psl' { - export type ParsedDomain = { - input: string - tld: string | null - sld: string | null - domain: string | null - subdomain: string | null - listed: boolean - } - - export type ParseError = { - input: string - error: { code: string; message: string } - } - - export function parse(input: string): ParsedDomain | ParseError -} diff --git a/tests/e2e/relay-region-compatibility.unit.test.ts b/tests/e2e/relay-region-compatibility.unit.test.ts index 65f54067fd4..9019498ada7 100644 --- a/tests/e2e/relay-region-compatibility.unit.test.ts +++ b/tests/e2e/relay-region-compatibility.unit.test.ts @@ -75,9 +75,7 @@ describe('relay correction mixed-version wire contracts', () => { appVersion: 'test' } expect(BaselineHello.parse(HostHelloSchema.parse(hello))).toEqual(hello) - expect(BaselineHello.safeParse({ ...hello, idleRegionalRehome: true }).success).toBe( - false - ) + expect(BaselineHello.safeParse({ ...hello, idleRegionalRehome: true }).success).toBe(false) }) it('the idle cutover uses a drain frame understood by the pinned old desktop', () => {