「浏览器插件」:监听系统剪贴板&实现网页截图

浏览器插件的结构就不介绍了,官方文档。 笔者有一年多chrome插件开发经验,懂一些皮毛,奈何chrome迭代太快,插件api层出不穷,有很多api都没有试用

背景

  • 监听系统剪贴板(剪切板):前司在职期间,某功能还未开发完便遭遇业务调整,意难平之际在github上建了个仓库joot,自己实现了最小可行性版本。前司如果要重新启动项目,可以用于参考
  • 网页截图:女朋友在写论文期间经常性的在网上截取资料,奈何常用截图工具不能截取网页的全部内容,于是笔者在考虑可行性后,花了一天时间实现,集成在joot仓库中

调研

监听剪贴板

剪贴板有多种实现方式,综合背景场景和笔者有限的能力,收集了以下方案

方案 用法 限制
原生clipboard api navigator.clipboard 调用时当前页面要聚焦,且要用户允许,没有提供clipboardchange事件
chrome.offscreen createDocument,参数传 CLIPBOARD,在离屏html中访问剪贴板 没有提供clipboardchange事件
chrome.runtime.connectNative 插件和客户端程序通信,客户端程序监听剪贴板 需要在本机配置客户端程序,并使用stdio通信

综合业务背景选择第三个方案

网页截图

最直接的思路,一次截取一屏内容并生成dataurl,然后滚动页面再次截屏,直到所有内容都截取到,最后将多个dataurl绘制到canvas,裁剪拼接成一张大图。这个思路存在几个问题

  1. 有的网页是懒加载的,不能确定他有多少内容
  2. 目前只支持滚动根元素,不支持精确到某个元素,待优化
  3. 网页中有脱离文档流的元素,如菜单、浮窗等,会遮挡内容,待优化

实现

话不多说直接开灶

监听剪贴板

接上文的方案,按以下步骤调试

  1. 调通原生消息通信
  2. 客户端程序监听剪贴板
  3. 浏览器插件接受剪贴板

原生消息通信

native messaging 文档中描述了,需要在本机指定的目录下创建配置文件,限制了只能使用标准输入输出流,并且原生消息的前4个字节表示后续data的长度,data写入之流前要json格式序列化。

比如笔者是用windows hyper-v创建的ubuntu 22虚拟机,执行命令创建文件

touch ~/.config/google-chrome/NativeMessagingHosts/com.joot.json
vim ~/.config/google-chrome/NativeMessagingHosts/com.joot.json

写入内容,runtime_id是插件的id,自己能够拿到

{
  "name": "com.joot",
  "description": "provide clipboard change event",
  "path": "可执行文件的绝对路径,
  "type": "stdio",
  "allowed_origins": ["chrome-extension://runtime_id/"]
}

笔者在github查到一个rust的库提供了clipboardchange事件clipboard-rs,看源码是每500ms查一次剪贴板,开箱即用,于是用rust实现了客户端程序(rust还没入门(∩_∩))

json序列化使用serde、serde_json

通信方面使用了chrome-native-messaging

[package]
name = "clipboard"
version = "0.1.0"
edition = "2021"

[dependencies]
clipboard-rs = “0.1.6”
serde = { version = “1.", features = [“derive”] }
serde_json = "1.

chrome_native_messaging = “0.3.0”

message.rs,消息长度有限制,后续考虑分片

use chrome_native_messaging::{send_message, Error};
use serde::Serialize;
use std::io;

#[derive(Serialize)]
pub enum EClipboardFormat {
Text,
Image,
}

#[derive(Serialize)]
pub struct Message<'a, T> {
data: &'a T,
format: EClipboardFormat,
total: u8,
index: u8,
}

impl<'a, T> Message<'a, T>
where
T: Serialize,
{
pub fn new(data: &'a T, format: EClipboardFormat) -> Message<'a, T> {
Message {
data,
format,
total: 1,
index: 1,
}
}
pub fn send(self: Message<'a, T>) -> Result<(), Error> {
send_message(io::stdout(), &self)
}
}

manager.rs

use clipboard_rs::{common::RustImage, Clipboard, ClipboardContext, ClipboardHandler};

use crate::message::{EClipboardFormat, Message};

struct ClipboardRecord {
text: String,
image: Vec,
}

pub struct Manager {
ctx: ClipboardContext,
clipboard_record: ClipboardRecord,
}

impl Manager {
pub fn new() -> Self {
Manager {
ctx: ClipboardContext::new().unwrap(),
clipboard_record: ClipboardRecord {
text: String::from(“”),
image: vec!,
},
}
}
}

impl ClipboardHandler for Manager {
fn on_clipboard_change(&mut self) {
let clipboard_record = &mut self.clipboard_record;

    if let Ok(text) = self.ctx.get_text() {
        if clipboard_record.text != text {
            Message::new(&amp;text, EClipboardFormat::Text).send().ok();
            clipboard_record.text = text;
        };
    };

    if let Ok(image_data) = self.ctx.get_image() {
        if let Ok(image) = image_data.to_png() {
            let bytes = image.get_bytes();
            if clipboard_record.image.len() != bytes.len() &amp;&amp; clipboard_record.image != bytes {
                Message::new(&amp;bytes, EClipboardFormat::Image).send().ok();
                clipboard_record.image.resize(bytes.len(), 0);
                // clipboard_record.image.clear();
                clipboard_record.image.copy_from_slice(&amp;bytes);
            };
        };
    };
}

}

lib.rs

pub mod manager;
pub mod message;

main.rs

use clipboard::manager::Manager;
use clipboard_rs::{ClipboardWatcher, ClipboardWatcherContext};

fn main() {
let mut watcher = ClipboardWatcherContext::new().unwrap();
let manager = Manager::new();

watcher.add_handler(manager);
watcher.start_watch();

}

background.js

enum EClipboardFormat {
    Text = "Text",
    Image = "Image"
}

interface IRustAppMessage {
index: number
total: number
data: string | Array
format: EClipboardFormat
}

const port = chrome.runtime.connectNative(“com.joot”)
port.onMessage.addListener((msg: IRustAppMessage, _port: chrome.runtime.Port) => {
console.log(“receive native message”)
if (typeof msg === ‘object’) {
const { index, total, data, format } = msg
if (format === EClipboardFormat.Image) {
# 这里data直接是png图片的hex数组,可以转blob再createObjectURL
# 但是background.js的运行环境是service_worker,不能调用createObjectURL,可以发送给popup.js
data
}
}
})

网页截图

主要是消息通信,改天另写一篇文章介绍。

总结

坑比较多,还有未知的bug,调试过程中把浏览器都搞崩溃好几次。笔者的代码仓库github,欢迎一起讨论浏览器插件。

另外笔者在求职中,有招聘需求的可以留意一下(😂),邮箱940457524@qq.com


这是一个从 https://juejin.cn/post/7368836713966288935 下的原始话题分离的讨论话题