從零實現的Chrome擴充套件

2023-07-16 18:00:41

從零實現的Chrome擴充套件

Chrome擴充套件是一種可以在Chrome瀏覽器中新增新功能和修改瀏覽器行為的軟體程式,例如我們常用的TamperMonkeyProxy SwitchyOmegaAdGuard等等,這些拓展都是可以通過WebExtensions API來修改、增強瀏覽器的能力,用來提供一些瀏覽器本體沒有的功能,從而實現一些有趣的事情。

描述

實際上FireFox是才第一個引入瀏覽器擴充套件/附加元件的主流瀏覽器,其在2004年釋出了第一個版本的擴充套件系統,允許開發人員為FireFox編寫自定義功能和修改瀏覽器行為的軟體程式。而Chrome瀏覽器則在2010年支援了擴充套件系統,同樣其也允許開發人員為Chrome編寫自定義功能和修改瀏覽器行為的軟體程式。

雖然FireFox是第一個引入瀏覽器擴充套件的瀏覽器,但是Chrome的擴充套件系統得到了廣泛的認可和使用,也已經成為了現代瀏覽器中最流行的擴充套件系統之一。目前用於構建FireFox擴充套件的技術在很大程度上與被基於Chromium核心的瀏覽器所支援的擴充套件API所相容,例如ChromeEdgeOpera等。在大多數情況下,為基於Chromium核心瀏覽器而寫的外掛只需要少許修改就可以在FireFox中執行。那麼本文就以Chrome擴充套件為例,聊聊如何從零實現一個Chrome擴充套件,本文涉及的相關的程式碼都在https://github.com/WindrunnerMax/webpack-simple-environmentrspack--chrome-extension分支中。

Manifest

我們可以先來想一下瀏覽器拓展到底是什麼,瀏覽器本身是支援了非常完備的Web能力的,也就是同時擁有渲染引擎和Js解析引擎,那麼瀏覽器拓展本身就不需要再去實現一套新的可執行能力了,完全複用Web引擎即可。那麼問題來了,單純憑藉Js是沒有辦法做到一些能力的,比如攔截請求、修改請求頭等等,這些Native的能力單憑Js肯定是做不到的,起碼也得上C++直接執行在瀏覽器程式碼中才可以,實際上解決這個問題也很簡單,直接通過類似於Js Bridge的方式暴露出一些介面就可以了,這樣還可以更方便地做到許可權控制,一定程度避免瀏覽器擴充套件執行一些惡意的行為導致使用者受損。

那麼由此看來,瀏覽器擴充套件其實就是一個Web應用,只不過其執行在瀏覽器的上下文中,並且可以呼叫很多瀏覽器提供的特殊API來做到一些額外的功能。那麼既然是一個Web應用,應該如何讓瀏覽器知道這是一個拓展而非普通的Web應用,那麼我們就需要標記和組態檔,這個檔案就是manifest.json,通過這個檔案我們可以來描述擴充套件的基本資訊,例如擴充套件的名稱、版本、描述、圖示、許可權等等。

manifest.json中有一個欄位為manifest_version,這個欄位標誌著當前Chrome的外掛版本,現在我們在瀏覽器安裝的大部分都是v2版本的外掛,v1版本的外掛早已廢棄,而v3版本的外掛因為存在大量的Breaking Changes,以及諸多原本v2支援的APIv3被限制或移除,導致諸多外掛無法無失真過渡到v3版本。但是自2022.01.17起,Chrome網上應用店已停止接受新的Manifest V2擴充套件,所以對於要新開發的拓展來說,我們還是需要使用v3版本的受限能力,而且因為谷歌之前宣佈v2版本將在2023初完全廢棄,但是又因為不能做到完全相容v2地能力,現在又延遲到了2024年初。但是無論如何,谷歌都準備逐步廢棄v2而使用v3,那麼我們在這裡也是基於v3來實現Chrome擴充套件。

那麼構建一個擴充套件應用,你就需要在專案的根目錄建立一個manifest.json檔案,一個簡單的manifest.json的結構如下所示,詳細的設定檔案可以參考https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/manifest.json:

