dsh-life-pack 0.2.0 → 0.2.1

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.
Files changed (41) hide show
  1. package/dist/client.js +811 -13
  2. package/dist/client.js.map +1 -1
  3. package/dist/dsh-ctx.d.ts +18 -2
  4. package/dist/dsh-ctx.d.ts.map +1 -1
  5. package/dist/index.d.ts +4 -8
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +63 -7
  8. package/dist/index.js.map +1 -1
  9. package/dist/nav.d.ts +34 -1
  10. package/dist/nav.d.ts.map +1 -1
  11. package/dist/nav.js +43 -1
  12. package/dist/nav.js.map +1 -1
  13. package/dist/update-client.d.ts +40 -0
  14. package/dist/update-client.d.ts.map +1 -0
  15. package/dist/update-client.js +121 -0
  16. package/dist/update-client.js.map +1 -0
  17. package/dist/update-contract.d.ts +47 -0
  18. package/dist/update-contract.d.ts.map +1 -0
  19. package/dist/update-contract.js +69 -0
  20. package/dist/update-contract.js.map +1 -0
  21. package/dist/update-env.d.ts +36 -0
  22. package/dist/update-env.d.ts.map +1 -0
  23. package/dist/update-env.js +242 -0
  24. package/dist/update-env.js.map +1 -0
  25. package/dist/update-host.d.ts +18 -0
  26. package/dist/update-host.d.ts.map +1 -0
  27. package/dist/update-host.js +161 -0
  28. package/dist/update-host.js.map +1 -0
  29. package/dist/update-panel.d.ts +63 -0
  30. package/dist/update-panel.d.ts.map +1 -0
  31. package/dist/update-panel.js +231 -0
  32. package/dist/update-panel.js.map +1 -0
  33. package/dist/update-targets.d.ts +17 -0
  34. package/dist/update-targets.d.ts.map +1 -0
  35. package/dist/update-targets.js +29 -0
  36. package/dist/update-targets.js.map +1 -0
  37. package/dist/update-view.d.ts +49 -0
  38. package/dist/update-view.d.ts.map +1 -0
  39. package/dist/update-view.js +54 -0
  40. package/dist/update-view.js.map +1 -0
  41. package/package.json +5 -2
package/dist/client.js CHANGED
@@ -85,6 +85,616 @@ window.__ModuleLoader__.load({
85
85
  hint: "未安装[" + tab.plugin + "],请补装后使用:" + installCmd
86
86
  };
87
87
  }
