BlackGlory / Gloria

🍂 A programmable notifier.
https://chrome.google.com/webstore/detail/cnelmenogjgobndnoddckekbojgginbn
MIT License
155 stars 6 forks source link

宣告Gloria项目的死亡 #41

Open BlackGlory opened 11 months ago

BlackGlory commented 11 months ago

失败的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:

const script = `import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.

Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.

尝试2:

const script = `import('https://esm.sh/@blackglory/wait-for@0.7.4')`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.

Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.

尝试3:

import { javascript } from 'extra-tags'

const script = esm(`import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`)
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url)

function esm(code) {
  return javascript`
    loadESMScript(${code})

    async function loadESMScript(script) {
      const blob = new Blob([script], { type: 'application/javascript' })
      const url = URL.createObjectURL(blob)
      await import(url)
      URL.revokeObjectURL(url)
    }
  `
}
Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/9fa8785b-5e3f-42fd-86df-7b845f443070' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

loadESMScript @ 321e052c-f1af-4bad-9d77-41d771f9e83e:6
321e052c-f1af-4bad-9d77-41d771f9e83e:6 Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/9fa8785b-5e3f-42fd-86df-7b845f443070' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

尝试4:

import { javascript } from 'extra-tags'

const script = esm(`import('https://esm.sh/@blackglory/wait-for@0.7.4')`)
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url)

function esm(code) {
  return javascript`
    loadESMScript(${code})

    async function loadESMScript(script) {
      const blob = new Blob([script], { type: 'application/javascript' })
      const url = URL.createObjectURL(blob)
      await import(url)
      URL.revokeObjectURL(url)
    }
  `
}
Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/e13f475e-4dbd-4dec-811e-cd417d0a37b5' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

loadESMScript @ 1b0b39d4-517b-42b2-af32-be06cf3be884:6
1b0b39d4-517b-42b2-af32-be06cf3be884:6 Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/e13f475e-4dbd-4dec-811e-cd417d0a37b5' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

实测表明针对性修改CSP也没用.

尽管我们确实可以在Worker里正常访问外部资源:

fetch('https://blackglory.me')
  .then(res => res.text())
  .then(console.log)

但这只能满足最低限度的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:

fetch('https://blackglory.me')
  .then(res => res.text())
  .then(console.log)
Access to fetch at 'https://blackglory.me/' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

原因在于iframe的origin是null, 因此不具有扩展程序的跨域能力, 在manifest.json里声明的host_permissions对iframe来说没有任何意义. 理论上, 可以通过为iframe启用allow-same-origin来使其获得与扩展程序相同的origin, 从而获得跨域能力.

尝试2:

const script = `import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to cross-origin redirects of the top-level worker script.

尝试3:

const script = `import('https://esm.sh/@blackglory/wait-for@0.7.4')`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to cross-origin redirects of the top-level worker script.

尝试4:

import { javascript } from 'extra-tags'

const script = esm(`import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`)
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url)

function esm(code) {
  return javascript`
    loadESMScript(${code})

    async function loadESMScript(script) {
      const blob = new Blob([script], { type: 'application/javascript' })
      const url = URL.createObjectURL(blob)
      await import(url)
      URL.revokeObjectURL(url)
    }
  `
}

正常运行, 至少我们有一种方式可以导入带有CORS header的外部模块.

尝试在manifest.json里添加allow-same-origin来解决跨域问题:

"content_security_policy": {
  "sandbox": "sandbox allow-scripts allow-same-origin;"
}
Invalid value for 'content_security_policy.sandbox'.

在HTML的iframe的sandbox属性上添加allow-same-origin则会静默失败.

显然, Chrome有意阻止为Sandbox启用allow-same-origin选项. 其中一个原因可能是同时启用allow-scriptsallow-same-origin能让沙盒内的代码逃逸.

至此我们陷入一个奇怪的局面:

一种解决方案是在offscreen document里向iframe暴露一个API, 使其能够访问任意外部资源. 这意味着对fetch, EventSource, WebSocket这样的Web API进行包装. 此方案的实施难度大, 兼容性差, 其中一些数据类型很可能无法在上下文之间复制或转移, 不可行.

另一种解决方案是通过Manifest V3臭名昭著的DNR为响应添加CORS header, 从而绕过跨域限制. 然而, DNR的过滤条件无法匹配到由扩展程序沙盒发出的来自opaque origin的请求.

理想状态下, 这应该适用于沙盒, 可惜它没有:

chrome.declarativeNetRequest.updateDynamicRules({
  removeRuleIds: [1]
, addRules: [
    {
      id: 1
    , condition: {
        initiatorDomains: [chrome.runtime.id]
      }
    , action: {
        type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS
      , responseHeaders: [
          {
            operation: chrome.declarativeNetRequest.HeaderOperation.SET
          , header: 'Access-Control-Allow-Origin'
          , value: '*'
          }
        ]
      }
    }
  ]
})