{
    "manifest_version": 3,              // 外掛版本
    "name": "Extension",                // 外掛名稱
    "version": "1.0.0",                 // 外掛版本號
    "description": "Chrome Extension",  // 外掛描述資訊
    "icons": {                          // 外掛在不同位置顯示的圖示 
      "16": "icon16.png",               // `16x16`畫素的圖示
      "32": "icon32.png",               // `32x32`畫素的圖示
      "48": "icon48.png",               // `48x48`畫素的圖示
      "128": "icon128.png"              // `128x128`畫素的圖示
    },
    "action": {                         // 單擊瀏覽器工具列按鈕時的行為
      "default_popup": "popup.html",    // 單擊按鈕時開啟的預設彈出視窗
      "default_icon": {                 // 彈出視窗按鈕圖示 // 可以直接設定為`string`
        "16": "icon16.png",             // `16x16`畫素的圖示
        "32": "icon32.png",             // `32x32`畫素的圖示
        "48": "icon48.png",             // `48x48`畫素的圖示
        "128": "icon128.png"            // `128x128`畫素的圖示
      }
    },
    "background": {                     // 定義後臺頁面的檔案和工作方式
      "service_worker": "background.js" // 註冊`Service Worker`檔案
    },
    "permissions": [                    // 定義外掛需要存取的`API`許可權
      "storage",                        // 儲存存取許可權
      "activeTab",                      // 當前索引標籤存取許可權
      "scripting"                       // 指令碼存取許可權
    ]
}

Bundle

既然在上邊我們確定了Chrome擴充套件實際上還是Web技術,那麼我們就完全可以利用Web的相關生態來完成外掛的開發,當前實際上是有很多比較成熟的擴充套件框架的,其中也集合了相當一部分的能力,只不過我們在這裡希望從零開始跑通一整套流程,那麼我們就自行藉助打包工具來完成產物的構建。在這裡選用的是RspackRspack是一個於Rust的高效能構建引擎,具備與Webpack生態系統的互操作性,可以被Webpack專案低成本整合,並提供更好的構建效能。選用Rspack的主要原因是其編譯速度會快一些,特別是在複雜專案中Webpack特別是CRA建立的專案打包速度簡直慘不忍睹,我這邊有個專案改造前後的dev速度對比大概是1min35s : 24s,速度提升還是比較明顯的,當然在我們這個簡單的Chrome擴充套件場景下實際上是區別不大的,相關的所有程式碼都在https://github.com/WindrunnerMax/webpack-simple-environment/tree/rspack--chrome-extension下。

那麼現在我們先從manifest.json開始,目標是在右上角實現一個彈窗,當前很多擴充套件程式也都是基於右上角的小彈窗互動來控制相關能力的。首先我們需要在manifest.json設定actionaction的設定就是控制單擊瀏覽器工具列按鈕時的行為,因為實際上是web生態,所以我們應該為其設定一個html檔案以及icon

"action": {
  "default_popup": "popup.html",
  "default_icon": "./static/favicon.png"
}

已經有了組態檔,現在我們就需要將HTML生成出來,在這裡就需要藉助rspack來實現了,實際上跟webpack差不多,整體思路就是先設定一個HTML模版,然後從入口開始打包Js,最後將Js注入到HTML當中就可以了,在這裡我們直接設定一個多入口的輸出能力,通常一個擴充套件外掛不會是隻有一個JsHTML檔案的,所以我們需要設定一個多入口的能力。在這裡我們還打包了兩個檔案,一個是popup.html作為入口,另一個是worker.js作為後臺執行的Service Worker獨立執行緒。

entry: {
    worker: "./src/worker/index.ts",
    popup: "./src/popup/index.tsx",
  },
plugins: [
  new HtmlPlugin({
    filename: "popup.html",
    template: "./public/popup.html",
    inject: false,
  }),
],

實際上我們的dev模式生成的程式碼都是在記憶體當中的,而谷歌擴充套件是基於磁碟的檔案的,所以我們需要將生成的相關檔案寫入到磁碟當中。在這裡這個設定是比較簡單的,直接在devServer中設定一下就好。

devServer: {
  devMiddleware: {
    writeToDisk: true,
  },
},