88
+ /** 面板右上角两个入口:星(去本仓点 star)+ 气泡(去本仓开 issue)。
89
+ *
90
+ * 两处都指本仓 FeatherHunter/ilife——这是 map #671 裁定过的(「星与气泡指向哪个仓」
91
+ * 一问:反馈要落在本仓的需求池,「引流到作者其他技能」是底部那张卡的事)。
92
+ * 三件并排里的第三件「检查更新」住隔壁票(#678),本表不管它。
93
+ */
94
+ const PANEL_LINKS = [{
95
+ key: "star",
96
+ glyph: "⭐",
97
+ tip: "去 GitHub 点 Star",
98
+ url: "https://github.com/FeatherHunter/ilife"
99
+ }, {
100
+ key: "feedback",
101
+ glyph: "💬",
102
+ tip: "反馈问题",
103
+ url: "https://github.com/FeatherHunter/ilife/issues/new"
104
+ }];
105
+ /** 「作者其他插件」四行:包名 + 一句说明 + 仓库地址。
106
+ *
107
+ * 文案口径(用户 2026-09-18 拍板):**不写数字**——数字会过期(用户截图里的「34 款」实测已到 38 款),
108
+ * 写错最伤推荐位的可信度;第一行也不用 deck 自己面板里的自指「本面板自己」,改成不自指的说法。
109
+ * 只收真实存在的仓库,网址已逐个验真。第一行的说明描述的是 deck 那个包(工程技能面板),不是爱生活面板。
110
+ */
111
+ const MORE_PLUGINS = [
112
+ {
113
+ pkg: "dsh-mattpocock-skills-deck",
114
+ desc: "工程技能面板:装好就能在右侧直接用",
115
+ url: "https://github.com/FeatherHunter/dsh-mattpocock-skills-deck"
116
+ },
117
+ {
118
+ pkg: "dsh-opencode-palette",
119
+ desc: "多款长时间编程护眼配色,一键换上",
120
+ url: "https://github.com/FeatherHunter/dsh-opencode-palette"
121
+ },
122
+ {
123
+ pkg: "dsh-prompt",
124
+ desc: "常用提示模板随手点,不用来回复制粘贴",
125
+ url: "https://github.com/FeatherHunter/dsh-prompt"
126
+ },
127
+ {
128
+ pkg: "dsh-im-companion",
129
+ desc: "聊天机器人的伴侣插件:扫码或填凭据就把飞书、微信等聊天接进来",
130
+ url: "https://github.com/FeatherHunter/dsh-im-companion"
131
+ }
132
+ ];
133
+ //#endregion
134
+ //#region src/update-contract.ts
135
+ /** 载体:宿主注册 `connection.fetch.register` 的路径 / 面板 `connection.rpc.call` 的两段参数。 */
136
+ const MANAGER_RPC = {
137
+ /** 单段通道名,须 match `/^\/[A-Za-z0-9._~-]+$/`(cookbook §6)。 */
138
+ channel: "/ilife-manager",
139
+ /** 面板侧第二段参数(等于注册路径去掉 `/api` 前缀后那一段)。 */
140
+ endpoint: "ilife-manager",
141
+ /** 宿主侧注册的完整路径(`/api` 是 DSH 公开载体)。 */
142
+ path: "/api/ilife-manager"
143
+ };
144
+ /** 总管自有电话名:更新包的三个动作名是冻结集合(`config.ts:14`),这里另起名字不撞车。 */
145
+ const MANAGER_ACTIONS = {
146
+ /** 装上缺席的目标包:入参 `{packageName, version}`,回包 `{packageName, version}`。 */
147
+ install: "ilife-manager.install",
148
+ /** 七个更新目标的表:入参 `{}`,回包 `{targets: [{key, title, packageName, phones, runningVersion, installedVersion, skill}]}`。
149
+ *
150
+ * 电话名为什么由宿主转交而不是面板侧写死:更新包的三个电话名只有它自己知道
151
+ * (`update.phoneNames`,`host.ts:335`);面板侧既不能 import 更新包(浏览器产物纯度门)
152
+ * 也不该把名字再写一份(写两份必然走散)。宿主把名字表连同版本行一起交出去,
153
+ * 面板一行字面量都不留。 */
154
+ targets: "ilife-manager.targets"
155
+ };
156
+ /** 装不了 / 装不成的人话原因表:八种 `blockedReason` + 过程错误码(更新包 README 第 8、12 节)。 */
157
+ const REASON_TEXT = {
158
+ "unknown-profile": "使用范围认不出(名字非法或目录不存在):先检查使用范围名与目录,这种情形不给手工命令。",
159
+ "source-install": "当前是从源码装的,不是按版本号装的:想走更新,先按版本号重装一次。",
160
+ "invalid-installation": "已装的包不完整(名字对不上、版本非法、入口文件缺失):重装当前版本,修好已装目录再查。",
161
+ "installation-changed": "安装位置在使用中途变了:重新打开宿主再查一次;还出现就重装。",
162
+ "pending-restart": "新版已装到磁盘,正在跑的还是旧版:重启宿主后生效。",
163
+ "registry-conflict": "本地声明的版本与磁盘实际版本互相矛盾:打开使用范围的清单,把目标包名那行改成版本号再试。",
164
+ "incompatible-node": "新版要求的 Node 与当前运行的 Node 对不上:先把 Node 升到 22 或更高,再查。",
165
+ "recovery-required": "上次安装被打断,留下一个半截任务:重新点一次安装;一直出现就查宿主日志。",
166
+ "check-failed": "查新版失败(联网不通或官方源不可达):检查网络与官方源地址,再查一次。",
167
+ "invalid-release": "远端发行信息不合规(名字对不上、版本非法、包地址或完整性校验不过):换个版本再查。",
168
+ "check-expired": "上次查新版的凭证过期了:重新查一次再点安装(不要重试旧编号)。",
169
+ "update-busy": "同一个使用范围同时只装一个:等当前任务结束再点。",
170
+ "install-failed": "安装没跑成:按下面那条手工命令在终端执行一次。",
171
+ "bad-request": "回执异常(请求形状不对):把宿主日志里的事件名报给作者。",
172
+ internal: "回执异常(总管内部错误):把宿主日志里的事件名报给作者。"
173
+ };
174
+ /** 原因码 → 人话(认不出的码不猜、原样回码并说明这是未知原因)。 */
175
+ function reasonText(code) {
176
+ return REASON_TEXT[code] ?? "未知原因(" + code + "):把宿主日志里的事件名报给作者。";
177
+ }
178
+ //#endregion
179
+ //#region src/update-client.ts
180
+ /** 面板到宿主的三条流程(纯异步函数:无 DOM、无 react;传输口由调用方注入,便于单测)。
181
+ *
182
+ * 三条流程与更新包 README 的对应关系:
183
+ * - 检查更新:`updateCheck`(第 3 节第 3 步的 `host.call(UPD_CHECK, {})`);
184
+ * - 装上更新:`updateCheck` 拿凭证 → `updateInstall` 提交 → **按间隔轮询 `updateStatus`**
185
+ * 直到任务离开 `installing`/`verifying`(第 3 节第 3 步与第 9 节:安装是后台跑,
186
+ * 调用方不干等;凭证过期 `check-expired` 是正常错误码,重查一次再提交,见第 11 节);
187
+ * - 装上缺席的包:走总管自有的 `ilife-manager.install`(更新包的三个电话只管已装的包)。
188
+ */
189
+ /** 轮询上限:1 秒一次、最多 900 次(覆盖更新包 15 分钟的安装时限)。 */
190
+ const MAX_POLL_TRIES = 900;
191
+ /** 轮询默认间隔(只有在宿主没给 `pollMs` 时才用;正常取值由宿主转交更新包的 panelPollMs)。 */
192
+ const FALLBACK_POLL_MS = 1e3;
193
+ let requestSeq = 0;
194
+ function failure(code, details = {}, manual = null) {
195
+ return {
196
+ ok: false,
197
+ code,
198
+ message: reasonText(code),
199
+ details,
200
+ manual
201
+ };
202
+ }
203
+ /** 取数间隔:settle 即清 timer。定时器有界重试的例外依据(p10 静态门三件套):
204
+ * 这里有清理(clearTimeout)、有次数上限(MAX_POLL_TRIES)、间隔取自宿主转交的更新包
205
+ * `panelPollMs`(不写死);用途是更新包 README 第 3 节要求的状态轮询(安装是后台跑,
206
+ * 面板每秒问一次进度),不是无界重试。 */
207
+ function sleep(ms) {
208
+ return new Promise((resolve) => {
209
+ const timer = setTimeout(() => {
210
+ clearTimeout(timer);
211
+ resolve();
212
+ }, ms);
213
+ });
214
+ }
215
+ async function callManager(call, method, payload) {
216
+ if (typeof call !== "function") return failure("internal", { detail: "宿主连接缺席:connection.rpc.call 不可用" });
217
+ let result;
218
+ try {
219
+ result = await call(MANAGER_RPC.channel, MANAGER_RPC.endpoint, {
220
+ method,
221
+ payload
222
+ });
223
+ } catch (error) {
224
+ return failure("check-failed", { detail: String(error?.message ?? error) });
225
+ }
226
+ if (!result || typeof result !== "object" || typeof result.ok !== "boolean") return failure("internal", { detail: "回执信封异常(非 ok 信封)" });
227
+ if (result.ok) return {
228
+ ok: true,
229
+ value: result.value
230
+ };
231
+ const error = result.error;
232
+ return failure(typeof error?.code === "string" ? error.code : "internal", error && typeof error.details === "object" && error.details !== null ? error.details : {});
233
+ }
234
+ /** 取七个更新目标的表(宿主转交更新包的三个电话名与版本行,面板不写死任何电话名)。 */
235
+ async function loadTargets(call) {
236
+ const out = await callManager(call, MANAGER_ACTIONS.targets, {});
237
+ if (!out.ok) return out;
238
+ return {
239
+ ok: true,
240
+ value: {
241
+ targets: Array.isArray(out.value?.targets) ? out.value.targets : [],
242
+ pollMs: typeof out.value?.pollMs === "number" && out.value.pollMs > 0 ? out.value.pollMs : FALLBACK_POLL_MS
243
+ }
244
+ };
245
+ }
246
+ /** 查一家:默认查新版(`updateCheck`,顺带回最新版本与凭证);给 `phoneName` 时查那个电话。 */
247
+ async function checkTarget(call, target, phoneName) {
248
+ const name = phoneName ?? target.phones?.check;
249
+ if (!target.phones || !name) return failure("bad-request", { target: target.key });
250
+ return callManager(call, name, {});
251
+ }
252
+ /** 装上缺席的包:版本取自查新版拿到的「最新版本」(宿主用更新包的执行器真装)。 */
253
+ async function installAbsent(call, target, version) {
254
+ return callManager(call, MANAGER_ACTIONS.install, {
255
+ packageName: target.packageName,
256
+ version
257
+ });
258
+ }
259
+ /** 装上更新:查新版 → 提交安装 → 有界轮询查状态到任务收尾;凭证过期重查一次再提交。 */
260
+ async function updateInstalled(call, target, pollMs) {
261
+ if (!target.phones) return failure("bad-request", { target: target.key });
262
+ const first = await checkTarget(call, target);
263
+ if (!first.ok) return first;
264
+ let checked = first.value;
265
+ if (!checked.receipt) return failure(checked.snapshot.blockedReason ?? "check-failed", { target: target.key }, checked.manual);
266
+ requestSeq += 1;
267
+ const requestId = "ilife-manager-" + Date.now().toString(36) + "-" + String(requestSeq);
268
+ let submitted = await callManager(call, target.phones.install, {
269
+ checkId: checked.receipt.checkId,
270
+ requestId
271
+ });
272
+ if (!submitted.ok && submitted.code === "check-expired") {
273
+ const again = await checkTarget(call, target);
274
+ if (!again.ok) return again;
275
+ checked = again.value;
276
+ if (!checked.receipt) return failure(checked.snapshot.blockedReason ?? "check-failed", { target: target.key }, checked.manual);
277
+ submitted = await callManager(call, target.phones.install, {
278
+ checkId: checked.receipt.checkId,
279
+ requestId: requestId + "r"
280
+ });
281
+ }
282
+ if (!submitted.ok) return {
283
+ ...submitted,
284
+ manual: submitted.manual ?? checked.manual
285
+ };
286
+ let latest = submitted.value;
287
+ const interval = pollMs > 0 ? pollMs : FALLBACK_POLL_MS;
288
+ for (let tries = 0;; tries += 1) {
289
+ const job = latest.snapshot.job;
290
+ if (job === null || job.state !== "installing" && job.state !== "verifying") break;
291
+ if (tries >= MAX_POLL_TRIES) break;
292
+ await sleep(interval);
293
+ const status = await checkTarget(call, target, target.phones.status);
294
+ if (!status.ok) return status;
295
+ latest = status.value;
296
+ }
297
+ return {
298
+ ok: true,
299
+ value: latest
300
+ };
301
+ }
302
+ //#endregion
303
+ //#region src/update-view.ts
304
+ /** 缺席:宿主启动时没装、现在磁盘上也没有(`runningVersion`/`installedVersion` 都是 null)。 */
305
+ function isAbsent(target) {
306
+ return target.runningVersion === null && target.installedVersion === null;
307
+ }
308
+ /** 把快照翻成一行结论:还没查 / 已是最新 / 有新版 / 装不了(附人话原因)。 */
309
+ function verdictOf(target, snapshot) {
310
+ const absent = isAbsent(target);
311
+ const latest = snapshot.latestVersion;
312
+ if (absent && latest === null) return {
313
+ kind: "unknown",
314
+ text: "未安装;点「检查更新」查最新版本后装上。",
315
+ action: null
316
+ };
317
+ if (absent) return {
318
+ kind: "update-available",
319
+ text: "未安装;最新版本 " + String(latest) + "。",
320
+ action: "install"
321
+ };
322
+ if (snapshot.blockedReason !== null) {
323
+ const blocked = snapshot.blockedReason;
324
+ if (blocked === "pending-restart") return {
325
+ kind: "blocked",
326
+ text: reasonText(blocked),
327
+ action: null
328
+ };
329
+ return {
330
+ kind: "blocked",
331
+ text: reasonText(blocked),
332
+ action: null
333
+ };
334
+ }
335
+ if (latest === null) return {
336
+ kind: "unknown",
337
+ text: "还没查过最新版本;点「检查更新」。",
338
+ action: null
339
+ };
340
+ if (snapshot.canInstall) return {
341
+ kind: "update-available",
342
+ text: "有新版:" + String(latest) + ",点「装上更新」。",
343
+ action: "update"
344
+ };
345
+ return {
346
+ kind: "up-to-date",
347
+ text: "已是最新(" + String(latest) + ")。",
348
+ action: null
349
+ };
350
+ }
351
+ /** 待重启横幅文案(更新包 README 第 10 节:说清新版号与「重启后才生效」两件事)。 */
352
+ function pendingRestartText(target, snapshot) {
353
+ if (snapshot.blockedReason !== "pending-restart") return null;
354
+ const installed = snapshot.installedVersion ?? snapshot.job?.targetVersion ?? null;
355
+ const running = snapshot.runningVersion;
356
+ if (installed === null) return "⚠️ 新版已装好,重启宿主后生效。";
357
+ if (installed === running) return "⚠️ 新版 " + installed + " 已装好,重启宿主后生效。";
358
+ return "⚠️ 新版 " + installed + " 已装好,正在跑的还是 " + running + ",重启宿主后生效。";
359
+ }
360
+ /** 版本行两行文字:插件包(本家) + 技能包(随插件,不单独查更新)。 */
361
+ function versionLines(target, snapshot) {
362
+ const running = snapshot?.runningVersion ?? target.runningVersion ?? "unknown";
363
+ const installed = snapshot?.installedVersion ?? target.installedVersion;
364
+ const lines = [target.packageName + " " + running + (installed && installed !== running ? "(磁盘已装 " + installed + ")" : "")];
365
+ if (target.skill) lines.push(target.skill.packageName + " " + target.skill.version + "(随插件,不单独查更新)");
366
+ else lines.push("技能包随插件(不单独查更新)");
367
+ return lines;
368
+ }
369
+ //#endregion
370
+ //#region src/update-panel.ts
371
+ /** 爱生活面板的更新界面:检查更新按钮、七家结果行、待重启横幅、缺席卡的「装上」。
372
+ *
373
+ * 面板侧纪律(沿 `client.ts` 既有口径):React.createElement 手写(禁 JSX)、内联 style、
374
+ * 颜色走 DSH 主题别名(深浅主题自适应,写死值只做回退)、只用 `dsh-ctx` 镜像内的上下文成员、
375
+ * 不 import 更新包(电话名与轮询间隔由宿主转交,见 `update-contract.ts` 的 `MANAGER_ACTIONS.targets`)。
376
+ *
377
+ * 视觉对照(用户给的第二张截图,参照实现 `dsh-mattpocock-skills-deck/src/client/views/SettingsPage.js:152-168`):
378
+ * 标题右侧并排若干件——本文件出「检查更新」一件,隔壁票(爱生活面板头部与底部)在同一个
379
+ * `actions` 容器里补星与气泡两件,故这里把容器单独留出来。
380
+ */
381
+ /** 面板视觉(沿用总管既有语言:内联 style,主题别名带回退)。 */
382
+ const PANEL_STYLE = {
383
+ head: {
384
+ fontSize: 15,
385
+ fontWeight: 700,
386
+ margin: "2px 0 8px",
387
+ color: "var(--dsw-alias-label-primary, inherit)"
388
+ },
389
+ headRow: {
390
+ display: "flex",
391
+ alignItems: "center",
392
+ justifyContent: "space-between",
393
+ gap: 8,
394
+ flexWrap: "wrap"
395
+ },
396
+ actions: {
397
+ display: "inline-flex",
398
+ alignItems: "center",
399
+ gap: 6
400
+ },
401
+ meta: {
402
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
403
+ fontSize: 12,
404
+ lineHeight: 1.7,
405
+ marginBottom: 10
406
+ },
407
+ tablist: {
408
+ display: "flex",
409
+ flexWrap: "wrap",
410
+ gap: 6,
411
+ marginBottom: 10
412
+ },
413
+ tab: {
414
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))",
415
+ background: "transparent",
416
+ color: "var(--dsw-alias-label-primary, inherit)",
417
+ borderRadius: 999,
418
+ padding: "4px 12px",
419
+ fontSize: 13,
420
+ cursor: "pointer"
421
+ },
422
+ tabActive: {
423
+ background: "var(--dsw-alias-brand-primary, #0a84ff)",
424
+ borderColor: "transparent",
425
+ color: "#fff",
426
+ fontWeight: 700
427
+ },
428
+ reco: {
429
+ padding: "12px 14px",
430
+ borderRadius: 10,
431
+ border: "1px dashed var(--dsw-alias-border, rgba(128,128,128,.45))",
432
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
433
+ fontSize: 13,
434
+ lineHeight: 1.7
435
+ },
436
+ cmd: {
437
+ display: "block",
438
+ marginTop: 8,
439
+ padding: "8px 10px",
440
+ borderRadius: 8,
441
+ background: "var(--dsw-alias-bg-base, rgba(128,128,128,.12))",
442
+ color: "var(--dsw-alias-label-primary, inherit)",
443
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
444
+ fontSize: 12,
445
+ userSelect: "all"
446
+ },
447
+ btn: {
448
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))",
449
+ background: "transparent",
450
+ color: "var(--dsw-alias-label-primary, inherit)",
451
+ borderRadius: 8,
452
+ padding: "4px 12px",
453
+ fontSize: 13,
454
+ cursor: "pointer"
455
+ },
456
+ banner: {
457
+ padding: "10px 12px",
458
+ marginBottom: 10,
459
+ borderRadius: 8,
460
+ border: "1px solid var(--dsw-alias-state-warning-primary, #d8a300)",
461
+ background: "var(--dsw-alias-bg-base, rgba(216,163,0,.12))",
462
+ color: "var(--dsw-alias-label-primary, inherit)",
463
+ fontSize: 13,
464
+ lineHeight: 1.7
465
+ },
466
+ row: {
467
+ padding: "10px 12px",
468
+ marginBottom: 8,
469
+ borderRadius: 10,
470
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.3))",
471
+ fontSize: 13,
472
+ lineHeight: 1.7
473
+ },
474
+ rowHead: {
475
+ fontWeight: 700,
476
+ marginBottom: 2
477
+ },
478
+ rowMeta: {
479
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
480
+ fontSize: 12
481
+ },
482
+ reason: {
483
+ color: "var(--dsw-alias-state-error-primary, #ff6b6b)",
484
+ fontSize: 12,
485
+ marginTop: 4
486
+ },
487
+ skill: {
488
+ color: "var(--dsw-alias-label-tertiary, #8a8a8a)",
489
+ fontSize: 12,
490
+ marginTop: 4
491
+ }
492
+ };
493
+ const IDLE = {
494
+ phase: "idle",
495
+ outcome: null,
496
+ failure: null
497
+ };
498
+ /** 面板的更新状态源:挂载时取七个目标的表,之后按需查/装(组件与按钮共用一个 face)。 */
499
+ function useUpdateRows(getCall) {
500
+ const [targets, setTargets] = react.useState([]);
501
+ const [rows, setRows] = react.useState({});
502
+ const [pollMs, setPollMs] = react.useState(1e3);
503
+ const [loadError, setLoadError] = react.useState(null);
504
+ const [checking, setChecking] = react.useState(false);
505
+ react.useEffect(() => {
506
+ let alive = true;
507
+ (async () => {
508
+ const loaded = await loadTargets(getCall());
509
+ if (!alive) return;
510
+ if (loaded.ok) {
511
+ setTargets(loaded.value.targets);
512
+ setPollMs(loaded.value.pollMs);
513
+ } else setLoadError(loaded.message);
514
+ })();
515
+ return () => {
516
+ alive = false;
517
+ };
518
+ }, [getCall]);
519
+ const patch = react.useCallback((key, next) => {
520
+ setRows((previous) => ({
521
+ ...previous,
522
+ [key]: next
523
+ }));
524
+ }, []);
525
+ return {
526
+ targets,
527
+ rows,
528
+ pollMs,
529
+ loadError,
530
+ checking,
531
+ checkAll: react.useCallback(() => {
532
+ setChecking(true);
533
+ const list = targets;
534
+ for (const target of list) patch(target.key, {
535
+ phase: "checking",
536
+ outcome: rows[target.key]?.outcome ?? null,
537
+ failure: null
538
+ });
539
+ Promise.all(list.map(async (target) => {
540
+ const result = await checkTarget(getCall(), target);
541
+ patch(target.key, result.ok ? {
542
+ phase: "ready",
543
+ outcome: result.value,
544
+ failure: null
545
+ } : {
546
+ phase: "failed",
547
+ outcome: null,
548
+ failure: result
549
+ });
550
+ })).finally(() => setChecking(false));
551
+ }, [
552
+ getCall,
553
+ patch,
554
+ rows,
555
+ targets
556
+ ]),
557
+ act: react.useCallback((target) => {
558
+ const current = rows[target.key] ?? IDLE;
559
+ patch(target.key, {
560
+ ...current,
561
+ phase: "installing",
562
+ failure: null
563
+ });
564
+ (async () => {
565
+ const absent = isAbsent(target);
566
+ let version = current.outcome?.snapshot.latestVersion ?? null;
567
+ if (absent && version === null) {
568
+ const checked = await checkTarget(getCall(), target);
569
+ if (!checked.ok) {
570
+ patch(target.key, {
571
+ phase: "failed",
572
+ outcome: null,
573
+ failure: checked
574
+ });
575
+ return;
576
+ }
577
+ patch(target.key, {
578
+ phase: "installing",
579
+ outcome: checked.value,
580
+ failure: null
581
+ });
582
+ version = checked.value.snapshot.latestVersion;
583
+ }
584
+ const result = absent ? await installAbsent(getCall(), target, version ?? "") : await updateInstalled(getCall(), target, pollMs);
585
+ if (result.ok) {
586
+ const refreshed = await checkTarget(getCall(), target, target.phones?.status);
587
+ patch(target.key, refreshed.ok ? {
588
+ phase: "ready",
589
+ outcome: refreshed.value,
590
+ failure: null
591
+ } : {
592
+ phase: "failed",
593
+ outcome: null,
594
+ failure: refreshed
595
+ });
596
+ return;
597
+ }
598
+ patch(target.key, {
599
+ phase: "failed",
600
+ outcome: current.outcome,
601
+ failure: {
602
+ ...result,
603
+ manual: result.manual ?? current.outcome?.manual ?? null
604
+ }
605
+ });
606
+ })();
607
+ }, [
608
+ getCall,
609
+ patch,
610
+ pollMs,
611
+ rows
612
+ ])
613
+ };
614
+ }
615
+ /** 标题右侧的「检查更新」:一个按钮一次查七家(吃 `useUpdateRows` 的表)。 */
616
+ function CheckUpdateButton(props) {
617
+ const { checking, checkAll, targets } = props.face;
618
+ const disabled = checking || targets.length === 0;
619
+ return react.createElement("button", {
620
+ type: "button",
621
+ style: disabled ? {
622
+ ...PANEL_STYLE.btn,
623
+ opacity: .55,
624
+ cursor: "default"
625
+ } : PANEL_STYLE.btn,
626
+ disabled,
627
+ onClick: checkAll,
628
+ title: "一次查七家(总管自己 + 六个单品)的插件包版本"
629
+ }, checking ? "检查中…" : "检查更新");
630
+ }
631
+ /** 待重启横幅:任何一家成 `pending-restart` 就常驻显示,重启宿主后自动消失(更新包 README 第 10 节)。 */
632
+ function RestartBanners(props) {
633
+ const lines = [];
634
+ for (const target of props.face.targets) {
635
+ const outcome = props.face.rows[target.key]?.outcome;
636
+ if (!outcome) continue;
637
+ const text = pendingRestartText(target, outcome.snapshot);
638
+ if (text) lines.push(text);
639
+ }
640
+ if (lines.length === 0) return null;
641
+ return react.createElement("div", { style: PANEL_STYLE.banner }, lines.map((line, index) => react.createElement("div", { key: String(index) }, line)));
642
+ }
643
+ /** 七家结果行:一家一行(版本行两行、结论一句、按钮一个、装不了给原因与手工命令)。 */
644
+ function UpdateResults(props) {
645
+ const { targets, rows, loadError, checking } = props.face;
646
+ const shown = targets.filter((target) => rows[target.key] && rows[target.key].phase !== "idle");
647
+ if (loadError) return react.createElement("div", { style: PANEL_STYLE.reason }, "更新能力没接上:" + loadError);
648
+ if (targets.length === 0) return null;
649
+ if (shown.length === 0) return null;
650
+ return react.createElement("div", null, react.createElement(RestartBanners, { face: props.face }), shown.map((target) => {
651
+ const row = rows[target.key];
652
+ const snapshot = row.outcome?.snapshot ?? null;
653
+ const verdict = snapshot ? verdictOf(target, snapshot) : null;
654
+ const manual = row.failure?.manual ?? row.outcome?.manual ?? null;
655
+ const busy = row.phase === "installing";
656
+ return react.createElement("div", {
657
+ key: target.key,
658
+ style: PANEL_STYLE.row
659
+ }, react.createElement("div", { style: PANEL_STYLE.rowHead }, target.title + " · " + target.packageName), react.createElement("div", { style: PANEL_STYLE.rowMeta }, "当前 " + String(snapshot?.runningVersion ?? target.runningVersion ?? "未安装") + " · 最新 " + String(snapshot?.latestVersion ?? "未查")), react.createElement("div", null, busy ? "正在装,请稍候…" : verdict?.text ?? (checking ? "检查中…" : "")), verdict?.action ? react.createElement("button", {
660
+ type: "button",
661
+ style: busy ? {
662
+ ...PANEL_STYLE.btn,
663
+ opacity: .55
664
+ } : {
665
+ ...PANEL_STYLE.btn,
666
+ marginTop: 6
667
+ },
668
+ disabled: busy,
669
+ onClick: () => props.face.act(target)
670
+ }, verdict.action === "install" ? "装上" : "装上更新") : null, row.failure ? react.createElement("div", { style: PANEL_STYLE.reason }, "装不上:" + row.failure.message) : null, versionLines(target, snapshot).map((line, index) => react.createElement("div", {
671
+ key: String(index),
672
+ style: PANEL_STYLE.skill
673
+ }, line)), manual ? react.createElement("code", { style: PANEL_STYLE.cmd }, manual) : null);
674
+ }));
675
+ }
676
+ /** 缺席卡:一句人话 +「装上」按钮(复制命令降为辅助展示);总管缺席时不显示此卡(面板也看不到)。 */
677
+ function AbsentCard(props) {
678
+ const target = props.target;
679
+ const row = target ? props.face.rows[target.key] : null;
680
+ const busy = row?.phase === "installing";
681
+ const manual = row?.failure?.manual ?? row?.outcome?.manual ?? props.fallbackCommand;
682
+ let reason = row?.failure?.message ?? null;
683
+ if (target && reason === null && row?.outcome && row.outcome.snapshot.blockedReason !== null) reason = verdictOf(target, row.outcome.snapshot).text;
684
+ return react.createElement("div", { style: PANEL_STYLE.reco }, react.createElement("div", null, target ? "未安装[" + target.packageName + "],装上后这个页签就能用了。" : "这个页签对应的插件还没装。"), target ? react.createElement("button", {
685
+ type: "button",
686
+ style: busy ? {
687
+ ...PANEL_STYLE.btn,
688
+ marginTop: 8,
689
+ opacity: .55
690
+ } : {
691
+ ...PANEL_STYLE.btn,
692
+ marginTop: 8
693
+ },
694
+ disabled: busy,
695
+ onClick: () => props.face.act(target)
696
+ }, busy ? "正在装,请稍候…" : "装上") : null, reason ? react.createElement("div", { style: PANEL_STYLE.reason }, reason) : null, react.createElement("code", { style: PANEL_STYLE.cmd }, manual));
697
+ }
88
698
  //#endregion
