hiwork-core 0.0.16 → 0.0.18

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/client.js CHANGED
@@ -350,6 +350,146 @@ function AiRouterSection(props) {
350
350
  var import_react2 = require("react");
351
351
  var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
352
352
 
353
+ // src/client/plugin-updates.ts
354
+ var PLUGIN_UPDATES_CHANNEL = "/hiwork-plugin-updates";
355
+ var PLUGIN_UPDATES_POLL_INTERVAL_MS = 5 * 6e4;
356
+ var PluginUpdatesRpcError = class extends Error {
357
+ constructor(code, message) {
358
+ super(message);
359
+ this.code = code;
360
+ this.name = "PluginUpdatesRpcError";
361
+ }
362
+ };
363
+ var GENERIC_FAILURE_MESSAGE = "\u63D2\u4EF6\u66F4\u65B0\u72B6\u6001\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
364
+ var INVALID_RESPONSE_MESSAGE = "\u63D2\u4EF6\u66F4\u65B0\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002";
365
+ function isRecord(value) {
366
+ return typeof value === "object" && value !== null && !Array.isArray(value);
367
+ }
368
+ function text(value) {
369
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
370
+ }
371
+ function unwrapPluginUpdatesResult(value) {
372
+ if (!isRecord(value) || !("ok" in value)) {
373
+ throw new PluginUpdatesRpcError("internal", INVALID_RESPONSE_MESSAGE);
374
+ }
375
+ if (value["ok"] === true) return value["value"];
376
+ if (value["ok"] === false) {
377
+ const error = isRecord(value["error"]) ? value["error"] : void 0;
378
+ const code = text(error?.["code"]) ?? "internal";
379
+ const message = typeof error?.["message"] === "string" && error["message"].trim() !== "" ? error["message"] : GENERIC_FAILURE_MESSAGE;
380
+ throw new PluginUpdatesRpcError(code, message);
381
+ }
382
+ throw new PluginUpdatesRpcError("internal", INVALID_RESPONSE_MESSAGE);
383
+ }
384
+ function toPackageUpdate(value) {
385
+ if (!isRecord(value)) return void 0;
386
+ const packageName = text(value["packageName"]);
387
+ if (packageName === void 0) return void 0;
388
+ return {
389
+ packageName,
390
+ displayName: text(value["displayName"]) ?? packageName,
391
+ from: text(value["from"]) ?? "",
392
+ to: text(value["to"]) ?? ""
393
+ };
394
+ }
395
+ function toPluginUpdatesSnapshot(value) {
396
+ if (!isRecord(value)) return void 0;
397
+ const packages = [];
398
+ for (const item of Array.isArray(value["packages"]) ? value["packages"] : []) {
399
+ const normalized = toPackageUpdate(item);
400
+ if (normalized !== void 0) packages.push(normalized);
401
+ }
402
+ const revision = value["revision"];
403
+ const fetchedAt = value["fetchedAt"];
404
+ return {
405
+ packages,
406
+ fetchedAt: typeof fetchedAt === "number" && Number.isFinite(fetchedAt) ? fetchedAt : 0,
407
+ revision: typeof revision === "number" && Number.isFinite(revision) ? revision : null
408
+ };
409
+ }
410
+ var PluginUpdatesStore = class {
411
+ constructor(rpc) {
412
+ this.rpc = rpc;
413
+ }
414
+ current;
415
+ dismissed;
416
+ version = 0;
417
+ listeners = /* @__PURE__ */ new Set();
418
+ /** 最近一次成功取到的快照(同步读,可能比服务端现状旧)。没有 / 不可用时 `undefined`。 */
419
+ snapshot() {
420
+ return this.current;
421
+ }
422
+ /** 变化计数:`useSyncExternalStore` 的 `getSnapshot`(值变化即重渲染)。 */
423
+ getVersion = () => this.version;
424
+ /** 订阅变化(快照更新 / 关闭)。返回退订函数。 */
425
+ subscribe = (listener) => {
426
+ this.listeners.add(listener);
427
+ return () => {
428
+ this.listeners.delete(listener);
429
+ };
430
+ };
431
+ /** 这个 revision 是否已经被用户关掉过(同 revision 不再提示;换 revision 会重新提示)。 */
432
+ isDismissed(revision) {
433
+ return this.dismissed !== void 0 && this.dismissed === revision;
434
+ }
435
+ /**
436
+ * 现在是否该渲染提示卡片。
437
+ *
438
+ * 三个条件缺一不可:**有快照**(不可用 / 从没取到 → 不渲染)、**`packages` 非空**
439
+ * (只有降级项时桌面端返回的也是空 `packages`,spec §4.5 明确不弹)、**这个 revision 没被关过**。
440
+ * ⚠️ 判据里**没有** `held` / `from` / `displayName`(spec §4.3 的 ⚠️)。
441
+ */
442
+ shouldPrompt() {
443
+ const snapshot = this.current;
444
+ if (snapshot === void 0 || snapshot.packages.length === 0) return false;
445
+ return !this.isDismissed(snapshot.revision);
446
+ }
447
+ /**
448
+ * 记住「用户关掉了这一个 revision 的提示」。**只记内存**(spec §4.5:本次会话 + 该 revision),
449
+ * 不落盘、不调任何 apply。
450
+ */
451
+ dismiss() {
452
+ const snapshot = this.current;
453
+ if (snapshot === void 0) return;
454
+ if (this.dismissed === snapshot.revision) return;
455
+ this.dismissed = snapshot.revision;
456
+ this.bump();
457
+ }
458
+ /**
459
+ * 向 Host 取一次「有没有待生效的更新」。
460
+ *
461
+ * 返回 `undefined` 有两种来源,对调用方是同一件事(**不渲染**):Host 说「不可用」(`null`),
462
+ * 或响应形状不合法。⚠️ **失败(`ok:false` / 传输异常)一律抛错且不覆写镜像**——提示条不该
463
+ * 因为一次抖动而消失,而「更新已经在排队」这件事不会因为读不出来就不存在。
464
+ */
465
+ async refresh(signal) {
466
+ const raw = await this.rpc.call(PLUGIN_UPDATES_CHANNEL, "snapshot", {}, signal);
467
+ const next = toPluginUpdatesSnapshot(unwrapPluginUpdatesResult(raw));
468
+ this.current = next;
469
+ this.bump();
470
+ return next;
471
+ }
472
+ /**
473
+ * 请求「重启并更新」。**这是全链路唯一的回收入口,且只能由用户点击到达。**
474
+ *
475
+ * 返回 `true` 表示触发**已经递下去**(最终落到主进程的 `recycleDshForPluginUpdate()`),
476
+ * **不是**「更新成功」——主进程会重建窗口,客户端那一刻就没了。
477
+ * `false` 只在「老桌面端上没有 `desktopPlugins`」时出现(Host 回 `requested: false`)。
478
+ */
479
+ async requestRestart(signal) {
480
+ const raw = await this.rpc.call(PLUGIN_UPDATES_CHANNEL, "apply", {}, signal);
481
+ const value = unwrapPluginUpdatesResult(raw);
482
+ return isRecord(value) && value["requested"] === true;
483
+ }
484
+ bump() {
485
+ this.version += 1;
486
+ for (const listener of [...this.listeners]) listener();
487
+ }
488
+ };
489
+ function createPluginUpdates(rpc) {
490
+ return new PluginUpdatesStore(rpc);
491
+ }
492
+
353
493
  // src/client/settings-navigation.ts
354
494
  function pickSettingsSectionButton(buttons, labels) {
355
495
  for (const label of labels) {
@@ -576,6 +716,90 @@ var confirmActionsStyle = {
576
716
  gap: 8,
577
717
  marginTop: 8
578
718
  };
719
+ var UPDATES_CARD_GAP = 8;
720
+ var UPDATES_CARD_FALLBACK_BOTTOM = 64;
721
+ var UPDATES_CARD_MIN_BOTTOM = 8;
722
+ var updatesCardStyle = (bottom) => ({
723
+ position: "fixed",
724
+ left: 8,
725
+ bottom,
726
+ zIndex: 1200,
727
+ boxSizing: "border-box",
728
+ width: 268,
729
+ maxWidth: "calc(100vw - 16px)",
730
+ padding: "12px 12px 10px",
731
+ display: "flex",
732
+ flexDirection: "column",
733
+ gap: 6,
734
+ background: bgLayer2,
735
+ borderRadius: 12,
736
+ border: `1px solid ${borderL2}`,
737
+ boxShadow: "var(--dsw-shadow-lv3, 0 12px 32px rgba(15,17,21,0.18))"
738
+ });
739
+ var updatesTitleStyle = {
740
+ display: "flex",
741
+ alignItems: "center",
742
+ gap: 6,
743
+ fontSize: 13,
744
+ fontWeight: 600,
745
+ color: textPrimary
746
+ };
747
+ var updatesBodyStyle = { fontSize: 12, lineHeight: 1.6, color: textSecondary };
748
+ var updatesErrorStyle = { fontSize: 12, lineHeight: 1.6, color: warnText };
749
+ var updatesActionsStyle = {
750
+ display: "flex",
751
+ justifyContent: "flex-end",
752
+ gap: 8,
753
+ marginTop: 4
754
+ };
755
+ function PluginUpdatesCard(props) {
756
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
757
+ "div",
758
+ {
759
+ style: updatesCardStyle(props.bottom),
760
+ "data-hiwork-plugin-updates": true,
761
+ role: "status",
762
+ "aria-label": "\u63D2\u4EF6\u66F4\u65B0\u63D0\u793A",
763
+ children: [
764
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: updatesTitleStyle, children: "\u63D2\u4EF6\u66F4\u65B0\u5DF2\u5C31\u7EEA" }),
765
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: updatesBodyStyle, children: "\u91CD\u542F\u540E\u751F\u6548\u3002" }),
766
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: updatesBodyStyle, children: [
767
+ "\u70B9\u300C\u91CD\u542F\u5E76\u66F4\u65B0\u300D\u4F1A\u7ACB\u523B\u91CD\u542F\u5E94\u7528\uFF1A",
768
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("strong", { children: "\u5F53\u524D\u6B63\u5728\u8F93\u5165\u7684\u5185\u5BB9\u4F1A\u4E22\u5931" }),
769
+ "\uFF08\u4F1A\u8BDD\u8BB0\u5F55\u4FDD\u5B58\u5728\u78C1\u76D8\u4E0A\uFF0C\u4E0D\u4F1A\u4E22\uFF09\u3002"
770
+ ] }),
771
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: updatesBodyStyle, children: "\u4E5F\u53EF\u4EE5\u7A0D\u540E\u624B\u52A8\u91CD\u542F\uFF1A\u9000\u51FA HiWork \u540E\u91CD\u65B0\u6253\u5F00\uFF08macOS \u6309 Cmd+Q\uFF0C\u5173\u95ED\u7A97\u53E3\u4E0D\u7B97\u9000\u51FA\uFF09\u3002" }),
772
+ props.restartFailed ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: updatesErrorStyle, children: "\u6682\u65F6\u65E0\u6CD5\u89E6\u53D1\u91CD\u542F\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\uFF0C\u6216\u6309\u4E0A\u9762\u7684\u65B9\u5F0F\u624B\u52A8\u9000\u51FA\u540E\u91CD\u65B0\u6253\u5F00\u3002" }) : null,
773
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: updatesActionsStyle, children: [
774
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
775
+ "button",
776
+ {
777
+ type: "button",
778
+ "data-hiwork-core-secondary": true,
779
+ "data-hiwork-plugin-updates-dismiss": true,
780
+ style: secondaryButton,
781
+ onClick: props.onDismiss,
782
+ children: "\u5173\u95ED"
783
+ }
784
+ ),
785
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
786
+ "button",
787
+ {
788
+ type: "button",
789
+ "data-hiwork-core-primary": true,
790
+ "data-hiwork-plugin-updates-restart": true,
791
+ style: primaryButton,
792
+ disabled: props.restarting,
793
+ onClick: props.onRestart,
794
+ children: props.restarting ? "\u5DF2\u8BF7\u6C42\u91CD\u542F\u2026" : "\u91CD\u542F\u5E76\u66F4\u65B0"
795
+ }
796
+ )
797
+ ] })
798
+ ]
799
+ }
800
+ );
801
+ }
802
+ var serverUpdatesVersion = () => 0;
579
803
  function LogoutIcon() {
580
804
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
581
805
  "svg",
@@ -631,6 +855,41 @@ function LogoutConfirm(props) {
631
855
  function UserBadge(props) {
632
856
  const state = props.useAiRouterCard((snapshot) => snapshot);
633
857
  const [confirming, setConfirming] = (0, import_react2.useState)(false);
858
+ const updates = props.pluginUpdates;
859
+ const [restarting, setRestarting] = (0, import_react2.useState)(false);
860
+ const [restartFailed, setRestartFailed] = (0, import_react2.useState)(false);
861
+ const [cardBottom, setCardBottom] = (0, import_react2.useState)(UPDATES_CARD_FALLBACK_BOTTOM);
862
+ const wideRowRef = (0, import_react2.useRef)(null);
863
+ const railRowRef = (0, import_react2.useRef)(null);
864
+ const loggedIn = state.status === "logged-in" && state.username !== void 0;
865
+ (0, import_react2.useSyncExternalStore)(updates.subscribe, updates.getVersion, serverUpdatesVersion);
866
+ (0, import_react2.useEffect)(() => {
867
+ if (!loggedIn) return;
868
+ const tick = () => {
869
+ void updates.refresh().catch(() => {
870
+ });
871
+ };
872
+ tick();
873
+ const timer = setInterval(tick, PLUGIN_UPDATES_POLL_INTERVAL_MS);
874
+ return () => {
875
+ clearInterval(timer);
876
+ };
877
+ }, [loggedIn, updates]);
878
+ const showUpdates = loggedIn && updates.shouldPrompt();
879
+ (0, import_react2.useLayoutEffect)(() => {
880
+ if (!showUpdates) return;
881
+ const measure = () => {
882
+ const node = props.wide ? wideRowRef.current : railRowRef.current;
883
+ if (node === null) return;
884
+ const rect = node.getBoundingClientRect();
885
+ setCardBottom(Math.max(UPDATES_CARD_MIN_BOTTOM, Math.round(window.innerHeight - rect.top + UPDATES_CARD_GAP)));
886
+ };
887
+ measure();
888
+ window.addEventListener("resize", measure);
889
+ return () => {
890
+ window.removeEventListener("resize", measure);
891
+ };
892
+ }, [showUpdates, props.wide]);
634
893
  if (state.status !== "logged-in" || state.username === void 0) return null;
635
894
  const displayName = state.displayName ?? state.username;
636
895
  const initial = displayName.slice(0, 1).toUpperCase();
@@ -638,22 +897,51 @@ function UserBadge(props) {
638
897
  setConfirming(false);
639
898
  void props.actions.logout();
640
899
  } }) : null;
641
- if (!props.wide) {
642
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
643
- "button",
644
- {
645
- type: "button",
646
- "data-hiwork-core-icon": true,
647
- style: railRowStyle,
648
- title: displayName,
649
- "aria-label": "\u6253\u5F00\u8BBE\u7F6E",
650
- onClick: openGatewaySettings,
651
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: avatarStyle(24), children: initial })
900
+ const onRestart = () => {
901
+ setRestartFailed(false);
902
+ setRestarting(true);
903
+ void updates.requestRestart().then((requested) => {
904
+ if (!requested) {
905
+ setRestarting(false);
906
+ setRestartFailed(true);
652
907
  }
653
- );
908
+ }).catch(() => {
909
+ setRestarting(false);
910
+ setRestartFailed(true);
911
+ });
912
+ };
913
+ const updatesCard = showUpdates ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
914
+ PluginUpdatesCard,
915
+ {
916
+ bottom: cardBottom,
917
+ restarting,
918
+ restartFailed,
919
+ onRestart,
920
+ onDismiss: () => {
921
+ updates.dismiss();
922
+ }
923
+ }
924
+ ) : null;
925
+ if (!props.wide) {
926
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
927
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
928
+ "button",
929
+ {
930
+ ref: railRowRef,
931
+ type: "button",
932
+ "data-hiwork-core-icon": true,
933
+ style: railRowStyle,
934
+ title: displayName,
935
+ "aria-label": "\u6253\u5F00\u8BBE\u7F6E",
936
+ onClick: openGatewaySettings,
937
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: avatarStyle(24), children: initial })
938
+ }
939
+ ),
940
+ updatesCard
941
+ ] });
654
942
  }