这适用于沙盒, 但影响了浏览器内的所有请求, 引入巨大的安全问题:

chrome.declarativeNetRequest.updateDynamicRules({
  removeRuleIds: [1]
, addRules: [
    {
      id: 1
    , condition: {}
    , action: {
        type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS
      , responseHeaders: [
          {
            operation: chrome.declarativeNetRequest.HeaderOperation.SET
          , header: 'Access-Control-Allow-Origin'
          , value: '*'
          }
        ]
      }
    }
  ]
})

另一方面, 很难用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原本预定实装的新脚本格式, 对想要开发类似项目的开发者也许会有参考价值:

// -- 此脚本的各种元数据, 语法类似于油猴脚本 --
// @name 脚本显示的名称
// @update-url 脚本的更新地址

// -- 导入外部ESM模块 --

// -- 其他只在创建Worker时运行一次的代码 --

// -- 作为ESM模块的默认项返回, 执行器将会根据返回值类型决定是否采用轮询方式 --
export default function (signal: AbortSignal):
| INotification[]
| PromiseLike<INotification[]>
| Observable<INotification>
| AsyncIterable<INotification>

接下来会发生什么?

事情还会有转机吗?

一旦我开始开发替代解决方案, 就不再可能会有转机, 因为我不能同时维护复数服务于相似目的的项目.

arpir commented 11 months ago

真是个悲伤的故事,好在我一直在用绿色版的Edge,也通过 https://gloria.pub/ 网站,学到了一些基础编写小任务的知识,就算以后脱离了网站自己也可以编写一些小任务,即使在这个项目的生命周期结束之前,后来的人也能通过 Wayback Machine 项目来查看网页的快照

最后感谢项目的开发者,向你致敬😘

BlackGlory commented 11 months ago

我在替代方案方面已经有了一些想法. 新的方案是一个命名为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 }
)

RSS订阅

// @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
BlackGlory commented 11 months ago

image

Hallu的基础功能做完了, 目前的主要障碍是Deno的开发环境太烂, 没有动力写测试, ~以及还没有想好该怎么处理Cookies~.


Gloria过去的功能请求和未实现功能, 在Hallu里已经被实现的:

BlackGlory commented 11 months ago

今天才发现不知道哪个版本的Chrome又把这个flag加回来了, 光在我记忆里这个flag已经被删除过两次.

image

image

由于我非常嫌弃Windows系统的通知系统, 我还用Electron重新实现过Chrome风格的通知. 如果Chrome能继续把这个flag保留下去, 那么开发专门用于弹出Chrome风格通知的客户端就不必要了, 只要做一个PWA就可以通杀所有支持Chrome的平台.

甚至有两个现成的项目可以改:

362227 commented 11 months ago

chrome插件别下架,我还在用

BlackGlory commented 11 months ago

扩展不会主动下架的. 如果未来发现扩展的商店页打不开了, 那也是CWS把它判定为已淘汰的扩展类型, 将其隐藏掉了. 直到这个项目彻底死亡我估计还有1年的时间.

LightAPIs commented 11 months ago

今天才发现不知道哪个版本的Chrome又把这个flag加回来了, 光在我记忆里这个flag已经被删除过两次.

😝 Chrome 里的 flag 确实经常回旋镖。

BlackGlory commented 10 months ago

替代解决方案Hallu的设计更新

弃用透明fetch的支持

Hallu原计划实现一个透明的fetch供用户脚本使用, 这意味着Hallu有一个自动处理Cookies的内置fetch. 这一计划最终被放弃, 其原因有两个:

同时被弃用的还有Hallu里与Cookies相关的配置项, 因为它们现在并不被使用. 如果用户脚本需要获取Cookies, 只需要将getCookies函数声明为参数, 并没有实现一个统一接口的必要. 同理, 如果用户脚本需要设置Cookies, 只需要声明一个setCookies参数. 当用户能够提供getCookiessetCookies时, 通过开源库来实现一个透明fetch包装并不困难, 因此也可以将包装过的fetch作为参数传给用户脚本.

这一决定会削弱用户脚本的统一性, 因为不同来源的用户脚本会要求不同的参数, 但同时也带来了更大的灵活性.

附: 透明fetch包装的实现

要实现一个透明的fetch, 需要准备一个CookieJar, 然后根据CookieJar包装fetch, 在请求时注入Cookie头, 在响应时劫持Set-Cookie头. JavaScript生态环境中已经有tough-cookie这样被大量使用的CookieJar实现, 所以不需要自行实现CookieJar.

