dsh-plugin-wallpaper-engine-pet-link 1.4.0 → 1.4.2

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/lib/index.js CHANGED
@@ -1,20 +1,114 @@
1
+ import { join } from 'node:path';
1
2
  import { registerOfficialPetRoutes } from './routes/official-pet.js';
3
+ import { registerPetInstallRoutes } from './routes/pet-install.js';
4
+ import { createStateProbe } from './pet-install/state-probe.js';
5
+
6
+ /**
7
+ * 解析 DSH 数据根(`$DSH_HOME` 或 `<home>/.dsh`)。
8
+ *
9
+ * 与 `lib/subpackage-boot.js:493` 的 `dshHome()` 同口径(那边由
10
+ * `test/verify-subpackage-boot.mjs` 用合成 `DSH_HOME` 交叉对账)。
11
+ * @param env - process.env(可注入便于测试)
12
+ * @returns {string|null} 绝对路径;都取不到时返回 null
13
+ */
14
+ function resolveDshHomeLocal(env = {}) {
15
+ const e = env || {};
16
+ const fromEnv = e.DSH_HOME || '';
17
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
18
+ const home = e.USERPROFILE || e.HOME || '';
19
+ if (typeof home === 'string' && home.trim()) return join(home.trim(), '.dsh');
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * profile 下真实装包的地方(`<profileDir>/node_modules`)。
25
+ *
26
+ * state-probe 读的是这个目录;此前调用方传了 `profileDir`,两边键名对不上,
27
+ * 于是 `detect()` 恒判「未安装」——2026-11-02 修。
28
+ * @param {string|null|undefined} profileDir
29
+ * @returns {string|null}
30
+ */
31
+ function resolveProfiledNodeModulesLocal(profileDir) {
32
+ return profileDir ? join(profileDir, 'node_modules') : null;
33
+ }
34
+
35
+ /**
36
+ * dsh-pet-dshwe 的运行期数据根(`<DSH_HOME>/dsh-pet-dshwe`,logs.txt 一类运行痕迹)。
37
+ * @param env - process.env(可注入便于测试)
38
+ * @returns {string|null}
39
+ */
40
+ function resolvePetRuntimeDirLocal(env = {}) {
41
+ const dshHome = resolveDshHomeLocal(env);
42
+ return dshHome ? join(dshHome, 'dsh-pet-dshwe') : null;
43
+ }
44
+
45
+ /**
46
+ * 解析当前 DSH profile 目录(原 transaction.js 的 resolveProfileDir,随下载器一起移除后内联保留)。
47
+ *
48
+ * 优先级:config.profileDir → 环境变量 DSH_PROFILE_DIR → <home>/.dsh/profiles/desktop。
49
+ * 只用于「报告运行时装在哪」,不再参与任何下载或写盘。
50
+ * @param env - process.env(可注入便于测试)
51
+ * @returns {string|null} 绝对路径;都取不到时返回 null
52
+ */
53
+ function resolveProfileDirLocal(env = {}) {
54
+ const e = env || {};
55
+ const fromEnv = e.DSH_PROFILE_DIR || e.DSH_PROFILE || '';
56
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
57
+ const dshHome = resolveDshHomeLocal(e);
58
+ if (dshHome) {
59
+ return join(dshHome, 'profiles', 'desktop');
60
+ }
61
+ return null;
62
+ }
63
+
2
64
 
3
65
  export const name = 'dsh-plugin-wallpaper-engine-pet-link';
4
66
  export const inject = ['webServer', 'wallpaperEngineHost'];
5
67
  export const routeConfigs = Object.freeze([Object.freeze({})]);
6
68
 
7
- /** 官方 pet 是用户独立安装的可选插件;装载时不写它的配置或资源目录。 */
69
+ /**
70
+ * 可选服务探测:读取同页可能**不存在**的 Cordis 服务。
71
+ *
72
+ * ⚠️ 这是可选探测,不是必需依赖 —— 不许把 petApiDshwe 加进上面的 inject。
73
+ *
74
+ * 为什么用 `ctx.get` 而不是 inject 声明:pet-link 必须在「没装 dsh-pet-dshwe」时也能加载,
75
+ * 这是双 provider 的降级前提(联动计划 §3.3)。本机 DSH(dsh-desktop-next 2.0.20-next)实际
76
+ * 使用的 `@deepseek-ai/cordis@4.0.5` 里,`Inject = (keyof M)[] | { [K in keyof M]?: M[K] }`:
77
+ * 对象形态的键值对是「服务名 → 拦截配置」,**没有** required / optional 之分;凡写进 inject 的
78
+ * 服务都是**必需依赖**(fiber 会等它出现才激活,见 registry.ts「required service」)。
79
+ * 相反,`ctx.get(name)` 是 Cordis 官方提供的「不声明 inject 也能读服务」的入口
80
+ * (reflect.ts:「Read a service from the store without the inject requirement」),读不到返回
81
+ * undefined —— 正好是可选探测。所以这里保留 `ctx.get`,并把「可选 vs 必需」的判断收敛到本函数。
82
+ *
83
+ * @param {object} ctx - Cordis 上下文。
84
+ * @param {string} name - 服务名。
85
+ * @returns {any|null} 服务实例;不存在时为 null(绝不让探测异常冒到装载流程)。
86
+ */
87
+ export function probeOptionalService(ctx, name) {
88
+ try {
89
+ return ctx && typeof ctx.get === 'function' ? (ctx.get(name) ?? null) : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** 官方 pet 是用户独立安装的可选插件;装载时不写它的配置或资源目录。
96
+ *
97
+ * ⚠️ 这里**不做资源注入**:九段专属动画与五张气泡图已经内置进 `dsh-pet-dshwe` 自己的包
98
+ * (`assets/webm`、`assets/memes` 与 `assets/config.jsonc` 的 `DSHWE` 池)。桥接只负责发指令,
99
+ * 不再在装载期往运行时数据根写文件 —— 那是「pet 是第三方包、只能外部注入」时代的做法。
100
+ */
8
101
  export function apply(ctx, config) {
102
+ config ??= {};
9
103
  const host = ctx.wallpaperEngineHost;
10
104
  if (!host) throw new Error('[wallpaper-engine] pet-link 缺少 wallpaperEngineHost 服务');
11
105
  const keys = host.keys('pet-link', ctx);
12
106
  const deps = Object.freeze({ ...keys, http: host.http });
13
- return keys.handOffTeardown(ctx, keys.runComponent('pet-link', ctx, config || {}, deps, applyImpl));
107
+ return keys.handOffTeardown(ctx, keys.runComponent('pet-link', ctx, config, deps, applyImpl));
14
108
  }
15
109
 
16
110
  export function applyImpl(ctx, config = {}, c) {
17
- const { webServer } = ctx;
111
+ const webServer = c.tolerantWebServer(ctx.webServer);
18
112
  const { base, http } = c;
19
113
  const disposers = [];
20
114
  let active = true;
@@ -29,6 +123,17 @@ export function applyImpl(ctx, config = {}, c) {
29
123
  registerOfficialPetRoutes(webServer, { disposers, base, http }, {
30
124
  isLinkEnabled: () => config.linkEnabled !== false,
31
125
  });
126
+ // 运行时安装态报告(M4):只读——下载/安装已改由 DSH 官方入口完成。
127
+ const profileDir = config.profileDir || resolveProfileDirLocal(process.env);
128
+ const installer = createStateProbe({
129
+ // 键名必须与 state-probe 的 `opts.profiledNodeModules` / `opts.runtimeDir` 对齐;
130
+ // 之前传的是 `profileDir`,导致 detect() 永远拿到 null、卡片恒显示「未安装」。
131
+ profiledNodeModules: config.profiledNodeModules || resolveProfiledNodeModulesLocal(profileDir),
132
+ runtimeDir: config.runtimeDir || resolvePetRuntimeDirLocal(process.env),
133
+ // 可选探测:本页没有 dsh-pet-dshwe 时返回 false,不影响 pet-link 装载(见 probeOptionalService)。
134
+ probeRunning: () => Boolean(probeOptionalService(ctx, 'petApiDshwe')),
135
+ });
136
+ registerPetInstallRoutes(webServer, { disposers, base, http }, { installer });
32
137
  } catch (error) { dispose(); throw error; }
33
138
  return dispose;
34
139
  }
@@ -0,0 +1,79 @@
1
+ // state-only 探测:只回答「装没装 / 什么版本 / 在不在跑」,不做任何下载或写盘。
2
+ // 2026-11-01 取代原 transaction.js(那套自研下载解包已整体移除:
3
+ // 它绕过 DSH 的依赖管理,与本地 link: 布局冲突,曾造成重复注册 / 误判未安装 / 启动崩溃)。
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const RUNTIME_NAME = 'dshpet-dshwe';
8
+ const LEGACY_NAME = 'dsh-pet-dshwe';
9
+ const CANDIDATES = [RUNTIME_NAME, LEGACY_NAME];
10
+
11
+ /**
12
+ * 找已安装副本:在给定 node_modules 下按候选名找目录,读它的 package.json 版本。
13
+ * @param {string} nodeModulesDir - profile 的 node_modules 绝对路径
14
+ * @returns {{installed:boolean,name:string|null,version:string|null,path:string|null}}
15
+ */
16
+ function detect(nodeModulesDir) {
17
+ if (!nodeModulesDir || !existsSync(nodeModulesDir)) {
18
+ return { installed: false, name: null, version: null, path: null };
19
+ }
20
+ for (const name of CANDIDATES) {
21
+ const dir = join(nodeModulesDir, name);
22
+ const pkg = join(dir, 'package.json');
23
+ if (!existsSync(pkg)) continue;
24
+ try {
25
+ const json = JSON.parse(readFileSync(pkg, 'utf8'));
26
+ return { installed: true, name, version: typeof json.version === 'string' ? json.version : null, path: dir };
27
+ } catch {
28
+ return { installed: true, name, version: null, path: dir };
29
+ }
30
+ }
31
+ return { installed: false, name: null, version: null, path: null };
32
+ }
33
+
34
+ /**
35
+ * 跑一次探测;探测本身抛错时按「没在跑」处理(状态行不该因为探测异常变红)。
36
+ * @param {() => unknown} fn
37
+ * @returns {boolean}
38
+ */
39
+ function safelyProbe(fn) {
40
+ try { return Boolean(fn()); } catch { return false; }
41
+ }
42
+
43
+ /**
44
+ * 造一个只读状态提供者,接口与原 installer 的 `state()` 保持兼容
45
+ * (这样路由与 UI 不用改调用方式)。
46
+ * @param {{ profiledNodeModules?: string, runtimeDir?: string, probeRunning?: () => boolean }} opts
47
+ * @returns {{ state: () => object }}
48
+ */
49
+ export function createStateProbe(opts = {}) {
50
+ const nodeModulesDir = opts.profiledNodeModules || null;
51
+ const runtimeDir = opts.runtimeDir || null;
52
+ const probeRunning = typeof opts.probeRunning === 'function' ? opts.probeRunning : null;
53
+ return {
54
+ /** 当前安装态:installed / version / name / path / runtimePresent。纯读,无副作用。 */
55
+ state() {
56
+ const found = detect(nodeModulesDir);
57
+ let runtimePresent = false;
58
+ try {
59
+ runtimePresent = Boolean(runtimeDir && existsSync(runtimeDir) && readdirSync(runtimeDir).length > 0);
60
+ } catch {
61
+ runtimePresent = false;
62
+ }
63
+ return {
64
+ installed: found.installed,
65
+ version: found.version,
66
+ name: found.name,
67
+ path: found.path,
68
+ runtimePresent,
69
+ // pet 宿主半边是否已在本页注册服务(可选探测;没有就是 false,不报错)。
70
+ running: probeRunning ? safelyProbe(probeRunning) : false,
71
+ // UI 需要的官方安装入口(不再由本插件代下载)
72
+ installUrl: 'https://www.npmjs.com/package/dshpet-dshwe',
73
+ installCommand: 'dsh plugin add dshpet-dshwe',
74
+ };
75
+ },
76
+ };
77
+ }
78
+
79
+ export { RUNTIME_NAME, LEGACY_NAME, CANDIDATES, detect };
@@ -74,7 +74,16 @@ export function registerOfficialPetRoutes(webServer, c, options = {}) {
74
74
  const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
75
75
  if (!enabled()) return send(res, 200, { ok: false, reason: 'bridge-disabled' });
76
76
  const methods = { start: coordinator.start, heartbeat: coordinator.heartbeat,
77
- command: coordinator.command, finish: coordinator.finish };
77
+ command: coordinator.command, finish: coordinator.finish,
78
+ // 浏览器半边 → 宿主日志的诊断通道。
79
+ // 桌面版没有 DevTools(用户看不到 console),所以把「走位目标算成了什么」
80
+ // 「气泡配图取图成不成功」这类运行期真值送到宿主日志,由维护者读文件取证。
81
+ diag: async payload => {
82
+ try {
83
+ console.log('[pet-link diag] ' + JSON.stringify(payload?.payload ?? payload));
84
+ } catch { /* 诊断序列化失败不影响业务 */ }
85
+ return { ok: true };
86
+ } };
78
87
  if (!Object.hasOwn(methods, input?.op)) return send(res, 400, { ok: false, reason: 'bad-request' });
79
88
  return send(res, 200, await methods[input.op](input));
80
89
  } catch { return send(res, 400, { ok: false, reason: 'bad-request' }); }
@@ -114,5 +123,58 @@ export function registerOfficialPetRoutes(webServer, c, options = {}) {
114
123
  stream.pipe(res);
115
124
  } catch { res.statusCode = 404; res.end(); }
116
125
  } }));