但是實際上,如果我們是基於磁碟的檔案來完成的擴充套件開發,那麼devServer就顯得沒有那麼必要了,我們直接可以通過watch來完成,也就是build --watch,這樣就可以實現磁碟檔案的實時更新了。我們使用devServer是更希望能夠藉助於HMR的能力,但是這個能力在Chrome擴充套件v3上的限制下目前表現的並不好,所以在這裡這個能力先暫時放下,畢竟實際上v3當前還是在收集社群意見來更新的。不過我們可以有一些簡單的方法,來緩解這個問題,我們在開發擴充套件的最大的一個問題是需要在更新的時候去手動點選重新整理來載入外掛,那麼針對於這個問題,我們可以藉助chrome.runtime.reload()來實現一個簡單的外掛重新載入能力,讓我們在更新程式碼之後不必要去手動重新整理。

在這裡主要提供一個思路,我們可以編寫一個rspack外掛,利用ws.Server啟動一個WebSocket伺服器,之後在worker.js也就是我們將要啟動的Service Worker來連結WebSocket伺服器,可以通過new WebSocket來連結並且在監聽訊息,當收到來自伺服器端的reload訊息之後,我們就可以執行chrome.runtime.reload()來實現外掛的重新載入了,那麼在開啟的WebSocket伺服器中需要在每次編譯完成之後例如afterDone這個hook向用戶端傳送reload訊息,這樣就可以實現一個簡單的外掛重新載入能力了。但是實際上這引入了另一個問題,在v3版本的Service Worker不會常駐,所以這個WebSocket連結也會隨著Service Worker的銷燬而銷燬,是比較坑的一點,同樣也是因為這一點大量的Chrome擴充套件無法從v2平滑過渡到v3,所以這個能力後續還有可能會被改善。

接下來,開發外掛我們肯定是需要使用CSS以及元件庫的,在這裡我們引入了@arco-design/web-react,並且設定了scssless的相關樣式處理。首先是define,這個能力可以幫助我們藉助TreeShaking來在打包的時候將dev模式的程式碼刪除,當然不光是dev模式,我們可以藉助這個能力以及設定來區分任意場景的程式碼打包;接下來pluginImport這個處理參照路徑的設定,實際上就相當於babel-plugin-import,用來實現按需載入;最後是CSS以及前處理器相關的設定,用來處理scss module以及元件庫的less檔案。

builtins: {
  define: {
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
  },
  pluginImport: [
    {
      libraryName: "@arco-design/web-react",
      style: true,
    },
  ],
},
module: {
  rules: [
    {
      test: /\.module.scss$/,
      use: [{ loader: "sass-loader" }],
      type: "css/module",
    },
    {
      test: /\.less$/,
      use: [
        {
          loader: "less-loader",
          options: {
            lessOptions: {
              javascriptEnabled: true,
              importLoaders: true,
              localIdentName: "[name]__[hash:base64:5]",
            },
          },
        },
      ],
      type: "css",
    },
  ],
},

最後,我們需要處理一下資原始檔,因為我們在程式碼中實際上是不會參照manifest.json以及我們設定的資原始檔的,所以在這裡我們需要通過一個rspack外掛來完成相關的功能,因為rspack的相關介面是按照webpack5來做相容的,所以在編寫外掛的時候實際跟編寫webpack外掛差不多。在這裡主要是實現兩個功能,一個是監聽manifest.json組態檔以及資源目錄public/static的變化,另一個是將manifest.json檔案以及資原始檔拷貝到打包目錄中。

const thread = require("child_process");
const path = require("path");

const exec = command => {
  return new Promise((resolve, reject) => {
    thread.exec(command, (err, stdout) => {
      if (err) reject(err);
      resolve(stdout);
    });
  });
};

class FilesPlugin {
  apply(compiler) {
    compiler.hooks.make.tap("FilePlugin", compilation => {
      const manifest = path.join(__dirname, "../src/manifest.json");
      const resources = path.join(__dirname, "../public/static");
      !compilation.fileDependencies.has(manifest) && compilation.fileDependencies.add(manifest);
      !compilation.contextDependencies.has(resources) &&
        compilation.contextDependencies.add(resources);
    });

    compiler.hooks.done.tapPromise("FilePlugin", () => {
      return Promise.all([
        exec("cp ./src/manifest.json ./dist/"),
        exec("cp -r ./public/static ./dist/static"),
      ]);
    });
  }
}

module.exports = FilesPlugin;

Service Worker