89
699
  //#region src/client.ts
90
700
  /** dsh-life-pack client 适配器(六边形:browser 侧;爱生活单卡方案)。
@@ -93,33 +703,205 @@ window.__ModuleLoader__.load({
93
703
  * - 本文件经 tsdown 打成 loader 工厂包(browser/CJS + 注册包裹),classic 执行
94
704
  * 只注册,副作用全在 factory 内;传递闭包禁 node 内建与 ESM 语法(回路
95
705
  * test/client-bundle-48.test.mjs 看门)。
96
- * - inject 短名 ['slots'](总管机制范围无 RPC,不声明 connection,不碰 sidebar);
97
- * apply 只注册一处 DSH设置面板槽(kind list,id 必填)。
706
+ * - inject 短名 ['slots','connection']:本包现在有 RPC(装与更新的能力由宿主半提供,
707
+ * 电话名与轮询间隔由宿主转交,面板不写死),读了 ctx.connection 就必须声明 'connection'(#48 血例)。
98
708
  * - 爱生活页签条走 slot 驱动(settings-plugins 先例,禁纯 useState 手画 tab):
99
709
  * section 声明 children 爱生活页签槽,各技能往里注册自家技能设置页;
100
710
  * tab 行从 ledger 投影(entries+getVersion+subscribe),面板用 props.renderSlot
101
711
  * 按 `{only}` 投影(一次只挂载一个)+ visited 缓存;ledger 无某技能=缺席,
102
712
  * 显示推荐安装只读文本(不返空冒充)。
103
713
  * - 组件 React.createElement 手写(禁 JSX),无数据轮询、无 document/window/process。
714
+ * - 面板外壳两处(票 #679):标题行右上角两个入口(星/气泡,悬停出说明文字,
715
+ * 窄窗口折到标题下方);页签面板之后是底部「作者其他插件」引流卡(四行,点开新窗口)。
716
+ * 文案与网址都取自 nav.ts 的两张静态表,本文件只画不算。
104
717
  */
