dsh-xdpool 2.4.3 → 2.4.4

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/CHANGELOG.md CHANGED
@@ -1,6 +1,34 @@
1
1
  # Changelog
2
2
  本项目的所有重要变更都会记录在此文件。
3
3
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+ ## 2.4.4 — 扫码登录后仍显示「登录已被拒绝」,以及侧栏入口
5
+
6
+ > 两个都不算新功能:一个是**我上一版自己的代码**把刚扫的凭据判成「旧的」;另一个是入口位置——账号池一直只能从「设置」里进。
7
+
8
+ ### 扫了码、账号池还是说「登录已被拒绝」
9
+
10
+ **你看到的**:某两个账号卡片一直显示「登录已被拒绝 / 上游已不再接受这个登录状态」,提示你去 WorkBuddy 桌面端重新登录。可你的登录途径是这个插件的扫码登录,而且刚刚扫过——扫完还是那句。
11
+
12
+ **真实原因**(两处,都在本插件自己的代码里,不在上游):
13
+
14
+ 1. **新鲜度排序把扫码凭据排在最后**。同一个账号可能同时存在两份凭据:桌面 App 留在 `auth` 目录里的快照文件,和你扫码后由本插件铸造的凭据。`compareFreshness` 原先只认三档——桌面 live 文件、`lastRefreshAtMs`、`expiresAtMs`。扫码凭据的 `sourcePath` 是 `channel-pack:<provider>:<ref>`,既不是 live 文件名、也永远没有 `lastRefreshAtMs`,于是**无条件输给任何残留的旧快照**。它输一次,凭据就不被替换;不替换,就走不到「清除死亡标记」那一行——卡片上那句提示于是永远不会消失,反复扫码也没反应。
15
+
16
+ 现在改为按来源分三级:桌面 live 文件 > 本插件扫码凭据 > 其它残留快照,同级才继续比签发时间和有效期。
17
+
18
+ 2. **渠道包的去重按昵称分组,会删掉刚扫进来的凭据**。本插件对渠道包做过本地补丁:同渠道同昵称视为重复条目,只保留一条。问题有两个——重扫时新凭据遇到未过期的旧条目会被当作重复删掉(连凭据一起删);而**同一个人的两个账号如果昵称相同,也会被当成同一个人删掉一个**。本机正好有两个都叫「青楫渡」的账号。
19
+
20
+ 现在改用凭据里的上游身份 `user_id` 判重,并且:认不出身份的一律保留(宁可留一条重复,也不能删掉别人的账号);登录半成品(昵称还是生成 id、没有有效期)在比较中让位给已完成登录的条目;都可用时按 `createdAt` 留新的。
21
+
22
+ ### 左侧栏可以直接进账号池了
23
+
24
+ 账号池此前只能从「设置 → XD Pool」进。现在侧栏底部(设置按钮附近)多了一个图标,点开就是同一个账号池卡片,`Esc` 或点面板外关闭。
25
+
26
+ 按钮与面板是两个互不相邻的宿主插槽(`sidebar.footer.action` 与 `shell.overlay`),没有共同祖先,因此开合状态放在模块级而不是组件状态里;面板直接渲染同一个 `PoolCard`,不复制一份界面。宿主只给侧栏注册者 `{ wide }`,没有「打开设置」的接口——这也是同类插件都用自己的浮层而不是跳转的原因。老宿主缺少 `shell.overlay` 时只损失这个入口,不影响设置页。
27
+
28
+ ### 测试
29
+
30
+ 账号发现新增 3 例:扫码凭据胜过旧快照、桌面 live 文件仍胜过扫码凭据、凭据被替换后死亡标记清除;另有 1 例钉住「同昵称的两个人不会被合并」。全套 **79 文件 1023 用例**。
31
+
4
32
  ## 2.4.3 — 渠道包补上 11128、粘性绑定跨重启、网关白名单、目录 TTL,外加 CodeArts 重签开关
5
33
 
