MoreRSS

site iconrxliuli | 琉璃修改

Web 前端开发,在日本,喜欢自称「吾辈」让我很难受。。博客主要记录一些技术相关的东西
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

将数据导入到 Cloudflare D1

2025-01-30 09:21:28

背景

最近在实现 Mass Block Twitter 插件的 Spam 账户共享黑名单时,使用了 Cloudflare D1 作为服务端的存储数据库。而之前在本地 indexedDB 中已经存储了一些数据,所以需要导入现有的数据到 Cloudflare D1 中。使用 IndexedDB Exporter 插件将 indexedDB 数据库导出为 JSON 数据,但如何导入仍然是个问题。

最初参考了 Cloudfalre D1 的官方文档,但它并没有如何导入 JSON 数据的指南,而且它也只能在 Cloudflare Worker 中使用,但它确实提供通过 wrangler 执行 SQL 的功能,所以重要的是如何从 JSON 得到 SQL。

尝试

直接从 JSON 生成 Insert SQL

最初看起来这很简单,既然已经有了 JSON 数据,从 JSON 数据生成 SQL 字符串似乎并不难,只需要一些简单的字符串拼接即可。但实际上比想象中的麻烦,主要是 SQL 中有许多转义很烦人,边界情况似乎很多,JSON.stringify 生成的字符串在 SQL 中有错误的语法。

例如

  • " 双引号
  • ' 单引号
  • \n 换行
  • 可能的 SQL 注入
  • 其他。。。

虽然也调研过一些现有的 npm 包,例如 json-sql-builder2,但它似乎并不支持生成在单个 SQL 中插入多行的功能,因此一开始并为考虑。而且它生成的结果如下,看起来是给代码用的,而非直接可以执行的 SQL 文件。

1
2
3
4
{
"sql": "INSERT INTO people (first_name, last_name, age) VALUES (?, ?, ?)",
"values": ["John", "Doe", 40]
}

调用 Rest API

在生成的 SQL 一直出现语法错误之后,一度尝试过直接在 Worker 中实现一个 API 用来做这件事,直到在 wrangler dev 在本地测试发现效率很低后放弃了,实现 API 本身反而并不复杂。

导入到 sqlite 然后 dump 出 Insert SQL

最后还是回归到生成 SQL 的方案上,基本流程如下

  1. 从 JSON 生成 CSV
  2. 使用 sqlite3 导入 CSV 到本地 .sqlite 文件
  3. 使用 sqlite3 dump 数据到 .sql 文件
  4. 使用 wrangler cli 执行 .sql 文件导入数据
1
2
3
4
5
6
7
8
9
10
11
12
sqlite3 db.sqlite < migrations/0001_init.sql # 初始化一个 sqlite 数据库

vite-node 0002_import.ts # 生成需要的 csv 文件

sqlite3 db.sqlite <<EOF
.mode csv
.import users.csv Tweet
EOF # 导入 csv 到 sqlite 中

sqlite3 db.sqlite .dump > db.sql # 导出数据到 sql 文件
# ⚠️ 注意,导出数据后需要进行一些必要的编辑,删除创建 table 和 index 的语句,还有 csv 导入时可能错误导入的标题行,仅保留正确的 insert into 语句
npx wrangler d1 execute mass-block-twitter --local --file=migrations/db.sql # 执行 sql 文件导入数据,修改 --local 为 --remote 在远端执行操作
0002_import.ts 的具体实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import _data from './assets/mass-db_exported_data.json'
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Parser } from '@json2csv/plainjs'
import { tweetSchema, userSchema } from '../lib/request'
import { Tweet, User } from '@prisma/client'

const data = _data as {
tweets: (typeof tweetSchema._type & {
user_id: string
updated_at: string
})[]
users: (typeof userSchema._type & {
updated_at: string
})[]
}

const list: User[] = data.users.map(
(it) =>
({
id: it.id,
screenName: it.screen_name,
name: it.name,
description: it.description ?? null,
profileImageUrl: it.profile_image_url ?? null,
accountCreatedAt: it.created_at ? new Date(it.created_at) : null,
spamReportCount: 0,
createdAt: new Date(it.updated_at!),
updatedAt: new Date(it.updated_at!),
} satisfies User),
)
const parser = new Parser({
fields: [
'id',
'screenName',
'name',
'description',
'profileImageUrl',
'accountCreatedAt',
'spamReportCount',
'createdAt',
'updatedAt',
],
})
const csv = parser.parse(list)
await writeFile(path.resolve(__dirname, 'users.csv'), csv)