我們在Chrome瀏覽器中開啟chrome://extensions/,可以看到我們瀏覽器中已經裝載的外掛,可以看到很多外掛都會有一個類似於background.html的檔案,這是v2版本的擴充套件獨有的能力,是一個獨立的執行緒,可以用來處理一些後臺任務,比如網路請求、訊息推播、定時任務等等。那麼現在擴充套件已經發展到了v3版本,在v3版本中一個非常大的區別就是Service Workers不能保證常駐,需要主動喚醒,所以在chrome://extensions/中如果是v3版本的外掛,我們會看到一個Service Worker的標識,那麼在一段時間不動之後,這個Service Worker就會標記上Idle,在這個時候其就處於休眠狀態了,而不再常駐於記憶體。

對於這個Service WorkerChrome會每5分鐘清理所有擴充套件Service Workers,也就是說擴充套件的Worker最多存活5分鐘,然後等待使用者下次啟用,但是啟用方式沒有明確的表述,那假如我們的拓展要做的工作沒做完,要接上次的工作怎麼辦,Google答覆是用chrome.storage類似儲存來暫存工作任務,等待下次啟用。為了對抗隨機的清理事件,出現了很多骯髒的手段,甚至有的為了保持持續後臺,做兩個擴充套件然後相互喚醒。除了這方面還有一些類似於webRequest -> declarativeNetRequestsetTimeout/setIntervalDOM解析、window/document等等的限制,會影響大部分的外掛能力。

當然如果我們想在使用者主觀執行時實現相關能力的常駐,就可以直接chrome.tabs.create在瀏覽器Tab中開啟擴充套件程式的HTML頁面,這樣就可以作為前臺執行,同樣這個擴充套件程式的程式碼就會一直執行著。

Chrome官方部落格釋出了一個宣告More details on the transition to Manifest V3,將Manifest V2的廢除時間從20231月向後推遲了一年:

Starting in June in Chrome 115, Chrome may run experiments to turn off support for Manifest V2 extensions in all channels, including stable channel.

In January 2024, following the expiration of the Manifest V2 enterprise policy, the Chrome Web Store will remove all remaining Manifest V2 items from the store.

再來看看兩年前對廢除Manifest V2的宣告:

January 2023: The Chrome browser will no longer run Manifest V2 extensions. Developers may no longer push updates to existing Manifest V2 extensions.

從原本的斬釘截鐵,變成現在的含糊和留有餘地,看來強如Google想要執行一個影響全世界65%網際網路使用者的Breaking Change,也不是那麼容易的。但v3實際上並不全是缺點,在使用者隱私上面,v3絕對是一個提升,v3增加了很多在隱私方面的限制,非常重要的一點是不允許參照外部資源。Chrome擴充套件能做的東西實在是太多了,如果不瞭解或者不開源的話根本不敢安裝,因為擴充套件許可權太高可能會造成很嚴重的例如使用者資訊洩漏等問題,即使是比如像Firefox那樣必須要上傳原始碼的方式來加強稽核,也很難杜絕所有的隱患。

通訊方案

Chrome擴充套件在設計上有非常多的模組和能力,我們常見的模組有background/workerpopupcontentinjectdevtools等,不同的模組對應著不同的作用,共同作業構成了外掛的擴充套件功能。

  • background/worker: 這個模組負責在後臺執行擴充套件,可以實現一些需要長期執行的操作,例如與伺服器通訊、定時任務等。
  • popup: 這個模組是擴充套件的彈出層介面,可以通過點選擴充套件圖示在瀏覽器中彈出,用於顯示擴充套件的一些資訊或操作介面。
  • content: 這個模組可以存取當前頁面的DOM結構和樣式,可以實現一些與頁面互動的操作,但該模組的window與頁面的window是隔離的。
  • inject: 這個模組可以向當前頁面注入自定義的JavaScriptCSS程式碼,可以實現一些與頁面互動的操作,例如修改頁面行為、新增樣式等。
  • devtools: 這個模組可以擴充套件Chrome開發者工具的功能,可以新增新的面板、修改現有面板的行為等。
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/Content_scripts
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Background_scripts
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Popups
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/user_interface/devtools_panels
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/manifest.json/web_accessible_resources

在外掛的能力上,不同的模組也有著不同的區別,這個能力主要在於Chrome APIDOM存取、跨域存取、頁面Window物件存取等。