655
943
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
656
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: wideRowStyle, title: displayName, children: [
944
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { ref: wideRowRef, style: wideRowStyle, title: displayName, children: [
657
945
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
658
946
  "button",
659
947
  {
@@ -694,7 +982,8 @@ function UserBadge(props) {
694
982
  }
695
983
  )
696
984
  ] }),
697
- confirm
985
+ confirm,
986
+ updatesCard
698
987
  ] });
699
988
  }
700
989
 
@@ -2876,28 +3165,28 @@ function toAssignment(value) {
2876
3165
  const record = value;
2877
3166
  const authRaw = record["auth"];
2878
3167
  const auth = typeof authRaw === "object" && authRaw !== null ? authRaw : void 0;
2879
- const assignmentId = text(record["assignmentId"]);
2880
- const serverName = text(record["serverName"]);
2881
- const url = text(record["url"]);
2882
- const kind = text(auth?.["kind"]);
2883
- const mintUrl = text(auth?.["mintUrl"]);
3168
+ const assignmentId = text2(record["assignmentId"]);
3169
+ const serverName = text2(record["serverName"]);
3170
+ const url = text2(record["url"]);
3171
+ const kind = text2(auth?.["kind"]);
3172
+ const mintUrl = text2(auth?.["mintUrl"]);
2884
3173
  if (assignmentId === void 0 || serverName === void 0 || url === void 0 || kind === void 0 || mintUrl === void 0) {
2885
3174
  return void 0;
2886
3175
  }
2887
3176
  return {
2888
3177
  assignmentId,
2889
3178
  revision: typeof record["revision"] === "number" ? record["revision"] : 0,
2890
- slug: text(record["slug"]) ?? serverName,
2891
- displayName: text(record["displayName"]) ?? serverName,
3179
+ slug: text2(record["slug"]) ?? serverName,
3180
+ displayName: text2(record["displayName"]) ?? serverName,
2892
3181
  serverName,
2893
- transport: text(record["transport"]) ?? "",
3182
+ transport: text2(record["transport"]) ?? "",
2894
3183
  url,
2895
3184
  extraHeaders: stringMap(record["extraHeaders"]),
2896
- auth: { kind, mintUrl, revokeUrl: text(auth?.["revokeUrl"]) ?? "" },
2897
- updatedAt: text(record["updatedAt"]) ?? ""
3185
+ auth: { kind, mintUrl, revokeUrl: text2(auth?.["revokeUrl"]) ?? "" },
3186
+ updatedAt: text2(record["updatedAt"]) ?? ""
2898
3187
  };
2899
3188
  }