总结

Cloudflare D1 是一个不错的数据库,基本在 Web 场景中是完全可用的,而且与 Worker 一起使用时也无需关注 Worker 执行的区域与数据库所在的区域可能不一致的问题。而关于这次的数据导入的麻烦,如果已经熟悉 D1 或数据库的话,可能 10 分钟就搞定了,还是太久不碰数据库和后端生疏了。

在 Chrome 插件中拦截网络请求

2025-01-02 22:58:59

动机

在实现 Chrome 插件 Mass Block Twitter 时,需要批量屏蔽 twitter spam 用户,而 twitter 的请求 header 包含的 auth 信息似乎是通过 js 动态生成的,所以考虑到与其检查 twitter 的 auth 信息是如何生成的,还不如拦截现有的网络请求,记录使用的所有 header,然后在调用 /i/api/1.1/blocks/create.json 接口时直接使用现成的 headers。因而出现了拦截 xhr 的需求,之前也遇到过需要拦截 fetch 请求的情况,而目前现有的库并不能满足需要。

1736254864238.jpg

已经调查的库包括

  • mswjs: 一个 mock 库,可以拦截 xhr/fetch 请求,但是需要使用 service worker,而这对于 Chrome 插件的 Content Script 来说是不可能的。
  • xhook: 一个拦截库,可以拦截 xhr 请求,但无法拦截 fetch 请求,而且最后一个版本是两年前,似乎不再有人维护了

因此,吾辈打算自己实现一个。

设计

首先,吾辈思考了自己的需求

  1. 拦截 fetch/xhr 请求
  2. 支持修改 request url 以实现代理请求
  3. 支持调用原始请求并修改 response
  4. 支持 response sse 流式响应

确定了需求之后吾辈开始尝试设计 API,由于之前使用过非常优秀的 Web 框架 hono,所以吾辈希望 API 能够尽可能的简单,就像 hono 的 Middleware 那样。

下面借用 hono 官方的 Middleware 的洋葱图 [1]

1736247259291.jpg

例如下面使用了两个 Middleware

1
2
3
4
5
6
7
8
9
10
11
app
.use(async (c, next) => {
console.log('middleware 1 before')
await next()
console.log('middleware 1 after')
})
.use(async (c, next) => {
console.log('middleware 2 before')
await next()
console.log('middleware 2 after')
})

实际运行结果将会如下,因为最早注册的 Middleware 将在“洋葱”的最外层,在请求处理开始时最先执行,在请求完成后最后执行。

1
2
3
4
5
middleware 1 before
middleware 2 before
// 实际处理请求...
middleware 2 after
middleware 1 after

实现

接下来就涉及到具体实现 fetch/xhr 的请求拦截了,这里不会给出完整的实现代码,而是更多给出实现思路,最后将会链接到实际的 GitHub 仓库。

fetch

首先说 fetch,它的拦截还是比较简单的,因为 fetch 本身只涉及到一个函数,而且输入与输出比较简单。

先看一个简单的 fetch 使用示例

1
2
3
fetch('https://api.github.com/users/rxliuli')
.then((res) => res.json())
.then((data) => console.log(data))

基本思路就是重写 globalThis.fetch,然后使用自定的实现运行 middlewares,并在合适的时机调用原始的 fetch。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function interceptFetch(...middlewares: Middleware[]) {
const pureFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
// 构造一个 Context,包含 request 和 response
const c: Context = {
req: new Request(input, init),
res: new Response(),
type: 'fetch',
}
// 运行 middlewares,由于处理原始请求需要在“洋葱”最内层运行,所以处理原始请求实现为一个 middleware
await handleRequest(c, [
...middlewares,
async (context) => {
context.res = await pureFetch(c.req)
},
])
// 返回处理后的 response
return c.res
}
}

// 以洋葱模型运行所有的 middlewares
async function handleRequest(context: Context, middlewares: Middleware[]) {
const compose = (i: number): Promise<void> => {
if (i >= middlewares.length) {
return Promise.resolve()
}
return middlewares[i](context, () => compose(i + 1)) as Promise<void>
}
await compose(0)
}

