Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/browser/base/content/zen-assets.jar.inc.mn
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 32 additions & 3 deletions src/browser/modules/BrowserDOMWindow-sys-mjs.patch
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
);
Expand Down
139 changes: 139 additions & 0 deletions src/zen/atc/ZenAirTrafficControl.mjs
Original file line number Diff line number Diff line change
@@ -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();
109 changes: 109 additions & 0 deletions src/zen/atc/ZenAirTrafficControlIntegration.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
},
};
83 changes: 83 additions & 0 deletions src/zen/atc/popups/settings.xhtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?xml version="1.0"?>
<!-- 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/. -->

<?csp default-src chrome:; img-src moz-icon: chrome:; style-src chrome:
'unsafe-inline'; ?>

<!DOCTYPE window>

<window
id="commonDialogWindow"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
aria-describedby="infoBody"
headerparent="dialogGrid"
>
<dialog id="commonDialog" buttonpack="end">
<linkset>
<html:link rel="stylesheet" href="chrome://global/skin/global.css" />
<html:link
rel="stylesheet"
href="chrome://global/content/commonDialog.css"
/>
<html:link
rel="stylesheet"
href="chrome://global/skin/commonDialog.css"
/>

<html:link rel="localization" href="branding/brand.ftl" />
<html:link rel="localization" href="toolkit/global/commonDialog.ftl" />
</linkset>
<script src="chrome://global/content/adjustableTitle.js" />
<script src="chrome://global/content/commonDialog.js" />
<script src="chrome://global/content/globalOverlay.js" />
<script src="chrome://global/content/editMenuOverlay.js" />
<script src="chrome://global/content/customElements.js" />

<commandset id="selectEditMenuItems">
</commandset>

<popupset id="contentAreaContextSet">
</popupset>

<div xmlns="http://www.w3.org/1999/xhtml" id="dialogGrid">
<div class="dialogRow" id="infoRow" hidden="hidden">
<div id="iconContainer">
<xul:image id="infoIcon" />
</div>
<div id="infoContainer">
<xul:description id="infoTitle" />
<xul:description
id="infoBody"
context="contentAreaContextMenu"
noinitialfocus="true"
/>
</div>
</div>
<div id="loginContainer" class="dialogRow" hidden="hidden">
<xul:label
id="loginLabel"
data-l10n-id="common-dialog-username"
control="loginTextbox"
/>
<input type="text" id="loginTextbox" dir="ltr" />
</div>
<div id="password1Container" class="dialogRow" hidden="hidden">
<xul:label
id="password1Label"
data-l10n-id="common-dialog-password"
control="password1Textbox"
/>
<input type="password" id="password1Textbox" dir="ltr" />
</div>
<div id="checkboxContainer" class="dialogRow" hidden="hidden">
<div />
<!-- spacer -->
<xul:checkbox id="checkbox" />
</div>
</div>
</dialog>
</window>
Loading