diff --git a/apps/desktop/extensions/ai-sidebar/background-helper.test.js b/apps/desktop/extensions/ai-sidebar/background-helper.test.js index 6364fb2..a1601c4 100644 --- a/apps/desktop/extensions/ai-sidebar/background-helper.test.js +++ b/apps/desktop/extensions/ai-sidebar/background-helper.test.js @@ -20,8 +20,9 @@ describe('extension network helper requests', () => { onMessage: { addListener: (listener) => listeners.push(listener) }, sendMessage: done(), }, - storage: { local: storage(), session: storage() }, + storage: { local: storage(), session: storage(), onChanged: { addListener: vi.fn() } }, tabs: { onUpdated: { addListener: vi.fn() }, update: done() }, + declarativeNetRequest: { updateSessionRules: done() }, proxy: { settings: { set: done(), clear: done() } }, privacy: { network: { webRTCIPHandlingPolicy: { set: done(), clear: done() } } }, }); diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index 75a8fac..e930a59 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,7 +1,7 @@ import { decideInstallTarget, lookupInstalled } from './install-state.js'; import { PIT_SOCKS_PORT, pitProxyConfig } from './pit-proxy.js'; import { installPush } from './push-client.js'; -import { w3bsFromSearch } from './w3bs.js'; +import { PIT_DIRECT_RULES, w3bsFromSearch } from './w3bs.js'; // Web Push through TronBrowser's configured push service (the engine has none). installPush(); @@ -9,11 +9,29 @@ installPush(); // A w3bs:// URI typed in the address bar becomes a web search. w3bs-rules.json // redirects that search before it loads, but not when the omnibox serves it // from its search prefetch, so catch whatever a tab lands on as well. -chrome.tabs.onUpdated.addListener((tabId, change) => { - const target = change.url && w3bsFromSearch(change.url); +chrome.tabs.onUpdated.addListener(async (tabId, change) => { + if (!change.url || !/w3bs/i.test(change.url)) return; + const { pitEnabled } = await chrome.storage.local.get('pitEnabled'); + const target = w3bsFromSearch(change.url, { pit: !!pitEnabled }); if (target) chrome.tabs.update(tabId, { url: target }).catch(() => {}); }); +// With Pit on, w3bs://chovy.hacker opens chovy.hacker itself instead of its +// pit.world gateway. Session rules, kept in step with the toggle. +async function syncW3bsPitRules() { + const { pitEnabled } = await chrome.storage.local.get('pitEnabled'); + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: PIT_DIRECT_RULES.map((rule) => rule.id), + addRules: pitEnabled ? PIT_DIRECT_RULES : [], + }); +} +syncW3bsPitRules().catch((err) => console.warn('w3bs pit rules:', err)); +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && 'pitEnabled' in changes) { + syncW3bsPitRules().catch((err) => console.warn('w3bs pit rules:', err)); + } +}); + // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel .setPanelBehavior({ openPanelOnActionClick: true }) diff --git a/apps/desktop/extensions/ai-sidebar/w3bs.js b/apps/desktop/extensions/ai-sidebar/w3bs.js index 5d84ec7..f466d21 100644 --- a/apps/desktop/extensions/ai-sidebar/w3bs.js +++ b/apps/desktop/extensions/ai-sidebar/w3bs.js @@ -1,6 +1,9 @@ // W3BS URIs (w3bs://type/namespace/name@version) open through the w3bs.org // resolver page: Chromium has no handler for the scheme. The launcher and the -// w3bs-links.js content script use the same gateway. +// w3bs-links.js content script use the same gateway. w3bs://chovy.hacker names +// a site rather than a resource, and the resolver opens it at its pit.world +// gateway; with Pit on, Moshpit names resolve in this browser, so it opens as +// itself (PIT_DIRECT_RULES). export const W3BS_GATEWAY = 'https://w3bs.org/browse?q='; /** The resolver URL for a w3bs: URI, or null when `input` is not one. */ @@ -9,21 +12,53 @@ export function w3bsUrl(input) { return /^w3bs:/i.test(uri) ? W3BS_GATEWAY + encodeURIComponent(uri) : null; } +/** https://chovy.hacker/path for w3bs://chovy.hacker/path (a dotted authority is a site); else null. */ +export function w3bsSiteUrl(input) { + const m = /^w3bs:\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)(\/\S*)?$/i.exec(String(input ?? '').trim()); + return m ? `https://${m[1].toLowerCase()}${m[2] || '/'}` : null; +} + +/** Where a w3bs: URI opens: the site itself when Pit is on and it names one, else the resolver. */ +export function w3bsTarget(input, { pit = false } = {}) { + return (pit && w3bsSiteUrl(input)) || w3bsUrl(input); +} + /** - * The resolver URL for a search results page whose query is a W3BS URI, or - * null. Chromium searches a typed w3bs://chovy.hacker instead of opening it, - * and when the omnibox serves that search from its prefetch the - * declarativeNetRequest rule in w3bs-rules.json never sees a request, so the - * background worker also checks every URL a tab lands on. + * Where to send a tab that landed on a search results page whose query is a + * W3BS URI (or, with Pit on, on the resolver page for a site), or null. + * Chromium searches a typed w3bs://chovy.hacker instead of opening it, and when + * the omnibox serves that search from its prefetch the declarativeNetRequest + * rules never see a request, so the background worker also checks every URL a + * tab lands on. */ -export function w3bsFromSearch(href) { +export function w3bsFromSearch(href, { pit = false } = {}) { let url; try { url = new URL(href); } catch { return null; } - if (url.protocol !== 'https:' || url.hostname === 'w3bs.org') return null; + if (url.protocol !== 'https:') return null; const q = url.searchParams.get('q'); - return q ? w3bsUrl(q) : null; + if (!q) return null; + if (url.hostname === 'w3bs.org') return pit && url.pathname === '/browse' ? w3bsSiteUrl(q) : null; + return w3bsTarget(q, { pit }); } + +// Session rules while Pit is on: a search for w3bs://, or the hop to +// w3bs.org/browse it redirects to, goes straight to https:///. Higher +// priority than w3bs-rules.json, which sends every w3bs:// search to w3bs.org. +// A path is left to the resolver: the rule cannot percent-decode it. +export const PIT_DIRECT_RULES = [ + { + id: 101, + priority: 2, + action: { type: 'redirect', redirect: { regexSubstitution: 'https://\\1/' } }, + condition: { + regexFilter: + '^https://[^/]+/[^?#]*\\?(?:[^#]*&)?q=w3bs(?:%3A|:)(?:%2F|/){2}([a-z0-9-]+(?:\\.[a-z0-9-]+)+)(?:%2F|/)?(?:[&#].*)?$', + isUrlFilterCaseSensitive: false, + resourceTypes: ['main_frame'], + }, + }, +]; diff --git a/apps/desktop/extensions/ai-sidebar/w3bs.test.js b/apps/desktop/extensions/ai-sidebar/w3bs.test.js index 301861c..ec8a272 100644 --- a/apps/desktop/extensions/ai-sidebar/w3bs.test.js +++ b/apps/desktop/extensions/ai-sidebar/w3bs.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { w3bsFromSearch, w3bsUrl } from './w3bs.js'; +import { PIT_DIRECT_RULES, w3bsFromSearch, w3bsSiteUrl, w3bsUrl } from './w3bs.js'; describe('w3bsUrl', () => { it('sends a W3BS URI to the w3bs.org resolver', () => { @@ -37,4 +37,49 @@ describe('w3bsFromSearch', () => { expect(w3bsFromSearch('chrome://newtab/')).toBeNull(); expect(w3bsFromSearch('not a url')).toBeNull(); }); + + it('with Pit on, opens a named site itself, from the search or the resolver hop', () => { + const pit = { pit: true }; + expect(w3bsFromSearch('https://pit.world/search?q=w3bs%3A%2F%2Fchovy.hacker', pit)).toBe('https://chovy.hacker/'); + expect(w3bsFromSearch('https://w3bs.org/browse?q=w3bs%3A%2F%2FChovy.Hacker%2Fblog', pit)).toBe( + 'https://chovy.hacker/blog', + ); + // Resources still go to the resolver; w3bs.org pages that are not a site stay put. + expect(w3bsFromSearch('https://pit.world/search?q=w3bs%3A%2F%2Fprompt%2Fw3bs%2Fresearch', pit)).toBe( + 'https://w3bs.org/browse?q=w3bs%3A%2F%2Fprompt%2Fw3bs%2Fresearch', + ); + expect(w3bsFromSearch('https://w3bs.org/browse?q=w3bs%3A%2F%2Fprompt%2Fw3bs%2Fresearch', pit)).toBeNull(); + }); +}); + +describe('w3bsSiteUrl', () => { + it('reads a dotted authority as a site, and nothing else', () => { + expect(w3bsSiteUrl('w3bs://chovy.hacker')).toBe('https://chovy.hacker/'); + expect(w3bsSiteUrl('w3bs://example.com/a?b=1')).toBe('https://example.com/a?b=1'); + expect(w3bsSiteUrl('w3bs://prompt/w3bs/research@1')).toBeNull(); + expect(w3bsSiteUrl('https://chovy.hacker')).toBeNull(); + }); +}); + +describe('PIT_DIRECT_RULES', () => { + // Chromium's regexFilter is RE2; this pattern is plain enough to read the same in JS. + const rule = PIT_DIRECT_RULES[0]; + const re = new RegExp(rule.condition.regexFilter, 'i'); + const apply = (href) => { + const m = re.exec(href); + return m ? rule.action.redirect.regexSubstitution.replace('\\1', m[1]) : null; + }; + + it('sends a w3bs:// search, or its resolver hop, to the name itself', () => { + expect(apply('https://pit.world/search?q=w3bs%3A%2F%2Fchovy.hacker')).toBe('https://chovy.hacker/'); + expect(apply('https://duckduckgo.com/?q=w3bs%3A%2F%2Fchovy.hacker&ia=web')).toBe('https://chovy.hacker/'); + expect(apply('https://w3bs.org/browse?q=w3bs%3A%2F%2Fchovy.hacker')).toBe('https://chovy.hacker/'); + expect(apply('https://w3bs.org/browse?q=w3bs://chovy.hacker')).toBe('https://chovy.hacker/'); + }); + + it('leaves resources, paths and other searches to the resolver', () => { + expect(apply('https://pit.world/search?q=w3bs%3A%2F%2Fprompt%2Fw3bs%2Fresearch')).toBeNull(); + expect(apply('https://pit.world/search?q=w3bs%3A%2F%2Fchovy.hacker%2Fblog')).toBeNull(); + expect(apply('https://pit.world/search?q=chovy.hacker')).toBeNull(); + }); });