现在,可以简单的拦截所有的 fetch 请求了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
interceptFetch(
async (context, next) => {
console.log('fetch interceptor 1')
await next()
console.log('fetch interceptor 1 after')
},
async (context, next) => {
console.log('fetch interceptor 2')
await next()
console.log('fetch interceptor 2 after')
},
)
fetch('https://api.github.com/users/rxliuli')
.then((res) => res.json())
.then((data) => console.log(data))
// 输出
// fetch interceptor 1
// fetch interceptor 2
// fetch interceptor 1 after
// fetch interceptor 2 after
// {
// "login": "rxliuli",
// "id": 24560368,
// "node_id": "MDQ6VXNlcjI0NTYwMzY4",
// "avatar_url": "https://avatars.githubusercontent.com/u/24560368?v=4",
// ...
// }

有人可能会有疑问,这与 hono 的 Middleware API 也不一样啊?别着急,API 在最外层包一下就好了,先实现关键的请求拦截部分。

xhr

接下来是 xhr,它与 fetch 非常不同,先看一个简单的 xhr 使用示例

1
2
3
4
5
6
const xhr = new XMLHttpRequest()
xhr.open('GET', 'https://api.github.com/users/rxliuli')
xhr.onload = () => {
console.log(xhr.responseText)
}
xhr.send()

可以看出,xhr 涉及到多个方法,例如 open/onload/send 等等,所以需要重写多个方法。而且由于 middlewares 只应该运行一次,而 xhr 的 method/url 与 body 是分步传递的,所以在实际调用 send 之前,都不能调用原始 xhr 的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function interceptXhr(...middlewares: Middleware[]) {
const PureXhr = XMLHttpRequest
XMLHttpRequest = class extends PureXhr {
#method: string = ''
#url: string | URL = ''
#body?: Document | XMLHttpRequestBodyInit | null
// 重写 open 方法,在调用原始 open 方法之后,仅记录参数
open(method: string, url: string) {
this.#method = method
this.#url = url
}
// 保存所有事件监听器
#listeners: [
string,
(this: XMLHttpRequest, ev: ProgressEvent) => any,
boolean,
][] = []
set onload(callback: (this: XMLHttpRequest, ev: ProgressEvent) => any) {
this.#listeners.push(['load', callback, false])
}
// 重写 send 方法,在调用原始 send 方法之前,运行 middlewares
async send(body?: Document | XMLHttpRequestBodyInit | null) {
this.#body = body
const c: Context = {
req: new Request(this.#url, {
method: this.#method,
body: this.#body as any,
}),
res: new Response(),
type: 'xhr',
}
// 绑定注册的事件监听器
this.#listeners.forEach(([type, listener, once]) => {
super.addEventListener.apply(this, [type, listener as any, once])
})
// 运行 middlewares
await handleRequest(c, [
...middlewares,
async (c) => {
super.addEventListener('load', () => {
// 设置响应
c.res = new Response(this.responseText, { status: this.status })
})
super.send.apply(this, [c.req.body as any])
},
])
}
}
}

现在实现了一个非常基本的 xhr 拦截器,可以记录和修改 request 的 method/url/body,还能记录 response 的 status/body。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interceptXhr(async (c, next) => {
console.log('method', c.req.method, 'url', c.req.url)
await next()
console.log('json', await c.res.clone().json())
})
const xhr = new XMLHttpRequest()
xhr.open('GET', 'https://api.github.com/users/rxliuli')
xhr.onload = () => {
console.log(xhr.responseText)
}
xhr.send()
// 输出
// method GET url https://api.github.com/users/rxliuli
// json {
// "login": "rxliuli",
// "id": 24560368,
// "node_id": "MDQ6VXNlcjI0NTYwMzY4",
// "avatar_url": "https://avatars.githubusercontent.com/u/24560368?v=4",
// ...
// }

当然,目前 xhr 的实现还非常简陋,没有记录所有 onload/onerror/onreadystatechange 事件,也没有记录所有 header,更不能修改 response。但作为一个演示,整体的实现思路已经出来了。

更多

目前已经实现了完整的 fetch/xhr 拦截器,发布至 npm 上的 @rxliuli/vista 包中,欢迎使用。

2024,不上班的第一年

2025-01-02 22:54:49

自从 2023 年底前往日本之后,吾辈就没有再上过一天班。在日本的时候,基本没有考虑过打零工。而回国之后,则开始了间隔性的旅行和躺平。回国的主要目标之一成为独立开发者仍未实现,即使尝试过开发一些东西,但都没有找到正确的途径。而且严重缺乏输入,现实几乎没有人可以长期交流,这是一个问题,可能一直都是一个问题。

时间线回顾

