從零實現的Chrome擴展

從零實現的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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章