模組 Chrome API DOM存取 跨域存取 頁面Window物件存取
background/worker 絕大部分API,除了devtools系列 不可直接存取頁面DOM 可跨域存取 不可直接存取頁面Window
popup 絕大部分API,除了devtools系列 能直接存取自身的DOM 可跨域存取 能直接存取自身的Window
content 有限制,只能存取runtimeextension等部分API 可以存取頁面DOM 不可跨域存取 不可直接存取頁面Window
inject 不能存取Chrome API 可以存取頁面DOM 不可跨域存取 可直接存取頁面Window
devtools 有限制,只能存取devtoolsruntimeextension等部分API 可以存取頁面DOM 不可跨域存取 可直接存取頁面Window

對於訊息通訊,在不同的模組需要配合三種API來實現,短連結chrome.runtime.onMessage + chrome.runtime/tabs.sendMessage、長連結chrome.runtime.connect + port.postMessage + port.onMessage + chrome.runtime/tabs.onConnect,原生訊息window.postMessage + window.addEventListener,下邊的表格中展示的是直接通訊的情況,我們可以根據實際的業務來完成間接通訊方案。

background/worker popup content inject devtools
background/worker / chrome.extension.getViews chrome.tabs.sendMessage / chrome.tabs.connect / /
popup chrome.extension.getBackgroundPage / chrome.tabs.sendMessage / chrome.tabs.connect / /
content chrome.runtime.sendMessage / chrome.runtime.connect chrome.runtime.sendMessage / chrome.runtime.connect / window.postMessage /
inject / / window.postMessage / /
devtools chrome.runtime.sendMessage chrome.runtime.sendMessage / chrome.devtools.inspectedWindow.eval /

範例

接下來我們來實現一個範例,主要的功能是解除瀏覽器複製限制的通用方案,具體可以參考https://github.com/WindrunnerMax/TKScript文字選中複製-通用這部分,完整的操作範例都在https://github.com/WindrunnerMax/webpack-simple-environment/tree/rspack--chrome-extension中。此外註冊Chrome擴充套件的開發者價格是5$,註冊之後才能在谷歌商店釋出擴充套件。那麼首先,我們先在popup中繪製一個介面,用來展示當前的擴充套件狀態,以及提供一些操作按鈕。

export const App: FC = () => {
  const [copyState, setCopyState] = useState(false);
  const [copyStateOnce, setCopyStateOnce] = useState(false);
  const [menuState, setMenuState] = useState(false);
  const [menuStateOnce, setMenuStateOnce] = useState(false);
  const [keydownState, setKeydownState] = useState(false);
  const [keydownStateOnce, setKeydownStateOnce] = useState(false);

  // 與`content`通訊 操作事件與`DOM`
  const onSwitchChange = (
    type:
      | typeof POPUP_CONTENT_ACTION.MENU
      | typeof POPUP_CONTENT_ACTION.KEYDOWN
      | typeof POPUP_CONTENT_ACTION.COPY,
    checked: boolean,
    once = false
  ) => {
    PopupContentBridge.postMessage({ type: type, payload: { checked, once } });
  };

  // 與`content`通訊 查詢開啟狀態
  useLayoutEffect(() => {
    const queue = [
      { key: QUERY_STATE_KEY.STORAGE_COPY, state: setCopyState },
      { key: QUERY_STATE_KEY.STORAGE_MENU, state: setMenuState },
      { key: QUERY_STATE_KEY.STORAGE_KEYDOWN, state: setKeydownState },
      { key: QUERY_STATE_KEY.SESSION_COPY, state: setCopyStateOnce },
      { key: QUERY_STATE_KEY.SESSION_MENU, state: setMenuStateOnce },
      { key: QUERY_STATE_KEY.SESSION_KEYDOWN, state: setKeydownStateOnce },
    ];
    queue.forEach(item => {
      PopupContentBridge.postMessage({
        type: POPUP_CONTENT_ACTION.QUERY_STATE,
        payload: item.key,
      }).then(r => {
        r && item.state(r.payload);
      });
    });
  }, []);

  return (
    <div className={cs(style.container)}>
      { /* xxx */ }
    </div>
  );
};

