diff --git a/apps/content/src/browser/browser.js b/apps/content/src/browser/browser.js index 98aff99..7f9a0fe 100644 --- a/apps/content/src/browser/browser.js +++ b/apps/content/src/browser/browser.js @@ -4,6 +4,7 @@ // @ts-check import BrowserWindow from './BrowserWindow.svelte' import './browser.css' +import { browserImports } from './browserImports.js' import * as WindowTabs from './windowApi/WindowTabs.js' import { registerEventBus } from './windowApi/eventBus.js' @@ -27,3 +28,5 @@ WindowTabs.initialize(initialUrls) registerEventBus() new BrowserWindow({ target: document.body }) + +browserImports.WindowTracker.registerWindow(window) diff --git a/apps/content/src/browser/browserImports.js b/apps/content/src/browser/browserImports.js index 9bc0499..b18962a 100644 --- a/apps/content/src/browser/browserImports.js +++ b/apps/content/src/browser/browserImports.js @@ -10,4 +10,5 @@ export const browserImports = lazyESModuleGetters({ EPageActions: 'resource://app/modules/EPageActions.sys.mjs', NetUtil: 'resource://gre/modules/NetUtil.sys.mjs', PageThumbs: 'resource://gre/modules/PageThumbs.sys.mjs', + WindowTracker: 'resource://app/modules/BrowserWindowTracker.sys.mjs', }) diff --git a/apps/content/src/browser/windowApi/WindowTabs.js b/apps/content/src/browser/windowApi/WindowTabs.js index bc0dac3..334d8cc 100644 --- a/apps/content/src/browser/windowApi/WindowTabs.js +++ b/apps/content/src/browser/windowApi/WindowTabs.js @@ -2,18 +2,11 @@ * 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/. */ // @ts-check -import { writable } from '@amadeus-it-group/tansu' -import { derived } from 'svelte/store' +import { derived, writable } from '@amadeus-it-group/tansu' import { browserImports } from '../browserImports.js' import * as WebsiteViewApi from './WebsiteView.js' -/** - * @typedef {object} WebsiteTab - * @property {'website'} kind - * @property {WebsiteView} view - */ - export const activeTabId = writable(0) /** @@ -53,7 +46,7 @@ activeTabId.subscribe((activeId) => { }) /** - * @type {import('@amadeus-it-group/tansu').WritableSignal} + * @type {import('@amadeus-it-group/tansu').WritableSignal} */ export const windowTabs = writable([]) @@ -63,6 +56,9 @@ export const activeTab = derived( $windowTabs.find((tab) => tab.view.windowBrowserId === $activeTabId), ) +window.windowTabs = windowTabs +window.activeTab = activeTab + /** * @param {string[]} urls */ diff --git a/apps/extensions/lib/ext-browser.json b/apps/extensions/lib/ext-browser.json index a5f0c5b..b86845e 100644 --- a/apps/extensions/lib/ext-browser.json +++ b/apps/extensions/lib/ext-browser.json @@ -5,5 +5,11 @@ "scopes": ["addon_parent"], "manifest": ["page_action"], "paths": [["pageAction"]] + }, + "tabs": { + "schema": "chrome://bextensions/content/schemas/tabs.json", + "url": "chrome://bextensions/content/parent/ext-tabs.js", + "scopes": "addon_parent", + "paths": [["tabs"]] } } diff --git a/apps/extensions/lib/parent/ext-pageAction.js b/apps/extensions/lib/parent/ext-pageAction.js index dd00f1e..5339ca1 100644 --- a/apps/extensions/lib/parent/ext-pageAction.js +++ b/apps/extensions/lib/parent/ext-pageAction.js @@ -68,9 +68,7 @@ this.pageAction = class extends ExtensionAPIPersistent { */ onClicked({ fire }) { const callback = async (_name, clickInfo) => { - console.log(fire, fire.wakeup, !!fire.wakeup) if (fire.wakeup) await fire.wakeup() - console.log('fire') fire.sync(clickInfo) } diff --git a/apps/extensions/lib/parent/ext-tabs.js b/apps/extensions/lib/parent/ext-tabs.js new file mode 100644 index 0000000..a6ad9ed --- /dev/null +++ b/apps/extensions/lib/parent/ext-tabs.js @@ -0,0 +1,98 @@ +// @ts-check +/// +/// +/// + +/** + * @param {tabs__tabs.QueryInfo} queryInfo + * @returns {[import("@browser/tabs").WindowTab, Window][]} + */ +function query(queryInfo) { + const windows = [...lazy.WindowTracker.registeredWindows.entries()] + + const urlMatchSet = + (queryInfo.url && + (Array.isArray(queryInfo.url) + ? new MatchPatternSet(queryInfo.url) + : new MatchPatternSet([queryInfo.url]))) || + null + + return windows.flatMap(([windowId, window]) => { + const tabs = window.windowTabs() + const activeTab = window.activeTab() + + if ( + typeof queryInfo.windowId !== 'undefined' && + queryInfo.windowId != windowId + ) { + return [] + } + + return tabs + .filter((tab) => { + const active = + typeof queryInfo.active !== 'undefined' + ? queryInfo.active + ? tab === activeTab + : tab !== activeTab + : true + const title = queryInfo.title + ? queryInfo.title === tab.view.title + : true + const url = + urlMatchSet === null + ? true + : urlMatchSet.matches(tab.view.browser.browsingContext?.currentURI) + + return active && title && url + }) + .map( + /** @returns {[import("@browser/tabs").WindowTab, Window]} */ (tab) => [ + tab, + window, + ], + ) + }) +} + +const serialize = + (extension) => + /** + * @param {[import("@browser/tabs").WindowTab, Window]} in + * @returns {tabs__tabs.Tab} + */ + ([tab, window]) => { + // TODO: Active tab & host permissions + const hasTabPermission = extension.hasPermission('tabs') + + return { + id: tab.view.browserId, + index: window.windowTabs().findIndex((wTab) => wTab === tab), + active: window.activeTab() === tab, + highlighted: false, // TODO + title: hasTabPermission && tab.view.title, + url: + hasTabPermission && + tab.view.browser.browsingContext?.currentURI.asciiSpec, + windowId: window.windowId, + } + } + +this.tabs = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = {} + + /** + * @returns {tabs__tabs.ApiGetterReturn} + */ + getAPI(context) { + const { extension } = context + + return { + tabs: { + async query(queryInfo) { + return query(queryInfo).map(serialize(extension)) + }, + }, + } + } +} diff --git a/apps/extensions/lib/schemaTypes/tabs.d.ts b/apps/extensions/lib/schemaTypes/tabs.d.ts new file mode 100644 index 0000000..fef36b0 --- /dev/null +++ b/apps/extensions/lib/schemaTypes/tabs.d.ts @@ -0,0 +1,29 @@ +// @not-mpl +// This file is generated from '../schemas/tabs.json'. This file inherits its license +// Please check that file's license +// +// DO NOT MODIFY MANUALLY + +declare module tabs__tabs { + type Tab = { + id?: number + index: number + active: boolean + highlighted: boolean + title?: string + url?: string + windowId: number + } + type QueryInfo = { + active?: boolean + title?: string + url?: string | string[] + windowId?: number + } + type TabStatus = 'loading' | 'complete' + type ApiGetterReturn = { + tabs: { + query: (queryInfo: QueryInfo) => Promise + } + } +} diff --git a/apps/extensions/lib/schemas/tabs.json b/apps/extensions/lib/schemas/tabs.json new file mode 100644 index 0000000..5a15007 --- /dev/null +++ b/apps/extensions/lib/schemas/tabs.json @@ -0,0 +1,95 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "tabs", + "description": "Provides access to information about currently open tabs", + "types": [ + { + "id": "Tab", + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": -1, + "optional": true + }, + "index": { + "type": "integer", + "minimum": -1 + }, + "active": { + "type": "boolean" + }, + "highlighted": { + "type": "boolean" + }, + "title": { + "type": "string", + "optional": true, + "permissions": ["tabs"] + }, + "url": { + "type": "string", + "optional": true, + "permissions": ["tabs"] + }, + "windowId": { + "type": "integer" + } + } + }, + { + "type": "object", + "id": "QueryInfo", + "properties": { + "active": { + "type": "boolean", + "optional": true + }, + "title": { + "type": "string", + "optional": true + }, + "url": { + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "optional": true + }, + "windowId": { + "type": "integer", + "optional": true + } + } + }, + { + "type": "string", + "id": "TabStatus", + "enum": [{ "name": "loading" }, { "name": "complete" }] + } + ], + "functions": [ + { + "name": "query", + "type": "function", + "async": true, + "parameters": [ + { + "name": "queryInfo", + "$ref": "QueryInfo" + } + ], + "returns": { + "type": "array", + "items": { + "$ref": "Tab" + } + } + } + ] + } +] diff --git a/apps/extensions/package.json b/apps/extensions/package.json index ef75707..b49203b 100644 --- a/apps/extensions/package.json +++ b/apps/extensions/package.json @@ -1,8 +1,18 @@ { "name": "extensions", + "type": "module", "version": "1.0.0", + "scripts": { + "build": "node ./scripts/buildTypes.js", + "dev": "watch 'pnpm build' ./lib/schemas/" + }, "dependencies": { "@browser/link": "workspace:*" }, - "license": "ISC" + "license": "ISC", + "devDependencies": { + "prettier": "^3.0.3", + "typescript": "^5.4.5", + "watch": "^1.0.2" + } } diff --git a/apps/extensions/scripts/buildTypes.js b/apps/extensions/scripts/buildTypes.js new file mode 100644 index 0000000..b16101e --- /dev/null +++ b/apps/extensions/scripts/buildTypes.js @@ -0,0 +1,266 @@ +// @ts-check +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as prettier from 'prettier' +import ts from 'typescript' + +const { + ListFormat, + ScriptTarget, + createSourceFile, + NewLineKind, + createPrinter, + SyntaxKind, + factory, +} = ts + +/** + * @typedef {object} Namespace + * @property {string} namespace + * @property {string} description + * @property {Type[]} [types] + * @property {FunctionType[]} [functions] + */ + +/** + * @typedef {object} ObjectType + * @property {'object'} type + * @property {Record} [properties] + */ + +/** + * @typedef {object} FunctionType + * @property {'function'} type + * @property {string} name + * @property {FunctionParamType[]} [parameters] + * @property {PropertyType} [returns] + * @property {boolean} [async] + */ + +/** + * @typedef {object} UnionType + * @property {PropertyType[]} choices + */ + +/** + * @typedef {object} ArrayType + * @property {'array'} type + * @property {PropertyType} items + */ + +/** + * @typedef {object} StringType + * @property {'string'} type + * @property {{ name: string }[]} [enum] + */ + +/** + * @typedef {object} NumberType + * @property {'number' | 'integer'} type + */ + +/** + * @typedef {object} BooleanType + * @property {'boolean'} type + */ + +/** + * @typedef {{ '$ref': string }} RefType + */ + +/** + * @typedef {(ObjectType | FunctionType | UnionType | ArrayType | StringType | NumberType | BooleanType | RefType) & { optional?: boolean }} PropertyType + * @typedef {PropertyType & { id: string }} Type + * @typedef {PropertyType & { name: string }} FunctionParamType + */ + +const schemaFolder = path.join(process.cwd(), 'lib', 'schemas') +const outFolder = path.join(process.cwd(), 'lib', 'schemaTypes') + +const printer = createPrinter({ + newLine: NewLineKind.LineFeed, + omitTrailingSemicolon: true, +}) + +const QUESTION_TOKEN = factory.createToken(SyntaxKind.QuestionToken) + +for (const file of fs.readdirSync(schemaFolder)) { + const fileName = file.replace('.json', '') + let text = fs.readFileSync(path.join(schemaFolder, file), 'utf8') + const sourceFile = createSourceFile(file, text, ScriptTarget.Latest) + + const header = `// @not-mpl +// This file is generated from '../schemas/${file}'. This file inherits its license +// Please check that file's license +// +// DO NOT MODIFY MANUALLY\n\n` + + { + const startIndex = text.indexOf('[') + text = text.slice(startIndex) + } + + const namespaces = JSON.parse(text).map( + (/** @type {Namespace} */ namespace) => { + const block = factory.createModuleBlock([ + ...generateTypes(namespace.types || []), + factory.createTypeAliasDeclaration( + undefined, + 'ApiGetterReturn', + undefined, + generateApiGetter(namespace.functions || [], namespace.namespace), + ), + ]) + + return factory.createModuleDeclaration( + [factory.createToken(SyntaxKind.DeclareKeyword)], + factory.createIdentifier(`${fileName}__${namespace.namespace}`), + block, + ) + }, + ) + + fs.writeFileSync( + path.join(outFolder, `${fileName}.d.ts`), + await prettier.format( + header + printer.printList(ListFormat.None, namespaces, sourceFile), + { parser: 'typescript', semi: false, singleQuote: true }, + ), + ) +} + +/** + * @param {Type[]} types + * @returns {import('typescript').TypeAliasDeclaration[]} + */ +function generateTypes(types) { + return types.map((type) => + factory.createTypeAliasDeclaration( + undefined, + type.id, + undefined, + generateTypeNode(type), + ), + ) +} + +/** + * @param {FunctionType[]} functions + * @param {string} apiName + */ +function generateApiGetter(functions, apiName) { + return factory.createTypeLiteralNode([ + factory.createPropertySignature( + undefined, + factory.createIdentifier(apiName), + undefined, + factory.createTypeLiteralNode( + functions.map((fn) => + factory.createPropertySignature( + undefined, + factory.createIdentifier(fn.name), + undefined, + generateTypeNode(fn), + ), + ), + ), + ), + ]) +} + +/** + * @param {PropertyType} type + * @returns {type is RefType} + */ +function isRef(type) { + /** @type {RefType} */ + // @ts-ignore + const asRef = type + return typeof asRef.$ref !== 'undefined' +} + +/** + * @param {PropertyType} type + * @returns {type is UnionType} + */ +function isUnion(type) { + /** @type {UnionType} */ + // @ts-ignore + const asUnion = type + return typeof asUnion.choices !== 'undefined' +} + +/** + * @param {PropertyType} type + * @returns {import('typescript').TypeNode} + */ +function generateTypeNode(type) { + if (isRef(type)) { + return factory.createTypeReferenceNode(type.$ref) + } + + if (isUnion(type)) { + return factory.createUnionTypeNode(type.choices.map(generateTypeNode)) + } + + switch (type.type) { + case 'object': + return factory.createTypeLiteralNode( + Object.entries(type.properties || {}).map(([name, type]) => + factory.createPropertySignature( + undefined, + factory.createIdentifier(name), + typeof type.optional !== 'undefined' && type.optional + ? QUESTION_TOKEN + : undefined, + generateTypeNode(type), + ), + ), + ) + + case 'function': + return factory.createFunctionTypeNode( + undefined, + (type.parameters || []).map((param) => + factory.createParameterDeclaration( + undefined, + undefined, + factory.createIdentifier(param.name), + typeof type.optional !== 'undefined' && type.optional + ? QUESTION_TOKEN + : undefined, + generateTypeNode(param), + ), + ), + type.returns + ? typeof type.async !== 'undefined' && type.async + ? factory.createTypeReferenceNode( + factory.createIdentifier('Promise'), + [generateTypeNode(type.returns)], + ) + : generateTypeNode(type.returns) + : factory.createKeywordTypeNode(SyntaxKind.UnknownKeyword), + ) + + case 'array': + return factory.createArrayTypeNode(generateTypeNode(type.items)) + + case 'string': + if (type.enum) { + return factory.createUnionTypeNode( + type.enum.map((e) => + factory.createLiteralTypeNode(factory.createStringLiteral(e.name)), + ), + ) + } + + return factory.createKeywordTypeNode(SyntaxKind.StringKeyword) + + case 'number': + case 'integer': + return factory.createKeywordTypeNode(SyntaxKind.NumberKeyword) + + case 'boolean': + return factory.createKeywordTypeNode(SyntaxKind.BooleanKeyword) + } +} diff --git a/apps/modules/lib/BrowserWindowTracker.sys.mjs b/apps/modules/lib/BrowserWindowTracker.sys.mjs index c234b2f..709087f 100644 --- a/apps/modules/lib/BrowserWindowTracker.sys.mjs +++ b/apps/modules/lib/BrowserWindowTracker.sys.mjs @@ -1,7 +1,6 @@ /* 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/. */ - /// @ts-check /// import mitt from 'resource://app/modules/mitt.sys.mjs' @@ -21,13 +20,13 @@ export const WindowTracker = { * @param w The window to register */ registerWindow(w) { - w.windowApi.id = this.nextWindowId++ - this.registeredWindows.set(w.windowApi.id, w) + w.windowId = this.nextWindowId++ + this.registeredWindows.set(w.windowId, w) this.events.emit('windowCreated', w) }, removeWindow(w) { - this.registeredWindows.delete(w.windowApi.id) + this.registeredWindows.delete(w.windowId) this.events.emit('windowDestroyed', w) }, @@ -37,9 +36,9 @@ export const WindowTracker = { getWindowWithBrowser(browser) { for (const window of this.registeredWindows.values()) { - const tab = window.windowApi.tabs.tabs.find( - (t) => t.getTabId() === browser.browserId, - ) + const tab = window + .windowTabs() + .find((t) => t.view.browserId === browser.browserId) if (tab) return { window, tab } } return null diff --git a/libs/link/types/globals/WindowApi.d.ts b/libs/link/types/globals/WindowApi.d.ts index 5772acb..017a206 100644 --- a/libs/link/types/globals/WindowApi.d.ts +++ b/libs/link/types/globals/WindowApi.d.ts @@ -3,5 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ declare interface Window { + windowId: number eventBus: import('@browser/event-bus').EventBus + windowTabs: import('@amadeus-it-group/tansu').WritableSignal< + import('@browser/tabs').WindowTabs + > + activeTab: import('@amadeus-it-group/tansu').ReadableSignal< + import('@browser/tabs').WindowTab | undefined + > } diff --git a/libs/link/types/windowApi/WindowTabs.d.ts b/libs/link/types/windowApi/WindowTabs.d.ts index ba08371..7f941ac 100644 --- a/libs/link/types/windowApi/WindowTabs.d.ts +++ b/libs/link/types/windowApi/WindowTabs.d.ts @@ -5,6 +5,6 @@ /// declare module '@browser/tabs' { - export type WindowTab = { kind: 'tab'; view: WebsiteView } - export type WindowTabs = WindowTabs[] + export type WindowTab = { kind: 'website'; view: WebsiteView } + export type WindowTabs = WindowTab[] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a07c39..dc0e415 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,16 @@ importers: '@browser/link': specifier: workspace:* version: link:../../libs/link + devDependencies: + prettier: + specifier: ^3.0.3 + version: 3.0.3 + typescript: + specifier: ^5.4.5 + version: 5.4.5 + watch: + specifier: ^1.0.2 + version: 1.0.2 apps/misc: {} @@ -1831,6 +1841,12 @@ packages: engines: {node: '>=0.8.x'} dev: true + /exec-sh@0.2.2: + resolution: {integrity: sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==} + dependencies: + merge: 1.2.1 + dev: true + /execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2667,6 +2683,10 @@ packages: engines: {node: '>= 8'} dev: true + /merge@1.2.1: + resolution: {integrity: sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==} + dev: true + /methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4051,6 +4071,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /undici-types@5.25.3: resolution: {integrity: sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==} dev: true @@ -4104,6 +4130,15 @@ packages: engines: {node: '>= 0.8'} dev: true + /watch@1.0.2: + resolution: {integrity: sha512-1u+Z5n9Jc1E2c7qDO8SinPoZuHj7FgbgU1olSFoyaklduDvvtX7GMMtlE6OC9FTXq4KvNAOfj6Zu4vI1e9bAKA==} + engines: {node: '>=0.1.95'} + hasBin: true + dependencies: + exec-sh: 0.2.2 + minimist: 1.2.8 + dev: true + /watchpack@2.4.0: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'}