Open BlackGlory opened 11 months ago
真是个悲伤的故事,好在我一直在用绿色版的Edge,也通过 https://gloria.pub/ 网站,学到了一些基础编写小任务的知识,就算以后脱离了网站自己也可以编写一些小任务,即使在这个项目的生命周期结束之前,后来的人也能通过 Wayback Machine 项目来查看网页的快照。
最后感谢项目的开发者,向你致敬😘
我在替代方案方面已经有了一些想法. 新的方案是一个命名为Hallu的项目, ~它本身是一个Git存储库, 用户需要将存储库clone到本地,~ 针对自己的需要修改配置文件, 然后通过Deno运行时启动它.
config.ts
~~使用者需要手动编辑存储库根目录下的config.ts
文件以实现Cookies获取和通知弹出/转发.~
~这是暂定的默认实现的样子:~
import { config } from '@src/types.ts'
import { Notification } from 'https://deno.land/x/deno_notify@1.4.3/ts/mod.ts'
export default config({
getCookies() {
return null
}
, notify(notifications) {
notifications.forEach(notification => {
const instance = new Notification()
if (notification.title) {
instance.title(notification.title)
}
if (notification.message) {
instance.body(notification.message)
}
instance.show()
})
}
})
main.ts
~~使用者需要手动编辑存储库根目录下的main.ts
文件以决定启动哪些脚本, 你可以通过参数来复用用户脚本, 包含一定的配置项.~
~事实上你可以在这里将被运行的用户脚本包装进Worker, 但这对于大多数用户脚本来说没有必要.~
import { start } from '@src/start.ts'
import startup from '@scripts/startup.ts'
import subscribeRSS from '@scripts/subscribe-rss.ts'
import watchPageChanges from '@scripts/watch-page-changes.ts'
start(startup(), {
once: true
, ignoreInitialCommit: false
, ignoreStartupCommit: false
})
start(subscribeRSS('https://news.ycombinator.com/rss'))
start(watchPageChanges({
name: 'Hacker News'
, url: 'https://news.ycombinator.com/'
, selector: 'body'
}))
// @name Startup notification
import { script, Mode } from '@src/script.ts'
export default script(
() => ({
id: Date.now().toString()
, title: 'Hallu started'
})
, { mode: Mode.Passthrough }
)
// @name Subscribe to RSS Feed
import { parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts'
import { unescape } from 'https://deno.land/std@0.207.0/html/mod.ts'
import { firstNotNullishOf } from 'https://deno.land/std@0.207.0/collections/mod.ts'
import { script, Mode } from '@src/script.ts'
export default script(
async (url: string) => {
const xml = await fetch(url).then(res => res.text())
const feed = await parseFeed(xml)
return feed.entries.map(entry => ({
id: entry.id
, title: entry.title?.value
? unescape(entry.title?.value)
: undefined
, message: entry.content?.value
? unescape(entry.content.value)
: undefined
, url: firstNotNullishOf(entry.links, link => link.href)
}))
}
, { mode: Mode.KeepDiff }
)
// @name Watch page changes
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.43/deno-dom-wasm.ts'
import { assert } from 'https://deno.land/std@0.207.0/assert/mod.ts'
import { script, Mode } from '@src/script.ts'
const parser = new DOMParser()
export default script(
async ({ name, url, selector }: {
name: string
url: string
selector: string
}) => {
const html = await fetch(url).then(res => res.text())
const document = parser.parseFromString(html, 'text/html')
assert(document)
const element = document.querySelector(selector)
assert(element)
return {
id: element.outerHTML
, title: `${name} Changed`
, url
}
}
, { mode: Mode.KeepLatestDiff }
)
# 以开发模式启动项目
deno task dev
# 将项目编译为可执行文件
deno task build
# 启动可执行文件
deno task start
# 试运行用户脚本
deno task test <script-relative-filename>
# 更新用户脚本(基于用户脚本的元数据`@update-url`)
deno task update <script-relative-filename>
# 更新所有用户脚本(基于用户脚本的元数据`@update-url`)
deno task update-all
# 清空存储数据
deno task clean <id>
# 清空所有存储数据
deno task clean-all
Hallu的基础功能做完了, 目前的主要障碍是Deno的开发环境太烂, 没有动力写测试, ~以及还没有想好该怎么处理Cookies~.
Gloria过去的功能请求和未实现功能, 在Hallu里已经被实现的:
id
字段现在可以是number
类型, 主要区别在于可以用Date.now()
替代Date.now().toString()
.~expires?: number
(以毫秒为单位的Unix时间戳)以满足让通知过期的需求.~
~具体如何处理过期通知需要由通知器决定.~@update-url
, 从而允许通过网络更新脚本.~ignoreInitialCommit: boolean
: 是否忽略掉生成的首个commit.ignoreStartupCommit: boolean
: 是否忽略掉此次启动后生成的首个commit.interval: number
: 重新运行的时间间隔(毫秒).~once: boolean
: 是否只需要此次启动后运行一次.~id: string
: 标识名, 用于生成存储路径.
该属性可以省略, 省略的情况下, 会生成一个临时的标识名, 存储数据会在Hallu退出时被删除.commit
函数被移除, 以返回值类型Awaitable | Observable | Iterable | AsyncIterable
替代.~notify
函数的中间环节:~今天才发现不知道哪个版本的Chrome又把这个flag加回来了, 光在我记忆里这个flag已经被删除过两次.
由于我非常嫌弃Windows系统的通知系统, 我还用Electron重新实现过Chrome风格的通知. 如果Chrome能继续把这个flag保留下去, 那么开发专门用于弹出Chrome风格通知的客户端就不必要了, 只要做一个PWA就可以通杀所有支持Chrome的平台.
甚至有两个现成的项目可以改:
chrome插件别下架,我还在用
扩展不会主动下架的. 如果未来发现扩展的商店页打不开了, 那也是CWS把它判定为已淘汰的扩展类型, 将其隐藏掉了. 直到这个项目彻底死亡我估计还有1年的时间.
今天才发现不知道哪个版本的Chrome又把这个flag加回来了, 光在我记忆里这个flag已经被删除过两次.
😝 Chrome 里的 flag 确实经常回旋镖。
Hallu原计划实现一个透明的fetch
供用户脚本使用, 这意味着Hallu有一个自动处理Cookies的内置fetch
.
这一计划最终被放弃, 其原因有两个:
fetch
是不够的, 还有像WebSocket
和EventSource
这样的API需要凭据, 凭据也未必会保存成Cookies, 需要实现的东西似乎无穷无尽.fetch
的行为与浏览器的行为保持一致会对项目维护带来隐患.同时被弃用的还有Hallu里与Cookies相关的配置项, 因为它们现在并不被使用.
如果用户脚本需要获取Cookies, 只需要将getCookies
函数声明为参数, 并没有实现一个统一接口的必要.
同理, 如果用户脚本需要设置Cookies, 只需要声明一个setCookies
参数.
当用户能够提供getCookies
和setCookies
时, 通过开源库来实现一个透明fetch
包装并不困难,
因此也可以将包装过的fetch
作为参数传给用户脚本.
这一决定会削弱用户脚本的统一性, 因为不同来源的用户脚本会要求不同的参数, 但同时也带来了更大的灵活性.
fetch
包装的实现要实现一个透明的fetch
, 需要准备一个CookieJar, 然后根据CookieJar包装fetch
, 在请求时注入Cookie
头, 在响应时劫持Set-Cookie
头.
JavaScript生态环境中已经有tough-cookie这样被大量使用的CookieJar实现, 所以不需要自行实现CookieJar.
麻烦的部分在于, 若要保持和原生fetch
的行为一致, 你需要手动处理重定向等与浏览器同源策略相关的边缘情况, 这往往需要额外编写和持续维护上百行代码.
好在, 总是有一些开源项目可以直接使用:
当用户脚本需要Cookies时, 用户希望能直接利用浏览器的Cookies.
Hallu的原始设计在利用浏览器Cookies方面非常繁琐, 它考虑额外增加这些项目:
实际上, Firefox用户有一个不错的替代方案, 因为Firefox的Cookies是毫无保护的SQLite数据库, 你可以像处理一般SQLite一样直接读写它.
对于Chromium用户, 可以使用DevTools协议的Network.getCookies
和Network.setCookies
方法来获取Cookies和设置Cookies.
尽管这也有一些限制, 但比原始设计还是方便多了.
事实上, 用户脚本也可以更进一步, 直接通过DevTools协议发出请求或打开网页.
config.ts
被合并到main.ts
在移除Cookies相关的配置项后, config.ts
里只剩下了notify
函数这一个配置项.
此时单独保留一个config.ts
文件变得不再那么有意义.
于是, 原本config.ts
文件里的配置项现在被移动到main.ts
里, 并且直接作为start
函数的选项存在.
与单独的config.ts
相比, 新的设计允许用户为不同的脚本启用不同的配置项.
在上周的设计更新里, 我对Hallu的功能进行了简化. 今天回顾Hallu的设计时, 我发现上周的简化很有启发性. 现在Hallu变得更适合作为一个库, 而不是作为一个Git存储库存在. 既然如此, 那么就让它作为一个库存在, 你不需要学会Git.
在原本的设计里, 存在用户脚本的概念, 而这个概念里的"用户脚本"实际上只是一个满足特定接口的函数. 让用户脚本成为用户脚本的, 是它的元数据.
用户脚本的元数据目前为止只有两个字段:
@name
: 脚本的名称@update-url
: 脚本的更新链接和油猴脚本不同, Hallu的用户脚本并没有权限相关的字段. 这是因为用户脚本直接在本机上运行, 天生具有很高的权限, 在这之上设计任何权限控制相关的字段都显得多余.
脚本的名称并不是真的很有意义, 因为我们实际上没有需要显示脚本名称的地方. 一个关键事实是, 由于用户脚本是可以重用的, 即使需要显示用户脚本的名称, 用户也会想要能够动态生成脚本名称, 而不是硬编码的名称.
唯一有用的元数据是脚本的更新链接, Hallu可以凭此检查用户脚本是否需要更新, 并在需要时完成更新. 然而, 这也是一个可以被放弃的设计, 因为低频率地手动更新少量脚本是很容易的.
如果用户有大量脚本需要更新, 或者需要高频率的更新, 乃至每次运行脚本之前都检查脚本是否需要更新, 更适合通过一个独立的脚本更新CLI程序来实现. 只要约定好格式, 这样的CLI程序不仅可以适用于Hallu用户脚本, 也可以适用于任何形式的Deno脚本, 而包括这样的CLI程序并不是Hallu需要完成的目标.
至此, 每一个需要用户脚本继续存在的理由都已经被消除, 那么用户脚本这一概念也可以被删除了: 只需要写函数就好, 函数是不是写在一个单独的脚本文件里并不重要.
~历史上, Gloria通知的id的意义经过修改, 这使得id这个名称变得不符合它实际上代表的东西.~ ~现在, id被重命名为salt, 即盐:~ ~掺入盐后, 内容相同的通知在去重时会被视作两个不同的通知.~
由于Hallu现在并不负责弹出通知, 继续使用通知结构这一约定是没有必要的. 你所需要的只是数据去重——在获取数据后滤出新数据(附带相关的持久化机制)——仅此而已.
原定的Gloria继任项目Hallu现已被Deno包extra-deduplicator替代, 展开上方被标记为过时的评论可以找到这么做的原因.
extra-deduplicator项目是直接在Hallu项目的基础上重写的, ce96a1a21471fcd703c74b17dc829240364852c4为Hallu的最后一次提交, 之后的提交为extra-deduplicator的代码.
提供类Chrome通知的跨平台桌面应用程序notifier现在也已基本可用, 它基本上使用与Gloria相同的通知结构.
gloria.pub网站现已下线, 域名将于1个月后到期, 期间访问网站将跳转至本页.
这是网站所有任务脚本的数据库dump: gloria.pub scripts.json.
失败的Manifest V3迁移之旅
Gloria是建立在Manifest V2之上的浏览器扩展程序, Manifest V2现已被Manifest V3替代, 最终会失去浏览器支持, 详见Chrome的Manifest V2支持时间表. 在尝试将Gloria从Manifest V2迁移至Manifest V3的过程中, 我们遇到了无法克服的障碍, 这导致迁移无法完成, 项目因此走向终结.
Offscreen Documents + Web Workers
Manifest V3的Service Worker限制了执行动态代码的能力, 因此我们需要通过offscreen document来绕过限制.
在offscreen document里, 存在一个奇妙的例外允许执行动态代码, 尚不确定这是否属于安全漏洞.
借助这一例外, 仍然不足以运行预期中的Gloria脚本, 因为Worker无法导入外部模块(原本使用内置模块
gloria-utils
的做法因为无法实现对依赖项的版本控制, 遭到废弃).尝试1:
尝试2:
尝试3:
尝试4:
实测表明针对性修改CSP也没用.
尽管我们确实可以在Worker里正常访问外部资源:
但这只能满足最低限度的Gloria脚本用例. 例如, 你可能会需要JSDOM, 因为你需要DOMParser来解析HTML或XML(原生Web Workers环境里并不存在DOMParser).
总之, 直接在offscreen document里执行动态代码的做法并不怎么靠谱:
Offscreen Documents + Iframe + Web Workers
Manifest V3实际上也有正规的执行不安全代码的方法, 即从Manifest V2就有的基于iframe的沙盒. 对于Gloria的用例, 需要在offscreen document里创建和使用基于iframe的沙盒.
最初, 我对此方案很有信心, 毕竟官方已经给出了执行不安全代码的方法, 还能出什么错呢?
尝试1:
原因在于iframe的origin是
null
, 因此不具有扩展程序的跨域能力, 在manifest.json
里声明的host_permissions
对iframe来说没有任何意义. 理论上, 可以通过为iframe启用allow-same-origin
来使其获得与扩展程序相同的origin, 从而获得跨域能力.尝试2:
尝试3:
尝试4:
正常运行, 至少我们有一种方式可以导入带有CORS header的外部模块.
尝试在
manifest.json
里添加allow-same-origin
来解决跨域问题:在HTML的iframe的sandbox属性上添加
allow-same-origin
则会静默失败.显然, Chrome有意阻止为Sandbox启用
allow-same-origin
选项. 其中一个原因可能是同时启用allow-scripts
和allow-same-origin
能让沙盒内的代码逃逸.至此我们陷入一个奇怪的局面:
一种解决方案是在offscreen document里向iframe暴露一个API, 使其能够访问任意外部资源. 这意味着对
fetch
,EventSource
,WebSocket
这样的Web API进行包装. 此方案的实施难度大, 兼容性差, 其中一些数据类型很可能无法在上下文之间复制或转移, 不可行.另一种解决方案是通过Manifest V3臭名昭著的DNR为响应添加CORS header, 从而绕过跨域限制. 然而, DNR的过滤条件无法匹配到由扩展程序沙盒发出的来自opaque origin的请求.
理想状态下, 这应该适用于沙盒, 可惜它没有:
这适用于沙盒, 但影响了浏览器内的所有请求, 引入巨大的安全问题:
另一方面, 很难用DNR维持Gloria现有的
Cookie
,Referer
,Origin
动态注入能力, 这破坏了Gloria订阅私人消息的用例.对Gloria的事后验尸
我在Gloria上的大多数技术决策都受到开发Gloria时的时代局限, 在这方面我不认为有做错什么. 在开发Gloria时, JavaScript被CoffeeScript替代, 因此我选择用CoffeeScript的超集LiveScript来开发, ESM支持和ESM CDN不存在, 流行模块标准至少有三个, 大多数MVVM都被Angular带上了双向数据绑定的弯路, TypeScript则根本没几个人使用. 如今JavaScript已经发展到ES2023, 我们有了原生的ESM支持, 有像https://esm.sh这样的ESM CDN, 有React这样成熟的MVVM框架, 并且大多数仍被使用的npm模块要么用TypeScript重写, 要么具有TypeScript类型定义.
现有的Web技术是当年难以想象的, Gloria项目最失败的部分是没有跟上Web技术的步伐, 这都是因为我在开发Gloria时没有采用一个易于维护的架构. 当Gloria的代码逐渐变得陈旧, 任何大的改变都需要以重写的形式来实现时, 项目的发展理所当然地停滞了. 最终, 重写没有到来, 到来的是Manifest V3替代Manifest V2的历史车轮.
这是Gloria原本预定实装的新脚本格式, 对想要开发类似项目的开发者也许会有参考价值:
接下来会发生什么?
事情还会有转机吗?
一旦我开始开发替代解决方案, 就不再可能会有转机, 因为我不能同时维护复数服务于相似目的的项目.