105
- const inject = ["slots"];
718
+ const inject = ["slots", "connection"];
106
719
  /** 爱生活页签槽(总管声明的 children,技能设置页注册进来;单段名,避开官方 settings.* 前缀)。 */
107
720
  const CONFIG_TAB_SLOT = "ilife.config-tab";
721
+ /** 总管视觉(内联 style;颜色走 DSH 主题别名,深浅主题自适应,写死值只做回退;与技能面板同语言)。 */
722
+ const S = {
723
+ headRow: {
724
+ display: "flex",
725
+ flexWrap: "wrap",
726
+ alignItems: "center",
727
+ gap: 8,
728
+ margin: "2px 0 8px"
729
+ },
730
+ head: {
731
+ fontSize: 15,
732
+ fontWeight: 700,
733
+ flex: "0 0 auto",
734
+ color: "var(--dsw-alias-label-primary, inherit)"
735
+ },
736
+ headActions: {
737
+ display: "inline-flex",
738
+ alignItems: "center",
739
+ gap: 2,
740
+ marginLeft: "auto",
741
+ flex: "0 0 auto"
742
+ },
743
+ iconLink: {
744
+ display: "inline-flex",
745
+ alignItems: "center",
746
+ justifyContent: "center",
747
+ width: 26,
748
+ height: 26,
749
+ borderRadius: 6,
750
+ border: "1px solid transparent",
751
+ color: "var(--dsw-alias-label-primary, inherit)",
752
+ textDecoration: "none",
753
+ fontSize: 14,
754
+ lineHeight: 1
755
+ },
756
+ meta: {
757
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
758
+ fontSize: 12,
759
+ lineHeight: 1.7,
760
+ marginBottom: 10
761
+ },
762
+ tablist: {
763
+ display: "flex",
764
+ flexWrap: "wrap",
765
+ gap: 6,
766
+ marginBottom: 10
767
+ },
768
+ tab: {
769
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))",
770
+ background: "transparent",
771
+ color: "var(--dsw-alias-label-primary, inherit)",
772
+ borderRadius: 999,
773
+ padding: "4px 12px",
774
+ fontSize: 13,
775
+ cursor: "pointer"
776
+ },
777
+ tabActive: {
778
+ background: "var(--dsw-alias-brand-primary, #0a84ff)",
779
+ borderColor: "transparent",
780
+ color: "#fff",
781
+ fontWeight: 700
782
+ },
783
+ reco: {
784
+ padding: "12px 14px",
785
+ borderRadius: 10,
786
+ border: "1px dashed var(--dsw-alias-border, rgba(128,128,128,.45))",
787
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
788
+ fontSize: 13,
789
+ lineHeight: 1.7
790
+ },
791
+ cmd: {
792
+ display: "block",
793
+ marginTop: 8,
794
+ padding: "8px 10px",
795
+ borderRadius: 8,
796
+ background: "var(--dsw-alias-bg-base, rgba(128,128,128,.12))",
797
+ color: "var(--dsw-alias-label-primary, inherit)",
798
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
799
+ fontSize: 12,
800
+ userSelect: "all"
801
+ },
802
+ moreCard: {
803
+ marginTop: 16,
804
+ padding: "10px 12px",
805
+ borderRadius: 10,
806
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))"
807
+ },
808
+ moreTitle: {
809
+ fontSize: 13,
810
+ fontWeight: 700,
811
+ marginBottom: 4,
812
+ color: "var(--dsw-alias-label-primary, inherit)"
813
+ },
814
+ moreRow: {
815
+ display: "flex",
816
+ flexWrap: "wrap",
817
+ alignItems: "center",
818
+ gap: "2px 8px",
819
+ padding: "7px 4px",
820
+ textDecoration: "none",
821
+ color: "inherit"
822
+ },
823
+ morePkg: {
824
+ flex: "0 1 auto",
825
+ minWidth: 0,
826
+ overflowWrap: "anywhere",
827
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
828
+ fontSize: 12,
829
+ fontWeight: 650
830
+ },
831
+ moreDesc: {
832
+ flex: "1 1 180px",
833
+ minWidth: 0,
834
+ fontSize: 11.5,
835
+ lineHeight: 1.6,
836
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)"
837
+ },
838
+ moreIcon: {
839
+ flex: "0 0 auto",
840
+ marginLeft: "auto",
841
+ display: "inline-flex",
842
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)"
843
+ }
844
+ };
845
+ /** 缺席卡的画法与流程住 `update-panel.ts`(票 #678:一句人话 +「装上」按钮 + 可复制命令),
846
+ * `recoFor` 的补装命令仍是卡里那条辅助展示(B11 口径不变)。 */
108
847
  /** 标签解析(resolveSlotLabel 同形:thunk 跟活,无则空字串;见 slots lib:27-29)。 */