1~3 月: 在日本留学,在关西地区旅行,也前往东京进行过短期旅行
4~6 月: 完成了全国旅行,虽然途径磕磕绊绊,但最终吾辈还是做到了
7 月: 在阳江租房长住,但实际上几乎没有出门玩过,月底回到老家两周
8 月: 在阳江躺平,月底终于前往了当地的海陵岛,(服务)体验一般
9 月: 月初终于继续旅行,历经衡山长沙南京镇江之后,遇到台风匆匆赶回
10 月: 前往新一家暂住一周多,之后前往云南昆明大理等地,10 月下旬返回
11~12 月: 完全在阳江躺平,并陷入了严重失调的日常生活

1736650224627.jpg

自问自答

  1. 你今年最大的成就是什么 – 第一次尝试并完成了全国旅行
  2. 你今年最大的失败是什么 – 没有找到独立开发的正确途径
  3. 你有没有遵守年初时和自己许下的约定 – 几乎没有,作息规律和独立开发都是
  4. 今年的哪个或哪些日子会铭刻在你的记忆中,为什么 – 在新疆抱团旅行的时光,和其他人一起旅行的感觉很好
  5. 明年你想要获得哪些你今年没有的东西 – 在苏州长住,独立开发/或者其他什么获得收入(无论多少)、作息规律、开始晨跑
  6. 今年你学到了什么宝贵的人生经验 – 在选择有限的情况下不要匆匆决定,它会限制视野,而且很可能不是正确的
  7. 有什么是你想要且得到了的 – 没有约束的生活
  8. 是什么让你保持理智 – Twitter/Ao3,有时候开发一些插件或应用在 Twitter 发布,以及入坑 Worm 系列,这是自 PMMM 之后花了最多时间的小说系列
  9. 在你新认识的人之中,谁是最好的 – 新一,很厉害的一位开发者,不仅尝试创业,还逐渐步入正轨,几乎就是吾辈期望达到的一种状态。而且人也特别好,允许吾辈在他那里借住
  10. 你想念哪些人 – 曾经的老师。尽管去过他家里很多次,一起吃过饭出去玩,但现在逐渐渐行渐远,平时也不再能说得上话了

总结

独立开发

2024 年,主要还是开发了各种各样的 Chrome 插件,目前还在维护的大概有十几个,但大多数都反响平平,而且始终没有考虑过盈利问题,也许是因为毕竟没有盈利的压力。其中花费了大量时间开发的 PhotoShare 和 NovaChat,则没有真正完成和推广过。也许随着旅程的继续,吾辈将寻找到真正通往胜利的道路
下面是 2024 维护过的东西

  • Mass Block Twitter: 年尾 Twitter 又开始妖魔横行,所以当即开发了一个插件来批量屏蔽各种币圈 Spam 账号
  • Bilibili Markdown: 发布同人小说到 b 站专栏时粘贴 Markdown 到 Quill 富文本编辑器
  • Redirector: 根据规则重定向网页,避免外链跳转或者其他需求
  • ZenFS Viewer: 开发者工具,ZenFS 的可视化集成
  • TypeScript Console: 开发者工具,在 Devtools 中使用 TypeScript
  • Google Search Console - Bulk Index Cleaner: Google Search Console 的索引批量清理工具
  • Clean Twitter: 还原干净的 Twitter 体验,清理 UI 上的各种元素
  • NovaChat: 一个支持插件、本地优先的 ChatUI。时隔两年再次尝试写 ChatUI,想要的插件系统只在 Poe 中见过,但它不是本地优先的

《Worm》

Worm 是一部网络小说,也是自魔法少女小圆之后又一部真正热爱的作品。同样是从同人小说开始入坑,看完可能上百本同人小说,仅浏览记录就有 4257 个,而相同时间内 Twitter 浏览记录仅有 205 个,这确实说明了吾辈花费了多少时间。小说中的泰勒面对了各种困境和挑战,不得不因正确的理由做错事,从一个备受欺凌、想成为超级英雄的女高中生,到成为接管城市的超级反派,最终拯救了世界却被人害怕而招致死亡。至少可以说,“升级女王”是一个让人尊敬的女孩,上一个让吾辈有这种感觉的还是一之濑琴美,以及更早的樱小路露娜,她们都曾经历了痛苦但都成功地克服了它们,或许这正是吾辈喜欢她们的理由。

下面是作品简介:
主角是一个名叫泰勒・赫伯特的女高中生,是个失去母亲的黑发褐肤、性格阴郁的女孩。她在学校遭受霸凌,唯一支撑她活下去的希望是:不久前她经历了 “触发事件”,获得了控制昆虫的超能力。如果她能顺利参加“监护者计划”,就能成为一个真正的超级英雄,为她那悲惨的人生增添一些意义…