可以看到我們實際上主要是通過bridgecontent script進行了通訊,在前邊我們也描述瞭如何進行通訊,在這裡我們可以通過設計一個通訊類來完成相關操作,同時為了保持完整的TS型別,在這裡定義了很多通訊時的標誌。實際上在這裡我們選擇了一個相對麻煩的操作,所有的操作都必須要要通訊到content script中完成,因為事件與DOM操作都必須要在content script或者inject script中才可以完成,但是實際上chrome.scripting.executeScript也可以完成類似的操作,但是在這裡為了演示通訊能力所以採用了比較麻煩的操作,另外如果要保持下次開啟該頁面的狀態依舊是保持Hook狀態的話,也必須要用content script

export const POPUP_CONTENT_ACTION = {
  COPY: "___COPY",
  MENU: "___MENU",
  KEYDOWN: "___KEYDOWN",
  QUERY_STATE: "___QUERY_STATE",
} as const;

export const QUERY_STATE_KEY = {
  STORAGE_COPY: "___STORAGE_COPY",
  STORAGE_MENU: "___STORAGE_MENU",
  STORAGE_KEYDOWN: "___STORAGE_KEYDOWN",
  SESSION_COPY: "___SESSION_COPY",
  SESSION_MENU: "___SESSION_MENU",
  SESSION_KEYDOWN: "___SESSION_KEYDOWN",
} as const;

export const POPUP_CONTENT_RTN = {
  STATE: "___STATE",
} as const;

export type PopupContentAction =
  | {
      type:
        | typeof POPUP_CONTENT_ACTION.MENU
        | typeof POPUP_CONTENT_ACTION.KEYDOWN
        | typeof POPUP_CONTENT_ACTION.COPY;
      payload: { checked: boolean; once: boolean };
    }
  | {
      type: typeof POPUP_CONTENT_ACTION.QUERY_STATE;
      payload: (typeof QUERY_STATE_KEY)[keyof typeof QUERY_STATE_KEY];
    };

type PopupContentRTN = {
  type: (typeof POPUP_CONTENT_RTN)[keyof typeof POPUP_CONTENT_RTN];
  payload: boolean;
};

export class PopupContentBridge {
  static async postMessage(data: PopupContentAction) {
    return new Promise<PopupContentRTN | null>(resolve => {
      chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
        const tabId = tabs[0] && tabs[0].id;
        if (tabId) {
          chrome.tabs.sendMessage(tabId, data).then(resolve);
          // https://developer.chrome.com/docs/extensions/reference/scripting/#runtime-functions
          // chrome.scripting.executeScript;
        } else {
          resolve(null);
        }
      });
    });
  }

  static onMessage(cb: (data: PopupContentAction) => void | PopupContentRTN) {
    const handler = (
      message: PopupContentAction,
      sender: chrome.runtime.MessageSender,
      sendResponse: (response?: PopupContentRTN | null) => void
    ) => {
      const rtn = cb(message);
      sendResponse(rtn || null);
    };
    chrome.runtime.onMessage.addListener(handler);
    return () => {
      chrome.runtime.onMessage.removeListener(handler);
    };
  }
}

最後,我們在content script中之行了實際上的操作,複製行為的Hook在這裡抹除了細節,如果感興趣可以直接看上邊的倉庫地址,在content script主要實現的操作就是接收popup傳送過來的訊息執行操作,並且根據儲存在storage中的資料來做一些初始化的行為。

let DOMLoaded = false;
const collector: (() => void)[] = [];

// Equivalent to content_scripts document_end
window.addEventListener("DOMContentLoaded", () => {
  DOMLoaded = true;
  collector.forEach(fn => fn());
});

const withDOMReady = (fn: () => void) => {
  if (DOMLoaded) {
    fn();
  } else {
    collector.push(fn);
  }
};