6
34
  > 2.4.1 与 2.4.2 修的是**池车道**(`src/upstream.ts`);这一版把同一套 11128 规则补进**渠道车道**自己那份 channel pack,并顺带解决三件独立的事:会话粘性绑定不该止于进程边界、网关页面的勾选该是真开关、远端模型目录该会过期。四个 PR 来自社区([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)),复核与发布由维护者完成。另附一个默认关闭的 CodeArts 重签开关(见末节)。
package/lib/bin.js CHANGED
@@ -3084,24 +3084,49 @@ function isLiveAuthFile(path) {
3084
3084
  return basename(path) === WORKBUDDY_LIVE_FILENAME;
3085
3085
  }
3086
3086
  /**
3087
+ * True for a credential this plugin minted from the channel-pack sign-in flow.
3088
+ *
3089
+ * `packCredentialToWorkBuddy` stamps `channel-pack:<provider>:<ref>` here, so the
3090
+ * prefix is the only thing distinguishing a scanned credential from a file the
3091
+ * desktop app wrote. Nothing about it is a path on disk — there is no file to
3092
+ * stat and no mtime to read.
3093
+ */
3094
+ function isChannelPackSource(path) {
3095
+ return path.startsWith("channel-pack:");
3096
+ }
3097
+ /**
3087
3098
  * Which of two credentials for the same account the pool should keep.
3088
3099
  *
3089
3100
  * Ordering, highest first:
3090
3101
  *
3091
3102
  * 1. the live file the desktop app is signed in with;
3092
- * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
3093
- * 3. the longer stored expiry, as a fallback for documents that carry no issue
3103
+ * 2. a credential minted by our own channel-pack sign-in;
3104
+ * 3. the credential the upstream issued most recently (`lastRefreshAtMs`);
3105
+ * 4. the longer stored expiry, as a fallback for documents that carry no issue
3094
3106
  * time (the plugin's own refreshed copy, older builds).
3095
3107
  *
3096
3108
  * The stored expiry alone is NOT a freshness signal: the upstream does not
3097
3109
  * rewrite it when it revokes a token, so a long-dead backup can claim to expire
3098
3110
  * later than the token that actually works. Selecting on it made every upstream
3099
3111
  * call return 401 while a perfectly good credential sat in the same directory.
3112
+ *
3113
+ * Rank 2 exists because a scanned credential loses both of the lower tests by
3114
+ * construction: it is not the live filename, and the sign-in flow records no
3115
+ * `lastRefreshAtMs`. Ranking it below a leftover snapshot meant a user who had
3116
+ * just scanned a fresh sign-in still saw "sign-in was rejected", because the
3117
+ * abandoned file kept winning the comparison and the dead mark was never
3118
+ * cleared. The live file stays on top: that is the session the running desktop
3119
+ * app is genuinely using right now, and it is newer than anything we mint.
3100
3120
  */
