diff --git a/package.json b/package.json index 8f0cbfcf..d8939ca5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,18 @@ "build-th": "webpack --env browser=thunderbird", "build-th:watch": "webpack --watch --env browser=thunderbird --env dev=true" }, + "jest": { + "setupFiles": [ + "jest-webextension-mock" + ], + "testEnvironment": "jsdom", + "testMatch": [ + "**/src/tests/**/*.test.ts" + ], + "transform": { + "^.+\\.tsx?$": "ts-jest" + } + }, "license": "GPL-3.0", "dependencies": { "pako": "^3.0.1", diff --git a/src/core/TabManager.ts b/src/core/TabManager.ts index 2549650c..ba9bd107 100644 --- a/src/core/TabManager.ts +++ b/src/core/TabManager.ts @@ -20,6 +20,7 @@ import { CompiledProxyRule, FailedRequestType, ProxyServer, TabProxyStatus } fro import { api, environment } from "../lib/environment"; import { Settings } from "./Settings"; import { ProxyRules } from "./ProxyRules"; +import { Utils } from "../lib/Utils"; export class TabManager { @@ -44,6 +45,20 @@ export class TabManager { // listen to tab URL changes api.tabs.onUpdated.addListener(TabManager.updateActiveTab); + let webNavigation = api.webNavigation; + if (webNavigation) { + if (webNavigation.onBeforeNavigate) + webNavigation.onBeforeNavigate.addListener(TabManager.handleNavigationStarted); + if (webNavigation.onCommitted) + webNavigation.onCommitted.addListener(TabManager.handleNavigationCommitted); + if (webNavigation.onHistoryStateUpdated) + webNavigation.onHistoryStateUpdated.addListener(TabManager.handleNavigationCommitted); + if (webNavigation.onReferenceFragmentUpdated) + webNavigation.onReferenceFragmentUpdated.addListener(TabManager.handleNavigationCommitted); + if (webNavigation.onErrorOccurred) + webNavigation.onErrorOccurred.addListener(TabManager.handleNavigationError); + } + api.tabs.onRemoved.addListener(TabManager.handleTabRemoved); // listen for window switching @@ -86,19 +101,28 @@ export class TabManager { if (!tabData) { tabData = TabManager.getOrSetTab(tabId, false); } - if (tabData.proxifiedParentDocumentUrl != tabInfo.url) { + + // Chrome may expose the destination as pendingUrl while url is still the old/placeholder page. + // Firefox only has url, and during loading that is often about:blank / about:newtab. + let incomingUrl = tabInfo.pendingUrl || tabInfo.url || ""; + let keepExistingUrl = Utils.shouldPreserveTrackedUrl(tabData.url, incomingUrl); + let effectiveUrl = keepExistingUrl ? tabData.url : incomingUrl; + + if (effectiveUrl && tabData.proxifiedParentDocumentUrl != effectiveUrl) { // resettings the state tabData.resetTabState(); // apply `proxified` value - TabManager.setRuleForProxyPerOrigin(tabData, tabInfo.url); + TabManager.setRuleForProxyPerOrigin(tabData, effectiveUrl); } tabData.updated = new Date(); tabData.incognito = tabInfo.incognito; - tabData.url = tabInfo.url; tabData.index = tabInfo.index; - if (!tabData.proxifiedParentDocumentUrl) - tabData.proxifiedParentDocumentUrl = tabInfo.url; + if (effectiveUrl) { + tabData.url = effectiveUrl; + if (!tabData.proxifiedParentDocumentUrl) + tabData.proxifiedParentDocumentUrl = effectiveUrl; + } // saving the tab in the storage TabManager.tabs[tabId] = tabData; @@ -192,6 +216,58 @@ export class TabManager { tabData.clearFailedRequests(); } + private static isMainFrameNavigation(details: any): boolean { + return details && details.tabId > -1 && details.frameId === 0 && details.url; + } + + private static handleNavigationStarted(details: any) { + if (!TabManager.isMainFrameNavigation(details)) + return; + + TabManager.updateTabUrlFromNavigation(details.tabId, details.url); + } + + private static handleNavigationCommitted(details: any) { + if (!TabManager.isMainFrameNavigation(details)) + return; + + TabManager.updateTabUrlFromNavigation(details.tabId, details.url); + } + + private static handleNavigationError(details: any) { + if (!TabManager.isMainFrameNavigation(details)) + return; + + let tabData = TabManager.tabs[details.tabId]; + if (!tabData || tabData.url !== details.url) + return; + + TabManager.loadTabData(tabData); + } + + private static updateTabUrlFromNavigation(tabId: number, url: string) { + let tabData = TabManager.tabs[tabId]; + let tabDataCreated = false; + if (!tabData) { + tabData = TabManager.getOrSetTab(tabId, false, url); + tabDataCreated = true; + } + + if (!tabDataCreated && tabData.url === url) + return; + + tabData.clearFailedRequests(); + tabData.resetTabState(); + TabManager.setRuleForProxyPerOrigin(tabData, url); + tabData.updated = new Date(); + tabData.url = url; + tabData.proxifiedParentDocumentUrl = url; + + if (!TabManager.currentTab || TabManager.currentTab.tabId === tabId) + TabManager.currentTab = tabData; + TabManager.onTabUpdated.trigger(tabData); + } + private static handleTabUpdated(tabId: number, changeInfo: any, tabInfo: any) { // only if url of the page is changed @@ -228,7 +304,10 @@ export class TabManager { if (tabData) { // reload tab data tabData.clearFailedRequests(); - TabManager.loadTabData(tabData); + if (changeInfo.url && + !Utils.shouldPreserveTrackedUrl(tabData.url, changeInfo.url)) { + TabManager.updateTabUrlFromNavigation(tabId, changeInfo.url); + } callOnUpdate = true; } } @@ -300,4 +379,4 @@ export class TabDataStatuses { /** Always enabled has bypassed and no proxy is applied */ public hasAlwaysEnabledByPassed: boolean; -} \ No newline at end of file +} diff --git a/src/lib/Utils.ts b/src/lib/Utils.ts index 65d85f4b..0af589cc 100644 --- a/src/lib/Utils.ts +++ b/src/lib/Utils.ts @@ -272,6 +272,37 @@ export class Utils { catch (e) { return false; } } + /** New-tab placeholders reported by tabs.query/get while a real navigation is already in flight. */ + public static isTransientTabUrl(url: string): boolean { + if (!url) + return true; + + let value = url.toLowerCase(); + return value === "about:blank" + || value === "about:newtab" + || value === "about:home" + || value === "about:privatebrowsing" + || value.startsWith("chrome://newtab") + || value.startsWith("chrome://new-tab-page") + || value.startsWith("edge://newtab"); + } + + /** + * tabs.Tab.url lags webNavigation in both Firefox and Chrome. + * Preserve the tracked URL only when the tabs API has no URL or is still + * reporting a new-tab placeholder. + */ + public static shouldPreserveTrackedUrl(existingUrl: string, incomingUrl: string): boolean { + if (!existingUrl) + return false; + if (!incomingUrl) + return true; + if (incomingUrl === existingUrl) + return false; + + return Utils.isTransientTabUrl(incomingUrl) && !Utils.isTransientTabUrl(existingUrl); + } + public static urlHasSchema(url: string): boolean { // note: this will accept like http:/example.org/ in Chrome and Firefox if (!url) diff --git a/src/manifest-chrome-mv2.json b/src/manifest-chrome-mv2.json index 6c9e67c3..77021c0d 100644 --- a/src/manifest-chrome-mv2.json +++ b/src/manifest-chrome-mv2.json @@ -17,6 +17,7 @@ "", "activeTab", "tabs", + "webNavigation", "proxy", "webRequest", "webRequestBlocking", diff --git a/src/manifest-chrome.json b/src/manifest-chrome.json index 24e615d9..6ed61960 100644 --- a/src/manifest-chrome.json +++ b/src/manifest-chrome.json @@ -16,6 +16,7 @@ "permissions": [ "activeTab", "tabs", + "webNavigation", "proxy", "webRequest", "webRequestAuthProvider", diff --git a/src/manifest-edge.json b/src/manifest-edge.json index 2aa4ad42..f7576b4c 100644 --- a/src/manifest-edge.json +++ b/src/manifest-edge.json @@ -17,6 +17,7 @@ "", "activeTab", "tabs", + "webNavigation", "proxy", "webRequest", "webRequestBlocking", diff --git a/src/manifest-firefox-android.json b/src/manifest-firefox-android.json index 969f94b1..27f30f4a 100644 --- a/src/manifest-firefox-android.json +++ b/src/manifest-firefox-android.json @@ -17,6 +17,7 @@ "", "activeTab", "tabs", + "webNavigation", "proxy", "webRequest", "webRequestBlocking", diff --git a/src/manifest-firefox-unlisted.json b/src/manifest-firefox-unlisted.json index 642de991..fe3ad308 100644 --- a/src/manifest-firefox-unlisted.json +++ b/src/manifest-firefox-unlisted.json @@ -15,9 +15,10 @@ }, "permissions": [ "", - "activeTab", - "tabs", - "proxy", + "activeTab", + "tabs", + "webNavigation", + "proxy", "webRequest", "webRequestBlocking", "storage", diff --git a/src/manifest-firefox.json b/src/manifest-firefox.json index 8751c782..c51d8c2b 100644 --- a/src/manifest-firefox.json +++ b/src/manifest-firefox.json @@ -15,9 +15,10 @@ }, "permissions": [ "", - "activeTab", - "tabs", - "proxy", + "activeTab", + "tabs", + "webNavigation", + "proxy", "webRequest", "webRequestBlocking", "storage", diff --git a/src/manifest-opera.json b/src/manifest-opera.json index 468b1254..2f182688 100644 --- a/src/manifest-opera.json +++ b/src/manifest-opera.json @@ -17,6 +17,7 @@ "", "activeTab", "tabs", + "webNavigation", "proxy", "webRequest", "webRequestBlocking", diff --git a/src/manifest-thunderbird.json b/src/manifest-thunderbird.json index 537ab4f8..fc2adfee 100644 --- a/src/manifest-thunderbird.json +++ b/src/manifest-thunderbird.json @@ -17,6 +17,7 @@ "", "activeTab", "tabs", + "webNavigation", "proxy", "webRequest", "webRequestBlocking", diff --git a/src/tests/TabManager.test.ts b/src/tests/TabManager.test.ts new file mode 100644 index 00000000..d11f9639 --- /dev/null +++ b/src/tests/TabManager.test.ts @@ -0,0 +1,126 @@ +jest.mock('../lib/environment', () => ({ + environment: { + chrome: true, + name: 'chrome', + version: 1, + manifestV3: false, + notSupported: {}, + notAllowed: {}, + bugFreeVersions: {}, + initialConfig: {}, + storageQuota: { syncQuotaBytesPerItem: () => 8000 }, + browserConfig: {} + }, + api: { + runtime: { lastError: null }, + browserAction: {}, + i18n: { getMessage: (key: string) => key }, + tabs: {} + } +})); + +import { api } from '../lib/environment'; +import { TabManager } from '../core/TabManager'; + +const tabManagerType = TabManager as any; + +describe('TabManager webNavigation tracking', () => { + beforeEach(() => { + tabManagerType.tabs = {}; + tabManagerType.currentTab = null; + api.runtime.lastError = null; + api.tabs.get = jest.fn((tabId: number, callback: Function) => callback({ + id: tabId, + url: 'https://current.example/', + incognito: false, + index: 0 + })); + }); + + it('updates the tab url when main-frame navigation starts', () => { + let tabData = TabManager.getOrSetTab(1, false, 'https://old.example/'); + let updates = 0; + const onUpdated = () => updates++; + TabManager.TabUpdated.on(onUpdated); + + tabManagerType.handleNavigationStarted({ + tabId: 1, + frameId: 0, + url: 'https://new.example/path' + }); + + TabManager.TabUpdated.off(onUpdated); + expect(tabData.url).toBe('https://new.example/path'); + expect(tabData.proxifiedParentDocumentUrl).toBe('https://new.example/path'); + expect(updates).toBe(1); + }); + + it('ignores sub-frame navigation changes', () => { + let tabData = TabManager.getOrSetTab(1, false, 'https://old.example/'); + + tabManagerType.handleNavigationStarted({ + tabId: 1, + frameId: 1, + url: 'https://subframe.example/' + }); + + expect(tabData.url).toBe('https://old.example/'); + }); + + it('reloads current document url when pending navigation fails', () => { + let tabData = TabManager.getOrSetTab(1, false, 'https://loading.example/'); + + tabManagerType.handleNavigationError({ + tabId: 1, + frameId: 0, + url: 'https://loading.example/' + }); + + expect(tabData.url).toBe('https://current.example/'); + }); + + it('does not let a loading placeholder from tabs.query overwrite webNavigation url', () => { + let tabData = TabManager.getOrSetTab(56, false, 'https://example.com/'); + + TabManager.updateTabData(tabData, { + id: 56, + url: 'about:blank', + status: 'loading', + incognito: false, + index: 4 + }); + + expect(tabData.url).toBe('https://example.com/'); + expect(tabData.index).toBe(4); + }); + + it('accepts chrome pendingUrl from the tabs API', () => { + let tabData = TabManager.getOrSetTab(56, false, 'https://old.example/'); + + TabManager.updateTabData(tabData, { + id: 56, + url: 'https://old.example/', + pendingUrl: 'https://new.example/', + status: 'loading', + incognito: false, + index: 1 + }); + + expect(tabData.url).toBe('https://new.example/'); + }); + + it('accepts tabs.onUpdated url when webNavigation is unavailable', () => { + let tabData = TabManager.getOrSetTab(1, false, 'https://old.example/'); + + tabManagerType.handleTabUpdated(1, { + url: 'https://new.example/', + status: 'loading' + }, { + id: 1, + url: 'https://new.example/', + status: 'loading' + }); + + expect(tabData.url).toBe('https://new.example/'); + }); +}); diff --git a/src/tests/Utils.test.ts b/src/tests/Utils.test.ts index 2dfbee40..e2279aca 100644 --- a/src/tests/Utils.test.ts +++ b/src/tests/Utils.test.ts @@ -16,6 +16,33 @@ describe('Utils', () => { }); }); + describe('isTransientTabUrl', () => { + it('treats new-tab placeholders as transient', () => { + expect(Utils.isTransientTabUrl('')).toBe(true); + expect(Utils.isTransientTabUrl('about:blank')).toBe(true); + expect(Utils.isTransientTabUrl('about:newtab')).toBe(true); + expect(Utils.isTransientTabUrl('chrome://newtab/')).toBe(true); + }); + + it('does not treat real pages as transient', () => { + expect(Utils.isTransientTabUrl('https://example.com/')).toBe(false); + }); + }); + + describe('shouldPreserveTrackedUrl', () => { + it('keeps a real URL when tabs.query reports a loading placeholder', () => { + expect(Utils.shouldPreserveTrackedUrl('https://example.com/', 'about:blank')).toBe(true); + }); + + it('keeps a real URL when tabs.query has no url yet', () => { + expect(Utils.shouldPreserveTrackedUrl('https://example.com/', '')).toBe(true); + }); + + it('accepts a real tabs url even while the tab is loading', () => { + expect(Utils.shouldPreserveTrackedUrl('https://old.example/', 'https://current.example/')).toBe(false); + }); + }); + describe('extractHostFromUrl', () => { it('should extract hostname from URL', () => { expect(Utils.extractHostFromUrl('https://example.com/path')).toBe('example.com');