推荐从 TV Tropes 寻找喜欢的 Worm 同人小说,就英文圈子而言,它大概有 15k 本同人小说,是 Madoka 英文同人圈的 3~4 倍。

2025

2025 年吾辈仍然不会考虑长期工作,目前仍然有时间和成本去尝试做一些不一样的事情,如果八爷独立开发之路步入正轨花了 3 年,那么吾辈也可以花费同样多的时间。下面立一些 2025 的 Flag,将每个月都 check 一下

  • 通过独立开发获得收入,不管它是多么少,必须开始接入 Paddle 等支付系统
  • 作息规律,这是自工作之后一直想做而未成之事
  • 开始晨跑,从 Worm 中得到的想法,泰勒经历了更糟糕的事情,她通过晨跑来改善心情
  • 寻找新的兴趣,目前动画已经几乎不再维持,沉迷同人小说是个糟糕的兴趣,户外徒步和摄影还不错但还未长期维持过

在 Chrome 插件中将 ArrayBuffer 从网页传递到 Devtools Panel

2024-11-13 02:52:02

背景

最近使用了 ZenFS 在浏览器中模拟文件系统,以在浏览器中像使用 node fs api 一样存储一些文件。但想要可视化的检查当前存储的文件时,却没有一个可以直观的工具来完成。所以就创建了一个 Chrome Devtools Extension ZenFS Viewer,以实现这个目标。在此过程中就遇到了如何传递 ArrayBuffer 从网页到 devtools panel 线程的问题,一些尝试如下。

browser.devtools.inspectedWindow.eval

首先尝试了最简单的方法,browser.devtools.inspectedWindow.eval 可以在网页执行任意代码并得到结果,例如

1
browser.devtools.inspectedWindow.eval(`__zenfs__.readdir('/')`)

然而 inspectedWindow.eval 并不支持 Promise 返回值,例如下面的表达式无法得到 Promise 结果

1
browser.devtools.inspectedWindow.eval(`await __zenfs__.promises.readdir('/')`)

同样的,也无法支持 ArrayBuffer。所以这个显而易见的 API 被放弃了。

1
2
3
browser.devtools.inspectedWindow.eval(
`await __zenfs__.promises.readFile('/test.png')`,
)

chrome.runtime.sendMessage

接下来就是思想体操的时候了,一开始考虑的方案就是通过 devtools panel => background script => content-script(isolation) => content-script(main) 进行中转通信,以此在 devtools panel 中调用网页的全局变量并传递和获取 ArrayBuffer 的响应。大概示意图如下

before.excalidraw.svg

然而在使用 chrome.runtime.sendMessage 时也遇到了和 inspectedWindow.eval 类似的问题,无法传递 ArrayBuffer,仅支持 JSON Value。当然可以序列化 ArrayBuffer 为 JSON,但在传输视频之类的大型数据时并不现实。

解决

之后经过一些搜索和思考,找到了一种方法可以绕道 chrome.runtime.message,因为注入的 iframe 和 devtools panel 同源,所以可以使用 BroadcastChannel 通信,而 iframe 和注入的 content-script(main world) 之间尽管不同源,但仍然可以通过 postMessage/onMessage 来通信,并且两者都支持传递 ArrayBuffer,这样便可绕道成功。

after.excalidraw.svg

Content-Script <=> Iframe

网页与注入的 iframe 之间,通信可以使用基本的 postMessage/onMessage 实现,为了减少冗余代码,这里使用 comlink 来实现。

先看看注入的 content-script,它主要是负责对 iframe 暴露一些 API 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// entrypoints/main-content.ts
import { expose, windowEndpoint } from 'comlink'

export default defineUnlistedScript(() => {
const map = new Map<string, ArrayBuffer>()

interface IFS {
readFile: (path: string) => Promise<ArrayBuffer>
writeFile: (path: string, data: ArrayBuffer) => Promise<void>
readdir: (path: string) => Promise<string[]>
}

expose(
{
readFile: async (path: string) => {
return map.get(path) || new Uint8Array([1, 2, 3]).buffer
},
writeFile: async (path: string, data: ArrayBuffer) => {
map.set(path, data)
},
readdir: async (path: string) => {
return Array.from(map.keys()).filter((p) => p.startsWith(path))
},
} as IFS,
windowEndpoint(
(document.querySelector('#inject-iframe')! as HTMLIFrameElement)
.contentWindow!,
),
)
console.log('main-content')
})