麻烦的部分在于, 若要保持和原生fetch的行为一致, 你需要手动处理重定向等与浏览器同源策略相关的边缘情况, 这往往需要额外编写和持续维护上百行代码.

好在, 总是有一些开源项目可以直接使用:

如何利用浏览器Cookies

当用户脚本需要Cookies时, 用户希望能直接利用浏览器的Cookies.

Hallu的原始设计在利用浏览器Cookies方面非常繁琐, 它考虑额外增加这些项目:

实际上, Firefox用户有一个不错的替代方案, 因为Firefox的Cookies是毫无保护的SQLite数据库, 你可以像处理一般SQLite一样直接读写它.

对于Chromium用户, 可以使用DevTools协议Network.getCookiesNetwork.setCookies方法来获取Cookies和设置Cookies. 尽管这也有一些限制, 但比原始设计还是方便多了. 事实上, 用户脚本也可以更进一步, 直接通过DevTools协议发出请求或打开网页.

config.ts被合并到main.ts

在移除Cookies相关的配置项后, config.ts里只剩下了notify函数这一个配置项. 此时单独保留一个config.ts文件变得不再那么有意义.

于是, 原本config.ts文件里的配置项现在被移动到main.ts里, 并且直接作为start函数的选项存在. 与单独的config.ts相比, 新的设计允许用户为不同的脚本启用不同的配置项.

BlackGlory commented 9 months ago

替代解决方案Hallu的设计更新

Hallu现在是一个库

在上周的设计更新里, 我对Hallu的功能进行了简化. 今天回顾Hallu的设计时, 我发现上周的简化很有启发性. 现在Hallu变得更适合作为一个库, 而不是作为一个Git存储库存在. 既然如此, 那么就让它作为一个库存在, 你不需要学会Git.

删除用户脚本的概念

在原本的设计里, 存在用户脚本的概念, 而这个概念里的"用户脚本"实际上只是一个满足特定接口的函数. 让用户脚本成为用户脚本的, 是它的元数据.

用户脚本的元数据目前为止只有两个字段:

和油猴脚本不同, Hallu的用户脚本并没有权限相关的字段. 这是因为用户脚本直接在本机上运行, 天生具有很高的权限, 在这之上设计任何权限控制相关的字段都显得多余.

脚本的名称并不是真的很有意义, 因为我们实际上没有需要显示脚本名称的地方. 一个关键事实是, 由于用户脚本是可以重用的, 即使需要显示用户脚本的名称, 用户也会想要能够动态生成脚本名称, 而不是硬编码的名称.

唯一有用的元数据是脚本的更新链接, Hallu可以凭此检查用户脚本是否需要更新, 并在需要时完成更新. 然而, 这也是一个可以被放弃的设计, 因为低频率地手动更新少量脚本是很容易的.

如果用户有大量脚本需要更新, 或者需要高频率的更新, 乃至每次运行脚本之前都检查脚本是否需要更新, 更适合通过一个独立的脚本更新CLI程序来实现. 只要约定好格式, 这样的CLI程序不仅可以适用于Hallu用户脚本, 也可以适用于任何形式的Deno脚本, 而包括这样的CLI程序并不是Hallu需要完成的目标.

至此, 每一个需要用户脚本继续存在的理由都已经被消除, 那么用户脚本这一概念也可以被删除了: 只需要写函数就好, 函数是不是写在一个单独的脚本文件里并不重要.

~通知的id被重命名为salt~

~历史上, Gloria通知的id的意义经过修改, 这使得id这个名称变得不符合它实际上代表的东西.~ ~现在, id被重命名为salt, 即:~ ~掺入盐后, 内容相同的通知在去重时会被视作两个不同的通知.~

Deduplication Is All You Need

由于Hallu现在并不负责弹出通知, 继续使用通知结构这一约定是没有必要的. 你所需要的只是数据去重——在获取数据后滤出新数据(附带相关的持久化机制)——仅此而已.

BlackGlory commented 9 months ago

原定的Gloria继任项目Hallu现已被Deno包extra-deduplicator替代, 展开上方被标记为过时的评论可以找到这么做的原因.

extra-deduplicator项目是直接在Hallu项目的基础上重写的, ce96a1a21471fcd703c74b17dc829240364852c4为Hallu的最后一次提交, 之后的提交为extra-deduplicator的代码.

提供类Chrome通知的跨平台桌面应用程序notifier现在也已基本可用, 它基本上使用与Gloria相同的通知结构.

BlackGlory commented 5 months ago

gloria.pub网站现已下线, 域名将于1个月后到期, 期间访问网站将跳转至本页.

这是网站所有任务脚本的数据库dump: gloria.pub scripts.json.