126
+ // 引导截图:安装教程用三张图(插件入口 / 添加插件按钮 / 输入包名对话框)。
127
+ // 与 meme 同款 exact 路由,避免 prefix 命中问题;只从本包 assets/guide/ 出。
128
+ const guideShots = [
129
+ { file: fileURLToPath(new URL('../../assets/guide/step1-plugin-entry.png', import.meta.url)), type: 'image/png' },
130
+ { file: fileURLToPath(new URL('../../assets/guide/step2-add-plugin.png', import.meta.url)), type: 'image/png' },
131
+ { file: fileURLToPath(new URL('../../assets/guide/step3-dialog.png', import.meta.url)), type: 'image/png' },
132
+ ];
133
+ disposers.push(webServer.register({ kind: 'exact', path: `${BASE}/pet-link/official/guide-step`, handler: (req, res) => {
134
+ try {
135
+ const step = Number(new URL(req.url || '/', 'http://x').searchParams.get('n'));
136
+ const shot = Number.isSafeInteger(step) ? guideShots[step - 1] : null;
137
+ if (!shot) { res.statusCode = 404; res.end(); return; }
138
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.statusCode = 405; res.end(); return; }
139
+ const { size } = statSync(shot.file);
140
+ res.statusCode = 200;
141
+ res.setHeader('Content-Type', shot.type);
142
+ res.setHeader('Content-Length', size);
143
+ res.setHeader('Cache-Control', 'private, max-age=3600');
144
+ if (req.method === 'HEAD') { res.end(); return; }
145
+ const stream = createReadStream(shot.file);
146
+ streams.add(stream);
147
+ stream.on('error', () => res.destroy());
148
+ stream.on('close', () => streams.delete(stream));
149
+ res.on('close', () => stream.destroy());
150
+ stream.pipe(res);
151
+ } catch { res.statusCode = 404; res.end(); }
152
+ } }));
153
+ // 气泡配图专用:**exact 路由**按 `?i=<index>` 出一张 PNG。
154
+ //
155
+ // 为什么不复用上面的 prefix:`/assets/m<i>.png` 在实测里稳定 404(prefix 路由没命中——
156
+ // 浏览器侧日志 `{"kind":"meme","ok":false,"reason":"404"}` ×5)。而同一注册器里的 exact
157
+ // 路由(`/pet-link/official` 的 diag)是通的,所以这里改走 exact,绕开 prefix 的路径解析。
158
+ // 图仍然只从本包的 `assets/memes/` 出,不复制、不注入。
159
+ disposers.push(webServer.register({ kind: 'exact', path: `${BASE}/pet-link/official/meme`, handler: (req, res) => {
160
+ try {
161
+ const index = Number(new URL(req.url || '/', 'http://x').searchParams.get('i'));
162
+ const image = Number.isSafeInteger(index) ? images[index] : null;
163
+ if (!image) { res.statusCode = 404; res.end(); return; }
164
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.statusCode = 405; res.end(); return; }
165
+ const { size } = statSync(image.file);
166
+ res.statusCode = 200;
167
+ res.setHeader('Content-Type', image.type);
168
+ res.setHeader('Content-Length', size);
169
+ res.setHeader('Cache-Control', 'private, max-age=3600');
170
+ if (req.method === 'HEAD') { res.end(); return; }
171
+ const stream = createReadStream(image.file);
172
+ streams.add(stream);
173
+ stream.on('error', () => res.destroy());
174
+ stream.on('close', () => streams.delete(stream));
175
+ res.on('close', () => stream.destroy());
176
+ stream.pipe(res);
177
+ } catch { res.statusCode = 404; res.end(); }
178
+ } }));
117
179
  return coordinator;