2900
- function text(value) {
3189
+ function text2(value) {
2901
3190
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
2902
3191
  }
2903
3192
  function stringMap(raw) {
@@ -2961,17 +3250,17 @@ var ManagedMcpStore = class {
2961
3250
  throw new ManagedMcpRpcError("internal", "MCP \u6E05\u5355\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002");
2962
3251
  }
2963
3252
  const record = value;
2964
- const assertion = text(record["assertion"]);
2965
- const mintUrl = text(record["mintUrl"]);
3253
+ const assertion = text2(record["assertion"]);
3254
+ const mintUrl = text2(record["mintUrl"]);
2966
3255
  if (assertion === void 0 || mintUrl === void 0) {
2967
3256
  throw new ManagedMcpRpcError("internal", "MCP \u6E05\u5355\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002");
2968
3257
  }
2969
3258
  return {
2970
3259
  assertion,
2971
3260
  mintUrl,
2972
- revokeUrl: text(record["revokeUrl"]) ?? "",
2973
- expiresAt: text(record["expiresAt"]) ?? "",
2974
- serverName: text(record["serverName"]) ?? ""
3261
+ revokeUrl: text2(record["revokeUrl"]) ?? "",
3262
+ expiresAt: text2(record["expiresAt"]) ?? "",
3263
+ serverName: text2(record["serverName"]) ?? ""
2975
3264
  };
2976
3265
  }
2977
3266
  /** 订阅镜像变化(连接器域据此重渲染 / 重排同步)。返回退订函数。 */
@@ -3020,29 +3309,29 @@ var ManagedSkillsRpcError = class extends Error {
3020
3309
  this.name = "ManagedSkillsRpcError";
3021
3310
  }
3022
3311
  };
3023
- var GENERIC_FAILURE_MESSAGE = "\u6280\u80FD\u6E05\u5355\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
3024
- var INVALID_RESPONSE_MESSAGE = "\u6280\u80FD\u6E05\u5355\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002";
3312
+ var GENERIC_FAILURE_MESSAGE2 = "\u6280\u80FD\u6E05\u5355\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
3313
+ var INVALID_RESPONSE_MESSAGE2 = "\u6280\u80FD\u6E05\u5355\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002";
3025
3314
  function unwrapManagedSkillsResult(value) {
3026
3315
  if (typeof value !== "object" || value === null || !("ok" in value)) {
3027
- throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE);
3316
+ throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3028
3317
  }
3029
3318
  const result = value;
3030
3319
  if (result.ok === true) return result.value;
3031
3320
  if (result.ok === false) {
3032
3321
  const code = typeof result.error?.code === "string" ? result.error.code : "internal";
3033
- const message = typeof result.error?.message === "string" && result.error.message.trim() !== "" ? result.error.message : GENERIC_FAILURE_MESSAGE;
3322
+ const message = typeof result.error?.message === "string" && result.error.message.trim() !== "" ? result.error.message : GENERIC_FAILURE_MESSAGE2;
3034
3323
  throw new ManagedSkillsRpcError(code, message);
3035
3324
  }
3036
- throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE);
3325
+ throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3037
3326
  }
3038
3327
  function toSnapshot2(value) {
3039
3328
  if (typeof value !== "object" || value === null) {
3040
- throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE);
3329
+ throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3041
3330
  }
3042
3331
  const record = value;
3043
3332
  const rawList = record["assignments"];
3044
3333
  if (!Array.isArray(rawList)) {
3045
- throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE);
3334
+ throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3046
3335
  }
3047
3336
  const assignments = [];
3048
3337
  for (const item of rawList) {
@@ -3060,7 +3349,7 @@ function toSnapshot2(value) {
3060
3349
  const detail = lastError;
3061
3350
  snapshot.lastError = {
3062
3351
  code: typeof detail.code === "string" ? detail.code : "internal",
3063
- message: typeof detail.message === "string" ? detail.message : GENERIC_FAILURE_MESSAGE
3352
+ message: typeof detail.message === "string" ? detail.message : GENERIC_FAILURE_MESSAGE2
3064
3353
  };
3065
3354
  }
3066
3355
  return snapshot;
@@ -3068,24 +3357,24 @@ function toSnapshot2(value) {
3068
3357
  function toAssignment2(value) {
3069
3358
  if (typeof value !== "object" || value === null) return void 0;
3070
3359
  const record = value;
3071
- const assignmentId = text2(record["assignmentId"]);
3072
- const skillName = text2(record["skillName"]);
3073
- const contentSha256 = text2(record["contentSha256"]);
3360
+ const assignmentId = text3(record["assignmentId"]);
3361
+ const skillName = text3(record["skillName"]);
3362
+ const contentSha256 = text3(record["contentSha256"]);
3074
3363
  if (assignmentId === void 0 || skillName === void 0 || contentSha256 === void 0) return void 0;
3075
3364
  return {
3076
3365
  assignmentId,
3077
3366
  revision: typeof record["revision"] === "number" ? record["revision"] : 0,
3078
- slug: text2(record["slug"]) ?? skillName,
3079
- displayName: text2(record["displayName"]) ?? skillName,
3367
+ slug: text3(record["slug"]) ?? skillName,
3368
+ displayName: text3(record["displayName"]) ?? skillName,
3080
3369
  skillName,
3081
- description: text2(record["description"]) ?? "",
3370
+ description: text3(record["description"]) ?? "",
3082
3371
  contentSha256,
3083
3372
  sizeBytes: typeof record["sizeBytes"] === "number" ? record["sizeBytes"] : 0,
3084
- version: text2(record["version"]) ?? "",
3085
- updatedAt: text2(record["updatedAt"]) ?? ""
3373
+ version: text3(record["version"]) ?? "",
3374
+ updatedAt: text3(record["updatedAt"]) ?? ""
3086
3375
  };
3087
3376
  }
3088
- function text2(value) {
3377
+ function text3(value) {
3089
3378
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
3090
3379
  }
3091
3380
  var ManagedSkillsStore = class {
@@ -3155,15 +3444,15 @@ var ManagedSkillsStore = class {
3155
3444
  const raw = await this.rpc.call(MANAGED_SKILLS_CHANNEL, "package", { assignmentId }, signal);
3156
3445
  const value = unwrapManagedSkillsResult(raw);
3157
3446
  if (typeof value !== "object" || value === null) {
3158
- throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE);
3447
+ throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3159
3448
  }
3160
3449
  const record = value;
3161
- const id = text2(record["assignmentId"]);
3162
- const skillName = text2(record["skillName"]);
3163
- const contentSha256 = text2(record["contentSha256"]);
3164
- const archiveBase64 = text2(record["archiveBase64"]);
3450
+ const id = text3(record["assignmentId"]);
3451
+ const skillName = text3(record["skillName"]);
3452
+ const contentSha256 = text3(record["contentSha256"]);
3453
+ const archiveBase64 = text3(record["archiveBase64"]);
3165
3454
  if (id === void 0 || skillName === void 0 || contentSha256 === void 0 || archiveBase64 === void 0) {
3166
- throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE);
3455
+ throw new ManagedSkillsRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3167
3456
  }
3168
3457
  return { assignmentId: id, skillName, contentSha256, archiveBase64 };
3169
3458
  }
@@ -3251,25 +3540,25 @@ var ManagedWorkbenchRpcError = class extends Error {
3251
3540
  this.name = "ManagedWorkbenchRpcError";
3252
3541
  }
3253
3542
  };
3254
- var GENERIC_FAILURE_MESSAGE2 = "\u5DE5\u4F5C\u53F0\u5E94\u7528\u6E05\u5355\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
3255
- var INVALID_RESPONSE_MESSAGE2 = "\u5DE5\u4F5C\u53F0\u6E05\u5355\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002";
3543
+ var GENERIC_FAILURE_MESSAGE3 = "\u5DE5\u4F5C\u53F0\u5E94\u7528\u6E05\u5355\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
3544
+ var INVALID_RESPONSE_MESSAGE3 = "\u5DE5\u4F5C\u53F0\u6E05\u5355\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002";
3256
3545
  function unwrapManagedWorkbenchResult(value) {
3257
3546
  if (typeof value !== "object" || value === null || !("ok" in value)) {
3258
- throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3547
+ throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE3);
3259
3548
  }
3260
3549
  const result = value;
3261
3550
  if (result.ok === true) return result.value;
3262
3551
  if (result.ok === false) {
3263
3552
  const code = typeof result.error?.code === "string" ? result.error.code : "internal";
3264
- const message = typeof result.error?.message === "string" && result.error.message.trim() !== "" ? result.error.message : GENERIC_FAILURE_MESSAGE2;
3553
+ const message = typeof result.error?.message === "string" && result.error.message.trim() !== "" ? result.error.message : GENERIC_FAILURE_MESSAGE3;
3265
3554
  throw new ManagedWorkbenchRpcError(code, message);
3266
3555
  }
3267
- throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3556
+ throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE3);
3268
3557
  }
