dsh-xdpool 2.4.3 → 2.4.5
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 +40 -0
- package/lib/bin.js +30 -5
- package/lib/client.js +121 -8
- 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,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
本项目的所有重要变更都会记录在此文件。
|
|
3
3
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
## 2.4.5 — 侧栏面板能滚动了
|
|
5
|
+
|
|
6
|
+
> 2.4.4 把账号池搬到侧栏那行之后,页面在折叠处被切掉、怎么都翻不到下半页。这不是新功能缺失,是我把设置页里的组件原样塞进了另一个容器,而**那个容器不滚动**。
|
|
7
|
+
|
|
8
|
+
### 面板被裁切
|
|
9
|
+
|
|
10
|
+
`@deepseek-ai/dsh-client-ui-layout` 把中央列样式成 `display:flex; flex-direction:column; overflow:hidden`——设置对话框自己带滚动容器,所以同一个组件放在设置里能滚动,放进主区域就只能被裁掉。现在页面外面包一层 `.dsm-xdpool-page`(`height:100%; min-height:0; overflow:auto`),由它当滚动容器;顺带补上设置对话框原本提供的内边距,否则卡片会贴着窗口边缘。设置页那条路径**不受影响**:那里宿主已经在滚,再加一层会双份。
|
|
11
|
+
|
|
12
|
+
### 其他
|
|
13
|
+
|
|
14
|
+
- 侧栏那行标签保持 `XD Pool`(中英文都不译)。
|
|
15
|
+
|
|
16
|
+
## 2.4.4 — 扫码登录后仍显示「登录已被拒绝」,以及侧栏入口
|
|
17
|
+
|
|
18
|
+
> 两个都不算新功能:一个是**我上一版自己的代码**把刚扫的凭据判成「旧的」;另一个是入口位置——账号池一直只能从「设置」里进。
|
|
19
|
+
|
|
20
|
+
### 扫了码、账号池还是说「登录已被拒绝」
|
|
21
|
+
|
|
22
|
+
**你看到的**:某两个账号卡片一直显示「登录已被拒绝 / 上游已不再接受这个登录状态」,提示你去 WorkBuddy 桌面端重新登录。可你的登录途径是这个插件的扫码登录,而且刚刚扫过——扫完还是那句。
|
|
23
|
+
|
|
24
|
+
**真实原因**(两处,都在本插件自己的代码里,不在上游):
|
|
25
|
+
|
|
26
|
+
1. **新鲜度排序把扫码凭据排在最后**。同一个账号可能同时存在两份凭据:桌面 App 留在 `auth` 目录里的快照文件,和你扫码后由本插件铸造的凭据。`compareFreshness` 原先只认三档——桌面 live 文件、`lastRefreshAtMs`、`expiresAtMs`。扫码凭据的 `sourcePath` 是 `channel-pack:<provider>:<ref>`,既不是 live 文件名、也永远没有 `lastRefreshAtMs`,于是**无条件输给任何残留的旧快照**。它输一次,凭据就不被替换;不替换,就走不到「清除死亡标记」那一行——卡片上那句提示于是永远不会消失,反复扫码也没反应。
|
|
27
|
+
|
|
28
|
+
现在改为按来源分三级:桌面 live 文件 > 本插件扫码凭据 > 其它残留快照,同级才继续比签发时间和有效期。
|
|
29
|
+
|
|
30
|
+
2. **渠道包的去重按昵称分组,会删掉刚扫进来的凭据**。本插件对渠道包做过本地补丁:同渠道同昵称视为重复条目,只保留一条。问题有两个——重扫时新凭据遇到未过期的旧条目会被当作重复删掉(连凭据一起删);而**同一个人的两个账号如果昵称相同,也会被当成同一个人删掉一个**。本机正好有两个都叫「青楫渡」的账号。
|
|
31
|
+
|
|
32
|
+
现在改用凭据里的上游身份 `user_id` 判重,并且:认不出身份的一律保留(宁可留一条重复,也不能删掉别人的账号);登录半成品(昵称还是生成 id、没有有效期)在比较中让位给已完成登录的条目;都可用时按 `createdAt` 留新的。
|
|
33
|
+
|
|
34
|
+
### 左侧栏可以直接进账号池了
|
|
35
|
+
|
|
36
|
+
账号池此前只能从「设置 → XD Pool」进。现在侧栏底部(设置按钮附近)多了一个图标,点开就是同一个账号池卡片,`Esc` 或点面板外关闭。
|
|
37
|
+
|
|
38
|
+
按钮与面板是两个互不相邻的宿主插槽(`sidebar.footer.action` 与 `shell.overlay`),没有共同祖先,因此开合状态放在模块级而不是组件状态里;面板直接渲染同一个 `PoolCard`,不复制一份界面。宿主只给侧栏注册者 `{ wide }`,没有「打开设置」的接口——这也是同类插件都用自己的浮层而不是跳转的原因。老宿主缺少 `shell.overlay` 时只损失这个入口,不影响设置页。
|
|
39
|
+
|
|
40
|
+
### 测试
|
|
41
|
+
|
|
42
|
+
账号发现新增 3 例:扫码凭据胜过旧快照、桌面 live 文件仍胜过扫码凭据、凭据被替换后死亡标记清除;另有 1 例钉住「同昵称的两个人不会被合并」。全套 **79 文件 1023 用例**。
|
|
43
|
+
|
|
4
44
|
## 2.4.3 — 渠道包补上 11128、粘性绑定跨重启、网关白名单、目录 TTL,外加 CodeArts 重签开关
|
|
5
45
|
|
|
6
46
|
> 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,19 @@ window.__ModuleLoader__.load({
|
|
|
1670
1670
|
):active:not(:disabled){transform:none}
|
|
1671
1671
|
}
|
|
1672
1672
|
|
|
1673
|
+
`.trim();
|
|
1674
|
+
/**
|
|
1675
|
+
* Frame for hosting the pool in the main column instead of the settings dialog.
|
|
1676
|
+
*
|
|
1677
|
+
* `.centerCol` is `overflow:hidden`, so this element is the scrollport; without
|
|
1678
|
+
* it the page clips at the fold. The horizontal inset is asymmetric on purpose —
|
|
1679
|
+
* the sidebar sits on the left of this column, so the left edge needs less air
|
|
1680
|
+
* than the right one to look centred.
|
|
1681
|
+
*/
|
|
1682
|
+
const PAGE_CSS = `
|
|
1683
|
+
.dsm-xdpool-page{flex:1 1 auto;height:100%;min-height:0;overflow:auto;box-sizing:border-box;padding:20px 28px 34px}
|
|
1684
|
+
.dsm-xdpool-page > .dsm-xdpool-shell{margin:0 auto}
|
|
1685
|
+
@media (max-width:720px){.dsm-xdpool-page{padding:14px 16px 26px}}
|
|
1673
1686
|
`.trim();
|
|
1674
1687
|
//#endregion
|
|
1675
1688
|
//#region src/client/avatar.ts
|
|
@@ -12325,7 +12338,7 @@ window.__ModuleLoader__.load({
|
|
|
12325
12338
|
*
|
|
12326
12339
|
* @module dsh-xdpool/version
|
|
12327
12340
|
*/
|
|
12328
|
-
const POOL_VERSION = "2.4.
|
|
12341
|
+
const POOL_VERSION = "2.4.5";
|
|
12329
12342
|
//#endregion
|
|
12330
12343
|
//#region src/client/PoolCard.tsx
|
|
12331
12344
|
/**
|
|
@@ -14699,15 +14712,33 @@ window.__ModuleLoader__.load({
|
|
|
14699
14712
|
* `fill-rule="evenodd"` cuts the interior notches out of the silhouette so the
|
|
14700
14713
|
* three layers stay distinguishable at that size.
|
|
14701
14714
|
*/
|
|
14715
|
+
/**
|
|
14716
|
+
* The mark's geometry, independent of any wrapper element.
|
|
14717
|
+
*
|
|
14718
|
+
* Kept separate from {@link POOL_NAV_ICON_SVG} because the two consumers need
|
|
14719
|
+
* different frames: the settings nav row masks a standalone document, while the
|
|
14720
|
+
* sidebar panel row renders paths into an `svg` the shell sizes for it (16px
|
|
14721
|
+
* wide, 18px in the collapsed rail). Sharing the paths — rather than the whole
|
|
14722
|
+
* document, parsed back out with a regex — is what keeps the two placements from
|
|
14723
|
+
* drifting apart when the artwork changes.
|
|
14724
|
+
*/
|
|
14725
|
+
const POOL_NAV_ICON_PATHS = [{
|
|
14726
|
+
fill: "currentColor",
|
|
14727
|
+
fillRule: "evenodd",
|
|
14728
|
+
d: "M12 2.6a3.1 3.1 0 1 1 0 6.2 3.1 3.1 0 0 1 0-6.2Zm0 1.7a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8ZM6.6 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM17.4 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
|
|
14729
|
+
}, {
|
|
14730
|
+
fill: "none",
|
|
14731
|
+
stroke: "currentColor",
|
|
14732
|
+
strokeWidth: 1.7,
|
|
14733
|
+
strokeLinecap: "round",
|
|
14734
|
+
strokeDasharray: "2.6 2.2",
|
|
14735
|
+
d: "M4.4 17.2a8.6 8.6 0 0 0 15.2 0"
|
|
14736
|
+
}];
|
|
14737
|
+
/** The same mark as a standalone SVG document, for `mask-image`. */
|
|
14702
14738
|
const POOL_NAV_ICON_SVG = [
|
|
14703
14739
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\">",
|
|
14704
|
-
|
|
14705
|
-
"
|
|
14706
|
-
"M6.6 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
|
|
14707
|
-
"M17.4 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
|
|
14708
|
-
"\"/>",
|
|
14709
|
-
"<path fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"",
|
|
14710
|
-
" d=\"M4.4 17.2a8.6 8.6 0 0 0 15.2 0\" stroke-dasharray=\"2.6 2.2\"/>",
|
|
14740
|
+
`<path fill="currentColor" fill-rule="evenodd" d="${POOL_NAV_ICON_PATHS[0].d}"/>`,
|
|
14741
|
+
`<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" d="${POOL_NAV_ICON_PATHS[1].d}" stroke-dasharray="2.6 2.2"/>`,
|
|
14711
14742
|
"</svg>"
|
|
14712
14743
|
].join("");
|
|
14713
14744
|
/**
|
|
@@ -14719,6 +14750,70 @@ window.__ModuleLoader__.load({
|
|
|
14719
14750
|
*/
|
|
14720
14751
|
const POOL_NAV_ICON_MASK_URL = `data:image/svg+xml;utf8,${encodeURIComponent(POOL_NAV_ICON_SVG)}`;
|
|
14721
14752
|
//#endregion
|
|
14753
|
+
//#region src/client/panel-icon.tsx
|
|
14754
|
+
/**
|
|
14755
|
+
* Render the pool's mark.
|
|
14756
|
+
*
|
|
14757
|
+
* Inlined rather than masked from a data URI because the shell gives the glyph an
|
|
14758
|
+
* 18px box in the collapsed rail: a `mask-size: 16px` silhouette would read as
|
|
14759
|
+
* smaller than the first-party icons beside it, and scaling the mask would blur
|
|
14760
|
+
* its 1.7px arc. Both placements share {@link POOL_NAV_ICON_PATHS}, so the artwork
|
|
14761
|
+
* cannot drift between the settings nav row and this one.
|
|
14762
|
+
*/
|
|
14763
|
+
function PanelIcon({ size }) {
|
|
14764
|
+
const edge = typeof size === "number" && size > 0 ? size : 16;
|
|
14765
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
14766
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
14767
|
+
viewBox: "0 0 24 24",
|
|
14768
|
+
width: edge,
|
|
14769
|
+
height: edge,
|
|
14770
|
+
"aria-hidden": "true",
|
|
14771
|
+
focusable: "false",
|
|
14772
|
+
children: POOL_NAV_ICON_PATHS.map((shape, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { ...shape }, index))
|
|
14773
|
+
});
|
|
14774
|
+
}
|
|
14775
|
+
//#endregion
|
|
14776
|
+
//#region src/client/page.tsx
|
|
14777
|
+
/**
|
|
14778
|
+
* The wrapper that makes the pool usable as a *sidebar panel*.
|
|
14779
|
+
*
|
|
14780
|
+
* Two problems it solves, both invisible from inside `PoolShell`:
|
|
14781
|
+
*
|
|
14782
|
+
* 1. Scrolling. The settings dialog owns a scroll container, so a page dropped
|
|
14783
|
+
* into `settings.plugin.item` can be as tall as it likes. The main column
|
|
14784
|
+
* cannot: `@deepseek-ai/dsh-client-ui-layout` styles `.centerCol` as
|
|
14785
|
+
* `display:flex; flex-direction:column; overflow:hidden`, and the slot is
|
|
14786
|
+
* rendered into that box with no scrolling of its own. Without a scrollport
|
|
14787
|
+
* here the page simply clips — which is what happens if you register
|
|
14788
|
+
* `PoolCard` into the `main` slot directly. The reference community plugin
|
|
14789
|
+
* solves it the same way, with `height:100%; min-height:0; overflow:auto` on
|
|
14790
|
+
* its page root.
|
|
14791
|
+
*
|
|
14792
|
+
* 2. Insets. The settings dialog supplies padding around the card; the main
|
|
14793
|
+
* column supplies none, so the same markup arrives flush against the window
|
|
14794
|
+
* edge. The padding below restores the frame the dialog used to provide.
|
|
14795
|
+
*
|
|
14796
|
+
* `PAGE_CSS` is injected here rather than living in `SHARED_CARD_CSS` because
|
|
14797
|
+
* the stylesheets in `PoolShell` are rendered *inside* the shell — this wrapper
|
|
14798
|
+
* is its parent, so nothing from that subtree could style it.
|
|
14799
|
+
*
|
|
14800
|
+
* @module dsh-xdpool/client/page
|
|
14801
|
+
*/
|
|
14802
|
+
/**
|
|
14803
|
+
* `PoolCard`, sized and scrolled for the main column.
|
|
14804
|
+
*
|
|
14805
|
+
* Kept as a separate component instead of an option on `PoolCard` so the
|
|
14806
|
+
* settings registration stays exactly what it was: the dialog already provides
|
|
14807
|
+
* both the scrollport and the insets, and adding either again would double
|
|
14808
|
+
* them.
|
|
14809
|
+
*/
|
|
14810
|
+
function PoolPage(props) {
|
|
14811
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
14812
|
+
className: "dsm-xdpool-page",
|
|
14813
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: PAGE_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PoolCard, { ...props })]
|
|
14814
|
+
});
|
|
14815
|
+
}
|
|
14816
|
+
//#endregion
|
|
14722
14817
|
//#region src/client/locales.ts
|
|
14723
14818
|
/**
|
|
14724
14819
|
* Plugin-card copy registered under the `settings.xdpool` locale
|
|
@@ -15795,6 +15890,24 @@ window.__ModuleLoader__.load({
|
|
|
15795
15890
|
}
|
|
15796
15891
|
}, PoolCard));
|
|
15797
15892
|
installNavIcon(ctx, () => t("row.navLabel"));
|
|
15893
|
+
try {
|
|
15894
|
+
ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
|
|
15895
|
+
name: "sidebar.panellist",
|
|
15896
|
+
id: "xdpool",
|
|
15897
|
+
order: 20,
|
|
15898
|
+
label: () => t("row.navLabel")
|
|
15899
|
+
}, PanelIcon));
|
|
15900
|
+
ctx.slots.inject("main", () => ctx.slots.register({
|
|
15901
|
+
name: "main",
|
|
15902
|
+
key: "xdpool",
|
|
15903
|
+
inject: () => settingsScope === void 0 ? { t } : {
|
|
15904
|
+
t,
|
|
15905
|
+
settingsScope
|
|
15906
|
+
}
|
|
15907
|
+
}, PoolPage));
|
|
15908
|
+
} catch (error) {
|
|
15909
|
+
console.error("[dsh-xdpool] sidebar entry unavailable (settings page unaffected):", error);
|
|
15910
|
+
}
|
|
15798
15911
|
} catch (error) {
|
|
15799
15912
|
console.error("[dsh-xdpool] client page failed to load (host provider unaffected):", error);
|
|
15800
15913
|
}
|
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.5",
|
|
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):启动时补静音「黑名单为空但已有账号」的渠道 ──
|