diff --git a/apps/content/src/browser/windowApi/WebsiteView.js b/apps/content/src/browser/windowApi/WebsiteView.js index 858d07f..0fdfb2e 100644 --- a/apps/content/src/browser/windowApi/WebsiteView.js +++ b/apps/content/src/browser/windowApi/WebsiteView.js @@ -7,6 +7,7 @@ import mitt from 'mitt' import { readable } from 'svelte/store' +import { browserImports } from '../browserImports.js' import { createBrowser } from '../utils/browserElement.js' import { eventBus } from './eventBus.js' @@ -51,6 +52,7 @@ export function create(uri) { registerViewThemeListener(view) }) + view.events.on('goTo', (e) => goTo(view, browserImports.NetUtil.newURI(e))) view.events.on('locationChange', (e) => (view.uri = e.aLocation)) view.events.on( 'loadingChange', diff --git a/apps/content/src/browser/windowApi/WindowTabs.js b/apps/content/src/browser/windowApi/WindowTabs.js index 334d8cc..b279242 100644 --- a/apps/content/src/browser/windowApi/WindowTabs.js +++ b/apps/content/src/browser/windowApi/WindowTabs.js @@ -16,6 +16,9 @@ export const activeTabId = writable(0) */ export const selectedTabIds = writable([]) +window.activeTabId = activeTabId +window.selectedTabIds = selectedTabIds + /** * @param {number[]} ids */ diff --git a/apps/extensions/lib/parent/ext-tabs.js b/apps/extensions/lib/parent/ext-tabs.js index 7f8b24a..3cc0bd1 100644 --- a/apps/extensions/lib/parent/ext-tabs.js +++ b/apps/extensions/lib/parent/ext-tabs.js @@ -122,6 +122,68 @@ this.tabs = class extends ExtensionAPIPersistent { } } }, + + async update(tabId, updateProperties) { + const windows = lazy.WindowTracker.registeredWindows.values() + for (const window of windows) { + const tabs = window.windowTabs() + const hasTab = tabs.some((tab) => tab.view.browserId === tabId) + + if (!hasTab) { + continue + } + + let errors = null + let retTab + + window.windowTabs.update((tabs) => + tabs.map((tab) => { + if (tab.view.browserId === tabId) { + if (updateProperties.active) { + window.activeTabId.set(tab.view.windowBrowserId) + } + + if ( + updateProperties.highlighted && + window.activeTabId() !== tab.view.windowBrowserId + ) { + window.selectedTabIds.update((tabs) => { + if (tabs.includes(tab.view.windowBrowserId)) return tabs + return [...tabs, tab.view.windowBrowserId] + }) + } + + if (updateProperties.url) { + let url = context.uri.resolve(updateProperties.url) + + if ( + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + errors = `Invalid url: ${url}` + return tab + } + + tab.view.events.emit('goTo', url) + } + + retTab = tab + } + + return tab + }), + ) + + if (errors) { + return Promise.reject({ message: errors }) + } + + if (retTab) { + return serialize(extension)([retTab, window]) + } + + return + } + }, }, } } diff --git a/apps/extensions/lib/schemaTypes/tabs.d.ts b/apps/extensions/lib/schemaTypes/tabs.d.ts index 21ee398..e9df212 100644 --- a/apps/extensions/lib/schemaTypes/tabs.d.ts +++ b/apps/extensions/lib/schemaTypes/tabs.d.ts @@ -25,11 +25,17 @@ declare module tabs__tabs { url?: string | string[] windowId?: number } + type UpdateInfo = { + active?: boolean + highlighted?: boolean + url?: string + } type TabStatus = 'loading' | 'complete' type ApiGetterReturn = { tabs: { query: (queryInfo: QueryInfo) => Promise remove: (tabIds: number | number[]) => unknown + update: (tabId: number, updateProperties: UpdateInfo) => unknown } } } diff --git a/apps/extensions/lib/schemas/tabs.json b/apps/extensions/lib/schemas/tabs.json index a51e763..4f443e7 100644 --- a/apps/extensions/lib/schemas/tabs.json +++ b/apps/extensions/lib/schemas/tabs.json @@ -81,6 +81,24 @@ } } }, + { + "type": "object", + "id": "UpdateInfo", + "properties": { + "active": { + "type": "boolean", + "optional": true + }, + "highlighted": { + "type": "boolean", + "optional": true + }, + "url": { + "type": "string", + "optional": true + } + } + }, { "type": "string", "id": "TabStatus", @@ -118,6 +136,21 @@ ] } ] + }, + { + "name": "update", + "type": "function", + "async": true, + "parameters": [ + { + "name": "tabId", + "type": "integer" + }, + { + "name": "updateProperties", + "$ref": "UpdateInfo" + } + ] } ] } diff --git a/apps/modules/lib/ExtensionTestUtils.sys.mjs b/apps/modules/lib/ExtensionTestUtils.sys.mjs index 5746e2e..6a44705 100644 --- a/apps/modules/lib/ExtensionTestUtils.sys.mjs +++ b/apps/modules/lib/ExtensionTestUtils.sys.mjs @@ -202,16 +202,18 @@ class ExtensionTestUtilsImpl { } else if (kind == 'test-log') { console.info(msg) } else if (kind == 'test-result') { + testCount += 1 assert.ok(pass, msg) + } else if (kind == 'test-done') { + testCount += 1 + assert.truthy(pass, msg) } } extension.on('test-result', handleTestResults) extension.on('test-eq', handleTestResults) extension.on('test-log', handleTestResults) - extension.on('test-done', (...args) => - console.warn('Not Implemented', ...args), - ) + extension.on('test-done', handleTestResults) extension.on('test-message', (...args) => console.log('message', ...args)) diff --git a/apps/tests/integrations/extensions/tabs.mjs b/apps/tests/integrations/extensions/tabs.mjs index 0f39daa..b76c115 100644 --- a/apps/tests/integrations/extensions/tabs.mjs +++ b/apps/tests/integrations/extensions/tabs.mjs @@ -146,3 +146,165 @@ await TestManager.withBrowser( }) }, ) + +await TestManager.withBrowser( + ['https://example.com/', 'https://google.com'], + async (window) => { + await spinLock(() => + window + ?.windowTabs() + .map( + (tab) => + tab.view.browser?.mInitialized && + tab.view.websiteState === 'complete', + ) + .reduce((p, c) => p && c, true), + ) + + await TestManager.test('tabs - Update - Active', async (test) => { + const extension = ExtensionTestUtils.loadExtension( + { + manifest: { + permissions: ['tabs'], + }, + async background() { + /** @type {import('resource://app/modules/ExtensionTestUtils.sys.mjs').TestBrowser} */ + const b = this.browser + + b.test.onMessage.addListener(async (msg) => { + const windowId = Number(msg) + + const windowResults = await b.tabs.query({ + windowId, + }) + b.test.assertEq( + ['https://example.com/', 'https://www.google.com/'].join(','), + [windowResults[0].url, windowResults[1].url].join(','), + 'Window is correctly setup', + ) + b.test.assertTrue( + windowResults[0].active, + 'First tab should be active', + ) + + await b.tabs.update(windowResults[1].id || -1, { active: true }) + + const resultsAfter = await b.tabs.query({ + windowId, + }) + b.test.assertFalse( + resultsAfter[0].active, + 'First tab should be inactive', + ) + b.test.assertTrue( + resultsAfter[1].active, + 'Second tab should be active', + ) + b.test.sendMessage('done') + }) + }, + }, + test, + ) + + await extension + .testCount(4) + .startup() + .then((e) => e.sendMsg(window.windowId.toString())) + .then((e) => e.awaitMsg('done')) + .then((e) => e.unload()) + }) + + await TestManager.test('tabs - Update - Url', async (test) => { + const extension = ExtensionTestUtils.loadExtension( + { + manifest: { + permissions: ['tabs'], + }, + async background() { + /** @type {import('resource://app/modules/ExtensionTestUtils.sys.mjs').TestBrowser} */ + const b = this.browser + + b.test.onMessage.addListener(async (msg) => { + const windowId = Number(msg) + + let windowResults = await b.tabs.query({ + windowId, + }) + + try { + await b.tabs.update(windowResults[0].id || -1, { + url: 'chrome://browser/content/gtests.html', + }) + b.test.notifyFail('Failed to reject chrome:// url') + } catch (e) { + b.test.notifyPass('Failed to change url to chrome://') + } + + try { + await b.tabs.update(windowResults[0].id || -1, { + url: 'file://some/local/path.txt', + }) + b.test.notifyFail('Failed to reject file:// url') + } catch (e) { + b.test.notifyPass('Failed to change url to file://') + } + + try { + await b.tabs.update(windowResults[0].id || -1, { + url: 'about:addons', + }) + b.test.notifyFail('Failed to reject priviged about: url') + } catch (e) { + b.test.notifyPass('Failed to change url to priviged about:') + } + + try { + await b.tabs.update(windowResults[0].id || -1, { + url: 'javascript:console.log("Hello world!")', + }) + b.test.notifyFail('Failed to reject javascript: url') + } catch (e) { + b.test.notifyPass('Failed to change url to javascript:') + } + + try { + await b.tabs.update(windowResults[0].id || -1, { + url: 'data:text/vnd-example+xyz;foo=bar;base64,R0lGODdh', + }) + b.test.notifyFail('Failed to reject data: url') + } catch (e) { + b.test.notifyPass('Failed to change url to data:') + } + + await b.tabs.update(windowResults[0].id || -1, { + url: 'about:blank', + }) + await new Promise((res) => setTimeout(res, 200)) + + windowResults = await b.tabs.query({ + windowId, + }) + + b.test.assertEq( + 'about:blank', + windowResults[0].url, + 'URL update works', + ) + + b.test.sendMessage('done') + }) + }, + }, + test, + ) + + await extension + .testCount(6) + .startup() + .then((e) => e.sendMsg(window.windowId.toString())) + .then((e) => e.awaitMsg('done')) + .then((e) => e.unload()) + }) + }, +) diff --git a/libs/link/types/globals/WindowApi.d.ts b/libs/link/types/globals/WindowApi.d.ts index 017a206..f38c873 100644 --- a/libs/link/types/globals/WindowApi.d.ts +++ b/libs/link/types/globals/WindowApi.d.ts @@ -11,4 +11,6 @@ declare interface Window { activeTab: import('@amadeus-it-group/tansu').ReadableSignal< import('@browser/tabs').WindowTab | undefined > + activeTabId: import('@amadeus-it-group/tansu').WritableSignal + selectedTabIds: import('@amadeus-it-group/tansu').WritableSignal } diff --git a/libs/link/types/windowApi/WebsiteView.d.ts b/libs/link/types/windowApi/WebsiteView.d.ts index af05297..6490203 100644 --- a/libs/link/types/windowApi/WebsiteView.d.ts +++ b/libs/link/types/windowApi/WebsiteView.d.ts @@ -20,6 +20,7 @@ declare type OklchTheme = { } declare type WebsiteViewEvents = { + goTo: string loadingChange: boolean progressPercent: number changeIcon: string