而在 iframe 中,则需要转发所有来自 BroadcastChannel 的请求通过 postMessage 传递到上层注入的 content-script 中,其中在每次传递 ArrayBuffer 时都需要使用 transfer 来转移对象到不同线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// entrypoints/iframe/main.ts
import { expose, transfer, windowEndpoint, wrap } from 'comlink'

interface IFS {
readFile: (path: string) => Promise<ArrayBuffer>
writeFile: (path: string, data: ArrayBuffer) => Promise<void>
readdir: (path: string) => Promise<string[]>
}

async function main() {
const tabId = (await browser.tabs.getCurrent())!.id
if (!tabId) {
return
}
const ipc = wrap<IFS>(windowEndpoint(globalThis.parent))
const bc = new BroadcastChannel(
`${browser.runtime.getManifest().name}-iframe-${tabId}`,
)
expose(
{
readFile: async (path: string) => {
const r = await ipc.readFile(path)
// 将 ArrayBuffer 通过 transfer 传递回 devtools-panel 中
return transfer(r, [r])
},
writeFile: async (path: string, data: ArrayBuffer) => {
// 将 ArrayBuffer 通过 transfer 传递到 content-script 中
await ipc.writeFile(path, transfer(data, [data]))
},
readdir: async (path: string) => {
console.log('readdir', path)
return await ipc.readdir(path)
},
} as IFS,
bc,
)

console.log('iframe main')
}

main()

Iframe <=> Devtools

而在 Devtools 中,要做的事情有一点点多 🤏。首先需要注入两个 content-script,而其中 isolation-content.js 是用来创建 iframe 的 content-script。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// entrypoints/devtools-panel/main.ts
import { PublicPath } from 'wxt/browser'