118
180
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 路由:pet-link 宿主半的运行时**只读**状态面。
3
+ *
4
+ * GET `${BASE}/pet-link/pet/state` → { ok, installed, name, version, path, runtimePresent,
5
+ * installUrl, installCommand, running, ... }
6
+ *
7
+ * 安装 / 卸载 / 安装进度三条路由已于 2026-11-01 移除(自研下载解包绕过 DSH 依赖管理、
8
+ * 与 link: 布局冲突,实测造成重复注册 / 误判未安装 / 启动崩溃);安装改走 DSH 官方入口。
9
+ * 本文件只负责「装了没有、什么版本、运行时在不在」的只读探测。
10
+ *
11
+ * 安全口径与 `routes/official-pet.js` 一致:
12
+ * · 同源校验:带 Origin 时必须 http + 本机回环 + 同端口;`sec-fetch-site: cross-site` 一律 403。
13
+ * · 只读:GET 之外一律 405,不读请求体。
14
+ *
15
+ * @module pet-link/routes/pet-install
16
+ */
17
+ export const DEPS = ['disposers', 'base', 'http'];
18
+
19
+ /** 判断请求是否为本机同源(无 Origin 视为本机直连,放行)。仅本文件内部使用,不导出。 */
20
+ function isSameOrigin(req, webServer) {
21
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
22
+ const origin = req.headers?.origin;
23
+ if (!origin) return true;
24
+ try {
25
+ const from = new URL(origin);
26
+ return from.protocol === 'http:'
27
+ && Number(from.port || 80) === webServer.port
28
+ && ['127.0.0.1', 'localhost', '[::1]'].includes(from.hostname);
29
+ } catch { return false; }
30
+ }
31
+
32
+ /**
33
+ * 注册只读状态路由。
34
+ * @param {{ register: Function, port: number }} webServer
35
+ * @param {{ disposers: Function[], base: string, http: { sendJson: Function } }} c
36
+ */
37
+ export function registerPetInstallRoutes(webServer, c, options = {}) {
38
+ const { disposers, base, http } = c;
39
+ const BASE = base;
40
+ const installer = options.installer;
41
+ if (!installer) throw new Error('pet-install 路由需要 installer');
42
+ const send = (res, status, value) => http.sendJson(res, status, value);
43
+ let active = true;
44
+ disposers.push(() => { active = false; });
45
+
46
+ const guard = (req, res) => {
47
+ if (!active) { send(res, 410, { ok: false, reason: 'bridge-disposed' }); return false; }
48
+ if (!isSameOrigin(req, webServer)) { send(res, 403, { ok: false, reason: 'cross-site-request' }); return false; }
49
+ return true;
50
+ };
51
+
52
+ disposers.push(webServer.register({ kind: 'exact', path: `${BASE}/pet-link/pet/state`, handler: (req, res) => {
53
+ if (!guard(req, res)) return;
54
+ if (req.method !== 'GET') return send(res, 405, { ok: false, reason: 'method-not-allowed' });
55
+ return send(res, 200, installer.state());
56
+ } }));
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wallpaper-engine-pet-link",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Optional official dsh-pet bridge: public API control, four interaction sequences and independent page bubbles; no bundled pet or patching.",
5
5
  "icon": "./icon.png",
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "patch": "./cordis.patch.yml"
18
18
  },
19
19
  "client": {
20
- "inject": ["@deepseek-ai/dsh-client-runtime"],
20
+ "inject": ["@deepseek-ai/dsh-client-runtime", "dsh-plugin-wallpaper-engine"],
21
21
  "platform": "web",
22
22
  "immediately": true
23
23
  }