dsh-plugin-wallpaper-engine-about-notice 0.0.0-stage → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +14 -0
- package/icon.png +0 -0
- package/lib/index.js +125 -0
- package/lib/routes/about-qr.js +92 -0
- package/lib/routes/github-stars.js +143 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +27 -4
- package/README.md +0 -3
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# cordis.patch.yml — 「关于 / 更新提示」子包自己的行(ADR-0014 S4.1 / S4.2 / S4.4)。
|
|
2
|
+
#
|
|
3
|
+
# ⚠️ 一次安装形态(主包 patch 用 name 引用子包,ADR-0014 S4.1)下,这一行由**主包**的
|
|
4
|
+
# cordis.patch.yml 插入;只有把这个子包自己当成独立 bundle 时
|
|
5
|
+
# (各自 dsh.bundle.patch + 各自进 dsh.profile.bundles,**不采纳**)本文件才生效。
|
|
6
|
+
# 两处同时插同一个 id 会撞车 ⇒ 接线时以主包那份为准,本文件保持与它逐字一致(ADR-0014 S4.2)。
|
|
7
|
+
#
|
|
8
|
+
# 行名 = 真实包名(裸真名):本版 DSH 不传 bareModuleBaseUrl ⇒ 行名一律从 <profile>/cordis.yml
|
|
9
|
+
# 旁起算,裸名 → <profile>/node_modules/<name>;开发期靠 scripts/dev-link-subpackages.mjs 建的
|
|
10
|
+
# junction 让这个名字出现在 profile/node_modules 里(ADR-0014 S4.4),发布期靠传递依赖 hoist。
|
|
11
|
+
# id 一旦定下不再改(ADR-0014 S4.3)。
|
|
12
|
+
- insert:
|
|
13
|
+
- id: wallpaper-engine-about-notice
|
|
14
|
+
name: 'dsh-plugin-wallpaper-engine-about-notice'
|
package/icon.png
ADDED
|
Binary file
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `about-notice` 子包宿主入口(P-F 第一格:`lib/components/about-notice/` → `packages/about-notice/`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么这一格先出:它那两条路由与主包其余路由**没有共享可变状态**(规模由
|
|
5
|
+
* `node test/tools/host-route-index.mjs` 现算),出户成本最低,而收益是宿主可以把
|
|
6
|
+
* `cordis.patch.yml` 里这一行单独 disable(其余各行一个不动)。
|
|
7
|
+
*
|
|
8
|
+
* 两处加载路径共用同一份实现(照抄 dsh-ops 的组件真形):
|
|
9
|
+
* ① 宿主把 patch 行的 `name` 当**独立 plugin** 加载 ⇒ `apply(ctx, config)` 是入口;
|
|
10
|
+
* ② 门面的 `applyComponent(root, rawConfig, 'about-notice')` 是"按组件加载"入口。
|
|
11
|
+
* P-F 之后两条路径的 deps **不再是两份各写各的字面量**:门面与入口都从
|
|
12
|
+
* `lib/subpackage-boot.js` 的 `subpackageKeys('about-notice', ctx)` 取**同一份冻结键视图**
|
|
13
|
+
* ⇒ 两条路径不可能漂移(对账面 = `test/verify-inject-contracts.mjs` ② 的叶子冻结表逐键对账)。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **跨包纪律(ADR-0014 S5.1 / S5.2,判据 `test/verify-subpackage-purity.mjs`)**:本模块
|
|
16
|
+
* **零主包说明符、零相对路径逃出本包** —— 只 import 本包内的路由族与 `node:` 内建。宿主能力一律
|
|
17
|
+
* 经 `ctx.wallpaperEngineHost` 的键视图(`host.keys('about-notice', ctx)`)取。这不是风格问题:
|
|
18
|
+
* 相对 import 主包文件、或 import 主包名,都是该判据的硬红。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **取服务姿势**:`inject` 必须先声明(`webServer` + `wallpaperEngineHost`)再读
|
|
21
|
+
* `ctx.<服务>`。cordis 的 reflect `get` 对「未声明 inject 就取的服务属性」当场抛
|
|
22
|
+
* `cannot get property "webServer" without inject at applyImpl`(真机原文),随后的早退分支
|
|
23
|
+
* 永远执行不到(死代码),两条装载路径双双失败、该组件的路由全 404。声明后由 Loader 保证
|
|
24
|
+
* 「服务就绪才激活」,`applyImpl` 里的早退只兜「服务在、但路由表还没挂上」这一种情况。
|
|
25
|
+
* 对应判据:`test/verify-bundle-rows.mjs` ⑧(入口读的每个 `ctx.<服务>` 都必须在其 inject 里)。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **teardown 得交两份(关掉再打开子组件会撞 `duplicate … route`)**:本模块导出的 `apply`
|
|
28
|
+
* 是**函数声明**(有 `prototype`)⇒ cordis 的 loader 走 `new apply(ctx, config)` 分支、
|
|
29
|
+
* **返回值被丢弃** ⇒ 只 `return keys.runComponent(…)` 的话 fiber 卸载时没人撤路由(旧注册留在
|
|
30
|
+
* 宿主路由表里),下一次启用立刻撞「重复路由」。所以返回值一律走 `keys.handOffTeardown(ctx, …)`:
|
|
31
|
+
* 同一份 disposer 也交给 `ctx.effect`(cordis 的显式清理通道)并做幂等包装。见 `lib/host-boot.js`。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ **模块作用域纪律(P2-1b 定,P-F 后仍适用)**:模块作用域(顶层、任何函数体之外)只许放
|
|
34
|
+
* 声明/常量 —— 不得在模块求值期去读别的模块的绑定并调用它(那一刻对方可能还没求值完,抛
|
|
35
|
+
* `ReferenceError: Cannot access '…' before initialization`,P2-1b NC1 实测报错原文)。
|
|
36
|
+
*/
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
// 本包内的两条路由族(`lib/routes/*.js` 是路由族的**唯一**落点:落在别处 `docs/ROUTE-INDEX.md`
|
|
39
|
+
// 会静默少行,见 test/tools/host-route-index.mjs 的候选面)。
|
|
40
|
+
import { registerGithubStarsRoutes } from './routes/github-stars.js';
|
|
41
|
+
import { registerAboutQrRoutes } from './routes/about-qr.js';
|
|
42
|
+
|
|
43
|
+
/** patch 行 slug(`cordis.patch.yml` 里该行 `name` 的完整值,两处逐字相等)。 */
|
|
44
|
+
export const name = 'dsh-plugin-wallpaper-engine-about-notice';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 硬依赖 web 服务 + 主包宿主服务:与主行 `lib/index.js` 的 `inject` 同一套语义。
|
|
48
|
+
* 组件行走 patch 行加载时,Loader 靠这两句保证「服务就绪才激活」,`ctx.webServer` /
|
|
49
|
+
* `ctx.wallpaperEngineHost` 才读得到。
|
|
50
|
+
*/
|
|
51
|
+
export const inject = ['webServer', 'wallpaperEngineHost'];
|
|
52
|
+
|
|
53
|
+
/** 独立 plugin 入口:宿主按 patch 行加载本子包时走这里,转调本模块的实现(不经门面)。 */
|
|
54
|
+
export function apply(ctx, config) {
|
|
55
|
+
// ⚠️ 这个字面形态是**承重**的:`test/verify-inject-contracts.mjs` 的 `tableBindingNames()` 按
|
|
56
|
+
// `const <host> = ctx.<服务名>` 认「本文件里的冻结表绑定」(`recvRe`),再据此把下面调用点的
|
|
57
|
+
// `...keys` 展开成叶子冻结表。写成 `ctx && ctx.<服务名>` 守卫就认不出表 ⇒ 整包调用点判 dynamic
|
|
58
|
+
// 而红(nc-sub7 钉的就是这条:接收者必须源自 `ctx.<服务名>`)。
|
|
59
|
+
const host = ctx.wallpaperEngineHost;
|
|
60
|
+
// 拿不到服务名就大声失败:静默 `undefined` 会让下面每一步都在 undefined 上取属性(半死态)。
|
|
61
|
+
if (!host) {
|
|
62
|
+
throw new Error('[wallpaper-engine] about-notice 没拿到 wallpaperEngineHost 服务'
|
|
63
|
+
+ '(inject 声明与宿主装载顺序不匹配)');
|
|
64
|
+
}
|
|
65
|
+
// 键视图 = `lib/subpackage-boot.js` 的 SUBPACKAGE_KEY_TABLE 里本包那一行(值全部来自主包模块级绑定)。
|
|
66
|
+
// 记账 / 错误留证 / 「失败仍原样抛」三件事都在 `keys.runComponent()`(门面 `applyComponent`
|
|
67
|
+
// 走的是同一个函数)⇒ 两条装载路径共用一份语义,不可能一条记了、另一条忘了。
|
|
68
|
+
const keys = host.keys('about-notice', ctx);
|
|
69
|
+
// `http` 面(`sendJson` / `bodyReader`)不在冻结键表里,与 `packages/fonts` 同款:按需显式带上。
|
|
70
|
+
const deps = Object.freeze({ ...keys, http: host.http });
|
|
71
|
+
return keys.handOffTeardown(ctx, keys.runComponent('about-notice', ctx, config, deps, applyImpl));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 组件实现。第三个形参名取 `c`(与 `lib/kernel/host-session.js` 同一约定):
|
|
76
|
+
* `test/tools/host-route-index.mjs --deps` 按 `const { … } = c;` 现抽契约;路由族调用点的
|
|
77
|
+
* `...keys` 展开则由 `test/verify-inject-contracts.mjs` 按冻结表认(`keys` 与 `apply()` 里那个
|
|
78
|
+
* 绑定同名 ⇒ 两边指的是同一份键视图)。
|
|
79
|
+
*/
|
|
80
|
+
export function applyImpl(root, _rawConfig, c) {
|
|
81
|
+
const keys = c;
|
|
82
|
+
const { base, pluginName, repoSlugFromPkg, pluginDataDir, componentViewFrom, createComponentSession } = c;
|
|
83
|
+
// 两条装载路径都能这么读:① 组件行自己声明了 `inject = ['webServer', 'wallpaperEngineHost']`;② 门面路径的 `root`
|
|
84
|
+
// 就是门面的 ctx,而门面同样声明了 `inject = ['webServer']`(主行 `lib/index.js` 的 `inject` 声明)。
|
|
85
|
+
const webServer = root && root.webServer;
|
|
86
|
+
// 与门面 `apply()` 同款早退:服务在、但 register 面还没挂上(或换成了别的 webServer 实现)。
|
|
87
|
+
if (!webServer || typeof webServer.register !== 'function') return () => {};
|
|
88
|
+
|
|
89
|
+
// P2-7B 注入面收窄:从内核取**本组件声明的视图**(`COMPONENT_VIEW_KEYS['about-notice']`),
|
|
90
|
+
// 不再从整个 `hostKernel` 现解构 —— 后者等于把整个内核(键数与分级由
|
|
91
|
+
// `test/verify-component-face.mjs` 现算)当成注入面(M9 的现状)。视图缺键即抛,不静默 `undefined`。
|
|
92
|
+
const view = componentViewFrom('about-notice');
|
|
93
|
+
// 迷你会话(disposers / activeStreams / mediaMap / log / serveFile / 同序 teardown)收在内核侧;
|
|
94
|
+
// serve 套件的入参(`SESSION_SERVE_KEYS`:appendDiagLine / traceRequests / traceMediaRequests /
|
|
95
|
+
// readWebShim / buildSeedScript)由会话从视图取 ⇒ 它们不再出现在组件入口的解构面里。
|
|
96
|
+
const { log, disposers, serveFile, teardown } = createComponentSession(root, { base, pluginName }, view);
|
|
97
|
+
const { ABOUT_DIR } = view;
|
|
98
|
+
|
|
99
|
+
// ⚠️ **注册期必须包 try**(P2-4):本实现要注册**两条**路由族,第 N 条抛错时前面 N-1 条已经
|
|
100
|
+
// 把 disposer 推进了 `disposers`。若不在这里收尾就往外抛,`teardown` 就**永远不会交回宿主**
|
|
101
|
+
// (`runComponent()` 只在 impl 正常返回时拿到它)⇒ 已注册的路由留在宿主路由表里,下一次启用
|
|
102
|
+
// 立刻撞 `webserver: duplicate … route`。收尾失败不掩盖原始错误:teardown 自己吞异常,
|
|
103
|
+
// 原错照抛(装载失败必须仍然是红的)。
|
|
104
|
+
try {
|
|
105
|
+
// 「关于」页签的仓库 star 数 → lib/routes/github-stars.js。**只读**一条腿:本插件唯一一处
|
|
106
|
+
// 出站请求(api.github.com),带 10 分钟 TTL + 落盘缓存兜底;一键 star 不做(GitHub 要点星
|
|
107
|
+
// 必须有用户凭据,插件不存任何 token)。
|
|
108
|
+
registerGithubStarsRoutes(webServer, {
|
|
109
|
+
...keys, disposers, base, repoSlug: repoSlugFromPkg(),
|
|
110
|
+
cachePath: () => join(pluginDataDir(), 'star-count.json'),
|
|
111
|
+
log, http: keys.http,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 「关于」页签的两张联系方式二维码(静态 PNG,白名单直出)→ lib/routes/about-qr.js。
|
|
115
|
+
registerAboutQrRoutes(webServer, { ...keys, disposers, base, aboutDir: ABOUT_DIR, serveFile });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
teardown();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 本组件自己的收尾(会话的 teardown,见 lib/kernel/component-session.js):与 host-session 的
|
|
122
|
+
// teardown 同序(逐个 disposer → destroy 在途流 → 清两个容器),但**没有** releaseKernelLease
|
|
123
|
+
// —— 见文件头那条有意不对称。
|
|
124
|
+
return teardown;
|
|
125
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/about-notice/lib/routes/about-qr.js — 「关于」页签那两张联系方式二维码 + 「更新公告」配图的**静态资源路由**:
|
|
3
|
+
* `GET/HEAD <BASE>/about-qr/<文件名>`(`lib/about/` 下的 PNG/JPEG,白名单里没有的一律 404)。
|
|
4
|
+
* 公告配图(`update-notice.jpg`)与二维码同走这一族:同为随包静态图、同为"会换"的图
|
|
5
|
+
* (群号换了就得换码、公告配图随版本换图),URL 段沿用 `/about-qr/` 前缀 —— 本文件头注释
|
|
6
|
+
* 就是口径,改名反而会 churn 两处稳定 URL。
|
|
7
|
+
*
|
|
8
|
+
* 为什么单独成文件:这是全插件**唯一一处"把随包文件按 URL 直出"**的资源族(其余静态面要么是
|
|
9
|
+
* 用户数据 `/media`,要么是渲染页 `/scene-live`),而"URL 段怎么变成路径"必须能在一处读完 ——
|
|
10
|
+
* 这段代码离用户输入最近。
|
|
11
|
+
*
|
|
12
|
+
* 契约:`registerAboutQrRoutes(webServer, c)`;`c` 里是这一族用到但不属于它的东西:
|
|
13
|
+
* · `disposers` / `base: BASE` ← 清理句柄与路径前缀
|
|
14
|
+
* · `aboutDir` ← `lib/about/` 的绝对路径(包内资源,只读)
|
|
15
|
+
* · `serveFile` ← 出字节的统一出口(mime / Range / 条件 GET 都在它手里)
|
|
16
|
+
*
|
|
17
|
+
* 不变量(每条都有守卫):
|
|
18
|
+
* · **路径只能来自白名单**:URL 段**不做**任何拼接 —— 先用 `decodeURIComponent` 取到名字,
|
|
19
|
+
* 再在 `aboutQrFile(name)` 里**查表**换路径。没进表的名字连 `join()` 都不会被调用,
|
|
20
|
+
* 于是 `..`、绝对路径、NUL、编码穿越在结构上就不可能落到白名单之外(第二道是
|
|
21
|
+
* `serveFile` 前的存在性判断)。
|
|
22
|
+
* · **只认 GET/HEAD**:其余方法 405(本路由没有写面)。
|
|
23
|
+
* · **304 由 serveFile 负责**:`revalidate` 让应答带 ETag(`size-mtime`) + `no-cache`,
|
|
24
|
+
* 换掉 `lib/about/` 里的 PNG 后客户端**立刻**拿到新图(这是一张会变的图:群号换了就得换码)。
|
|
25
|
+
* · **错误响应不带缓存**(`serveFile` 的既有口径):Electron 缓存过 404 之后即使文件到位
|
|
26
|
+
* 也会一直显示旧的错误页。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { resolve, relative, isAbsolute } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 白名单:URL 段 → 文件名。**唯一**把请求映射到磁盘的地方。
|
|
33
|
+
* 名字用 `String` 的精确比较(`===`),不看前缀/后缀 —— 于是 `qq-group.png.bak`、
|
|
34
|
+
* `./qq-group.png`、`qq-group.png/` 之类都过不了。
|
|
35
|
+
*/
|
|
36
|
+
const ABOUT_QR_FILES = ['qq-group.png', 'douyin-group.png', 'update-notice.jpg'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 命中白名单返回绝对路径(含目录包含性复查),否则 null。
|
|
40
|
+
* ⚠️ **不导出**:路由族模块(`lib/routes/*.js` 或组件目录里的实现文件)里每个导出函数都会被
|
|
41
|
+
* 路由索引当成一个"注册入口"去解析 context 契约(多导一个普通函数会让那条判据误红)——
|
|
42
|
+
* 这条规则由 `verify-route-index` 钉住。
|
|
43
|
+
* 要测它就走路由(穿越 / 编码 / 未登记名字一律 404)。
|
|
44
|
+
*/
|
|
45
|
+
function aboutQrFile(name, aboutDir) {
|
|
46
|
+
const hit = ABOUT_QR_FILES.find((f) => f === String(name));
|
|
47
|
+
if (!hit) return null;
|
|
48
|
+
const dir = resolve(aboutDir);
|
|
49
|
+
const abs = resolve(dir, hit);
|
|
50
|
+
// 第二道(对将来的白名单维护者):文件名必须是该目录的**直接子项**。
|
|
51
|
+
// 现在这张表里不可能有 `..`,但表是人维护的 —— 判据不依赖"人不犯错"。
|
|
52
|
+
//
|
|
53
|
+
// ⚠️ 判据必须**分隔符无关**:`relative()`,不是 `abs.startsWith(dir + '/')`。
|
|
54
|
+
// **实测(CI windows-latest)**:前缀写法在 Windows 上必然判 null ——
|
|
55
|
+
// `resolve()` 产出的是反斜杠路径,而前缀拼的是正斜杠 ⇒ 连白名单命中的图都 404
|
|
56
|
+
// (本机 macOS 全绿、CI 红,症状就是这个)。
|
|
57
|
+
// 这条判据与 lib/routes/fontsets.js 的 `insideDir`、lib/upload-store.js 的 `resolveUploadFile`
|
|
58
|
+
// **逐字同一条**。
|
|
59
|
+
const rel = relative(dir, abs);
|
|
60
|
+
return rel && !rel.startsWith('..') && !isAbsolute(rel) ? abs : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 注入契约(P2-7A / ADR-0013 D12):本族从 `c` 消费的**必需**键 —— 门面/组件行调用点必须逐键给到(判据 test/verify-inject-contracts.mjs 双向对账)。 */
|
|
64
|
+
export const DEPS = ['disposers', 'base', 'aboutDir', 'serveFile'];
|
|
65
|
+
|
|
66
|
+
export function registerAboutQrRoutes(webServer, c) {
|
|
67
|
+
const { disposers, base: BASE, aboutDir, serveFile } = c;
|
|
68
|
+
|
|
69
|
+
disposers.push(webServer.register({
|
|
70
|
+
kind: 'prefix',
|
|
71
|
+
path: `${BASE}/about-qr`,
|
|
72
|
+
handler: (req, res) => {
|
|
73
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
74
|
+
if (method !== 'GET' && method !== 'HEAD') { res.statusCode = 405; res.end(); return; }
|
|
75
|
+
const headOnly = method === 'HEAD';
|
|
76
|
+
// `new URL` 会先把 `..` / `%2e%2e` / 反斜杠形式的点段规范化掉(WHATWG 语义),
|
|
77
|
+
// 能走到这里的只剩"看起来像文件名"的段;随后还要过白名单。
|
|
78
|
+
let url;
|
|
79
|
+
try { url = new URL(req.url || '/', 'http://x'); } catch { res.statusCode = 400; res.end('bad url'); return; }
|
|
80
|
+
const rest = url.pathname.slice(`${BASE}/about-qr`.length).replace(/^\/+/, '');
|
|
81
|
+
if (!rest || rest.includes('/')) { res.statusCode = 404; res.end('not found'); return; }
|
|
82
|
+
let name;
|
|
83
|
+
try { name = decodeURIComponent(rest); } catch { res.statusCode = 400; res.end('bad encoding'); return; }
|
|
84
|
+
const abs = aboutQrFile(name, aboutDir);
|
|
85
|
+
if (!abs) { res.statusCode = 404; res.setHeader('Cache-Control', 'no-store'); res.end('not found'); return; }
|
|
86
|
+
// revalidate:尺寸/时间未变 ⇒ 304 无体;变了 ⇒ 立刻送新图(这些都是"会换"的图:
|
|
87
|
+
// 群号换了就得换码,公告配图随版本换)。serveFile 按**扩展名**给 mime —— 混排
|
|
88
|
+
// PNG/JPEG 不需要这里做任何事。
|
|
89
|
+
serveFile(abs, req, res, headOnly, { revalidate: true });
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/about-notice/lib/routes/github-stars.js — **仓库 star 数**的唯一一条路由:`GET <BASE>/star-count`
|
|
3
|
+
* (「关于」页签那行「⭐ 当前 N star」的数据源)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独成文件:**这是本插件唯一一条出站请求**(其余部分全是本机数据)。"什么时候可以
|
|
6
|
+
* 联网 / 拉不到怎么办 / 缓存放哪 / 会不会被限流"必须能在一处读完 —— 写进 `lib/index.js` 只会
|
|
7
|
+
* 让那个门面再多一段与它无关的联网逻辑。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不做"一键 star":GitHub 不允许未认证的程序点星(`PUT /user/starred/…` 要用户凭据)。
|
|
10
|
+
* 插件**不存任何 token**、也不借用本机 `gh` 的凭据 ⇒ 这里只有**只读**一条腿,点星由用户
|
|
11
|
+
* 在仓库页完成(关于页给的是链接 + 可复制地址)。
|
|
12
|
+
*
|
|
13
|
+
* 契约:`registerGithubStarsRoutes(webServer, c)`;`c` 里是这一族用到但不属于它的东西:
|
|
14
|
+
* · `disposers` / `base: BASE` ← 清理句柄与路径前缀
|
|
15
|
+
* · `repoSlug` ← 'owner/repo'(`lib/host-boot.js` 从 package.json 现读,
|
|
16
|
+
* 见那里的 `repoSlugFromPkg` —— 仓库地址只有一处真源)
|
|
17
|
+
* · `cachePath()` ← 落盘缓存路径(`pluginDataDir()/star-count.json`)
|
|
18
|
+
* · `fetchJson(url, ms)` ← **可注入**的出站请求(守卫喂替身,避免测试联网)
|
|
19
|
+
* · `log` ← 失败留痕(成功不记:成功是常态,不该刷屏)
|
|
20
|
+
*
|
|
21
|
+
* 不变量(每条都有守卫):
|
|
22
|
+
* · **只读**:只 GET `api.github.com/repos/<slug>`,永不发写请求、永不带凭据。
|
|
23
|
+
* · **绝不把失败当错误甩给用户**:网络失败 / 非 2xx / 限流都回 200 + `ok:false` + 原因;
|
|
24
|
+
* 有落盘缓存时回它并标 `stale:true` —— 关于页显示"上一次取到的值",不是断网图标。
|
|
25
|
+
* · **不放大请求**:TTL(10 分钟)内直接回内存缓存;并发请求合并成一次出站(GitHub 未认证
|
|
26
|
+
* 限流是 **60 次/小时/IP**,且**整机共享** —— 别的程序也在用这个额度)。
|
|
27
|
+
* · **失败要重试、成功才计 TTL**:失败不写 `at`(下次一问就再试),只有成功才进入冷却。
|
|
28
|
+
* · **超时必须收**:出站 8s 超时(`AbortSignal.timeout`),不能让一个挂住的连接占住 handler。
|
|
29
|
+
* · **落盘必须原子**:同目录 `.tmp` + rename(半截 JSON 会让"离线兜底"这条腿永久失效)。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
33
|
+
|
|
34
|
+
/** 成功结果的新鲜期:10 分钟内不再出站(客户端侧同一量级,两处相乘 ≈ 最坏 20 分钟一次)。 */
|
|
35
|
+
const STAR_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
/** 出站超时:GitHub 正常在 1s 内应答,8s 足够,也不会把设置页的一次打开拖长。 */
|
|
37
|
+
const STAR_FETCH_TIMEOUT_MS = 8000;
|
|
38
|
+
|
|
39
|
+
/** 默认出站实现:只读 GET,带 UA(GitHub API 要求有 UA),带超时。 */
|
|
40
|
+
async function defaultFetchJson(url, ms) {
|
|
41
|
+
const res = await fetch(url, {
|
|
42
|
+
redirect: 'follow',
|
|
43
|
+
signal: AbortSignal.timeout(ms),
|
|
44
|
+
headers: {
|
|
45
|
+
'User-Agent': 'dsh-wallpaper-engine',
|
|
46
|
+
Accept: 'application/vnd.github+json',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error('HTTP ' + res.status);
|
|
51
|
+
}
|
|
52
|
+
return res.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 从 API 应答里取 star 数:形状不对就当失败(宁可显示"取不到",也不要显示一个假的数)。 */
|
|
56
|
+
function starsFromApi(json) {
|
|
57
|
+
const n = json && json.stargazers_count;
|
|
58
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 注入契约(P2-7A / ADR-0013 D12):本族从 `c` 消费的**必需**键 —— 门面/组件行调用点必须逐键给到(判据 test/verify-inject-contracts.mjs 双向对账)。
|
|
62
|
+
* `http` 是主包 json-response / http-body 的**唯一出口**(P-F:本族不再相对 import 主包文件)。 */
|
|
63
|
+
export const DEPS = ['disposers', 'base', 'repoSlug', 'cachePath', 'log', 'http'];
|
|
64
|
+
/** 允许缺席的键:真实调用点(组件行 about-notice)不传,缺省实现是下面的 `defaultFetchJson`。 */
|
|
65
|
+
export const OPTIONAL_DEPS = ['fetchJson'];
|
|
66
|
+
|
|
67
|
+
export function registerGithubStarsRoutes(webServer, c) {
|
|
68
|
+
const { disposers, base: BASE, repoSlug, cachePath, log, http } = c;
|
|
69
|
+
const fetchJson = typeof c.fetchJson === 'function' ? c.fetchJson : defaultFetchJson;
|
|
70
|
+
|
|
71
|
+
/** 内存缓存 { count, at } —— 进程内共享,重启后由落盘那份接上。 */
|
|
72
|
+
let mem = null;
|
|
73
|
+
/** 在途请求:并发合并(多个窗口同时打开设置页也只出站一次)。 */
|
|
74
|
+
let inflight = null;
|
|
75
|
+
|
|
76
|
+
const fresh = (v) => Boolean(v) && Number.isFinite(v.count) && Date.now() - Number(v.at || 0) < STAR_TTL_MS;
|
|
77
|
+
|
|
78
|
+
function readDisk() {
|
|
79
|
+
try {
|
|
80
|
+
const o = JSON.parse(readFileSync(cachePath(), 'utf8'));
|
|
81
|
+
if (o && Number.isFinite(o.count) && Number.isFinite(o.at)) return { count: o.count, at: o.at };
|
|
82
|
+
} catch { /* 没有 / 读不懂都当"没有" —— 缓存不是真源 */ }
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeDisk(v) {
|
|
87
|
+
try {
|
|
88
|
+
const file = cachePath();
|
|
89
|
+
const tmp = file + '.tmp';
|
|
90
|
+
writeFileSync(tmp, JSON.stringify({ count: v.count, at: v.at }));
|
|
91
|
+
renameSync(tmp, file);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// 写不进缓存不影响这次应答;留个痕就够(下次仍能联网取到)。
|
|
94
|
+
try { log('star-count 缓存写入失败:' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function fetchOnce() {
|
|
99
|
+
const url = 'https://api.github.com/repos/' + repoSlug;
|
|
100
|
+
const json = await fetchJson(url, STAR_FETCH_TIMEOUT_MS);
|
|
101
|
+
const count = starsFromApi(json);
|
|
102
|
+
if (count === null) throw new Error('unexpected payload');
|
|
103
|
+
const got = { count, at: Date.now() };
|
|
104
|
+
mem = got;
|
|
105
|
+
writeDisk(got);
|
|
106
|
+
return got;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
disposers.push(webServer.register({
|
|
110
|
+
kind: 'exact',
|
|
111
|
+
path: `${BASE}/star-count`,
|
|
112
|
+
handler: async (req, res) => {
|
|
113
|
+
// 薄别名:保留调用点写法(只有 200 + body),实现收敛到主包的 lib/json-response.js(经
|
|
114
|
+
// 宿主服务的 `http` 面注入 —— P-F 起本包不再相对 import 主包文件)。
|
|
115
|
+
// 缓存由**本路由**管(TTL 在上游),别让浏览器/CDN 再叠一层不可控的缓存 ⇒ no-store。
|
|
116
|
+
const reply = (body) => http.sendJson(res, 200, body);
|
|
117
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') { res.statusCode = 405; res.end(); return; }
|
|
118
|
+
// 没有可解析的仓库地址(package.json 被改坏 / 不是从包安装):功能关闭,但**不报错** ——
|
|
119
|
+
// 关于页只是不显示数字,其余照常。
|
|
120
|
+
if (!repoSlug) { reply({ ok: false, count: null, error: 'no-repo' }); return; }
|
|
121
|
+
|
|
122
|
+
const cached = mem || readDisk();
|
|
123
|
+
if (fresh(cached)) {
|
|
124
|
+
mem = cached;
|
|
125
|
+
reply({ ok: true, count: cached.count, fetchedAt: cached.at, stale: false });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (!inflight) {
|
|
130
|
+
inflight = fetchOnce().finally(() => { inflight = null; });
|
|
131
|
+
}
|
|
132
|
+
const got = await inflight;
|
|
133
|
+
reply({ ok: true, count: got.count, fetchedAt: got.at, stale: false });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const stale = cached || readDisk();
|
|
136
|
+
try { log('star-count 拉取失败:' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
|
|
137
|
+
// 有旧值就回旧值(标 stale):关于页显示"上一次取到的数"比显示"取不到"有用。
|
|
138
|
+
if (stale) reply({ ok: true, count: stale.count, fetchedAt: stale.at, stale: true });
|
|
139
|
+
else reply({ ok: false, count: null, error: String((err && err.message) || err) });
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wallpaper-engine-about-notice",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "About tab host half: repo star count (/star-count) and the two contact QR images (/about-qr).",
|
|
5
|
+
"icon": "./icon.png",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
13
|
+
},
|
|
14
|
+
"dsh": {
|
|
15
|
+
"bundle": {
|
|
16
|
+
"patch": "./cordis.patch.yml"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": ["lib/", "cordis.patch.yml", "locale/", "icon.png", "package.json"],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.5-rc.1"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"@deepseek-ai/dsh-host-webserver": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|
package/README.md
DELETED