async function injectScript() {
const includeIframe = await new Promise((resolve) => {
browser.devtools.inspectedWindow.eval(
`!!document.querySelector('#inject-iframe')`,
(result) => {
resolve(result)
},
)
})
if (includeIframe) {
return
}
const tabId = browser.devtools.inspectedWindow.tabId
if (!tabId) {
return
}
await browser.scripting.executeScript({
target: { tabId },
files: ['/isolation-content.js' as PublicPath],
world: 'ISOLATED',
})
await browser.scripting.executeScript({
target: { tabId },
files: ['/main-content.js' as PublicPath],
world: 'MAIN',
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// entrypoints/isolation-content.ts
function createIframeUi() {
const wrapper = document.createElement('div')
wrapper.style.height = '0'
wrapper.style.width = '0'
const ifr = document.createElement('iframe')
wrapper.appendChild(ifr)
ifr.src = browser.runtime.getURL('/iframe.html')
ifr.style.width = '0'
ifr.style.height = '0'
ifr.style.zIndex = '-9999'
ifr.style.border = 'none'
ifr.id = 'inject-iframe'
document.body.appendChild(wrapper)
return ifr
}

export default defineUnlistedScript(() => {
console.log('isolation-content', createIframeUi())
})

接下来就可以在 devtools-panel 中获取数据了,由于 iframe 的注入完成的时机并不能确定,所以需要加个简单的通知机制。

1
2
3
4
5
6
7
8
9
10
11
12
// entrypoints/iframe/main.ts
import { wrap } from 'comlink'

async function main() {
// Other code...
console.log('iframe main')
await wrap<{
onReady: () => void
}>(bc).onReady()
}

main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// entrypoints/devtools-panel/main.ts
async function main() {
await injectScript()
interface IFS {
readFile: (path: string) => Promise<ArrayBuffer>
writeFile: (path: string, data: ArrayBuffer) => Promise<void>
readdir: (path: string) => Promise<string[]>
}
const tabId = browser.devtools.inspectedWindow.tabId
if (!tabId) {
return
}
const bc = new BroadcastChannel(
`${browser.runtime.getManifest().name}-iframe-${tabId}`,
)
await new Promise<void>((resolve) => expose({ onReady: resolve }, bc))
console.log('onReady')
// Test code...
const ipc = wrap<IFS>(bc)
const r = await ipc.readdir('/')
console.log(r)
const data = new Uint8Array([1, 2, 3]).buffer
await ipc.writeFile('/test.txt', transfer(data, [data]))
const r2 = await ipc.readFile('/test.txt')
console.log(r2)
}

main()

完整代码参考: https://github.com/rxliuli/devtools-webpage-message-demo

总结

至今为止,仍然没有简单的方法来支持 Devtools Extension 与 Webpage 之间的通信来替代 inspectedWindow.eval,确实是有点神奇。

Mac Tips 分享:创建一个 Shortcut 快速调整窗口尺寸

2024-10-16 00:26:52

场景

之前发布 Chrome 扩展到 Chrome WebStore 时,WebStore 要求提供几张截图,而且必须是 1280x800 或者 640x400,而如果想要手动调整窗口大小为特定尺寸的话,会非常痛苦。所以一直想找到一种方法可以快速调整窗口尺寸到指定的大小。之前尝试过 AppleScript,甚至想过开发一个 Mac 原生应用来解决,但都遇到了一些问题(主要是权限问题),直到昨天看到一篇文章启发了吾辈。之前从未使用过 Shortcuts,没想到 Mac 自带的自动化工具还不错,完全解决了吾辈的问题。

尝试

AppleScript

在早前,吾辈曾经就该问题询问过 AI,得到的答案是创建一个 AppleScript 来自动化这个操作,看起来脚本很简单。

1
2
3
4
5
6
7
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
tell process frontApp
set position of window 1 to {0, 0}
set size of window 1 to {1280, 800}
end tell
end tell

事实上,如果在 Automactor 中直接执行,也确实符合预期,可以修改窗口大小。但在吾辈将之保存为 App 后,再次运行却出现了权限错误。

1
2
Can’t get window 1 of «class prcs» "Resize1280x800" of application "System Events". Invalid index.
System Events got an error: Can’t get window 1 of process "Resize1280x800". Invalid index. (-1719)

而 System Event 也确实是给了的,不知道发生了什么。🤷

1729049171168.jpg

Mac App 开发

在使用简单的脚本实现受挫之后,吾辈考虑快速开发一个 Mac App 来解决这个问题,但实际上仍然遇到了一些问题。主要是对 Swift 没有任何先验知识,XCode 相比之前使用 IDE(Jetbrains/VSCode)非常难用,再加上 AI 对 Swift 代码生成和修改支持并不好,所以开发起来很痛苦,而且最终仍然遇到了与上面 AppleScript 类似的权限问题。吾辈猜测这是个愚蠢的问题,熟悉 Mac App 开发的人或许十分钟就能解决,但确实卡住了吾辈。

Shortcuts

终于,Shortcuts 可以以低代码的方式创建一个应用。基本思路是,获取所有窗口 => 找到置顶的窗口 => 修改窗口尺寸。

  1. 拖拽一个 Find Windows Action
    1729049857159.jpg
  2. 修改 Find Windows 配置
    1729049910814.jpg
  3. 再拖拽一个 Resize Window Action
    1729049972605.jpg
  4. 修改 Resize Window 的配置
    1729050031565.jpg
  5. 尝试运行一下,确保没有问题
    1729050099222.jpg
  6. 现在,可以使用 Spotlight Search 输入 Resize Window 来快速运行这个 Shortcut 啦
    1729050190520.jpg

另外吾辈已经把这个 Shortcut 导出放到 GitHub 上了,可以自行下载使用:https://github.com/rxliuli/mac-resize-window

参考

The Easiest Way to Resize All Windows on Your Mac Simultaneously to the Same Dimensions

旅行 2024-09

2024-09-27 16:21:46

自从九月初从广州出发继续北上旅行,刚好躲过了一次台风摩羯。虽然旅行仍然能带来一些新鲜感,但或多或少已经有些不足了,所以在江浙连续碰上两次台风之后,九月下旬便匆匆赶回来了。

cover

衡山

第一站去往了衡山,之前前往山东时没能爬上泰山,这次顺路去爬下衡山。刚到衡山就被出租车上了一课,滴滴叫了车但司机实收很低线下想让吾辈额外付费,之前在广西阳朔、后面在江苏镇江都遇到过类似的糟心事,也许越是小地方越是不讲规则?

刚到就去了附近的万寿广场,35 度的天气下对吾辈和相机都是考验。

衡山 1
衡山 2

之后前往旁边的南岳大庙,本身吾辈并不信佛道,但拍拍风景也是好的。

衡山 3
衡山 4

晚上出来拍点夜景,偶然还看到了五岳特种兵的宣传牌,之前完全不知道这个活动。

衡山 5
衡山 6

次日一早开始爬衡山,说是爬山,也是要坐很久的区间车,在最后的山顶附近才开始爬。由于雾气很大,没能拍出来什么照片。相比于爬山爱好者,来此求神拜佛者更是络绎不绝。

衡山 7
衡山 8
衡山 9

影集

长沙

第二站选在了长沙,之前去了湖南的张家界,却没去省会城市,这次补上。不过前往之前刷到了一些关于臭豆腐的坏消息,所以臭豆腐一点也没尝。虽然耳边还记得那句“来长沙不吃长沙臭豆腐等于白来”的广告语。

先去城市里随便逛逛扫个街,有一说一,第一次看到把辣条作为宣传的。

长沙 1

大楼的阴凉处,好多正在直播的人,也许附近有“直播小区”?

长沙 2

途经坡子街,到处都是小吃和茶饮,尤其是茶颜悦色,非常显眼。

长沙 3

之后,来到湘江之畔,走在翠幕之下的河岸边,可以眺望对面的橘子洲。树荫之下,人们三三两两的坐着,或是聊天,或是休息。

长沙 4

途经太平老街,中午简单吃了个饭,随即前往河对岸,只是天色渐晚,遂未登岳麓山。

长沙 5

走了一天也有些累了,之后便回到青旅暂作休息。

长沙 6

次日早晨,前往湖南省博物馆,其中付费的两个场地人数不多,值得一看,尤其是数字科技展览部分。

长沙 7
长沙 8

不幸的是,下午刚出博物馆就遇上了暴雨,无奈之下只能打车回去,暂时避雨。直到傍晚,才出来去杜甫江阁附近观赏晚霞与夜景。

长沙 9
长沙 10
长沙 11

可能唯一留下不好印象的就是小巷之中贴的到处都是的标语了。众所周知,通常缺什么才写什么。。。

1727445989235.jpg

影集

南京

第三站是南京,可以说是整个行程中最满意的一个城市,是这次旅行中照片最多的城市。

当晚就去了知名的秦淮河畔,表面看上去光鲜亮丽无比。

南京 1
南京 2

实则水质较差,隐有臭味,把相机吓得都变形了。

南京 3

第二天前往中山陵和明孝陵,后者超级超级超级大,逛了好几个小时还没走完,直到下午 4 点钟才离开。

南京 4
南京 5

在离开中山陵,进入明孝陵之后,首先碰到了钟山文学馆,江南园林看起来永远都很舒服。

南京 6

之后是颜真卿碑林,第一看到时突然想起来黑猴里面第四章见到二姐时的屋顶,可能最近看到黑猴的视频太多了 emmmm。

南京 7

临近中午到达了紫霞湖,许多大爷大妈们在这儿野餐和游泳。

南京 8

到达中午,终于抵达了明孝陵。

南京 9
南京 10

之后更是收集到了红粉黄橙白五种颜色的彼岸花。

南京 11

里面还有非常非常多的小园子,不再一一赘述。之后打算前往明长城看看,结果下午 5 点就关闭了。

次日去了鸡鸣寺和城墙,吾辈是从电视剧《明王朝》的视频中看到过这个,实际去了之后也没什么。

南京 12
南京 13

在城墙上散步时,看到一个很漂亮的小园子,只是忘记记下具体的位置了。

南京 14

影集

镇江

第四站是镇江,到的时候还不能办理入住,所以先去了金山寺转转。

镇江 1
镇江 2

第二天遇到了台风,天气非常非常糟糕,所以完全没有出门。第三天倒是去了附近的南山风景区,台风刚过,天气异常的好。

镇江 3
镇江 4
镇江 5

下山之后,附近的风景也非常棒,真的很喜欢这种蓝天白云的干净。旁边还有一处隐秘的日月潭,还有人在那里游泳。

镇江 6
镇江 7

晚上去了当地的历史文化街区,无甚新意,直接略过。

次日前往焦山,相比于自然风景似乎其历史文化意义更为丰富,不太感冒。

镇江 8
镇江 9

影集

无锡

好了,终于到达了最后一站无锡,刚经历过一场台风,下一场台风接踵而至,气的吾辈提前回广东了,可以说这是最糟糕的一站。

抵达当晚便前往惠山古镇,因为预报明天就开始有雨,在旅行中下雨是最烦人的天气了。

无锡 1

夜景还算不错?

无锡 2
无锡 3

探索阴暗的小巷子。

无锡 4

由于台风的影响,旁边的公园也没有开放。

无锡 5

嗯?这就结束了?
是的,由于下雨呆了一天,在下一场台风来临之前便坐上火车回去了。

影集

总结

可以说,出去旅行排解心情的效果愈发糟糕,还是需要寻找到主线任务。