3269
- function text3(value) {
3558
+ function text4(value) {
3270
3559
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
3271
3560
  }
3272
- function isRecord(value) {
3561
+ function isRecord2(value) {
3273
3562
  return typeof value === "object" && value !== null && !Array.isArray(value);
3274
3563
  }
3275
3564
  function stringArray(value) {
@@ -3277,45 +3566,45 @@ function stringArray(value) {
3277
3566
  return value.filter((item) => typeof item === "string");
3278
3567
  }
3279
3568
  function toApp(value) {
3280
- if (!isRecord(value)) return void 0;
3281
- const code = text3(value["code"]);
3569
+ if (!isRecord2(value)) return void 0;
3570
+ const code = text4(value["code"]);
3282
3571
  const executor = value["executor"];
3283
- if (code === void 0 || !isRecord(executor)) return void 0;
3284
- const kind = text3(executor["kind"]);
3572
+ if (code === void 0 || !isRecord2(executor)) return void 0;
3573
+ const kind = text4(executor["kind"]);
3285
3574
  if (kind === void 0) return void 0;
3286
3575
  const normalizedExecutor = { ...executor, kind };
3287
- const mode = text3(executor["mode"]);
3576
+ const mode = text4(executor["mode"]);
3288
3577
  if (mode === void 0) delete normalizedExecutor.mode;
3289
3578
  else normalizedExecutor.mode = mode;
3290
- const category = isRecord(value["category"]) ? value["category"] : {};
3579
+ const category = isRecord2(value["category"]) ? value["category"] : {};
3291
3580
  const inputs = [];
3292
3581
  for (const item of Array.isArray(value["inputs"]) ? value["inputs"] : []) {
3293
- if (!isRecord(item)) continue;
3294
- const key = text3(item["key"]);
3582
+ if (!isRecord2(item)) continue;
3583
+ const key = text4(item["key"]);
3295
3584
  if (key === void 0) continue;
3296
3585
  inputs.push({
3297
3586
  ...item,
3298
3587
  key,
3299
- type: text3(item["type"]) ?? "",
3300
- label: text3(item["label"]) ?? key,
3588
+ type: text4(item["type"]) ?? "",
3589
+ label: text4(item["label"]) ?? key,
3301
3590
  required: item["required"] === true
3302
3591
  });
3303
3592
  }
3304
3593
  const outputs = [];
3305
3594
  for (const item of Array.isArray(value["outputs"]) ? value["outputs"] : []) {
3306
- if (!isRecord(item)) continue;
3307
- const key = text3(item["key"]);
3595
+ if (!isRecord2(item)) continue;
3596
+ const key = text4(item["key"]);
3308
3597
  if (key === void 0) continue;
3309
- outputs.push({ ...item, key, type: text3(item["type"]) ?? "" });
3598
+ outputs.push({ ...item, key, type: text4(item["type"]) ?? "" });
3310
3599
  }
3311
- const iconUrl = text3(value["iconUrl"]);
3600
+ const iconUrl = text4(value["iconUrl"]);
3312
3601
  const revision = value["revision"];
3313
3602
  return {
3314
3603
  code,
3315
- displayName: text3(value["displayName"]) ?? code,
3316
- summary: text3(value["summary"]) ?? "",
3604
+ displayName: text4(value["displayName"]) ?? code,
3605
+ summary: text4(value["summary"]) ?? "",
3317
3606
  ...iconUrl === void 0 ? {} : { iconUrl },
3318
- category: { code: text3(category["code"]) ?? "", name: text3(category["name"]) ?? "" },
3607
+ category: { code: text4(category["code"]) ?? "", name: text4(category["name"]) ?? "" },
3319
3608
  tags: stringArray(value["tags"]),
3320
3609
  featured: value["featured"] === true,
3321
3610
  revision: typeof revision === "number" && Number.isFinite(revision) ? revision : 0,
@@ -3326,9 +3615,9 @@ function toApp(value) {
3326
3615
  };
3327
3616
  }
3328
3617
  function toSnapshot3(value) {
3329
- if (!isRecord(value)) throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3618
+ if (!isRecord2(value)) throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE3);
3330
3619
  const rawApps = value["apps"];
3331
- if (!Array.isArray(rawApps)) throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE2);
3620
+ if (!Array.isArray(rawApps)) throw new ManagedWorkbenchRpcError("internal", INVALID_RESPONSE_MESSAGE3);
3332
3621
  const apps = [];
3333
3622
  for (const item of rawApps) {
3334
3623
  const app = toApp(item);
@@ -3341,10 +3630,10 @@ function toSnapshot3(value) {
3341
3630
  stale: value["stale"] === true
3342
3631
  };
3343
3632
  const lastError = value["lastError"];
3344
- if (isRecord(lastError)) {
3633
+ if (isRecord2(lastError)) {
3345
3634
  snapshot.lastError = {
3346
- code: text3(lastError["code"]) ?? "internal",
3347
- message: typeof lastError["message"] === "string" ? lastError["message"] : GENERIC_FAILURE_MESSAGE2
3635
+ code: text4(lastError["code"]) ?? "internal",
3636
+ message: typeof lastError["message"] === "string" ? lastError["message"] : GENERIC_FAILURE_MESSAGE3
3348
3637
  };
3349
3638
  }
3350
3639
  return snapshot;
@@ -3509,6 +3798,7 @@ function apply(ctx) {
3509
3798
  const managedSkills = new ManagedSkillsStore(connection.rpc);
3510
3799
  const managedWorkbench = new ManagedWorkbenchStore(connection.rpc);
3511
3800
  const appVersion = createAppVersion(connection.rpc);
3801
+ const pluginUpdates = createPluginUpdates(connection.rpc);
3512
3802
  ctx.slots.inject("settings.section", () => ctx.slots.register({
3513
3803
  name: "settings.section",
3514
3804
  id: "hiwork-usage",
@@ -3542,7 +3832,7 @@ function apply(ctx) {
3542
3832
  ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
3543
3833
  name: "sidebar.footer.action",
3544
3834
  id: AI_ROUTER_NS,
3545
- inject: () => controller.inject()
3835
+ inject: () => ({ ...controller.inject(), pluginUpdates })
3546
3836
  }, UserBadge));
3547
3837
  ctx.effect(() => mountLoginGate(scope, storeCredential), "hiwork-core: login gate");
3548
3838
  ctx.effect(() => observeHeroDecorations(document, dailyQuotes), "hiwork-core: hero decorations");
package/lib/index.js CHANGED
@@ -3137,7 +3137,7 @@ function isUnloading(ctx) {
3137
3137
  }
3138
3138
 
3139
3139
  // src/version.ts
3140
- var PKG_VERSION = false ? "dev" : "0.0.16";
3140
+ var PKG_VERSION = false ? "dev" : "0.0.18";
3141
3141
 
3142
3142
  // src/ai-router-client.ts
3143
3143
  var AUTH_AUTHORIZE_PATH = "/api/auth/authorize";
@@ -5446,6 +5446,101 @@ function registerWorkbenchRpc(ctx, deps, store) {
5446
5446
  };
5447
5447
  }
5448
5448
 
5449
+ // src/plugin-updates-rpc.ts
5450
+ var PLUGIN_UPDATES_RPC_CHANNEL = "/hiwork-plugin-updates";
5451
+ var PLUGIN_UPDATES_RPC_ENDPOINTS = ["snapshot", "apply"];
5452
+ var PluginUpdatesRequestError = class extends Error {
5453
+ constructor(code, message) {
5454
+ super(message);
5455
+ this.code = code;
5456
+ this.name = "PluginUpdatesRequestError";
5457
+ }
5458
+ };
5459
+ var PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE = "\u63D2\u4EF6\u66F4\u65B0\u72B6\u6001\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
5460
+ function isPluginUpdatesRpcEndpoint(endpoint) {
5461
+ return PLUGIN_UPDATES_RPC_ENDPOINTS.includes(endpoint);
5462
+ }
5463
+ function isRecord3(value) {
5464
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5465
+ }
5466
+ function textOrUndefined4(value) {
5467
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
5468
+ }
5469
+ function isAborted7(signal) {
5470
+ return signal?.aborted === true;
5471
+ }
5472
+ function parsePluginUpdatesPayload(endpoint, raw) {
5473
+ if (raw === void 0 || raw === null) return {};
5474
+ if (typeof raw !== "object" || Array.isArray(raw)) {
5475
+ throw new PluginUpdatesRequestError("bad-request", "\u63D2\u4EF6\u66F4\u65B0\u8BF7\u6C42\u53C2\u6570\u4E0D\u5408\u6CD5\u3002");
5476
+ }
5477
+ const keys = Object.keys(raw);
5478
+ const first = keys[0];
5479
+ if (first !== void 0) {
5480
+ throw new PluginUpdatesRequestError("bad-request", `\u63D2\u4EF6\u66F4\u65B0\u8BF7\u6C42\u5305\u542B\u672A\u77E5\u5B57\u6BB5 '${first}'\u3002`);
5481
+ }
5482
+ return {};
5483
+ }
5484
+ function toPackageUpdate(raw) {
5485
+ if (!isRecord3(raw)) return void 0;
5486
+ const packageName = textOrUndefined4(raw["packageName"]);
5487
+ if (packageName === void 0) return void 0;
5488
+ return {
5489
+ packageName,
5490
+ displayName: textOrUndefined4(raw["displayName"]) ?? packageName,
5491
+ from: textOrUndefined4(raw["from"]) ?? "",
5492
+ to: textOrUndefined4(raw["to"]) ?? ""
5493
+ };
5494
+ }
5495
+ function toSnapshot(raw) {
5496
+ if (!isRecord3(raw)) return void 0;
5497
+ const packages = [];
5498
+ for (const item of Array.isArray(raw["packages"]) ? raw["packages"] : []) {
5499
+ const normalized = toPackageUpdate(item);
5500
+ if (normalized !== void 0) packages.push(normalized);
5501
+ }
5502
+ const revision = raw["revision"];
5503
+ const fetchedAt = raw["fetchedAt"];
5504
+ return {
5505
+ packages,
5506
+ fetchedAt: typeof fetchedAt === "number" && Number.isFinite(fetchedAt) ? fetchedAt : 0,
5507
+ revision: typeof revision === "number" && Number.isFinite(revision) ? revision : null
5508
+ };
5509
+ }
5510
+ async function handlePluginUpdatesEndpoint(endpoint, rawPayload, deps, signal) {
5511
+ try {
5512
+ if (isAborted7(signal)) return failure7("cancelled", "\u63D2\u4EF6\u66F4\u65B0\u8BF7\u6C42\u5DF2\u53D6\u6D88\u3002");
5513
+ if (!isPluginUpdatesRpcEndpoint(endpoint)) {
5514
+ throw new PluginUpdatesRequestError("bad-request", `\u672A\u77E5\u7684\u63D2\u4EF6\u66F4\u65B0\u7AEF\u70B9 '${endpoint}'`);
5515
+ }
5516
+ parsePluginUpdatesPayload(endpoint, rawPayload);
5517
+ const plugins = deps.desktopPlugins();
5518
+ if (endpoint === "snapshot") {
5519
+ const value = plugins === void 0 ? null : toSnapshot(await plugins.pending()) ?? null;
5520
+ return { ok: true, value };
5521
+ }
5522
+ if (plugins === void 0) {
5523
+ return { ok: true, value: { requested: false } };
5524
+ }
5525
+ await plugins.applyNow();
5526
+ return { ok: true, value: { requested: true } };
5527
+ } catch (error) {
5528
+ if (error instanceof PluginUpdatesRequestError) return failure7(error.code, error.message);
5529
+ deps.log?.(`hiwork-core: \u63D2\u4EF6\u66F4\u65B0 RPC '${endpoint}' \u5931\u8D25\uFF08${error instanceof Error ? error.name : typeof error}\uFF09`);
5530
+ return failure7("internal", PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE);
5531
+ }
5532
+ }
5533
+ function failure7(code, message) {
5534
+ return { ok: false, error: { code, message, details: {} } };
5535
+ }
5536
+ function registerPluginUpdatesRpc(ctx, deps) {
5537
+ const handler = (endpoint, payload, signal) => handlePluginUpdatesEndpoint(endpoint, payload, deps, signal);
5538
+ const remove = ctx.connection.rpc.handle(PLUGIN_UPDATES_RPC_CHANNEL, handler, { authority: "loopback" });
5539
+ return async () => {
5540
+ await remove();
5541
+ };
5542
+ }
5543
+
5449
5544
  // src/office-credentials.ts
5450
5545
  var OFFICE_CREDENTIALS_SERVICE = "hiworkOfficeCredentials";
5451
5546
  var OFFICE_MESSAGE_FALLBACK = {
@@ -6058,6 +6153,12 @@ function apply(ctx, config) {
6058
6153
  ctx.logger.warn(message);
6059
6154
  }
6060
6155
  }), "hiwork-core: app version rpc");
6156
+ ctx.effect(() => registerPluginUpdatesRpc({ connection, logger: ctx.logger }, {
6157
+ desktopPlugins: () => ctx.get("desktopPlugins"),
6158
+ log: (message) => {
6159
+ ctx.logger.warn(message);
6160
+ }
6161
+ }), "hiwork-core: plugin updates rpc");
6061
6162
  ctx.effect(() => registerIpcMessageHandler({
6062
6163
  source: process,
6063
6164
  complete: (input) => {
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `/hiwork-plugin-updates` loopback RPC 的 Host 实现:把**桌面端**的 `desktopPlugins`
3
+ * (受管插件清单的只读面 + 唯一的回收入口)搬到渲染端够得着的地方。
4
+ *
5
+ * 为什么必须有这条 RPC(spec §4.5 的「数据来源」一段):`desktopPlugins` 是桌面端的
6
+ * `desktop-bridge.mts` 用 `ctx.provide('desktopPlugins', …)` 提供到 **DSH host** ctx 上的,
7
+ * 而 hiwork-core 的客户端跑在 **renderer**——两个进程。客户端现有的全部数据层(usage /
8
+ * session-cost / daily / managed-mcp / managed-skills / managed-workbench / app-version)
9
+ * 无一例外都经 `ctx.get('connection').rpc` 走 `/hiwork-*` loopback RPC,**没有任何
10
+ * client→host 的服务直连通道**。而「重启并更新」是个**点击**,它发生在客户端。所以取数与
11
+ * 触发都得过这条 RPC。
12
+ *
13
+ * 骨架照 `src/workbench-bridge.ts`(同 `src/managed-skills.ts` / `src/app-version-rpc.ts`):
14
+ * 导出频道名 + 端点白名单(`as const`)+ 结构化 deps 端口(**不 import cordis 运行时**,
15
+ * 便于单测直接伪造)+ 一个 `register…Rpc(...)` 返回 disposer。
16
+ *
17
+ * 三条本频道特有的纪律:
18
+ * - ⚠️ **`desktopPlugins` 是可选的**(不变量:依赖必须是可选的)。老桌面端上没有它 —— 那正是
19
+ * 「新 core + 老桌面端」这条共存路径。拿不到时 `snapshot` 回**成功信封 + `null`**
20
+ * (「不可用」**不是错误**,与 `app-version-rpc.ts` 的「桌面端没推过不是错误」同一条口径),
21
+ * 客户端据此**不渲染任何东西**。抛错会把共存路径变成一条报错日志。
22
+ * - ⚠️ **只搬运 `packages` / `revision` / `fetchedAt` 这三样**,**不搬 `held`**(plan T11
23
+ * Step 0)。它们三个是真的:落在 `.dsh-pending-updates.json` 里,跨进程可用。`held` /
24
+ * `from` / `displayName` 在生产路径上是空的——根因是 `desktop-bridge.mts` 调
25
+ * `createDesktopHostServices()` 时**没传 `pluginManifest`**,于是 `desktop-host.ts` 里那条
26
+ * `memory = options.pluginManifest?.current()` 恒 `undefined`、`held` 退到 `?? []`。
27
+ * 那条通道要修得补一条**主进程 → 子进程**的路(新 IPC 信封或 sidecar 文件),而**本 RPC 是
28
+ * client→host 的**,它跨的是「renderer↔DSH」,**跨不过「DSH↔主进程」这一层**。所以别「顺手把
29
+ * `held` 也传过去」——传过去也永远是空的,只会让下一个读这段的人以为服务端没下发降级项。
30
+ * - ⚠️ **`apply` 不是「更新成功」的回执**:它只把触发递下去(`desktopPlugins.applyNow()` →
31
+ * `APPLY_PLUGIN_UPDATES_IPC` → 主进程的 `recycleDshForPluginUpdate()`),重启由**主进程**执行、
32
+ * 客户端那一刻就没了。按钮语义是「已请求重启」。所以返回值是 `{ requested }`,
33
+ * **不是** `{ applied: true }`。
34
+ *
35
+ * 回收红线(spec §6.1 不变量②):本文件的 `apply` 是**唯一**允许触发回收的新入口,且只能由
36
+ * 用户点击到达(客户端只在「重启并更新」按钮的 onClick 里调它)。`snapshot` **绝不触碰**
37
+ * `applyNow()`——清单拉取、轮询、启动流程都不许触发回收。
38
+ */
39
+ /** RPC 频道;必须与 client 半边 `src/client/plugin-updates.ts` 的 `PLUGIN_UPDATES_CHANNEL` 一致(由测试锁定)。 */
40
+ export const PLUGIN_UPDATES_RPC_CHANNEL = '/hiwork-plugin-updates';
41
+ /**
42
+ * 端点白名单:只有两个。
43
+ *
44
+ * `snapshot` 是「现在有没有待生效的更新」(桌面端读 pending 文件,**不拉清单、不写文件、
45
+ * 不触发回收**),`apply` 是「现在就去重启并应用」——它只在用户点击时被调用。
46
+ * 白名单是客户端可用的能力边界,多一项就等于先发布了一个没有实现的承诺。
47
+ */
48
+ export const PLUGIN_UPDATES_RPC_ENDPOINTS = ['snapshot', 'apply'];
49
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
50
+ export class PluginUpdatesRequestError extends Error {
51
+ code;
52
+ constructor(code, message) {
53
+ super(message);
54
+ this.code = code;
55
+ this.name = 'PluginUpdatesRequestError';
56
+ }
57
+ }
58
+ /** 通用失败文案:不暴露任何桌面端细节。 */
59
+ export const PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE = '插件更新状态暂时无法获取,请稍后重试';
60
+ /** 是否为白名单端点。 */
61
+ export function isPluginUpdatesRpcEndpoint(endpoint) {
62
+ return PLUGIN_UPDATES_RPC_ENDPOINTS.includes(endpoint);
63
+ }
64
+ /** 宽松的记录判定(数组与 null 都不算)。 */
65
+ function isRecord(value) {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ }
68
+ /** 非空字符串或 undefined。 */
69
+ function textOrUndefined(value) {
70
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
71
+ }
72
+ /** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
73
+ function isAborted(signal) {
74
+ return signal?.aborted === true;
75
+ }
76
+ /**
77
+ * payload 校验:两个端点都**只放行空对象**(本频道没有任何入参)。
78
+ *
79
+ * 与 workbench / app-version 的空入参分支逐字同款:出现任何字段都是客户端实现缺陷,直接
80
+ * `bad-request`——一个 `{ force: true }` 被静默忽略,会让调用方以为「强制重启」生效了。
81
+ */
82
+ export function parsePluginUpdatesPayload(endpoint, raw) {
83
+ if (raw === undefined || raw === null)
84
+ return {};
85
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
86
+ throw new PluginUpdatesRequestError('bad-request', '插件更新请求参数不合法。');
87
+ }
88
+ const keys = Object.keys(raw);
89
+ const first = keys[0];
90
+ if (first !== undefined) {
91
+ throw new PluginUpdatesRequestError('bad-request', `插件更新请求包含未知字段 '${first}'。`);
92
+ }
93
+ // endpoint 已在调用方过白名单;这里保留参数是为了类型上与其它频道的 parse* 同形。
94
+ void endpoint;
95
+ return {};
96
+ }
97
+ /**
98
+ * 一条更新的规范化:**只在缺 `packageName` 时丢条目**,其余兜底。
99
+ *
100
+ * 为什么判据是 `packageName` 而不是「`to` 是不是精确版本」:界面**只**依赖
101
+ * `packages.length > 0`(spec §4.3 的 ⚠️)。在这里按更严的形状丢条目,等于把一份真的待生效
102
+ * 更新静默吃掉——用户就再也等不到重启提示了。所以「形状兜底」在此处的方向是**保住计数**:
103
+ * 只要这条还指得出是哪个包就留下,`to` 缺了就记空串(界面本来也不渲染它)。
104
+ * 反过来,缺 `packageName` 的条目连「有没有东西要装」都回答不了,留着只会让界面误报。
105
+ */
106
+ function toPackageUpdate(raw) {
107
+ if (!isRecord(raw))
108
+ return undefined;
109
+ const packageName = textOrUndefined(raw['packageName']);
110
+ if (packageName === undefined)
111
+ return undefined;
112
+ return {
113
+ packageName,
114
+ displayName: textOrUndefined(raw['displayName']) ?? packageName,
115
+ from: textOrUndefined(raw['from']) ?? '',
116
+ to: textOrUndefined(raw['to']) ?? '',
117
+ };
118
+ }
119
+ /**
120
+ * 把 `desktopPlugins.pending()` 的返回值规范化成 {@link PluginUpdatesSnapshot}。
121
+ *
122
+ * `undefined` / 非对象 → `undefined`(= 没有待生效项,界面不渲染)。**只有降级项(`held` 非空、
123
+ * `packages` 为空)时同样落成空 `packages`**,由客户端据此不渲染——回退对员工是要解释的事,
124
+ * 不该混在「更新已就绪」里(spec §4.5)。`held` 本身不搬运,理由见文件头。
125
+ */
126
+ export function toSnapshot(raw) {
127
+ if (!isRecord(raw))
128
+ return undefined;
129
+ const packages = [];
130
+ for (const item of Array.isArray(raw['packages']) ? raw['packages'] : []) {
131
+ const normalized = toPackageUpdate(item);
132
+ if (normalized !== undefined)
133
+ packages.push(normalized);
134
+ }
135
+ const revision = raw['revision'];
136
+ const fetchedAt = raw['fetchedAt'];
137
+ return {
138
+ packages,
139
+ fetchedAt: typeof fetchedAt === 'number' && Number.isFinite(fetchedAt) ? fetchedAt : 0,
140
+ revision: typeof revision === 'number' && Number.isFinite(revision) ? revision : null,
141
+ };
142
+ }
143
+ /**
144
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
145
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
146
+ */
147
+ export async function handlePluginUpdatesEndpoint(endpoint, rawPayload, deps, signal) {
148
+ try {
149
+ if (isAborted(signal))
150
+ return failure('cancelled', '插件更新请求已取消。');
151
+ if (!isPluginUpdatesRpcEndpoint(endpoint)) {
152
+ throw new PluginUpdatesRequestError('bad-request', `未知的插件更新端点 '${endpoint}'`);
153
+ }
154
+ parsePluginUpdatesPayload(endpoint, rawPayload);
155
+ const plugins = deps.desktopPlugins();
156
+ if (endpoint === 'snapshot') {
157
+ // 「不可用」是**成功信封 + null**,不是错误(老桌面端共存的正常路径,见文件头)。
158
+ const value = plugins === undefined ? null : toSnapshot(await plugins.pending()) ?? null;
159
+ return { ok: true, value };
160
+ }
161
+ // apply:**唯一**的回收触发点,只在用户点击时到达这里。
162
+ if (plugins === undefined) {
163
+ // 不可用时不调 applyNow(没有可调的对象),同样按「不是错误」回执:
164
+ // `requested: false` 如实告诉客户端「这次请求没有被递下去」。
165
+ return { ok: true, value: { requested: false } };
166
+ }
167
+ await plugins.applyNow();
168
+ return { ok: true, value: { requested: true } };
169
+ }
170
+ catch (error) {
171
+ if (error instanceof PluginUpdatesRequestError)
172
+ return failure(error.code, error.message);
173
+ deps.log?.(`hiwork-core: 插件更新 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
174
+ return failure('internal', PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE);
175
+ }
176
+ }
177
+ function failure(code, message) {
178
+ return { ok: false, error: { code, message, details: {} } };
179
+ }
180
+ /**
181
+ * 注册 `/hiwork-plugin-updates` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
182
+ */
183
+ export function registerPluginUpdatesRpc(ctx, deps) {
184
+ const handler = (endpoint, payload, signal) => handlePluginUpdatesEndpoint(endpoint, payload, deps, signal);
185
+ const remove = ctx.connection.rpc.handle(PLUGIN_UPDATES_RPC_CHANNEL, handler, { authority: 'loopback' });
186
+ return async () => { await remove(); };
187
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 「插件更新待重启」取数层:经 `/hiwork-plugin-updates` loopback RPC 问 Host
3
+ * 「现在有没有待生效的插件更新」,并在用户点「重启并更新」时触发那次重启。
4
+ *
5
+ * Host 半边见 `src/plugin-updates-rpc.ts`(照 `src/workbench-bridge.ts` 的骨架)。
6
+ * 为什么必须有这条 RPC:`desktopPlugins` 在 **DSH host** ctx 上,而本文件跑在 **renderer**
7
+ * ——客户端没有任何 client→host 的服务直连通道,只有 `ctx.get('connection').rpc`。
8
+ *
9
+ * 四条本层特有的纪律:
10
+ * - ⚠️ **不可用不是错误**(老桌面端共存路径):Host 回 `ok:true` + `null` 时本层归一为
11
+ * `undefined`,界面据此**不渲染任何东西**。RPC 失败同样是「这一轮没拿到」,
12
+ * **不覆写上一次镜像**——提示条不该因为一次读盘 / 传输抖动或一次网关重启而凭空消失。
13
+ * - ⚠️ **只依赖 `packages.length > 0` 与 `revision`**(spec §4.3 的 ⚠️):`from` /
14
+ * `displayName` 在生产路径恒为空 / 退化成包名,`held` 则恒为 `[]`(根因在桌面端
15
+ * `desktop-bridge.mts` 没传 `pluginManifest`,见 `src/plugin-updates-rpc.ts` 文件头)。
16
+ * 所以本层虽然按桌面端契约透传 `packages[i]` 的四个字段,但**界面不许拿它们渲染文案**。
17
+ * - ⚠️ **「关闭」按「本次会话 + 该 revision」记**(内存,不落盘):同一个 revision 不再提示,
18
+ * `revision` 变了要能重新提示——否则管理员推完修复、员工关过一次就再也看不见,又变成
19
+ * 「推了没反应」。`revision` 为 `null`(服务端没给)时 `null` 自成**一个桶**,语义同上。
20
+ * - ⚠️ **`requestRestart` 不是「更新成功」的回执**:它只把触发递下去,重启由主进程执行,
21
+ * 客户端那一刻就没了。`true` 的语义是「已请求重启」。
22
+ *
23
+ * client bundle 纯度:本文件不 import 任何 `@deepseek-ai/*` 运行时值,也不 import Host 代码
24
+ * (响应字段在这里按结构重声明)。
25
+ */
26
+ /** RPC 频道;必须与 Host `src/plugin-updates-rpc.ts` 的 `PLUGIN_UPDATES_RPC_CHANNEL` 一致(由测试锁定)。 */
27
+ export declare const PLUGIN_UPDATES_CHANNEL = "/hiwork-plugin-updates";
28
+ /** 端点白名单;必须与 Host `PLUGIN_UPDATES_RPC_ENDPOINTS` 一致(由测试锁定)。 */
29
+ export declare const PLUGIN_UPDATES_ENDPOINTS: readonly ["snapshot", "apply"];
30
+ export type PluginUpdatesEndpoint = (typeof PLUGIN_UPDATES_ENDPOINTS)[number];
31
+ /**
32
+ * 轮询间隔:5 分钟。
33
+ *
34
+ * 为什么必须轮询:清单是**主进程**在**运行期**每 6h 拉一次、**只写 pending 文件不应用**
35
+ * (spec §4.2 的时机表)——也就是说「有待重启的更新」这件事**只会在运行期出现**(启动边界那
36
+ * 一次拉完就当场应用掉了)。不轮询的话侧栏永远等不到提示条(验收 E3:员工不退出、保持运行 →
37
+ * 提示条出现)。
38
+ *
39
+ * 为什么是 5 分钟:它只是一次 loopback RPC + 桌面端一次读盘,代价远低于「更新提示迟到 6 小时」;
40
+ * 取值与 client 半边既有的本地镜像 TTL 同量级(`MANAGED_WORKBENCH_STALE_MS` = 5 分钟)。
41
+ */
42
+ export declare const PLUGIN_UPDATES_POLL_INTERVAL_MS: number;
43
+ /**
44
+ * 一条待生效的更新(**冻结契约**:与桌面端 `ManagedPluginUpdate` 逐字同形)。
45
+ *
46
+ * ⚠️ `from` / `displayName` **不得用于渲染**(见文件头)。
47
+ */
48
+ export interface PendingPluginUpdate {
49
+ packageName: string;
50
+ displayName: string;
51
+ from: string;
52
+ to: string;
53
+ }
54
+ /** 一次待更新快照。`revision` = 清单级版本号(服务端没给就是 `null`),供「关闭」去重。 */
55
+ export interface PluginUpdatesSnapshot {
56
+ packages: PendingPluginUpdate[];
57
+ fetchedAt: number;
58
+ revision: number | null;
59
+ }
60
+ /** 宿主 loopback RPC 调用面(结构兼容 `ctx.connection.rpc`)。 */
61
+ export interface PluginUpdatesRpcPort {
62
+ call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<unknown>;
63
+ }
64
+ /** 带稳定 code 的取数错误(message 已由 Host 消毒,可直接展示)。 */
65
+ export declare class PluginUpdatesRpcError extends Error {
66
+ readonly code: string;
67
+ constructor(code: string, message: string);
68
+ }
69
+ /**
70
+ * 解开 DSH Connection 的结果信封。
71
+ *
72
+ * `ok:false` 一律**抛错**(由调用方决定「保留上一次镜像」)——这是「这一轮没拿到」与
73
+ * 「拿到了一份空的」之间的那道闸,把它读成 `value` 会让一次 RPC 抖动变成「更新没了」。
74
+ * 只有 `ok === true` 才返回 `value`(`value` 可以是 `null`:那是**不可用**,不是错误)。
75
+ */
76
+ export declare function unwrapPluginUpdatesResult<T>(value: unknown): T;
77
+ /** 任意异常 → 可展示的 `{code, message}`(传输层异常不暴露原始 message)。 */
78
+ export declare function pluginUpdatesErrorOf(error: unknown): {
79
+ code: string;
80
+ message: string;
81
+ };
82
+ /**
83
+ * 快照形状校验:`null`(不可用)/ 形状不合法一律归一为 `undefined`。
84
+ *
85
+ * `undefined` 是**唯一**的「界面什么都不渲染」信号,所以「不可用」与「响应坏了」在这里合并
86
+ * ——两者对界面是同一件事,区分它们只会多一条没有消费者的分支。
87
+ */
88
+ export declare function toPluginUpdatesSnapshot(value: unknown): PluginUpdatesSnapshot | undefined;
89
+ /**
90
+ * 「插件更新待重启」的取数仓库。
91
+ *
92
+ * 状态有三样,全部只活在内存里(**不落盘**):
93
+ * - `current`:最近一次**成功**取到的快照(失败不覆写,见 {@link refresh});
94
+ * - `dismissed`:用户关掉过的那一个 `revision`(`undefined` = 从没关过;`null` = 关过
95
+ * 「服务端没给 revision」的那一份)。比较是**按值**的,所以 `revision` 一变就自动重新提示;
96
+ * - `version`:变化计数,供 `useSyncExternalStore` 当 `getSnapshot` 用(值变了才重渲染)。
97
+ */
98
+ export declare class PluginUpdatesStore {
99
+ private readonly rpc;
100
+ private current;
101
+ private dismissed;
102
+ private version;
103
+ private readonly listeners;
104
+ constructor(rpc: PluginUpdatesRpcPort);
105
+ /** 最近一次成功取到的快照(同步读,可能比服务端现状旧)。没有 / 不可用时 `undefined`。 */
106
+ snapshot(): PluginUpdatesSnapshot | undefined;
107
+ /** 变化计数:`useSyncExternalStore` 的 `getSnapshot`(值变化即重渲染)。 */
108
+ getVersion: () => number;
109
+ /** 订阅变化(快照更新 / 关闭)。返回退订函数。 */
110
+ subscribe: (listener: () => void) => (() => void);
111
+ /** 这个 revision 是否已经被用户关掉过(同 revision 不再提示;换 revision 会重新提示)。 */
112
+ isDismissed(revision: number | null): boolean;
113
+ /**
114
+ * 现在是否该渲染提示卡片。
115
+ *
116
+ * 三个条件缺一不可:**有快照**(不可用 / 从没取到 → 不渲染)、**`packages` 非空**
117
+ * (只有降级项时桌面端返回的也是空 `packages`,spec §4.5 明确不弹)、**这个 revision 没被关过**。
118
+ * ⚠️ 判据里**没有** `held` / `from` / `displayName`(spec §4.3 的 ⚠️)。
119
+ */
120
+ shouldPrompt(): boolean;
121
+ /**
122
+ * 记住「用户关掉了这一个 revision 的提示」。**只记内存**(spec §4.5:本次会话 + 该 revision),
123
+ * 不落盘、不调任何 apply。
124
+ */
125
+ dismiss(): void;
126
+ /**
127
+ * 向 Host 取一次「有没有待生效的更新」。
128
+ *
129
+ * 返回 `undefined` 有两种来源,对调用方是同一件事(**不渲染**):Host 说「不可用」(`null`),
130
+ * 或响应形状不合法。⚠️ **失败(`ok:false` / 传输异常)一律抛错且不覆写镜像**——提示条不该
131
+ * 因为一次抖动而消失,而「更新已经在排队」这件事不会因为读不出来就不存在。
132
+ */
133
+ refresh(signal?: AbortSignal): Promise<PluginUpdatesSnapshot | undefined>;
134
+ /**
135
+ * 请求「重启并更新」。**这是全链路唯一的回收入口,且只能由用户点击到达。**
136
+ *
137
+ * 返回 `true` 表示触发**已经递下去**(最终落到主进程的 `recycleDshForPluginUpdate()`),
138
+ * **不是**「更新成功」——主进程会重建窗口,客户端那一刻就没了。
139
+ * `false` 只在「老桌面端上没有 `desktopPlugins`」时出现(Host 回 `requested: false`)。
140
+ */
141
+ requestRestart(signal?: AbortSignal): Promise<boolean>;
142
+ private bump;
143
+ }
144
+ /**
145
+ * 造一份取数面:直接返回仓库本身(组件的 `inject` 面要的就是它)。
146
+ *
147
+ * 保留这层构造函数是为了与 client 半边既有的 `createAppVersion(connection.rpc)` 同形——
148
+ * 装配点只看这一个入口,不直接 new。
149
+ */
150
+ export declare function createPluginUpdates(rpc: PluginUpdatesRpcPort): PluginUpdatesStore;
@@ -1,5 +1,6 @@
1
1
  import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import { type AiRouterCardFace } from './controller.ts';
3
+ import { type PluginUpdatesStore } from './plugin-updates.ts';
3
4
  /**
4
5
  * 侧边栏底部「设置」上方的账号信息行:显示当前登录网关的显示名(网关 LDAP
5
6
  * 中文名,缺省回退登录账号)。「头像 + 用户名」是一个合并钮,hover 高亮与点击
@@ -9,6 +10,15 @@ import { type AiRouterCardFace } from './controller.ts';
9
10
  * 设置入口。行几何(高度/留白/字号/图标尺寸)与 ui-settings-general 的设置触发
10
11
  * 行完全一致。
11
12
  */
12
- export type UserBadgeProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<AiRouterCardFace>;
13
- /** 底部账号信息行。 */
13
+ /**
14
+ * 「插件更新待重启」的取数面(`/hiwork-plugin-updates`,见 `src/client/plugin-updates.ts`)。
15
+ *
16
+ * 单独成一格而**不是**塞进 `AiRouterCardFace`:那个面是登录卡片的控制器,与插件更新无关;
17
+ * 合成面在这里做,注册点只负责把两者拼起来(`src/client/index.ts`)。
18
+ */
19
+ export interface PluginUpdatesFace {
20
+ pluginUpdates: PluginUpdatesStore;
21
+ }
22
+ export type UserBadgeProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<AiRouterCardFace & PluginUpdatesFace>;
23
+ /** 底部账号信息行(宽栏 / 折叠栏共用;插件更新提示卡片也由它渲染,见下)。 */
14
24
  export declare function UserBadge(props: UserBadgeProps): import("react").JSX.Element | null;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `/hiwork-plugin-updates` loopback RPC 的 Host 实现:把**桌面端**的 `desktopPlugins`
3
+ * (受管插件清单的只读面 + 唯一的回收入口)搬到渲染端够得着的地方。
4
+ *
5
+ * 为什么必须有这条 RPC(spec §4.5 的「数据来源」一段):`desktopPlugins` 是桌面端的
6
+ * `desktop-bridge.mts` 用 `ctx.provide('desktopPlugins', …)` 提供到 **DSH host** ctx 上的,
7
+ * 而 hiwork-core 的客户端跑在 **renderer**——两个进程。客户端现有的全部数据层(usage /
8
+ * session-cost / daily / managed-mcp / managed-skills / managed-workbench / app-version)
9
+ * 无一例外都经 `ctx.get('connection').rpc` 走 `/hiwork-*` loopback RPC,**没有任何
10
+ * client→host 的服务直连通道**。而「重启并更新」是个**点击**,它发生在客户端。所以取数与
11
+ * 触发都得过这条 RPC。
12
+ *
13
+ * 骨架照 `src/workbench-bridge.ts`(同 `src/managed-skills.ts` / `src/app-version-rpc.ts`):
14
+ * 导出频道名 + 端点白名单(`as const`)+ 结构化 deps 端口(**不 import cordis 运行时**,
15
+ * 便于单测直接伪造)+ 一个 `register…Rpc(...)` 返回 disposer。
16
+ *
17
+ * 三条本频道特有的纪律:
18
+ * - ⚠️ **`desktopPlugins` 是可选的**(不变量:依赖必须是可选的)。老桌面端上没有它 —— 那正是
19
+ * 「新 core + 老桌面端」这条共存路径。拿不到时 `snapshot` 回**成功信封 + `null`**
20
+ * (「不可用」**不是错误**,与 `app-version-rpc.ts` 的「桌面端没推过不是错误」同一条口径),
21
+ * 客户端据此**不渲染任何东西**。抛错会把共存路径变成一条报错日志。
22
+ * - ⚠️ **只搬运 `packages` / `revision` / `fetchedAt` 这三样**,**不搬 `held`**(plan T11
23
+ * Step 0)。它们三个是真的:落在 `.dsh-pending-updates.json` 里,跨进程可用。`held` /
24
+ * `from` / `displayName` 在生产路径上是空的——根因是 `desktop-bridge.mts` 调
25
+ * `createDesktopHostServices()` 时**没传 `pluginManifest`**,于是 `desktop-host.ts` 里那条
26
+ * `memory = options.pluginManifest?.current()` 恒 `undefined`、`held` 退到 `?? []`。
27
+ * 那条通道要修得补一条**主进程 → 子进程**的路(新 IPC 信封或 sidecar 文件),而**本 RPC 是
28
+ * client→host 的**,它跨的是「renderer↔DSH」,**跨不过「DSH↔主进程」这一层**。所以别「顺手把
29
+ * `held` 也传过去」——传过去也永远是空的,只会让下一个读这段的人以为服务端没下发降级项。
30
+ * - ⚠️ **`apply` 不是「更新成功」的回执**:它只把触发递下去(`desktopPlugins.applyNow()` →
31
+ * `APPLY_PLUGIN_UPDATES_IPC` → 主进程的 `recycleDshForPluginUpdate()`),重启由**主进程**执行、
32
+ * 客户端那一刻就没了。按钮语义是「已请求重启」。所以返回值是 `{ requested }`,
33
+ * **不是** `{ applied: true }`。
34
+ *
35
+ * 回收红线(spec §6.1 不变量②):本文件的 `apply` 是**唯一**允许触发回收的新入口,且只能由
36
+ * 用户点击到达(客户端只在「重启并更新」按钮的 onClick 里调它)。`snapshot` **绝不触碰**
37
+ * `applyNow()`——清单拉取、轮询、启动流程都不许触发回收。
38
+ */
39
+ /** RPC 频道;必须与 client 半边 `src/client/plugin-updates.ts` 的 `PLUGIN_UPDATES_CHANNEL` 一致(由测试锁定)。 */
40
+ export declare const PLUGIN_UPDATES_RPC_CHANNEL = "/hiwork-plugin-updates";
41
+ /**
42
+ * 端点白名单:只有两个。
43
+ *
44
+ * `snapshot` 是「现在有没有待生效的更新」(桌面端读 pending 文件,**不拉清单、不写文件、
45
+ * 不触发回收**),`apply` 是「现在就去重启并应用」——它只在用户点击时被调用。
46
+ * 白名单是客户端可用的能力边界,多一项就等于先发布了一个没有实现的承诺。
47
+ */
48
+ export declare const PLUGIN_UPDATES_RPC_ENDPOINTS: readonly ["snapshot", "apply"];
49
+ export type PluginUpdatesRpcEndpoint = (typeof PLUGIN_UPDATES_RPC_ENDPOINTS)[number];
50
+ /**
51
+ * 一条待生效的更新(**冻结契约**:与桌面端 `ManagedPluginUpdate` 逐字同形)。
52
+ *
53
+ * ⚠️ `from` / `displayName` **不得用于渲染任何东西**(spec §4.3 的 ⚠️):生产路径上 `from` 恒空串、
54
+ * `displayName` 退化成包名(`pendingUpdatesFromDisk()`)。保留它们是为了贴合桌面端契约,
55
+ * 不是因为它们可用——界面只认 `packages.length > 0`。
56
+ */
57
+ export interface PendingPluginUpdate {
58
+ packageName: string;
59
+ displayName: string;
60
+ from: string;
61
+ to: string;
62
+ }
63
+ /**
64
+ * 一次待更新快照。
65
+ *
66
+ * `revision` 供提示条去重「关过一次的提示」(spec §4.5):**服务端没给就是 `null`**,
67
+ * 客户端把 `null` 当作一个独立的桶(关过它就不再提示,换了值就重新提示)。
68
+ *
69
+ * **本对象里没有 `held`**:见文件头第二条纪律。
70
+ */
71
+ export interface PluginUpdatesSnapshot {
72
+ packages: PendingPluginUpdate[];
73
+ /** 桌面端最近一次清单的时刻(epoch ms);拿不到就是 0。 */
74
+ fetchedAt: number;
75
+ /** 清单级版本号;服务端没给就是 `null`。 */
76
+ revision: number | null;
77
+ }
78
+ /** `snapshot` 端点的响应:`null` = 不可用(老桌面端没有 `desktopPlugins`)。**不是错误**。 */
79
+ export type PluginUpdatesSnapshotValue = PluginUpdatesSnapshot | null;
80
+ /** `apply` 端点的响应:`requested` = 触发是否递下去了(**不是**「更新是否成功」)。 */
81
+ export interface PluginUpdatesApplyValue {
82
+ requested: boolean;
83
+ }
84
+ /** 客户端可安全展示的失败。 */
85
+ export interface PluginUpdatesFailure {
86
+ code: string;
87
+ message: string;
88
+ /** DSH Connection 信封要求 `details` 是对象;协议层没有额外细节时补空对象。 */
89
+ details: object;
90
+ }
91
+ /** 端点结果信封(与 DSH Connection 的 `ConnectionRpcResult` 同形)。 */
92
+ export type PluginUpdatesRpcResult<T> = {
93
+ readonly ok: true;
94
+ readonly value: T;
95
+ } | {
96
+ readonly ok: false;
97
+ readonly error: PluginUpdatesFailure;
98
+ };
99
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
100
+ export declare class PluginUpdatesRequestError extends Error {
101
+ readonly code: 'bad-request';
102
+ constructor(code: 'bad-request', message: string);
103
+ }
104
+ /** Host 侧 RPC 依赖的最小上下文(结构兼容 cordis Context,便于单测伪造)。 */
105
+ export interface PluginUpdatesRpcConnection {
106
+ rpc: {
107
+ handle(channel: string, handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<unknown>, options: {
108
+ authority: 'loopback';
109
+ }): () => Promise<void> | void;
110
+ };
111
+ }
112
+ export interface PluginUpdatesRpcContext {
113
+ readonly connection: PluginUpdatesRpcConnection;
114
+ readonly logger: {
115
+ warn(message: string): void;
116
+ };
117
+ }
118
+ /**
119
+ * 桌面端 `desktopPlugins` 服务在本频道里真正用到的那两个成员(结构兼容,便于单测伪造)。
120
+ *
121
+ * 返回值一律按 `unknown` 收:它来自**另一个包**在运行时 provide 的对象,本文件不假设它的形状
122
+ * ——形状由 {@link toSnapshot} 兜底(这正是「不变量:拿不到就当不可用」在取值一侧的落实)。
123
+ */
124
+ export interface DesktopPluginsPort {
125
+ /** 待生效的更新;没有待生效项(含只有 `held`)时返回 `undefined`。 */
126
+ pending(): Promise<unknown>;
127
+ /** 等价于用户点插件市场的「更新」:立刻回收。**只能由用户点击触发**。 */
128
+ applyNow(): Promise<unknown>;
129
+ }
130
+ /** Host 侧取数依赖:桌面端的 `desktopPlugins` 服务(老桌面端上没有它)。 */
131
+ export interface PluginUpdatesRpcDeps {
132
+ /**
133
+ * 现取 `ctx.get('desktopPlugins')`;拿不到返回 `undefined`(老桌面端)。
134
+ *
135
+ * 做成**函数**而不是值:cordis 的服务可能在插件生命周期中途才被 provide / 被撤下,
136
+ * 一次性取到的值会把「还没 provide」误判成「永远没有」。
137
+ */
138
+ desktopPlugins(): DesktopPluginsPort | undefined;
139
+ /** Host 日志出口(只记事件与错误类型)。 */
140
+ log?: ((message: string) => void) | undefined;
141
+ }
142
+ /** 通用失败文案:不暴露任何桌面端细节。 */
143
+ export declare const PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE = "\u63D2\u4EF6\u66F4\u65B0\u72B6\u6001\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
144
+ /** 是否为白名单端点。 */
145
+ export declare function isPluginUpdatesRpcEndpoint(endpoint: string): endpoint is PluginUpdatesRpcEndpoint;
146
+ /**
147
+ * payload 校验:两个端点都**只放行空对象**(本频道没有任何入参)。
148
+ *
149
+ * 与 workbench / app-version 的空入参分支逐字同款:出现任何字段都是客户端实现缺陷,直接
150
+ * `bad-request`——一个 `{ force: true }` 被静默忽略,会让调用方以为「强制重启」生效了。
151
+ */
152
+ export declare function parsePluginUpdatesPayload(endpoint: PluginUpdatesRpcEndpoint, raw: unknown): Record<string, never>;
153
+ /**
154
+ * 把 `desktopPlugins.pending()` 的返回值规范化成 {@link PluginUpdatesSnapshot}。
155
+ *
156
+ * `undefined` / 非对象 → `undefined`(= 没有待生效项,界面不渲染)。**只有降级项(`held` 非空、
157
+ * `packages` 为空)时同样落成空 `packages`**,由客户端据此不渲染——回退对员工是要解释的事,
158
+ * 不该混在「更新已就绪」里(spec §4.5)。`held` 本身不搬运,理由见文件头。
159
+ */
160
+ export declare function toSnapshot(raw: unknown): PluginUpdatesSnapshot | undefined;
161
+ /**
162
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
163
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
164
+ */
165
+ export declare function handlePluginUpdatesEndpoint(endpoint: string, rawPayload: unknown, deps: PluginUpdatesRpcDeps, signal?: AbortSignal): Promise<PluginUpdatesRpcResult<unknown>>;
166
+ /**
167
+ * 注册 `/hiwork-plugin-updates` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
168
+ */
169
+ export declare function registerPluginUpdatesRpc(ctx: PluginUpdatesRpcContext, deps: PluginUpdatesRpcDeps): () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hiwork-core",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "type": "module",
5
5
  "description": "HiWork 企业统一登录与模型自动供给插件(ai-router 网关登录,Web 表单,无 CLI 认证)",
6
6
  "license": "UNLICENSED",