diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn index aaf4bced90..10bb61dce6 100644 --- a/src/browser/base/content/zen-assets.jar.inc.mn +++ b/src/browser/base/content/zen-assets.jar.inc.mn @@ -53,6 +53,9 @@ content/browser/zen-components/ZenKeyboardShortcuts.mjs (../../zen/kbs/ZenKeyboardShortcuts.mjs) + content/browser/zen-components/ZenAirTrafficControl.mjs (../../zen/atc/ZenAirTrafficControl.mjs) + content/browser/zen-components/ZenAirTrafficControlIntegration.mjs (../../zen/atc/ZenAirTrafficControlIntegration.mjs) + content/browser/zen-components/ZenPinnedTabsStorage.mjs (../../zen/tabs/ZenPinnedTabsStorage.mjs) content/browser/zen-components/ZenPinnedTabManager.mjs (../../zen/tabs/ZenPinnedTabManager.mjs) * content/browser/zen-styles/zen-tabs.css (../../zen/tabs/zen-tabs.css) diff --git a/src/browser/modules/BrowserDOMWindow-sys-mjs.patch b/src/browser/modules/BrowserDOMWindow-sys-mjs.patch index a0d49d8060..f781971f77 100644 --- a/src/browser/modules/BrowserDOMWindow-sys-mjs.patch +++ b/src/browser/modules/BrowserDOMWindow-sys-mjs.patch @@ -1,8 +1,37 @@ diff --git a/browser/modules/BrowserDOMWindow.sys.mjs b/browser/modules/BrowserDOMWindow.sys.mjs -index 534d23b3e66176ea77f3ef577bf9630626948b9d..752e229bbe725ae394b7648adb949635f2bd70e4 100644 +index 534d23b3e66176ea77f3ef577bf9630626948b9d..2bd136b76a4ac7209dba557889c43f4371bfb86e 100644 --- a/browser/modules/BrowserDOMWindow.sys.mjs +++ b/browser/modules/BrowserDOMWindow.sys.mjs -@@ -374,7 +374,7 @@ export class BrowserDOMWindow { +@@ -163,6 +163,28 @@ export class BrowserDOMWindow { + console.error("openURI should only be called with a valid URI"); + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } ++ // Zen Air Traffic Control integration - intercept external links ++ if (aURI && (aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL)) { ++ try { ++ const decision = this.win.ZenAirTrafficControlIntegration.handleExternalLink(aURI); ++ ++ if (decision) { ++ // Execute the routing and return the browsing context ++ const browsingContext = this.win.ZenAirTrafficControlIntegration.executeRouting( ++ decision, ++ aURI, ++ this.win ++ ); ++ if (browsingContext) { ++ // Ensure we return the browsing context and don't continue ++ return browsingContext; ++ } ++ } ++ } catch (e) { ++ console.error("Error in Air Traffic Control:", e); ++ // Fall through to default behavior on error ++ } ++ } + return this.getContentWindowOrOpenURI( + aURI, + aOpenWindowInfo, +@@ -374,7 +396,7 @@ export class BrowserDOMWindow { // Passing a null-URI to only create the content window, // and pass true for aSkipLoad to prevent loading of // about:blank @@ -11,7 +40,7 @@ index 534d23b3e66176ea77f3ef577bf9630626948b9d..752e229bbe725ae394b7648adb949635 null, aParams, aWhere, -@@ -382,6 +382,10 @@ export class BrowserDOMWindow { +@@ -382,6 +404,10 @@ export class BrowserDOMWindow { aName, true ); diff --git a/src/zen/atc/ZenAirTrafficControl.mjs b/src/zen/atc/ZenAirTrafficControl.mjs new file mode 100644 index 0000000000..742a612f6b --- /dev/null +++ b/src/zen/atc/ZenAirTrafficControl.mjs @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const MATCHER_FACTORIES = { + equals: (v) => new RegExp(`^${escapeRegExp(v)}$`, 'i'), + contains: (v) => new RegExp(escapeRegExp(v), 'i'), + startsWith: (v) => new RegExp(`^${escapeRegExp(v)}`, 'i'), + endsWith: (v) => new RegExp(`${escapeRegExp(v)}$`, 'i'), + regex: (v) => new RegExp(v, 'i'), +}; +const VALID_MATCH_TYPES = Object.keys(MATCHER_FACTORIES); + +const PREF_KEY = 'zen.airTrafficControl.rules'; +const readPref = () => Services.prefs.getStringPref(PREF_KEY, '[]'); +const writePref = (json) => Services.prefs.setStringPref(PREF_KEY, json); + +class ZenAirTrafficControlImpl { + _rules = []; + _initialized = false; + + init() { + if (this._initialized) return; + this.loadRules(); + this._initialized = true; + } + + _ensureInit() { + if (!this._initialized) this.init(); + } + + loadRules() { + try { + this._rules = JSON.parse(readPref()); + this._sortRules(); + } catch (e) { + console.error('ZenATC: failed to load rules', e); + this._rules = []; + } + } + + saveRules() { + try { + writePref(JSON.stringify(this._rules)); + } catch (e) { + console.error('ZenATC: failed to save rules', e); + } + } + + _sortRules() { + this._rules.sort((a, b) => a.createdAt - b.createdAt); + } + + _validateRuleShape({ matchType, matchValue, workspaceId }) { + if (!VALID_MATCH_TYPES.includes(matchType)) { + throw new Error(`ZenATC: invalid matchType '${matchType}'`); + } + if (typeof matchValue !== 'string' || !matchValue) { + throw new Error('ZenATC: matchValue must be non-empty string'); + } + if (typeof workspaceId !== 'string' || !workspaceId) { + throw new Error('ZenATC: workspaceId must be non-empty string'); + } + } + + createRule(rule) { + this._ensureInit(); + this._validateRuleShape(rule); + + const newRule = { + uuid: crypto.randomUUID(), + enabled: true, + createdAt: Date.now(), + ...rule, + }; + + this._rules.push(newRule); + this._sortRules(); + this.saveRules(); + return newRule; + } + + updateRule(uuid, updates) { + this._ensureInit(); + const idx = this._rules.findIndex((r) => r.uuid === uuid); + if (idx === -1) throw new Error(`ZenATC: rule '${uuid}' not found`); + + const updated = { ...this._rules[idx], ...updates, uuid }; + this._validateRuleShape(updated); + + this._rules[idx] = updated; + this.saveRules(); + } + + deleteRule(uuid) { + this._ensureInit(); + this._rules = this._rules.filter((r) => r.uuid !== uuid); + this.saveRules(); + } + + getRules() { + this._ensureInit(); + return [...this._rules]; + } + + _createMatcher(rule) { + const factory = MATCHER_FACTORIES[rule.matchType]; + return factory(rule.matchValue); + } + + _findFirstMatchingRule(url) { + this._ensureInit(); + return this._rules.find((r) => r.enabled && this._createMatcher(r).test(url)) || null; + } + + routeURL(url) { + this._ensureInit(); + const rule = this._findFirstMatchingRule(url); + if (!rule) return null; + return { rule, workspaceId: rule.workspaceId }; + } + + /** Return *all* enabled rules that match the given URL. */ + findMatchingRules(url) { + this._ensureInit(); + return this._rules.filter((r) => r.enabled && this._createMatcher(r).test(url)); + } + + /** Expose regex factory for UI consumers that still call it. */ + createMatchRegex(matchType, matchValue) { + const factory = MATCHER_FACTORIES[matchType]; + if (!factory) throw new Error(`ZenATC: unknown matchType '${matchType}'`); + return factory(matchValue); + } +} + +export const ZenAirTrafficControl = new ZenAirTrafficControlImpl(); diff --git a/src/zen/atc/ZenAirTrafficControlIntegration.mjs b/src/zen/atc/ZenAirTrafficControlIntegration.mjs new file mode 100644 index 0000000000..e275690c08 --- /dev/null +++ b/src/zen/atc/ZenAirTrafficControlIntegration.mjs @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, 'ZenAirTrafficControl', () => { + const { ZenAirTrafficControl } = ChromeUtils.importESModule( + 'chrome://browser/content/zen-components/ZenAirTrafficControl.mjs' + ); + return ZenAirTrafficControl; +}); + +/** + * Integration module for Air Traffic Control + * Hooks into BrowserDOMWindow to intercept external links before they open + */ +export const ZenAirTrafficControlIntegration = { + _initialized: false, + // Duplicate-URL throttle (simple timestamp of last processed URL) + _lastUrl: null, + _lastProcessedTs: 0, + + init() { + if (this._initialized) { + return; + } + + // Delegate heavy-lifting to the core engine + lazy.ZenAirTrafficControl.init(); + this._initialized = true; + }, + + /** + * Check if a URL was recently processed (within 1 second) + */ + _isRecentlyProcessed(url) { + const now = Date.now(); + if (url === this._lastUrl && now - this._lastProcessedTs < 1000) { + return true; + } + this._lastUrl = url; + this._lastProcessedTs = now; + return false; + }, + + /** + * Handle an external link before it opens a tab + * Called from BrowserDOMWindow.openURI + * + * @param {nsIURI} aURI - The URI to open + * @returns {Object|null} Routing decision or null if no routing needed + */ + handleExternalLink(aURI) { + if (!this._initialized) return null; + if (!aURI || !aURI.spec) return null; + + const url = aURI.spec; + + if (this._isRecentlyProcessed(url)) return null; + if (url.startsWith('about:')) return null; + + const decision = lazy.ZenAirTrafficControl.routeURL(url); + + return decision || null; + }, + + /** + * Execute a routing decision + * This actually opens the URL in the specified workspace + * + * @param {Object} decision - The routing decision + * @param {nsIURI} aURI - The URI to open + * @param {Object} aWindow - The browser window + * @returns {BrowsingContext|null} The browsing context if opened, null otherwise + */ + executeRouting(decision, aURI, aWindow) { + if (!decision) return null; + const { workspaceId } = decision; + if (!workspaceId) return null; + + const url = aURI.spec; + + try { + if (aWindow.gZenWorkspaces) { + const tab = aWindow.gBrowser.addTab(url, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + fromExternal: true, + }); + + tab.setAttribute('zen-atc-created', 'true'); + aWindow.gZenWorkspaces.moveTabToWorkspace(tab, workspaceId); + aWindow.gZenWorkspaces._lastSelectedWorkspaceTabs[workspaceId] = tab; + aWindow.gZenWorkspaces.changeWorkspaceWithID(workspaceId); + aWindow.gBrowser.selectedTab = tab; + aWindow.focus(); + + return tab?.linkedBrowser?.browsingContext || null; + } + + // Fallback if workspaces not available + const defaultTab = aWindow.gBrowser.addTrustedTab(url, { fromExternal: true }); + return defaultTab?.linkedBrowser?.browsingContext || null; + } catch (e) { + Cu?.reportError?.(e); + return null; + } + }, +}; diff --git a/src/zen/atc/popups/settings.xhtml b/src/zen/atc/popups/settings.xhtml new file mode 100644 index 0000000000..f46d8ce197 --- /dev/null +++ b/src/zen/atc/popups/settings.xhtml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + +