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 +28 -0
- package/lib/bin.js +30 -5
- package/lib/client.js +184 -1
- package/lib/index.js +30 -5
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +62 -25
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.
|
|
3093
|
-
* 3. the
|
|
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
|
|
3103
|
-
|
|
3104
|
-
|
|
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.
|
|
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.
|
|
3269
|
-
* 3. the
|
|
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
|
|
3279
|
-
|
|
3280
|
-
|
|
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.
|
|
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
|
-
//
|
|
68050
|
-
// 相同即视为同一个账号(上游所有渠道在凭据落定后都用 `updateAccount` 把
|
|
68051
|
-
// nickname 改成真实显示名,所以昵称才是稳定身份,id 每次都是新随机值)。
|
|
68048
|
+
// 是纯追加(无任何去重),于是同一账号攒出多条,账号池页一张卡里显示
|
|
68049
|
+
// 3-4 个一模一样的人。
|
|
68052
68050
|
//
|
|
68053
|
-
// ⚠️
|
|
68054
|
-
//
|
|
68055
|
-
//
|
|
68056
|
-
//
|
|
68057
|
-
//
|
|
68058
|
-
//
|
|
68059
|
-
//
|
|
68060
|
-
//
|
|
68061
|
-
//
|
|
68062
|
-
|
|
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
|
|
68074
|
-
|
|
68075
|
-
|
|
68076
|
-
|
|
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 ===
|
|
68116
|
+
if (kept === void 0) {
|
|
68079
68117
|
groups.set(key, account);
|
|
68080
68118
|
continue;
|
|
68081
68119
|
}
|
|
68082
|
-
const
|
|
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):启动时补静音「黑名单为空但已有账号」的渠道 ──
|