From 9ad80ccebc340904890fb255620761e39c53bf0f Mon Sep 17 00:00:00 2001 From: Guillaume Chau Date: Tue, 6 Feb 2024 16:25:32 +0100 Subject: [PATCH] feat: separate firefox shell with manifest v2 --- extension-zips.js | 2 +- package.json | 2 +- packages/shell-firefox/.gitignore | 3 + packages/shell-firefox/copy.sh | 7 + packages/shell-firefox/manifest.json | 58 ++++++++ packages/shell-firefox/package.json | 18 +++ packages/shell-firefox/src/backend.js | 55 ++++++++ packages/shell-firefox/src/background.js | 125 ++++++++++++++++++ packages/shell-firefox/src/detector.js | 100 ++++++++++++++ .../shell-firefox/src/devtools-background.js | 48 +++++++ packages/shell-firefox/src/devtools.js | 115 ++++++++++++++++ packages/shell-firefox/src/hook.js | 19 +++ packages/shell-firefox/src/proxy.js | 37 ++++++ packages/shell-firefox/webpack.config.js | 21 +++ 14 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 packages/shell-firefox/.gitignore create mode 100755 packages/shell-firefox/copy.sh create mode 100644 packages/shell-firefox/manifest.json create mode 100644 packages/shell-firefox/package.json create mode 100644 packages/shell-firefox/src/backend.js create mode 100644 packages/shell-firefox/src/background.js create mode 100644 packages/shell-firefox/src/detector.js create mode 100644 packages/shell-firefox/src/devtools-background.js create mode 100644 packages/shell-firefox/src/devtools.js create mode 100644 packages/shell-firefox/src/hook.js create mode 100644 packages/shell-firefox/src/proxy.js create mode 100644 packages/shell-firefox/webpack.config.js diff --git a/extension-zips.js b/extension-zips.js index 8a070b7f0..b678ce9ce 100644 --- a/extension-zips.js +++ b/extension-zips.js @@ -31,7 +31,7 @@ function bytesToSize(bytes) { (async () => { await writeZip('devtools-chrome.zip', 'shell-chrome') - await writeZip('devtools-firefox.zip', 'shell-chrome') + await writeZip('devtools-firefox.zip', 'shell-firefox') async function writeZip(fileName, packageDir) { // create a file to stream archive data to. diff --git a/package.json b/package.json index 18cc3b6a8..16e9f9ab6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build": "lerna run build", "build:watch": "lerna run build --scope @vue-devtools/app-backend* --scope @vue-devtools/shared-* --scope @vue/devtools-api && lerna run build:watch --stream --no-sort --concurrency 99", "lint": "eslint .", - "run:firefox": "web-ext run -s packages/shell-chrome -a dist -i src", + "run:firefox": "web-ext run -s packages/shell-firefox -a dist -i src -u http://localhost:8090/target.html", "zip": "node ./extension-zips.js", "sign:firefox": "node ./sign-firefox.js", "release": "npm run test && node release.js && npm run build && npm run zip && npm run pub", diff --git a/packages/shell-firefox/.gitignore b/packages/shell-firefox/.gitignore new file mode 100644 index 000000000..b6560633a --- /dev/null +++ b/packages/shell-firefox/.gitignore @@ -0,0 +1,3 @@ +icons/ +popups/ +*.html diff --git a/packages/shell-firefox/copy.sh b/packages/shell-firefox/copy.sh new file mode 100755 index 000000000..15cba66b8 --- /dev/null +++ b/packages/shell-firefox/copy.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +rm -rf icons + +cp -r ../shell-chrome/icons . +cp -r ../shell-chrome/popups . +cp ../shell-chrome/*.html . diff --git a/packages/shell-firefox/manifest.json b/packages/shell-firefox/manifest.json new file mode 100644 index 000000000..00306a8b6 --- /dev/null +++ b/packages/shell-firefox/manifest.json @@ -0,0 +1,58 @@ +{ + "name": "Vue.js devtools", + "version": "6.5.1", + "version_name": "6.5.1", + "description": "Browser DevTools extension for debugging Vue.js applications.", + "manifest_version": 2, + "icons": { + "16": "icons/16.png", + "48": "icons/48.png", + "128": "icons/128.png" + }, + "browser_action": { + "default_icon": { + "16": "icons/16-gray.png", + "48": "icons/48-gray.png", + "128": "icons/128-gray.png" + }, + "default_title": "Vue Devtools", + "default_popup": "popups/not-found.html" + }, + "web_accessible_resources": [ + "devtools.html", + "devtools-background.html", + "build/backend.js" + ], + "devtools_page": "devtools-background.html", + "background": { + "scripts": [ + "build/background.js" + ], + "persistent": true + }, + "permissions": [ + "", + "storage" + ], + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "build/hook.js" + ], + "run_at": "document_start" + }, + { + "matches": [ + "" + ], + "js": [ + "build/detector.js" + ], + "run_at": "document_idle" + } + ], + "content_security_policy": "script-src 'self'; object-src 'self'" +} diff --git a/packages/shell-firefox/package.json b/packages/shell-firefox/package.json new file mode 100644 index 000000000..d26478622 --- /dev/null +++ b/packages/shell-firefox/package.json @@ -0,0 +1,18 @@ +{ + "name": "@vue-devtools/shell-firefox", + "version": "0.0.0", + "scripts": { + "build": "rimraf ./build && ./copy.sh && cross-env NODE_ENV=production webpack --progress" + }, + "dependencies": { + "@vue-devtools/app-backend-core": "^0.0.0", + "@vue-devtools/app-frontend": "^0.0.0", + "@vue-devtools/shared-utils": "^0.0.0" + }, + "devDependencies": { + "@vue-devtools/build-tools": "^0.0.0", + "rimraf": "^3.0.2", + "webpack": "^5.35.1", + "webpack-cli": "^4.6.0" + } +} diff --git a/packages/shell-firefox/src/backend.js b/packages/shell-firefox/src/backend.js new file mode 100644 index 000000000..d0456e1dd --- /dev/null +++ b/packages/shell-firefox/src/backend.js @@ -0,0 +1,55 @@ +// this is injected to the app page when the panel is activated. + +import { initBackend } from '@back' +import { Bridge } from '@vue-devtools/shared-utils' + +window.addEventListener('message', handshake) + +function sendListening() { + window.postMessage({ + source: 'vue-devtools-backend-injection', + payload: 'listening', + }, '*') +} +sendListening() + +function handshake(e) { + if (e.data.source === 'vue-devtools-proxy' && e.data.payload === 'init') { + window.removeEventListener('message', handshake) + + let listeners = [] + const bridge = new Bridge({ + listen(fn) { + const listener = (evt) => { + if (evt.data.source === 'vue-devtools-proxy' && evt.data.payload) { + fn(evt.data.payload) + } + } + window.addEventListener('message', listener) + listeners.push(listener) + }, + send(data) { + // if (process.env.NODE_ENV !== 'production') { + // console.log('[chrome] backend -> devtools', data) + // } + window.postMessage({ + source: 'vue-devtools-backend', + payload: data, + }, '*') + }, + }) + + bridge.on('shutdown', () => { + listeners.forEach((l) => { + window.removeEventListener('message', l) + }) + listeners = [] + window.addEventListener('message', handshake) + }) + + initBackend(bridge) + } + else { + sendListening() + } +} diff --git a/packages/shell-firefox/src/background.js b/packages/shell-firefox/src/background.js new file mode 100644 index 000000000..d4b7bab2a --- /dev/null +++ b/packages/shell-firefox/src/background.js @@ -0,0 +1,125 @@ +// the background script runs all the time and serves as a central message +// hub for each vue devtools (panel + proxy + backend) instance. + +const ports = {} + +chrome.runtime.onConnect.addListener((port) => { + let tab + let name + if (isNumeric(port.name)) { + tab = port.name + name = 'devtools' + installProxy(+port.name) + } + else { + tab = port.sender.tab.id + name = 'backend' + } + + if (!ports[tab]) { + ports[tab] = { + devtools: null, + backend: null, + } + } + ports[tab][name] = port + + if (ports[tab].devtools && ports[tab].backend) { + doublePipe(tab, ports[tab].devtools, ports[tab].backend) + } +}) + +function isNumeric(str) { + return `${+str}` === str +} + +function installProxy(tabId) { + chrome.tabs.executeScript(tabId, { + file: '/build/proxy.js', + }, (res) => { + if (!res) { + ports[tabId].devtools.postMessage('proxy-fail') + } + else { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.log(`injected proxy to tab ${tabId}`) + } + } + }) +} + +function doublePipe(id, one, two) { + one.onMessage.addListener(lOne) + function lOne(message) { + if (message.event === 'log') { + // eslint-disable-next-line no-console + return console.log(`tab ${id}`, message.payload) + } + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.log('%cdevtools -> backend', 'color:#888;', message) + } + two.postMessage(message) + } + two.onMessage.addListener(lTwo) + function lTwo(message) { + if (message.event === 'log') { + // eslint-disable-next-line no-console + return console.log(`tab ${id}`, message.payload) + } + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.log('%cbackend -> devtools', 'color:#888;', message) + } + one.postMessage(message) + } + function shutdown() { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.log(`tab ${id} disconnected.`) + } + one.onMessage.removeListener(lOne) + two.onMessage.removeListener(lTwo) + one.disconnect() + two.disconnect() + ports[id] = null + } + one.onDisconnect.addListener(shutdown) + two.onDisconnect.addListener(shutdown) + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.log(`tab ${id} connected.`) + } +} + +chrome.runtime.onMessage.addListener((req, sender) => { + if (sender.tab && req.vueDetected) { + const suffix = req.nuxtDetected ? '.nuxt' : '' + + chrome.browserAction.setIcon({ + tabId: sender.tab.id, + path: { + 16: `icons/16${suffix}.png`, + 48: `icons/48${suffix}.png`, + 128: `icons/128${suffix}.png`, + }, + }) + chrome.browserAction.setPopup({ + tabId: sender.tab.id, + popup: req.devtoolsEnabled ? `popups/enabled${suffix}.html` : `popups/disabled${suffix}.html`, + }) + } + + if (req.action === 'vue-take-screenshot' && sender.envType === 'devtools_child') { + browser.tabs.captureVisibleTab({ + format: 'png', + }).then((dataUrl) => { + browser.runtime.sendMessage({ + action: 'vue-screenshot-result', + id: req.id, + dataUrl, + }) + }) + } +}) diff --git a/packages/shell-firefox/src/detector.js b/packages/shell-firefox/src/detector.js new file mode 100644 index 000000000..f604854fe --- /dev/null +++ b/packages/shell-firefox/src/detector.js @@ -0,0 +1,100 @@ +import { installToast } from '@back/toast' +import { isFirefox } from '@vue-devtools/shared-utils' + +window.addEventListener('message', (e) => { + if (e.source === window && e.data.vueDetected) { + chrome.runtime.sendMessage(e.data) + } +}) + +function detect(win) { + let delay = 1000 + let detectRemainingTries = 10 + + function runDetect() { + // Method 1: Check Nuxt + const nuxtDetected = !!(window.__NUXT__ || window.$nuxt) + + if (nuxtDetected) { + let Vue + + if (window.$nuxt) { + Vue = window.$nuxt.$root && window.$nuxt.$root.constructor + } + + win.postMessage({ + devtoolsEnabled: (/* Vue 2 */ Vue && Vue.config.devtools) + || (/* Vue 3.2.14+ */ window.__VUE_DEVTOOLS_GLOBAL_HOOK__ && window.__VUE_DEVTOOLS_GLOBAL_HOOK__.enabled), + vueDetected: true, + nuxtDetected: true, + }, '*') + + return + } + + // Method 2: Check Vue 3 + const vueDetected = !!(window.__VUE__) + if (vueDetected) { + win.postMessage({ + devtoolsEnabled: /* Vue 3.2.14+ */ window.__VUE_DEVTOOLS_GLOBAL_HOOK__ && window.__VUE_DEVTOOLS_GLOBAL_HOOK__.enabled, + vueDetected: true, + }, '*') + + return + } + + // Method 3: Scan all elements inside document + const all = document.querySelectorAll('*') + let el + for (let i = 0; i < all.length; i++) { + if (all[i].__vue__) { + el = all[i] + break + } + } + if (el) { + let Vue = Object.getPrototypeOf(el.__vue__).constructor + while (Vue.super) { + Vue = Vue.super + } + win.postMessage({ + devtoolsEnabled: Vue.config.devtools, + vueDetected: true, + }, '*') + return + } + + if (detectRemainingTries > 0) { + detectRemainingTries-- + setTimeout(() => { + runDetect() + }, delay) + delay *= 5 + } + } + + setTimeout(() => { + runDetect() + }, 100) +} + +// inject the hook +if (document instanceof HTMLDocument) { + installScript(detect) + installScript(installToast) +} + +function installScript(fn) { + const source = `;(${fn.toString()})(window)` + + if (isFirefox) { + // eslint-disable-next-line no-eval + window.eval(source) // in Firefox, this evaluates on the content window + } + else { + const script = document.createElement('script') + script.textContent = source + document.documentElement.appendChild(script) + script.parentNode.removeChild(script) + } +} diff --git a/packages/shell-firefox/src/devtools-background.js b/packages/shell-firefox/src/devtools-background.js new file mode 100644 index 000000000..69aeb183b --- /dev/null +++ b/packages/shell-firefox/src/devtools-background.js @@ -0,0 +1,48 @@ +// This is the devtools script, which is called when the user opens the +// Chrome devtool on a page. We check to see if we global hook has detected +// Vue presence on the page. If yes, create the Vue panel; otherwise poll +// for 10 seconds. + +let created = false +let checkCount = 0 + +chrome.devtools.network.onNavigated.addListener(createPanelIfHasVue) +const checkVueInterval = setInterval(createPanelIfHasVue, 1000) +createPanelIfHasVue() + +function createPanelIfHasVue() { + if (created || checkCount++ > 10) { + clearInterval(checkVueInterval) + return + } + chrome.devtools.inspectedWindow.eval( + '!!(window.__VUE_DEVTOOLS_GLOBAL_HOOK__ && (window.__VUE_DEVTOOLS_GLOBAL_HOOK__.Vue || window.__VUE_DEVTOOLS_GLOBAL_HOOK__.apps.length))', + (hasVue) => { + if (!hasVue || created) { + return + } + clearInterval(checkVueInterval) + created = true + chrome.devtools.panels.create( + 'Vue', + 'icons/128.png', + 'devtools.html', + (panel) => { + // panel loaded + panel.onShown.addListener(onPanelShown) + panel.onHidden.addListener(onPanelHidden) + }, + ) + }, + ) +} + +// Manage panel visibility + +function onPanelShown() { + chrome.runtime.sendMessage('vue-panel-shown') +} + +function onPanelHidden() { + chrome.runtime.sendMessage('vue-panel-hidden') +} diff --git a/packages/shell-firefox/src/devtools.js b/packages/shell-firefox/src/devtools.js new file mode 100644 index 000000000..21e2b2abc --- /dev/null +++ b/packages/shell-firefox/src/devtools.js @@ -0,0 +1,115 @@ +// this script is called when the VueDevtools panel is activated. + +import { initDevTools, setAppConnected } from '@front' +import { Bridge, BridgeEvents } from '@vue-devtools/shared-utils' + +let disconnected = false +let connectCount = 0 +let retryConnectTimer + +initDevTools({ + + /** + * Inject backend, connect to background, and send back the bridge. + * + * @param {Function} cb + */ + + connect(cb) { + // 1. inject backend code into page + injectScript(chrome.runtime.getURL('build/backend.js'), () => { + // 2. connect to background to setup proxy + let port + + const onMessageHandlers = [] + + function connect() { + try { + clearTimeout(retryConnectTimer) + connectCount++ + port = chrome.runtime.connect({ + name: `${chrome.devtools.inspectedWindow.tabId}`, + }) + disconnected = false + port.onDisconnect.addListener(() => { + disconnected = true + setAppConnected(false) + + // Retry + retryConnectTimer = setTimeout(connect, 1000) + }) + + if (connectCount > 1) { + onMessageHandlers.forEach(fn => port.onMessage.addListener(fn)) + } + } + catch (e) { + console.error(e) + disconnected = true + setAppConnected(false) + + // Retry + retryConnectTimer = setTimeout(connect, 5000) + } + } + connect() + + const bridge = new Bridge({ + listen(fn) { + port.onMessage.addListener(fn) + onMessageHandlers.push(fn) + }, + send(data) { + if (!disconnected) { + // if (process.env.NODE_ENV !== 'production') { + // console.log('[chrome] devtools -> backend', data) + // } + port.postMessage(data) + } + }, + }) + + bridge.on(BridgeEvents.TO_FRONT_RECONNECTED, () => { + setAppConnected(true) + }) + + // 3. send a proxy API to the panel + cb(bridge) + }) + }, + + /** + * Register a function to reload the devtools app. + * + * @param {Function} reloadFn + */ + + onReload(reloadFn) { + chrome.devtools.network.onNavigated.addListener(reloadFn) + }, +}) + +/** + * Inject a globally evaluated script, in the same context with the actual + * user app. + * + * @param {string} scriptName + * @param {Function} cb + */ + +function injectScript(scriptName, cb) { + const src = ` + (function() { + var script = document.constructor.prototype.createElement.call(document, 'script'); + script.src = "${scriptName}"; + document.documentElement.appendChild(script); + script.parentNode.removeChild(script); + })() + ` + chrome.devtools.inspectedWindow.eval(src, (res, err) => { + if (err) { + console.error(err) + } + cb() + }) +} diff --git a/packages/shell-firefox/src/hook.js b/packages/shell-firefox/src/hook.js new file mode 100644 index 000000000..9d1e4d030 --- /dev/null +++ b/packages/shell-firefox/src/hook.js @@ -0,0 +1,19 @@ +// This script is injected into every page. +import { installHook } from '@back/hook' +import { isFirefox } from '@vue-devtools/shared-utils' + +// inject the hook +if (document instanceof HTMLDocument) { + const source = `;(${installHook.toString()})(window)` + + if (isFirefox) { + // eslint-disable-next-line no-eval + window.eval(source) // in Firefox, this evaluates on the content window + } + else { + const script = document.createElement('script') + script.textContent = source + document.documentElement.appendChild(script) + script.parentNode.removeChild(script) + } +} diff --git a/packages/shell-firefox/src/proxy.js b/packages/shell-firefox/src/proxy.js new file mode 100644 index 000000000..cb7e0e88a --- /dev/null +++ b/packages/shell-firefox/src/proxy.js @@ -0,0 +1,37 @@ +// This is a content-script that is injected only when the devtools are +// activated. Because it is not injected using eval, it has full privilege +// to the chrome runtime API. It serves as a proxy between the injected +// backend and the Vue devtools panel. + +const port = chrome.runtime.connect({ + name: 'content-script', +}) + +port.onMessage.addListener(sendMessageToBackend) +window.addEventListener('message', sendMessageToDevtools) +port.onDisconnect.addListener(handleDisconnect) + +sendMessageToBackend('init') + +function sendMessageToBackend(payload) { + window.postMessage({ + source: 'vue-devtools-proxy', + payload, + }, '*') +} + +function sendMessageToDevtools(e) { + if (e.data && e.data.source === 'vue-devtools-backend') { + port.postMessage(e.data.payload) + } + else if (e.data && e.data.source === 'vue-devtools-backend-injection') { + if (e.data.payload === 'listening') { + sendMessageToBackend('init') + } + } +} + +function handleDisconnect() { + window.removeEventListener('message', sendMessageToDevtools) + sendMessageToBackend('shutdown') +} diff --git a/packages/shell-firefox/webpack.config.js b/packages/shell-firefox/webpack.config.js new file mode 100644 index 000000000..eb111ac97 --- /dev/null +++ b/packages/shell-firefox/webpack.config.js @@ -0,0 +1,21 @@ +const path = require('node:path') +const { createConfig } = require('@vue-devtools/build-tools') + +module.exports = createConfig({ + entry: { + 'hook': './src/hook.js', + 'devtools': './src/devtools.js', + 'background': './src/background.js', + 'devtools-background': './src/devtools-background.js', + 'backend': './src/backend.js', + 'proxy': './src/proxy.js', + 'detector': './src/detector.js', + }, + output: { + path: path.join(__dirname, 'build'), + filename: '[name].js', + }, + devtool: process.env.NODE_ENV !== 'production' + ? 'inline-source-map' + : false, +})