const onMessage = (data: PopupContentAction) => {
  switch (data.type) {
    case ACTION.COPY: {
      if (data.payload.checked) withDOMReady(enableCopyHook);
      else withDOMReady(disableCopyHook);
      const key = STORAGE_KEY_PREFIX + ACTION.COPY;
      if (!data.payload.once) {
        localStorage.setItem(key, data.payload.checked ? "true" : "");
      } else {
        console.log("111", 111);
        sessionStorage.setItem(key, data.payload.checked ? "true" : "");
      }
      break;
    }
    case ACTION.MENU: {
      if (data.payload.checked) enableContextMenuHook();
      else disableContextMenuHook();
      const key = STORAGE_KEY_PREFIX + ACTION.MENU;
      if (!data.payload.once) {
        localStorage.setItem(key, data.payload.checked ? "true" : "");
      } else {
        sessionStorage.setItem(key, data.payload.checked ? "true" : "");
      }
      break;
    }
    case ACTION.KEYDOWN: {
      if (data.payload.checked) enableKeydownHook();
      else disableKeydownHook();
      const key = STORAGE_KEY_PREFIX + ACTION.KEYDOWN;
      if (!data.payload.once) {
        localStorage.setItem(key, data.payload.checked ? "true" : "");
      } else {
        sessionStorage.setItem(key, data.payload.checked ? "true" : "");
      }
      break;
    }
    case ACTION.QUERY_STATE: {
      const STATE_MAP = {
        [QUERY_STATE_KEY.STORAGE_COPY]: { key: ACTION.COPY, storage: localStorage },
        [QUERY_STATE_KEY.STORAGE_MENU]: { key: ACTION.MENU, storage: localStorage },
        [QUERY_STATE_KEY.STORAGE_KEYDOWN]: { key: ACTION.KEYDOWN, storage: localStorage },
        [QUERY_STATE_KEY.SESSION_COPY]: { key: ACTION.COPY, storage: sessionStorage },
        [QUERY_STATE_KEY.SESSION_MENU]: { key: ACTION.MENU, storage: sessionStorage },
        [QUERY_STATE_KEY.SESSION_KEYDOWN]: { key: ACTION.KEYDOWN, storage: sessionStorage },
      };
      for (const [key, value] of Object.entries(STATE_MAP)) {
        if (key === data.payload)
          return {
            type: POPUP_CONTENT_RTN.STATE,
            payload: !!value.storage[STORAGE_KEY_PREFIX + value.key],
          };
      }
    }
  }
};

PopupContentBridge.onMessage(onMessage);

if (
  localStorage.getItem(STORAGE_KEY_PREFIX + ACTION.COPY) ||
  sessionStorage.getItem(STORAGE_KEY_PREFIX + ACTION.COPY)
) {
  withDOMReady(enableCopyHook);
}
if (
  localStorage.getItem(STORAGE_KEY_PREFIX + ACTION.MENU) ||
  sessionStorage.getItem(STORAGE_KEY_PREFIX + ACTION.MENU)
) {
  enableContextMenuHook();
}
if (
  localStorage.getItem(STORAGE_KEY_PREFIX + ACTION.KEYDOWN) ||
  sessionStorage.getItem(STORAGE_KEY_PREFIX + ACTION.KEYDOWN)
) {
  enableKeydownHook();
}

因為在這裡這個外掛並沒有釋出到Chrome的應用市場,所以如果想檢驗效果只能本地處理,在run dev後可以發現打包出來的產物已經在dist資料夾下了,接下來我們在chrome://extensions/開啟開發者模式,然後點選載入已解壓的擴充套件程式,選擇dist資料夾,這樣就可以看到我們的外掛了。之後我在百度搜尋了"實習報告"關鍵詞,出現了很多檔案,隨便開啟一個在複製的時候就會出現付費的行為,此時我們點選外掛,啟動Hook複製行為,再複製文字內容就會發現不會彈出付費框了,內容也是成功複製了。請注意在這裡我們實現的是一個通用的複製能力,對於百度文庫、騰訊檔案這類的canvas繪製的檔案站是需要單獨處理的,關於這些可以參考https://github.com/WindrunnerMax/TKScript

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://www.rspack.dev/
https://www.v2ex.com/t/861729
https://zhuanlan.zhihu.com/p/410510492
https://zhuanlan.zhihu.com/p/103072251
https://juejin.cn/post/7094545901967900686
https://juejin.cn/post/6844903985711677453
https://developer.chrome.com/docs/extensions/mv3/intro/
https://reorx.com/blog/understanding-chrome-manifest-v3/
https://tomzhu.site/2022/06/25/webpack開發Chrome擴充套件時的熱更新解決方案
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions
https://stackoverflow.com/questions/66618136/persistent-service-worker-in-chrome-extension