3101
3121
  function compareFreshness(a, b) {
3102
- const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
3103
- const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
3104
- if (aLive !== bLive) return bLive - aLive;
3122
+ const rankOf = (credential) => {
3123
+ if (isLiveAuthFile(credential.sourcePath)) return 2;
3124
+ if (isChannelPackSource(credential.sourcePath)) return 1;
3125
+ return 0;
3126
+ };
3127
+ const aRank = rankOf(a);
3128
+ const bRank = rankOf(b);
3129
+ if (aRank !== bRank) return bRank - aRank;
3105
3130
  const aIssued = a.lastRefreshAtMs ?? 0;
3106
3131
  const bIssued = b.lastRefreshAtMs ?? 0;
3107
3132
  if (aIssued !== bIssued) return bIssued - aIssued;
package/lib/client.js CHANGED
@@ -1670,6 +1670,39 @@ window.__ModuleLoader__.load({
1670
1670
  ):active:not(:disabled){transform:none}
1671
1671
  }
1672
1672
 
1673
+ /* ── Docked entry beside the settings control ────────────────────────────────
1674
+ The sidebar button and the overlay are two separate registrations with no
1675
+ shared ancestor, so neither can carry the shell's own custom properties: the
1676
+ easing curves are re-declared here rather than inherited. Everything below is
1677
+ scoped to .dsm-xdpool-dock so it cannot reach into the host's sidebar. */
1678
+ .dsm-xdpool-dock{--ease-instrument:cubic-bezier(.16,1,.3,1);position:relative;z-index:1;display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--dsw-alias-label-secondary,#c6c9d0);font:inherit;font-size:12px;line-height:17px;font-weight:560;text-align:left;cursor:pointer;overflow:hidden;transition:color .2s var(--ease-instrument),background .2s var(--ease-instrument),border-color .2s var(--ease-instrument)}
1679
+ .dsm-xdpool-dock:hover{color:var(--dsw-alias-label-primary,#e8e8ea);background:color-mix(in srgb, var(--dsw-alias-label-primary,#e8e8ea) 7%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 70%, transparent)}
1680
+ .dsm-xdpool-dock-on{color:var(--dsw-alias-label-primary,#e8e8ea);background:color-mix(in srgb, var(--dsw-alias-state-business-primary,#5686fe) 14%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-business-primary,#5686fe) 40%, transparent)}
1681
+ /* The sweep is the same gesture the card's own buttons make, so the dock reads
1682
+ as part of the plugin rather than a host control. */
1683
+ .dsm-xdpool-dock::after{content:"";position:absolute;top:0;bottom:0;left:-60%;width:42%;pointer-events:none;background:linear-gradient(100deg,transparent,rgb(255 255 255 / 12%),transparent);transform:skewX(-18deg);transition:left .5s var(--ease-instrument)}
1684
+ .dsm-xdpool-dock:hover::after{left:118%}
1685
+ .dsm-xdpool-dockicon{flex:none;width:16px;height:16px;background-color:currentColor;-webkit-mask-image:var(--dsm-xdpool-dock-glyph);mask-image:var(--dsm-xdpool-dock-glyph);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center;-webkit-mask-size:16px 16px;mask-size:16px 16px}
1686
+ .dsm-xdpool-docklabel{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1687
+ /* No dark backdrop: the panel is a companion to the conversation, not a modal,
1688
+ and dimming the whole host for a settings card reads as a page navigation.
1689
+ Clicking outside still closes it — the scrim covers the viewport and is
1690
+ transparent, so only the click is caught. */
1691
+ .dsm-xdpool-dockscrim{position:fixed;inset:0;z-index:60;display:flex;align-items:stretch;justify-content:flex-end;background:transparent}
1692
+ .dsm-xdpool-dockpanel{--ease-instrument:cubic-bezier(.16,1,.3,1);display:flex;flex-direction:column;width:min(760px, 92vw);margin:14px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 82%, transparent);border-radius:16px;background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#141517) 96%, transparent);box-shadow:0 20px 60px rgb(0 0 0 / 45%);overflow:hidden;animation:dsm-xdpool-dock-in .3s var(--ease-instrument)}
1693
+ @keyframes dsm-xdpool-dock-in{from{opacity:0;transform:translateX(18px)}to{opacity:1;transform:none}}
1694
+ .dsm-xdpool-dockhead{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid color-mix(in srgb, var(--dsw-alias-border-l1,#ffffff14) 80%, transparent)}
1695
+ .dsm-xdpool-docktitle{flex:1 1 auto;font-size:12px;line-height:17px;font-weight:640;color:var(--dsw-alias-label-primary,#e8e8ea)}
1696
+ .dsm-xdpool-dockclose{appearance:none;flex:none;width:22px;height:22px;padding:0;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a90a0);font:inherit;font-size:15px;line-height:1;cursor:pointer;transition:color .16s ease,background .16s ease}
1697
+ .dsm-xdpool-dockclose:hover{color:var(--dsw-alias-label-primary,#e8e8ea);background:color-mix(in srgb, var(--dsw-alias-label-primary,#e8e8ea) 9%, transparent)}
1698
+ /* The card inside the panel was built for the settings dialog's width; scroll
1699
+ the panel body, never the card, so its own sticky header keeps working. */
1700
+ .dsm-xdpool-dockbody{flex:1 1 auto;min-height:0;overflow:auto;padding:14px}
1701
+ @media (prefers-reduced-motion:reduce){
1702
+ .dsm-xdpool-dock::after{display:none}
1703
+ .dsm-xdpool-dockpanel{animation:none}
1704
+ }
1705
+
1673
1706
  `.trim();
1674
1707
  //#endregion
1675
1708
  //#region src/client/avatar.ts
@@ -12325,7 +12358,7 @@ window.__ModuleLoader__.load({
12325
12358
  *
12326
12359
  * @module dsh-xdpool/version
12327
12360
  */
12328
- const POOL_VERSION = "2.4.3";
12361
+ const POOL_VERSION = "2.4.4";
12329
12362
  //#endregion
12330
12363
  //#region src/client/PoolCard.tsx
12331
12364
  /**
@@ -14719,6 +14752,138 @@ window.__ModuleLoader__.load({
14719
14752
  */
14720
14753
  const POOL_NAV_ICON_MASK_URL = `data:image/svg+xml;utf8,${encodeURIComponent(POOL_NAV_ICON_SVG)}`;
14721
14754
  //#endregion
14755
+ //#region src/client/dock.tsx
14756
+ /**
14757
+ * The docked entry: a `sidebar.footer.action` button that opens the pool card in
14758
+ * a `shell.overlay` panel.
14759
+ *
14760
+ * Why not link to the Settings page the card already lives on: the host's
14761
+ * sidebar slot hands a registrant exactly `{ wide }` — no `openSettings`, no
14762
+ * navigation seat. dsh-purge, dshmarket and everything else third-party solve it
14763
+ * the same way, by owning the surface: a button that toggles an overlay of its
14764
+ * own. Re-implementing the card for the overlay would mean two copies to keep in
14765
+ * step, so the overlay renders the very same `PoolCard` the settings section
14766
+ * renders, page state and all.
14767
+ *
14768
+ * The open flag lives at module scope rather than in React state because the
14769
+ * button and the panel are two separate slot registrations with no common
14770
+ * ancestor; module state plus a subscription is the only channel between them.
14771
+ */
14772
+ /** Subscribers re-read {@link dockedOpen}; the setter notifies them. */
14773
+ const listeners = /* @__PURE__ */ new Set();
14774
+ let dockedOpen = false;
14775
+ /** Read whether the docked panel is showing. */
14776
+ function isDockedOpen() {
14777
+ return dockedOpen;
14778
+ }
14779
+ /** Show or hide the docked panel, notifying every registrant. */
14780
+ function setDockedOpen(open) {
14781
+ if (dockedOpen === open) return;
14782
+ dockedOpen = open;
14783
+ for (const listener of [...listeners]) listener();
14784
+ }
14785
+ /** Subscribe to {@link setDockedOpen}. Returns the unsubscribe. */
14786
+ function subscribeDockedOpen(listener) {
14787
+ listeners.add(listener);
14788
+ return () => {
14789
+ listeners.delete(listener);
14790
+ };
14791
+ }
14792
+ /** Subscribe a component to the open flag. */
14793
+ function useDockedOpen() {
14794
+ const [open, setOpen] = (0, react.useState)(isDockedOpen);
14795
+ (0, react.useEffect)(() => {
14796
+ setOpen(isDockedOpen());
14797
+ return subscribeDockedOpen(() => {
14798
+ setOpen(isDockedOpen());
14799
+ });
14800
+ }, []);
14801
+ return open;
14802
+ }
14803
+ /**
14804
+ * The sidebar button.
14805
+ *
14806
+ * Renders a glyph and, only when the rail is wide, the product name: a label in
14807
+ * the collapsed 56px rail is clipped mid-word, which reads as a layout bug.
14808
+ */
14809
+ function SidebarEntry({ wide }) {
14810
+ const open = useDockedOpen();
14811
+ const label = "XD Pool";
14812
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
14813
+ type: "button",
14814
+ className: `dsm-xdpool-dock${open ? " dsm-xdpool-dock-on" : ""}`,
14815
+ title: open ? "收起 XD Pool" : "打开 XD Pool",
14816
+ "aria-expanded": open,
14817
+ "aria-label": label,
14818
+ style: { "--dsm-xdpool-dock-glyph": `url("${POOL_NAV_ICON_MASK_URL}")` },
14819
+ onClick: () => {
14820
+ setDockedOpen(!isDockedOpen());
14821
+ },
14822
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
14823
+ className: "dsm-xdpool-dockicon",
14824
+ "aria-hidden": "true"
14825
+ }), wide === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
14826
+ className: "dsm-xdpool-docklabel",
14827
+ children: label
14828
+ }) : null]
14829
+ });
14830
+ }
14831
+ /**
14832
+ * The panel, rendered into `shell.overlay`.
14833
+ *
14834
+ * `children` is the card itself, passed in rather than imported so this module
14835
+ * stays free of the card's dependency graph (the overlay must not drag the
14836
+ * settings scope in with it when the host line has no scope to hand out).
14837
+ */
14838
+ function DockedPanel({ children }) {
14839
+ const open = useDockedOpen();
14840
+ (0, react.useEffect)(() => {
14841
+ if (!open) return;
14842
+ const onKeyDown = (event) => {
14843
+ if (event.key === "Escape") setDockedOpen(false);
14844
+ };
14845
+ window.addEventListener("keydown", onKeyDown);
14846
+ return () => {
14847
+ window.removeEventListener("keydown", onKeyDown);
14848
+ };
14849
+ }, [open]);
14850
+ if (!open) return null;
14851
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
14852
+ className: "dsm-xdpool-dockscrim",
14853
+ role: "presentation",
14854
+ onClick: () => {
14855
+ setDockedOpen(false);
14856
+ },
14857
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
14858
+ className: "dsm-xdpool-dockpanel",
14859
+ role: "dialog",
14860
+ "aria-modal": "false",
14861
+ "aria-label": "XD Pool",
14862
+ onClick: (event) => {
14863
+ event.stopPropagation();
14864
+ },
14865
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
14866
+ className: "dsm-xdpool-dockhead",
14867
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
14868
+ className: "dsm-xdpool-docktitle",
14869
+ children: "XD Pool"
14870
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
14871
+ type: "button",
14872
+ className: "dsm-xdpool-dockclose",
14873
+ "aria-label": "关闭",
14874
+ onClick: () => {
14875
+ setDockedOpen(false);
14876
+ },
14877
+ children: "×"
14878
+ })]
14879
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
14880
+ className: "dsm-xdpool-dockbody",
14881
+ children
14882
+ })]
14883
+ })
14884
+ });
14885
+ }
14886
+ //#endregion
14722
14887
  //#region src/client/locales.ts
14723
14888
  /**
14724
14889
  * Plugin-card copy registered under the `settings.xdpool` locale
@@ -15795,6 +15960,24 @@ window.__ModuleLoader__.load({
15795
15960
  }
15796
15961
  }, PoolCard));
15797
15962
  installNavIcon(ctx, () => t("row.navLabel"));
15963
+ try {
15964
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
15965
+ name: "sidebar.footer.action",
15966
+ id: "xdpool-dock",
15967
+ order: 60,
15968
+ label: "XD Pool"
15969
+ }, SidebarEntry));
15970
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
15971
+ name: "shell.overlay",
15972
+ id: "xdpool-dock-panel",
15973
+ order: 60
15974
+ }, () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DockedPanel, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PoolCard, { ...settingsScope === void 0 ? { t } : {
15975
+ t,
15976
+ settingsScope
15977
+ } }) })));
15978
+ } catch (error) {
15979
+ console.error("[dsh-xdpool] docked entry unavailable (settings page unaffected):", error);
15980
+ }
15798
15981
  } catch (error) {
15799
15982
  console.error("[dsh-xdpool] client page failed to load (host provider unaffected):", error);
15800
15983
  }
package/lib/index.js CHANGED
@@ -3260,24 +3260,49 @@ function isLiveAuthFile(path) {
3260
3260
  return basename(path) === WORKBUDDY_LIVE_FILENAME;
3261
3261
  }
3262
3262
  /**
3263
+ * True for a credential this plugin minted from the channel-pack sign-in flow.
3264
+ *
3265
+ * `packCredentialToWorkBuddy` stamps `channel-pack:<provider>:<ref>` here, so the
3266
+ * prefix is the only thing distinguishing a scanned credential from a file the
3267
+ * desktop app wrote. Nothing about it is a path on disk — there is no file to
3268
+ * stat and no mtime to read.
3269
+ */
3270
+ function isChannelPackSource(path) {
3271
+ return path.startsWith("channel-pack:");
3272
+ }
3273
+ /**
3263
3274
  * Which of two credentials for the same account the pool should keep.
3264
3275
  *
3265
3276
  * Ordering, highest first:
3266
3277
  *
3267
3278
  * 1. the live file the desktop app is signed in with;
3268
- * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
3269
- * 3. the longer stored expiry, as a fallback for documents that carry no issue
3279
+ * 2. a credential minted by our own channel-pack sign-in;
3280
+ * 3. the credential the upstream issued most recently (`lastRefreshAtMs`);
3281
+ * 4. the longer stored expiry, as a fallback for documents that carry no issue
3270
3282
  * time (the plugin's own refreshed copy, older builds).
3271
3283
  *
3272
3284
  * The stored expiry alone is NOT a freshness signal: the upstream does not
3273
3285
  * rewrite it when it revokes a token, so a long-dead backup can claim to expire
3274
3286
  * later than the token that actually works. Selecting on it made every upstream
3275
3287
  * call return 401 while a perfectly good credential sat in the same directory.
3288
+ *
3289
+ * Rank 2 exists because a scanned credential loses both of the lower tests by
3290
+ * construction: it is not the live filename, and the sign-in flow records no
3291
+ * `lastRefreshAtMs`. Ranking it below a leftover snapshot meant a user who had
3292
+ * just scanned a fresh sign-in still saw "sign-in was rejected", because the
3293
+ * abandoned file kept winning the comparison and the dead mark was never
3294
+ * cleared. The live file stays on top: that is the session the running desktop
3295
+ * app is genuinely using right now, and it is newer than anything we mint.
3276
3296
  */
3277
3297
  function compareFreshness(a, b) {
3278
- const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
3279
- const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
3280
- if (aLive !== bLive) return bLive - aLive;
3298
+ const rankOf = (credential) => {
3299
+ if (isLiveAuthFile(credential.sourcePath)) return 2;
3300
+ if (isChannelPackSource(credential.sourcePath)) return 1;
3301
+ return 0;
3302
+ };
3303
+ const aRank = rankOf(a);
3304
+ const bRank = rankOf(b);
3305
+ if (aRank !== bRank) return bRank - aRank;
3281
3306
  const aIssued = a.lastRefreshAtMs ?? 0;
3282
3307
  const bIssued = b.lastRefreshAtMs ?? 0;
3283
3308
  if (aIssued !== bIssued) return bIssued - aIssued;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-xdpool",
3
3
  "displayName": "DSH XD Pool",
4
4
  "description": "One DSH model pool for every free quota you have: merge all locally signed-in WorkBuddy accounts with auto-failover, sign in to 13 vendors by QR/web, aggregate 76 vendors' free tiers, plus a usage dashboard, model routing, a local OpenAI-compatible gateway and a points-automation suite.",
5
- "version": "2.4.3",
5
+ "version": "2.4.4",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-xdpool#readme",
@@ -68043,50 +68043,86 @@ function apply(ctx, config = {}) {
68043
68043
  // 非空(用户勾选的模型已被移出黑名单),不会重复静音。
68044
68044
  let channelPackModelAdaptersRef;
68045
68045
  const rawAddAccount = pool.addAccount.bind(pool);
68046
- // ── 本地适配(dsh-xdpool):清掉同渠道同昵称的重复账号 ──
68046
+ // ── 本地适配(dsh-xdpool):清掉同一账号的重复条目 ──
68047
68047
  // 需求(2026-10-16):用户在扫码登录页反复点「添加账号」,上游 `addAccount`
68048
- // 是纯追加(无任何去重),于是同一渠道攒出多条同号账号,账号池页一张卡里
68049
- // 显示 3-4 个一模一样的人。判定键取 (provider, nickname):同一渠道里昵称
68050
- // 相同即视为同一个账号(上游所有渠道在凭据落定后都用 `updateAccount` 把
68051
- // nickname 改成真实显示名,所以昵称才是稳定身份,id 每次都是新随机值)。
68048
+ // 是纯追加(无任何去重),于是同一账号攒出多条,账号池页一张卡里显示
68049
+ // 3-4 个一模一样的人。
68052
68050
  //
68053
- // ⚠️ 保留哪一条不能只看 createdAt newest。反复点「添加账号」时,**新加的
68054
- // 那条是登录还没走完的半成品**(expiresAt 缺失),而**旧的那条是完整可用
68055
- // 的旧凭据**(zcode 的凭据甚至不带过期时间)。按 newest 保留会留下半成品、
68056
- // 删掉能用的 —— 这正是本机 zcode 的实际形态(10-16 15:15 的三条无
68057
- // expiresAt,16:15 新加的那条也无 expiresAt,而其它渠道都有)。所以判定
68058
- // 优先级改成「可用性」:
68059
- // ① expiresAt 为空视为长期凭据、可用;有值时必须晚于「现在」才算可用;
68060
- // ② 双方都可用 → 留旧的(先登录的那条,凭据久经使用);
68061
- // 旧的已过期而新的可用 → 换新;其余 → 留旧。
68062
- const channelAccountIsUsable = (account, now) => {
68051
+ // ⚠️ 判定键必须是上游身份(凭据里的 `user_id`),不能是昵称。
68052
+ // 2026-10-23 实测教训:按 (provider, nickname) 分组会把**两位不同的人**
68053
+ // 误判成同一个 —— 本机有两个账号昵称都叫「青楫渡」(uid 29890334 与
68054
+ // 8e606182),结果扫进来的第二个被当成重复条目删掉。
68055
+ // 同一份代码还有第二个更严重的后果:新扫进来的凭据只要遇到一条
68056
+ // 「还没过期」的旧条目就会被删(旧规则「双方都可用 → 留旧的」),
68057
+ // 用户重新扫码后账号池毫无变化,卡片继续显示「登录已被拒绝」。
68058
+ //
68059
+ // 保留哪一条:优先「真实条目」而非登录半成品(半成品的 nickname 还是
68060
+ // 随机 id,因为上游要到 `updateAccount` 才写入真实显示名),再优先
68061
+ // 未过期的,最后取最新创建的那条 —— 用户刚完成的那次登录才是他此刻
68062
+ // 在用的凭据。
68063
+ const channelAccountUsable = (account, now) => {
68063
68064
  const expiresAt = Number(account.expiresAt);
68064
68065
  if (!Number.isFinite(expiresAt) || expiresAt <= 0) return true;
68065
68066
  return expiresAt > now;
68066
68067
  };
68068
+ const channelAccountIsPlaceholder = (account) =>
68069
+ typeof account.nickname !== "string" || account.nickname === "" || account.nickname === account.id;
68070
+ const channelAccountCreatedAt = (account) => {
68071
+ const createdAt = Number(account.createdAt);
68072
+ return Number.isFinite(createdAt) ? createdAt : 0;
68073
+ };
68074
+ /** True when `candidate` should survive and `incumbent` should be dropped. */
68075
+ const channelAccountBeats = (candidate, incumbent, now) => {
68076
+ const candidatePlaceholder = channelAccountIsPlaceholder(candidate);
68077
+ const incumbentPlaceholder = channelAccountIsPlaceholder(incumbent);
68078
+ if (candidatePlaceholder !== incumbentPlaceholder) return incumbentPlaceholder;
68079
+ const candidateUsable = channelAccountUsable(candidate, now);
68080
+ const incumbentUsable = channelAccountUsable(incumbent, now);
68081
+ if (candidateUsable !== incumbentUsable) return candidateUsable;
68082
+ return channelAccountCreatedAt(candidate) > channelAccountCreatedAt(incumbent);
68083
+ };
68084
+ /**
68085
+ * Resolve the upstream identity a row belongs to.
68086
+ *
68087
+ * Returns undefined when the credential cannot be read: an unresolvable row
68088
+ * cannot be proven to be a duplicate of anything, and deleting it would trade
68089
+ * one bug for a worse one.
68090
+ */
68091
+ const channelAccountIdentity = async (account) => {
68092
+ const refName = typeof account.credentialRef === "string" ? account.credentialRef : "";
68093
+ if (refName === "") return void 0;
68094
+ try {
68095
+ const credential = await pool.resolveCredentialByRef(refName);
68096
+ if (credential === void 0 || credential === null || typeof credential !== "object") return void 0;
68097
+ const userId = credential.user_id;
68098
+ return typeof userId === "string" && userId !== "" ? userId : void 0;
68099
+ } catch {
68100
+ return void 0;
68101
+ }
68102
+ };
68067
68103
  const dedupeChannelAccounts = async () => {
68068
68104
  try {
68069
68105
  const all = await pool.listAllAccounts();
68070
68106
  const now = Date.now();
68071
- const groups = new Map();
68107
+ const groups = /* @__PURE__ */ new Map();
68072
68108
  for (const account of all) {
68073
- const nickname = typeof account.nickname === "string" ? account.nickname : "";
68074
- // 昵称为空的条目无从判断是否重复(登录尚未落定的中间态),一律保留。
68075
- if (account.provider === "" || nickname === "") continue;
68076
- const key = `${account.provider}\u0000${nickname}`;
68109
+ const provider = typeof account.provider === "string" ? account.provider : "";
68110
+ if (provider === "") continue;
68111
+ const identity = await channelAccountIdentity(account);
68112
+ // 认不出身份的条目一律保留:宁可留一条重复,也不能删掉别人的账号。
68113
+ if (identity === void 0) continue;
68114
+ const key = `${provider}\u0000${identity}`;
68077
68115
  const kept = groups.get(key);
68078
- if (kept === undefined) {
68116
+ if (kept === void 0) {
68079
68117
  groups.set(key, account);
68080
68118
  continue;
68081
68119
  }
68082
- const keptUsable = channelAccountIsUsable(kept, now);
68083
- const accountUsable = channelAccountIsUsable(account, now);
68084
- const replace = !keptUsable && accountUsable;
68120
+ const replace = channelAccountBeats(account, kept, now);
68085
68121
  const drop = replace ? kept : account;
68086
68122
  if (replace) groups.set(key, account);
68087
68123
  await pool.removeAccount(drop.id);
68088
68124
  ctx.logger.info(
68089
- `[channel-pack] ${drop.provider} 重复账号 ${drop.nickname}(${drop.id}):已清理,保留 ${(replace ? account : kept).id}`
68125
+ `[channel-pack] ${drop.provider} 重复账号 ${drop.nickname ?? drop.id}(${drop.id}):已清理,保留 ${(replace ? account : kept).id}`
68090
68126
  );
68091
68127
  }
68092
68128
  } catch (error) {
@@ -68095,6 +68131,7 @@ function apply(ctx, config = {}) {
68095
68131
  );
68096
68132
  }
68097
68133
  };
68134
+
68098
68135
  // 启动时先扫一遍:用户升级前攒下的重复只能靠这一次清掉。
68099
68136
  void Promise.resolve().then(() => dedupeChannelAccounts());
68100
68137
  // ── 本地适配(dsh-xdpool):启动时补静音「黑名单为空但已有账号」的渠道 ──