109
848
  function resolveLabel(label) {
110
849
  if (typeof label === "function") return label();
111
850
  return label ?? "";
112
851
  }
113
- /** 缺席文案(grill 定案 Q2/Q5:调不通=没装;只读文本,不做假导航)。 */
114
- function absentHint(tab) {
115
- return recoFor(tab).hint;
852
+ /** 外链图标(行尾那个;纯装饰,语义在链接自己的 aria-label/文字上)。 */
853
+ function ExternalIcon() {
854
+ return react.createElement("svg", {
855
+ viewBox: "0 0 24 24",
856
+ width: 13,
857
+ height: 13,
858
+ fill: "none",
859
+ stroke: "currentColor",
860
+ strokeWidth: 1.9,
861
+ strokeLinecap: "round",
862
+ strokeLinejoin: "round",
863
+ "aria-hidden": "true",
864
+ focusable: "false"
865
+ }, react.createElement("path", { d: "M13.5 4.5H19.5V10.5" }), react.createElement("path", { d: "M19.5 4.5L11 13" }), react.createElement("path", { d: "M18 14.5V18A2 2 0 0 1 16 20H6A2 2 0 0 1 4 18V8A2 2 0 0 1 6 6H9.5" }));
866
+ }
867
+ /** 标题行右上角两件:星(去本仓点 Star)+ 气泡(去本仓开 issue),悬停出说明文字。
868
+ *
869
+ * 三件并排里的第三件「检查更新」住隔壁票(#678):本票只留位子——它就接在本组件之前,
870
+ * 不画一个点了没反应的假按钮。窄窗口靠 headRow 的 flexWrap 折到标题下方,
871
+ * 标题与两件都是 flex:'0 0 auto',谁也不挤谁。
872
+ */
873
+ function PanelActions() {
874
+ return react.createElement("div", { style: S.headActions }, PANEL_LINKS.map((link) => react.createElement("a", {
875
+ key: link.key,
876
+ href: link.url,
877
+ target: "_blank",
878
+ rel: "noreferrer",
879
+ title: link.tip,
880
+ "aria-label": link.tip,
881
+ style: S.iconLink
882
+ }, react.createElement("span", { "aria-hidden": "true" }, link.glyph))));
116
883
  }
117
- /** 爱生活面板:总设置区 + 爱生活页签条(slot 驱动)+ 技能设置页投影/缺席文案。 */
884
+ /** 底部「作者其他插件」引流卡:四行(包名 + 一句说明 + 行尾外链图标),点开新窗口。
885
+ *
886
+ * 卡片在页签面板之后,六页签切换它都在(它不属于任何一个页签)。
887
+ */
888
+ function MorePluginsCard() {
889
+ return react.createElement("div", { style: S.moreCard }, react.createElement("div", { style: S.moreTitle }, "作者其他插件"), MORE_PLUGINS.map((row) => react.createElement("a", {
890
+ key: row.pkg,
891
+ href: row.url,
892
+ target: "_blank",
893
+ rel: "noreferrer",
894
+ title: row.pkg + " 的 GitHub 仓库",
895
+ style: S.moreRow
896
+ }, react.createElement("span", { style: S.morePkg }, row.pkg), react.createElement("span", { style: S.moreDesc }, row.desc), react.createElement("span", { style: S.moreIcon }, react.createElement(ExternalIcon, null)))));
897
+ }
898
+ /** 爱生活面板:总设置区 + 检查更新(七家)+ 爱生活页签条(slot 驱动)+ 技能设置页投影/缺席卡。 */
118
899
  function LifePackSection(props) {
119
900
  const tabsId = react.useId();
120
901
  const tabRefs = react.useRef([]);
121
902
  const rows = props.useTabs((value) => value);
122
903
  const present = new Set(rows.map((r) => r.id));
904
+ const face = useUpdateRows(props.getCall);
123
905
  const [activeId, setActiveId] = react.useState(void 0);
124
906
  const [visitedIds, setVisitedIds] = react.useState(() => /* @__PURE__ */ new Set());
125
907
  const active = MANAGER_TABS.some((t) => t.plugin === activeId) ? activeId : MANAGER_TABS[0].plugin;
@@ -133,6 +915,9 @@ window.__ModuleLoader__.load({
133
915
  const row = rows.find((r) => r.id === tab.plugin);
134
916
  return row && row.label.length > 0 ? row.label : tab.title;
135
917
  }
918
+ function targetForTab(tab) {
919
+ return face.targets.find((t) => t.packageName === tab.plugin) ?? null;
920
+ }
136
921
  function onTabKeyDown(event, index) {
137
922
  let nextIndex;
138
923
  switch (event.key) {
@@ -155,9 +940,10 @@ window.__ModuleLoader__.load({
155
940
  setActiveId(next.plugin);
156
941
  tabRefs.current[nextIndex]?.focus();
157
942
  }
158
- return react.createElement("div", null, react.createElement("h2", null, "爱生活"), react.createElement("div", null, react.createElement("div", null, "总开关 · 开关(缺省启用,只读)"), react.createElement("div", null, "总管 dsh-life-pack · 0.2.0")), react.createElement("div", {
943
+ return react.createElement("div", null, react.createElement("div", { style: S.headRow }, react.createElement("div", { style: S.head }, "爱生活"), react.createElement("div", { style: S.headActions }, react.createElement(CheckUpdateButton, { face }), react.createElement(PanelActions, null))), react.createElement("div", { style: S.meta }, react.createElement("div", null, "总开关 · 开关(缺省启用,只读)"), react.createElement("div", null, "总管 dsh-life-pack · 0.2.1")), react.createElement(UpdateResults, { face }), react.createElement("div", {
159
944
  role: "tablist",
160
- "aria-label": "爱生活技能页签"
945
+ "aria-label": "爱生活技能页签",
946
+ style: S.tablist
161
947
  }, MANAGER_TABS.map((tab, index) => {
162
948
  const selected = tab.plugin === active;
163
949
  return react.createElement("button", {
@@ -171,13 +957,17 @@ window.__ModuleLoader__.load({
171
957
  "aria-selected": selected,
172
958
  "aria-controls": tabsId + "-panel-" + tab.plugin,
173
959
  tabIndex: selected ? 0 : -1,
960
+ style: selected ? {
961
+ ...S.tab,
962
+ ...S.tabActive
963
+ } : S.tab,
174
964
  onClick: () => {
175
965
  setActiveId(tab.plugin);
176
966
  },
177
967
  onKeyDown: (event) => {
178
968
  onTabKeyDown(event, index);
179
969
  }
180
- }, labelFor(tab));
970
+ }, (present.has(tab.plugin) ? "● " : "○ ") + labelFor(tab));
181
971
  })), MANAGER_TABS.filter((tab) => tab.plugin === active || visitedIds.has(tab.plugin)).map((tab) => {
182
972
  const selected = tab.plugin === active;
183
973
  return react.createElement("div", {
@@ -186,10 +976,15 @@ window.__ModuleLoader__.load({
186
976
  role: "tabpanel",
187
977
  "aria-labelledby": tabsId + "-tab-" + tab.plugin,
188
978
  hidden: !selected
189
- }, present.has(tab.plugin) ? props.renderSlot(CONFIG_TAB_SLOT, {}, { only: tab.plugin }) : absentHint(tab));
190
- }));
979
+ }, present.has(tab.plugin) ? props.renderSlot(CONFIG_TAB_SLOT, {}, { only: tab.plugin }) : react.createElement(AbsentCard, {
980
+ target: targetForTab(tab),
981
+ fallbackCommand: recoFor(tab).installCmd,
982
+ face
983
+ }));
984
+ }), react.createElement(MorePluginsCard, null));
191
985
  }
192
986
  function apply(ctx) {
987
+ const getCall = () => ctx.connection?.rpc?.call ?? null;
193
988
  let tabsVersion = -1;
194
989
  let tabs = [];
195
990
  const sectionInjected = () => ({ hooks: { tabs: {
@@ -217,7 +1012,10 @@ window.__ModuleLoader__.load({
217
1012
  kind: "list",
218
1013
  scope: "root"
219
1014
  } }
220
- }, LifePackSection));
1015
+ }, (props) => react.createElement(LifePackSection, {
1016
+ ...props,
1017
+ getCall
1018
+ })));
221
1019
  }
222
1020
  //#endregion
223
1021
  exports.CONFIG_TAB_SLOT = CONFIG_TAB_SLOT;