dsh-desktop-statusbar 2.2.0 → 2.2.2

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/README.en.md CHANGED
@@ -38,7 +38,7 @@ Replaces the stats line under the message input in the DSH desktop app with a co
38
38
  | Session cost | Cumulative token breakdown for this session |
39
39
  | Balance | Top-up balance / granted balance / quota left |
40
40
 
41
- - **Session data drill-down**: context compactions mark the node they happened at, skills injected counts the skills injected in this session, and tool calls list every tool the session used by count; click the title to go back up
41
+ - **Session data drill-down**: context compactions mark the node they happened at, skills injected counts the skills injected in this session, and tool calls list every tool the session used with its call count; click the title to go back up
42
42
  - **Version check and update**: DSH checks for the latest version on startup, shows a hint next to the "Status bar" nav item when one is available, and offers a one-click update button in settings
43
43
  - **GitHub support**: your support is the biggest motivation for the developer to keep going! Click the button to open the GitHub project page and help with a star — thanks, everyone!
44
44
  - **Cost estimation**: priced with the official peak/off-peak rules; each call is computed from the moment it happened and the model it used
@@ -73,6 +73,18 @@ Both ways above install from the npm registry; the package name is `dsh-desktop-
73
73
  npm view dsh-desktop-statusbar version
74
74
  ```
75
75
 
76
+ ### When the built-in updater fails
77
+
78
+ The one-click update button inside the plugin can fail on some desktop builds — it either does nothing or reports a failure. Do not keep clicking it. Use the official install entry instead and **append `@version` to the package name** to pin the exact version:
79
+
80
+ ```powershell
81
+ dsh plugin --profile desktop add dsh-desktop-statusbar@2.2.2
82
+ ```
83
+
84
+ The same string works in the plugin panel — type `dsh-desktop-statusbar@2.2.2` into the **Add plugin** field.
85
+
86
+ Use whatever version you see (the version on the release page, or the output of the `npm view` command above). Pinning the version has a second benefit: **you get exactly the build you asked for**, with no chance of a stale cached package.
87
+
76
88
  Also listed on [dshmarket](https://awesome-dsh-plugin.com/p/raphael-y7/dsh-desktop-statusbar/), [dshfind](https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar) and [1024Store](https://deepseek1024.com/plugins/Rapheal-Y7/dsh-desktop-statusbar).
77
89
 
78
90
  ## Usage
@@ -98,6 +110,7 @@ Peak hours are **09:00–12:00 and 14:00–18:00 Beijing time, Monday to Friday*
98
110
 
99
111
  - Fastest / slowest TTFT and speed come from a new host-side projection: **you must fully quit and restart DSH** before it starts collecting
100
112
  - A model with no price is not billed; adding the price back-fills its earlier calls
113
+ - One-click update relies on `pnpm`: the desktop app uses the copy bundled with the host, while CLI / web usage needs `pnpm` on the system. If neither is found, the update says so explicitly **and logs every location it tried**; you can also point it at a specific command with the `DSH_DESKTOP_STATUSBAR_PNPM` environment variable
101
114
 
102
115
  ## Development
103
116
 
package/README.md CHANGED
@@ -38,7 +38,7 @@
38
38
  | 总计费用 | 当前会话的累计 token 明细 |
39
39
  | 余额 | 充值余额 / 赠金余额 / 剩余配额 |
40
40
 
41
- - **会话数据下钻**:上下文压缩逐次标注发生节点,技能注入统计当前会话注入的技能,工具调用按次数列出会话使用的所有工具;点击标题返回上一层
41
+ - **会话数据下钻**:上下文压缩逐次标注发生节点,技能注入统计当前会话注入的技能,工具调用列出会话使用的所有工具与各自次数;点击标题返回上一层
42
42
  - **版本自检与更新**:DSH 启动时自动检测最新版本,发现新版时在「状态栏」导航项旁显示提示,并在设置页提供一键更新按钮
43
43
  - **费用估算**:按官方峰谷口径计价,每条调用按其发生时刻与所用模型分别计算
44
44
  - **价格库**:自定义单价、峰谷分档、CNY / USD 计价单位
@@ -73,6 +73,18 @@ dsh plugin --profile desktop add dsh-desktop-statusbar
73
73
  npm view dsh-desktop-statusbar version
74
74
  ```
75
75
 
76
+ ### 插件自带的更新失效时
77
+
78
+ 插件里那个一键更新按钮,在某些桌面端版本上会失败(点了没反应、或者直接显示失败)。这时不要反复点,改用官方安装入口、**在包名后面加上 `@版本号`** 指定到具体版本,就能精确装上:
79
+
80
+ ```powershell
81
+ dsh plugin --profile desktop add dsh-desktop-statusbar@2.2.2
82
+ ```
83
+
84
+ 插件面板里同样可以这样填 —— 在「添加插件」的输入框里写 `dsh-desktop-statusbar@2.2.2`。
85
+
86
+ 版本号填你看到的最新版本即可(发布页右侧的版本号,或上面那条 `npm view` 命令的输出)。带上版本号还有一个好处:**装上的就是你想要的那一版**,不会因为缓存拿到旧包。
87
+
76
88
  本插件亦收录于 [dshmarket](https://awesome-dsh-plugin.com/p/raphael-y7/dsh-desktop-statusbar/)、[dshfind](https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar) 与 [1024Store](https://deepseek1024.com/plugins/Rapheal-Y7/dsh-desktop-statusbar)。
77
89
 
78
90
  ## 使用
@@ -98,7 +110,7 @@ npm view dsh-desktop-statusbar version
98
110
 
99
111
  - 最快 / 最慢首字与速度来自 host 新增的投影,**升级后需完全退出并重启 DSH** 才开始统计
100
112
  - 价格库中缺少单价的模型不计费,补充单价后自动补算
101
- - 一键更新依赖 pnpm 可执行文件:桌面端用宿主随包提供的 pnpm,命令行 / 网页端用 PATH 上的 pnpm。两者都找不到时会明确提示「找不到 pnpm」,而不是静默失败
113
+ - 一键更新依赖 pnpm 可执行文件:桌面端用宿主随包提供的 pnpm,命令行 / 网页端需要系统里有 `pnpm`。都找不到时会明确提示「找不到 pnpm」,并把**尝试过的每个位置**一并写进日志;也可以用环境变量 `DSH_DESKTOP_STATUSBAR_PNPM` 直接指定要用的命令
102
114
 
103
115
  ## 开发
104
116
 
Binary file
@@ -11,6 +11,26 @@
11
11
  * { currency: 'CNY' | 'USD' | ..., total: '12.34', parts: [{ label, amount }] }
12
12
  * currency 缺失时由调用方按站点/域名推断;parts 用来填气泡里的构成行(充值 / 赠金等)。 */
13
13
 
14
+ /* 积分套餐的上游数据上限(见下面 workbuddy 的 parse)。
15
+ * 名字长度限制的是下游 creditDisplayName 的前缀剥离开销(O(n²)):实测 500KB 名字
16
+ * 会让浏览器主线程卡 18 秒。200 字符远大于任何真实包名(最长的也只有 16 字)。
17
+ * 条数限制的是渲染开销:这个列表在底栏的每秒重渲染路径上,没有 memo。 */
18
+ const CREDITS_NAME_MAX = 200;
19
+ const CREDITS_MAX_PACKAGES = 256;
20
+
21
+ /**
22
+ * 字符串的短哈希(base36,约 6~7 位),O(n)、无依赖。
23
+ * 只用于给截断后的超长包名加一个「不同原名不会碰撞」的标记 —— 不是密码学哈希,
24
+ * 也不需要抗碰撞攻击:这里要防的是「两个正常上游数据被截成同名」,不是恶意构造。
25
+ */
26
+ function nameHash(text) {
27
+ let hash = 5381;
28
+ for (let i = 0; i < text.length; i += 1) {
29
+ hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0;
30
+ }
31
+ return hash.toString(36);
32
+ }
33
+
14
34
  const adapters = [
15
35
  {
16
36
  id: 'deepseek',
@@ -27,6 +47,9 @@ const adapters = [
27
47
  const infos = Array.isArray(data && data.balance_infos) ? data.balance_infos : [];
28
48
  if (infos.length === 0) return null;
29
49
  const one = infos[0];
50
+ /* 数组元素可能是 null(上游坏数据):直接读 total_balance 会抛 TypeError,
51
+ 整条余额就永久变横杠,所以先挡一道。 */
52
+ if (one === null || one === undefined || typeof one !== 'object') return null;
30
53
  const total = typeof one.total_balance === 'string' ? one.total_balance : null;
31
54
  if (total === null || total.length === 0) return null;
32
55
  const parts = [];
@@ -194,6 +217,9 @@ const adapters = [
194
217
  let percent = null;
195
218
  for (let i = 0; i < limits.length; i += 1) {
196
219
  const one = limits[i];
220
+ /* 数组里可能是 null(上游坏数据):直接读 one.type 会抛 TypeError,
221
+ 整条余额就永久变横杠了,所以先挡一道。 */
222
+ if (one === null || one === undefined || typeof one !== 'object') continue;
197
223
  const kind = typeof one.type === 'string' ? one.type.toUpperCase() : '';
198
224
  if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue;
199
225
  const value = Number(one.percentage);
@@ -249,7 +275,11 @@ const adapters = [
249
275
  const packages = Array.isArray(credits.packages) ? credits.packages : null;
250
276
  if (packages === null) return out;
251
277
  const rewards = [];
252
- for (let i = 0; i < packages.length; i += 1) {
278
+ /* 条数上限:上游返回多少条不由我们决定,而列表渲染在底栏的每秒重渲染路径上
279
+ (无 memo),几万条会把界面拖死(实测 10 万条 ≈ 0.7 秒/帧)。
280
+ 真实账号最多二三十个包,256 足够宽松,超出的直接忽略。 */
281
+ const count = Math.min(packages.length, CREDITS_MAX_PACKAGES);
282
+ for (let i = 0; i < count; i += 1) {
253
283
  const one = packages[i];
254
284
  if (one === null || one === undefined || typeof one !== 'object') continue;
255
285
  const size = Number(one.size);
@@ -257,8 +287,20 @@ const adapters = [
257
287
  /* size/remain 缺一个就没法算「已用/总量」:跳过这个包,
258
288
  不让一条坏数据把整条气泡弄崩(旧插件/上游漂移都可能缺字段)。 */
259
289
  if (Number.isFinite(size) !== true || Number.isFinite(remain) !== true || size < 0) continue;
260
- const name = typeof one.packageName === 'string' ? one.packageName : '';
261
- if (name.length === 0) continue;
290
+ const rawName = typeof one.packageName === 'string' ? one.packageName : '';
291
+ if (rawName.length === 0) continue;
292
+ /* 包名长度上限:上游是一条外部 API 响应,名字多长不由我们决定。
293
+ 下游 creditDisplayName 要按前缀反复剥,超长名字是 O(n²)(实测 500KB 名字
294
+ 能让浏览器主线程卡 18 秒);标签行也放不下。200 字符足够覆盖任何真实包名
295
+ (最长的「CodeBuddy个人版国内运营裂变包」也只有 16 字)。
296
+ **截断必须保证不同原名不碰撞 + 保持截断开销 O(n)**:客户端会把「显示名相邻
297
+ 且相同」的包合并成 ×N 一行,若两个前缀相同、后缀不同的超长包被截成同名,
298
+ 就会被误合并、显示伪造的合计(防御代码自己制造错误数据)。
299
+ 只用「原始长度」不够 —— 长度相同的两个不同名字照样碰撞,所以带上一段
300
+ 由全文算出的短哈希。 */
301
+ const name = rawName.length > CREDITS_NAME_MAX
302
+ ? rawName.slice(0, CREDITS_NAME_MAX) + '…(' + rawName.length + ':' + nameHash(rawName) + ')'
303
+ : rawName;
262
304
  const monthly = one.monthly === true;
263
305
  rewards.push({
264
306
  name,
package/lib/client.js CHANGED
@@ -74,7 +74,7 @@ window.__ModuleLoader__.load({
74
74
  */
75
75
  const CONFIG_VERSION = 6;
76
76
  /** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
77
- const VERSION = "2.2.0";
77
+ const VERSION = "2.2.2";
78
78
  /** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
79
79
  const PRICE_VERSION = 2;
80
80
  /** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
@@ -2388,13 +2388,19 @@ window.__ModuleLoader__.load({
2388
2388
  }
2389
2389
 
2390
2390
  /**
2391
- * 纯文字提示在「可用区间比文字还窄」时的缩放比例(用户拍板:**特例才缩,一般情况不缩**)。
2391
+ * 纯文字提示能不能完整放进「可用区间」(用户拍板:**放不下就不显示,绝不缩小**)。
2392
+ *
2393
+ * 历史:以前放不下时会等比缩小到放得下(下限 0.5)。后来用户明确要求
2394
+ * 「工具名很长而气泡不够长时,所有悬停提示一律不出现」,缩小这条路就没意义了 ——
2395
+ * 缩到 5.5px 的文字本来就是不可读的,不如整组不显示。所以这里只回答「放得下吗」,
2396
+ * 不再算缩放比例。
2397
+ *
2392
2398
  * 可用区间 = plainBoundsOf 给的 min~max(再与视口夹);没有 bounds 就退回视口两侧各留 8px。
2393
- * 返回 1 = 不缩放;< 1 = 需要等比缩小。
2394
- * 下限 0.5:11px 缩到 5.5px 已经是能辨认的极限,再小不如不显示。
2399
+ * 拿不到可用宽度(无 bounds 且无视口 / 区间为负)时返回 true:
2400
+ * 量不出来就别关,宁可显示也不误伤。
2395
2401
  */
2396
- function plainFitScale(textWidth, bounds, viewportWidth) {
2397
- if (typeof textWidth !== "number" || Number.isFinite(textWidth) !== true || textWidth <= 0) return 1;
2402
+ function plainFits(textWidth, bounds, viewportWidth) {
2403
+ if (typeof textWidth !== "number" || Number.isFinite(textWidth) !== true || textWidth <= 0) return true;
2398
2404
  const viewport = typeof viewportWidth === "number" && viewportWidth > 0 ? viewportWidth : 0;
2399
2405
  let min = 8;
2400
2406
  let max = viewport > 0 ? viewport - 8 : null;
@@ -2402,12 +2408,11 @@ window.__ModuleLoader__.load({
2402
2408
  min = Math.max(min, bounds.min);
2403
2409
  if (max === null || bounds.max < max) max = bounds.max;
2404
2410
  }
2405
- if (max === null) return 1;
2411
+ if (max === null) return true;
2406
2412
  const available = max - min;
2407
- /* 区间本身就是负的(标题右边界已经越过色条右边):没有可用的横向空间,不缩也无处可放 */
2408
- if (available <= 0) return 1;
2409
- if (textWidth <= available) return 1;
2410
- return Math.max(0.5, available / textWidth);
2413
+ /* 区间本身就是负的(标题右边界已经越过色条右边):没有可用的横向空间,量不出结论 */
2414
+ if (available <= 0) return true;
2415
+ return textWidth <= available;
2411
2416
  }
2412
2417
 
2413
2418
  /**
@@ -2844,7 +2849,7 @@ window.__ModuleLoader__.load({
2844
2849
  style: { width: one.width + "%", backgroundColor: one.color }
2845
2850
  };
2846
2851
  if (opened !== null && sub !== undefined && sub !== null && typeof sub.hover === "function"
2847
- && one.name !== undefined) {
2852
+ && one.name !== undefined && sub.hoverSuppressed !== true) {
2848
2853
  /* 用户拍板:不要气泡,只显示一行文字(工具名 + 占比),贴在鼠标上方最近处 */
2849
2854
  segProps.onMouseEnter = (event) => sub.hover(event, {
2850
2855
  plain: one.name + " " + one.percentText,
@@ -3232,6 +3237,46 @@ window.__ModuleLoader__.load({
3232
3237
  return out.length === 0 ? name : out;
3233
3238
  }
3234
3239
 
3240
+ /* 裂变包的名字特征:同名包会聚合成「×N」一行、动辄十几条,排在中间会把正经套餐挤下去。
3241
+ 只按名字里有没有这两个字判断,不查任何外部清单 —— 上游新增什么裂变包都不用改代码。 */
3242
+ const CREDITS_SPLIT_KEYWORD = "裂变";
3243
+
3244
+ /**
3245
+ * 积分套餐的显示顺序(用户拍板,三档):
3246
+ * ① **每月固定刷新的套餐永远第一行** —— 月度包是天天要看的那个,位置不能漂;
3247
+ * ② **裂变包永远最后** —— 它们会聚合成「×11」这种一行,放在中间太占位;
3248
+ * ③ 其余按**过期时间先后**排,最先要过期的排最前 —— 快用完的用户得先看见。
3249
+ * 每一档内部同样按日期升序;**没有日期的排在同档有日期的之后**(不猜日期,也不让它插到前面)。
3250
+ * 日期取哪个字段跟着套餐性质走:月度包看「下期刷新」,一次性包看「到期」。
3251
+ * Array.prototype.sort 稳定,日期相同或都缺日期时保持上游原顺序,不会每次刷新跳来跳去。
3252
+ */
3253
+ function creditsSortGroup(one) {
3254
+ if (one.monthly === true) return 0;
3255
+ if (String(one.name).indexOf(CREDITS_SPLIT_KEYWORD) !== -1) return 2;
3256
+ return 1;
3257
+ }
3258
+
3259
+ /** 排序用的日期:月度包取刷新日、其余取到期日;都没有给空串(排在有日期的之后)。 */
3260
+ function creditsSortDate(one) {
3261
+ const value = one.monthly === true ? one.refreshAt : one.expireAt;
3262
+ return typeof value === "string" && value.length > 0 ? value : "";
3263
+ }
3264
+
3265
+ function creditsSort(rows) {
3266
+ return rows.slice().sort((a, b) => {
3267
+ const groupA = creditsSortGroup(a);
3268
+ const groupB = creditsSortGroup(b);
3269
+ if (groupA !== groupB) return groupA - groupB;
3270
+ const dateA = creditsSortDate(a);
3271
+ const dateB = creditsSortDate(b);
3272
+ if (dateA === dateB) return 0;
3273
+ /* 空串(没有日期)恒排在最后 */
3274
+ if (dateA.length === 0) return 1;
3275
+ if (dateB.length === 0) return -1;
3276
+ return dateA < dateB ? -1 : 1;
3277
+ });
3278
+ }
3279
+
3235
3280
  /** 两个 ISO 日期里取更早的那个(null 不参与);合并相邻同名包时「只显示最近到期日」。 */
3236
3281
  function earliestDate(a, b) {
3237
3282
  if (a === null || a === undefined) return b === undefined ? null : b;
@@ -3243,8 +3288,9 @@ window.__ModuleLoader__.load({
3243
3288
  * 积分套餐面板数据:`{ title, totalText, rewards[] }`。
3244
3289
  * 每个套餐带上显示用字段(label / valueText / percent),渲染函数只负责画。
3245
3290
  * **相邻且显示名相同**的套餐合并成一行:used/total 相加、percent 按合计重算、count 记原条数;
3246
- * 日期只取最近的一个(monthly 取最早 refreshAt,否则取最早 expireAt),不显示区间;
3247
- * 顺序按首次出现位置。字段缺失(name 不是非空字符串)的套餐直接跳过。
3291
+ * 日期只取最近的一个(monthly 取最早 refreshAt,否则取最早 expireAt),不显示区间。
3292
+ * 顺序:合并完再交给 `creditsSort` 排三档(月度置顶 / 其余按到期升序 / 裂变包置底)。
3293
+ * 字段缺失(name 不是非空字符串)的套餐直接跳过。
3248
3294
  * percent = clamp(used / total × 100, 0, 100),total<=0 时给 0(画空轨道)。
3249
3295
  */
3250
3296
  function creditsView(rewards, totalText, t) {
@@ -3287,7 +3333,9 @@ window.__ModuleLoader__.load({
3287
3333
  expireAt: expireAt, refreshAt: refreshAt, monthly: monthly, items: [item]
3288
3334
  });
3289
3335
  });
3290
- const rows = merged.map((one) => {
3336
+ /* 顺序:**先按原顺序聚合相邻同名包,最后再整体排序** —— 不能先排序,
3337
+ 排序会把这 11 个裂变包按日期打散,聚合就失效了(它们必须相邻才能并成一行)。 */
3338
+ const rows = creditsSort(merged.map((one) => {
3291
3339
  /* 该组剩余积分 = 总容量 - 已用(不小于 0);下钻标题右侧显示它,随用量动态变化 */
3292
3340
  const remaining = Math.max(0, one.total - one.used);
3293
3341
  return {
@@ -3305,7 +3353,7 @@ window.__ModuleLoader__.load({
3305
3353
  valueText: t("tipCreditsUsed", { used: formatCredits(one.used), total: formatCredits(one.total) }),
3306
3354
  percent: one.total > 0 ? Math.max(0, Math.min(100, (one.used / one.total) * 100)) : 0
3307
3355
  };
3308
- });
3356
+ }));
3309
3357
  return { title: t("tipCreditsTitle"), totalText: totalText, rewards: rows };
3310
3358
  }
3311
3359
 
@@ -4115,7 +4163,14 @@ window.__ModuleLoader__.load({
4115
4163
  /* box-sizing:border-box 必须留着:内联宽度要含 padding,否则面板右边缘会比对齐点偏出 22px */
4116
4164
  ".dsb-tip{position:fixed;box-sizing:border-box;padding:6px 10px;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f));background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));white-space:nowrap;z-index:40;pointer-events:auto}",
4117
4165
  ".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
4118
- /* 设置页 */
4166
+ /* 整块禁选(用户拍板):底栏、底栏气泡、设置页按住鼠标拖动会刷出蓝色选中底,
4167
+ 看着像出了 bug。这三处都是插件自己画的纯展示/操作界面,用户没有复制文字的需求;
4168
+ ::selection 一并写成透明 —— 只写 user-select:none 时,某些内核在
4169
+ "先按下再拖动"的路径上仍会画一层选中色。 */
4170
+ ".dsb-root,.dsb-tip,.dsb-settings,.dsb-hover-plain,.dsb-drag-card{user-select:none;-webkit-user-select:none}",
4171
+ ".dsb-root ::selection,.dsb-tip ::selection,.dsb-settings ::selection,.dsb-hover-plain::selection,.dsb-drag-card ::selection{background:transparent;color:inherit}",
4172
+ /* 设置页里的输入框要能选中/编辑(改价格那几个 number 框):单独放开 */
4173
+ ".dsb-settings input,.dsb-settings textarea{user-select:text;-webkit-user-select:text}",
4119
4174
  /* 这里的 14px 是面板内所有文字的继承来源;挂到 body 的跟手浮层继承不到它,
4120
4175
  那边(.dsb-drag-card)自己写死了一份 —— 改这里要同步那边。 */
4121
4176
  ".dsb-settings{display:flex;flex-direction:column;gap:18px;font-size:14px}",
@@ -4761,14 +4816,13 @@ window.__ModuleLoader__.load({
4761
4816
  const [hoverPos, setHoverPos] = react.useState(null);
4762
4817
  /* 工具彩条段悬停的「无气泡纯文字」内容(工具名 + 占比);有值时只渲染一行文字 */
4763
4818
  const [hoverPlain, setHoverPlain] = react.useState(null);
4819
+ /* 工具名悬停提示**整组开关**(用户拍板):当前气泡放不下那行小字时置 true,
4820
+ 此后所有工具名的悬停都不再挂 onMouseEnter。由气泡定位 effect 量出实际宽度后判定
4821
+ (见 plainFits);换气泡 / 换子视图时由那条 effect 重新得出结论。 */
4822
+ const [hoverPlainOff, setHoverPlainOff] = react.useState(false);
4764
4823
  /* 纯文字提示的固定垂直位置(与气泡标题行文字居中对齐):定位 effect 算好后存这里,
4765
4824
  段内左右移动时直接复用,保证「只左右移动、不上下移动」且不抖。 */
4766
4825
  const hoverPlainTopRef = react.useRef(null);
4767
- /* 纯文字提示的**未缩放原始宽度**(px):段内移动时按它算缩放比例。
4768
- 不能拿 getBoundingClientRect().width —— 那个节点此时已经带 `transform: scale(x)`,
4769
- 量到的是**缩放后**的视觉宽,再乘一次比例就会越缩越小、来回振荡(长 MCP 工具名
4770
- + 窄窗时可见)。定位 effect 在还没写 transform 的那一帧量一次存这里。 */
4771
- const hoverPlainWidthRef = react.useRef(0);
4772
4826
  /* 纯文字提示的水平夹取范围:左 = 「工具调用」标题右边界 + 1ch,右 = 色条右边界
4773
4827
  (再与视口右缘取小)。定位 effect 里算一次存这里,段内移动时复用。 */
4774
4828
  const hoverPlainBoundsRef = react.useRef(null);
@@ -4833,25 +4887,13 @@ window.__ModuleLoader__.load({
4833
4887
  if (node.style === undefined || node.style === null) return;
4834
4888
  const size = node.getBoundingClientRect();
4835
4889
  if (hoverPlain !== null) {
4836
- /* 纯文字提示:水平夹在「标题右边界 ~ 色条右边界」之间。
4837
- 区间放不下这段文字时(长 MCP 工具名 + 窄窗)**等比缩小到放得下**,
4838
- 一般情况 scale 就是 1、不做任何缩放。
4839
- ⚠ 宽度必须用**未缩放**的原始宽,两个来源都行、但都不能含 transform:
4840
- ① hoverPlainWidthRef(定位 effect 用 offsetWidth 存好的);
4841
- ② 现场量 node.offsetWidth(同样不含 transform)。
4842
- 绝不能用 getBoundingClientRect().width —— 此时节点可能已经带着
4843
- transform: scale(),量到的是缩放后的视觉宽,再乘一次比例就会越缩越小。 */
4890
+ /* 纯文字提示:水平夹在「标题右边界 ~ 色条右边界」之间(放不放得下已在定位 effect
4891
+ 判过,能走到这里就说明放得下,不做任何缩放)。宽度用 offsetWidth(不含 transform)。 */
4844
4892
  const layoutWidth = typeof node.offsetWidth === "number" && node.offsetWidth > 0
4845
4893
  ? node.offsetWidth
4846
4894
  : size.width;
4847
- const natural = hoverPlainWidthRef.current > 0 ? hoverPlainWidthRef.current : layoutWidth;
4848
- const scale = plainFitScale(natural, hoverPlainBoundsRef.current, window.innerWidth);
4849
- const shown = scale === 1 ? natural : Math.round(natural * scale);
4850
- const left = clampPlainLeft(event.clientX - shown / 2, shown, hoverPlainBoundsRef.current, window.innerWidth);
4895
+ const left = clampPlainLeft(event.clientX - layoutWidth / 2, layoutWidth, hoverPlainBoundsRef.current, window.innerWidth);
4851
4896
  node.style.left = left + "px";
4852
- /* 缩放以左端为基准:left 已经按缩小后的宽度算好,transform 原点放左上角才对得上 */
4853
- node.style.transform = scale === 1 ? "" : "scale(" + scale + ")";
4854
- node.style.transformOrigin = scale === 1 ? "" : "left center";
4855
4897
  /* 上下固定:沿用 title 行居中算出的 top */
4856
4898
  if (hoverPlainTopRef.current !== null) node.style.top = hoverPlainTopRef.current + "px";
4857
4899
  } else {
@@ -5143,7 +5185,8 @@ window.__ModuleLoader__.load({
5143
5185
  只升不降(回退会让行数在两档之间来回跳);换字段 / 开子视图 / 返回时都重置回 3。
5144
5186
  到 12 行仍放不下就停在 12,横向滚动兜底(不再折行)。
5145
5187
  量的是 DOM 几何,测试替身里拿不到就一律当放得下(量测函数自己兜住)。 */
5146
- const nextRows = nextToolRows(toolRows, toolColumnsOverflow(node));
5188
+ const overflow = toolColumnsOverflow(node);
5189
+ const nextRows = nextToolRows(toolRows, overflow);
5147
5190
  if (nextRows !== toolRows) setToolRows(nextRows);
5148
5191
  const centered = anchor.left + anchor.width / 2 - width / 2;
5149
5192
  /* 左右两条硬约束,任何气泡都适用,而且是**对齐**不是仅仅不越界:
@@ -5243,25 +5286,28 @@ window.__ModuleLoader__.load({
5243
5286
  : panel.getBoundingClientRect();
5244
5287
  const bounds = plainBoundsOf(titleBox, barBox, window.innerWidth);
5245
5288
  hoverPlainBoundsRef.current = bounds;
5246
- /* 记下**未缩放**的原始宽度,段内移动时按它算比例(见 onHoverMove)。
5247
- ⚠ 必须用 offsetWidth(布局宽度),**不能**用 getBoundingClientRect().width:
5248
- 后者含 transform。这条 effect 的依赖里有 hoverMouse(鼠标一动就变),
5249
- 所以它会重跑;重跑时节点上还留着上一帧写的 transform: scale(x),
5250
- 用 getBoundingClientRect 量到的是缩放后的宽 → ref 被写小 → 下次更小
5251
- → 悬停文字一路缩到 0.5 下限(实测自然宽 600 被写成 300)。
5252
- offsetWidth 不受 transform 影响,重跑多少次都是自然宽。
5253
- offsetWidth 是整数且老浏览器/替身里可能没有,取不到就退回 size.width。 */
5289
+ /* 提示文字的自然宽度。用 offsetWidth(布局宽度)而不是
5290
+ getBoundingClientRect().width:后者含 transform,而这条 effect 的依赖里有
5291
+ hoverMouse(鼠标一动就重跑),量到缩放后的宽会越缩越小。 */
5254
5292
  const layoutWidth = typeof node.offsetWidth === "number" && node.offsetWidth > 0
5255
5293
  ? node.offsetWidth
5256
5294
  : size.width;
5257
- hoverPlainWidthRef.current = layoutWidth;
5258
- /* 区间放不下这段文字时缩小到放得下(一般情况 scale=1,不缩) */
5259
- const scale = plainFitScale(layoutWidth, bounds, window.innerWidth);
5260
- const shown = scale === 1 ? layoutWidth : Math.round(layoutWidth * scale);
5295
+ /* 放不下就**整组不显示**(用户拍板):工具名很长、气泡不够长时,
5296
+ 这行小字会压到别的内容上或被裁掉。以前是等比缩小硬塞进去,
5297
+ 现在改成关掉 —— 缩到 5.5px 本来就读不清,不如不显示。
5298
+ 一旦这个气泡放不下,**所有**工具名的悬停提示都关(不是只关超长那个),
5299
+ 否则只有一部分能弹,看起来更像故障。 */
5300
+ if (plainFits(layoutWidth, bounds, window.innerWidth) !== true) {
5301
+ if (hoverPlainOff !== true) setHoverPlainOff(true);
5302
+ /* 内容也当场清掉:不留半行字孤零零挂在屏幕上 */
5303
+ setHoverPlain(null);
5304
+ setHoverPos(null);
5305
+ return;
5306
+ }
5307
+ if (hoverPlainOff === true) setHoverPlainOff(false);
5261
5308
  setHoverPos({
5262
- left: clampPlainLeft(one.x - shown / 2, shown, bounds, window.innerWidth),
5263
- top: Math.max(8, fixedTop),
5264
- scale: scale
5309
+ left: clampPlainLeft(one.x - layoutWidth / 2, layoutWidth, bounds, window.innerWidth),
5310
+ top: Math.max(8, fixedTop)
5265
5311
  });
5266
5312
  return;
5267
5313
  }
@@ -5273,11 +5319,15 @@ window.__ModuleLoader__.load({
5273
5319
  });
5274
5320
  }, [hoverCard, hoverPlain, hoverMouse]);
5275
5321
 
5276
- /* 主气泡收起时顺手把悬停浮层也收掉,避免它孤零零留在屏幕上 */
5322
+ /* 主气泡收起时顺手把悬停浮层也收掉,避免它孤零零留在屏幕上。
5323
+ `hoverPlainOff` 也必须在这里复位:它是「本气泡放不下所以整组不显示」的一次性结论,
5324
+ 换气泡 / 关气泡后必须重新评估 —— 不复位的话,用户碰到一次长工具名之后,
5325
+ 此后所有气泡的工具名悬停都会永久失效(2026-10 实测发现)。 */
5277
5326
  react.useEffect(() => {
5278
5327
  if (tipId === null) {
5279
5328
  setHoverCard(null);
5280
5329
  setHoverPos(null);
5330
+ setHoverPlainOff(false);
5281
5331
  }
5282
5332
  }, [tipId]);
5283
5333
 
@@ -5399,6 +5449,11 @@ window.__ModuleLoader__.load({
5399
5449
  setCreditSub(null);
5400
5450
  /* 工具调用子视图的行数也回到默认 3:换字段 / 重开都重新量 */
5401
5451
  setToolRows(LIST_ROWS_PER_COLUMN);
5452
+ /* 悬停整组开关也必须复位(用户拍板的那条「放不下就全不显示」)。
5453
+ 换字段时 tipId 是**直接切换**("counts" → "cost")、不经过 null,
5454
+ 只靠 tipId === null 那条 effect 复位不到 —— 结果是:碰到一次长工具名后,
5455
+ 点任何一个字段都会让本次会话的工具名悬停永久失效(2026-10 实测确认的死锁)。 */
5456
+ setHoverPlainOff(false);
5402
5457
  if (tipId === id) {
5403
5458
  setTipId(null);
5404
5459
  setTipPos(null);
@@ -5433,16 +5488,22 @@ window.__ModuleLoader__.load({
5433
5488
  if (tip.kind === "bars") return weekBarsPanel(tip, t, tipPos, tipRef, onHover, { week: hoverWeek, bar: hoverBar });
5434
5489
  return rowsPanel(tip, tipPos, tipRef, {
5435
5490
  id: tipSub,
5436
- /* 打开 / 返回都把行数重置回 3:另一个子视图不该沿用上一条量出来的行数 */
5491
+ /* 打开 / 返回都把行数重置回 3:另一个子视图不该沿用上一条量出来的行数。
5492
+ `hoverPlainOff` 同理必须复位:在工具子视图里碰到长工具名被关掉之后,
5493
+ 点标题返回再进别的子视图,如果不复位,新视图的色条段也一律不挂悬停。 */
5437
5494
  open: (index) => {
5438
5495
  setToolRows(LIST_ROWS_PER_COLUMN);
5496
+ setHoverPlainOff(false);
5439
5497
  setTipSub(index);
5440
5498
  },
5441
5499
  back: () => {
5442
5500
  setToolRows(LIST_ROWS_PER_COLUMN);
5501
+ setHoverPlainOff(false);
5443
5502
  setTipSub(null);
5444
5503
  },
5445
5504
  rowsPerColumn: toolRows,
5505
+ /* 工具名悬停提示整组开关:这个气泡放不下那行小字时一律不挂(用户拍板) */
5506
+ hoverSuppressed: hoverPlainOff,
5446
5507
  /* 工具调用彩条每段的悬停浮层(工具名 + 占比)走同一套 onHover,与柱状图互不干扰;
5447
5508
  onHoverMove 只更新鼠标坐标,让浮层在同一个色段里跟着鼠标走而不闪断。 */
5448
5509
  hover: onHover,
@@ -5483,17 +5544,11 @@ window.__ModuleLoader__.load({
5483
5544
  });
5484
5545
  /* 工具彩条悬停:**不要气泡**,只渲染一行文字(工具名 + 占比),贴在鼠标上方。
5485
5546
  字号 11px(比悬停浮层的 10px 大、比标题 12px 小),无背景/圆角/描边。
5486
- 极窄区间放不下时按 plainFitScale 等比缩小(transform 不动布局,left/top 照常)。 */
5547
+ 放不下时整组不显示(见 plainFits),**不做缩放**,所以这里只给 left/top。 */
5487
5548
  if (hoverPlain !== null) {
5488
5549
  const hoverStyle = hoverPos === null
5489
5550
  ? undefined
5490
- : (hoverPos.scale === undefined || hoverPos.scale === 1
5491
- ? { left: hoverPos.left + "px", top: hoverPos.top + "px" }
5492
- : {
5493
- left: hoverPos.left + "px", top: hoverPos.top + "px",
5494
- transform: "scale(" + hoverPos.scale + ")",
5495
- transformOrigin: "left center"
5496
- });
5551
+ : { left: hoverPos.left + "px", top: hoverPos.top + "px" };
5497
5552
  children.push(h("span", {
5498
5553
  className: "dsb-hover-plain",
5499
5554
  key: "__hoverplain",
package/lib/holiday.js CHANGED
@@ -17,15 +17,46 @@ const NOTICE_DATE = /(?:(\d{4})年)?(?:(\d{1,2})月)?(\d{1,2})日/g
17
17
  const DAY_MS = 86400000
18
18
  /** 一段假期最长不会超过这个天数,防正则意外把超长区间展开 */
19
19
  const MAX_RANGE_DAYS = 40
20
+ /** HTML 解析的体积上限(字符):真实通知页 100~300KB,2MB 已经非常宽松。
21
+ * 超过就只看前 2MB —— 宁可解析不全,也不能让 host 主线程卡住(见 holidayHtmlToText)。 */
22
+ const HOLIDAY_HTML_MAX = 2 * 1024 * 1024
20
23
 
21
- /** HTML → 纯文本:去脚本、样式与标签,解常见实体。 */
24
+ /**
25
+ * 线性切掉成对标签里的整段内容(`<script ...>...</script>` 这类)。
26
+ * 用 indexOf 前进而不是懒惰量词:找不到闭合标签时直接放弃剩余部分,
27
+ * 复杂度严格 O(n);`/<script[\s\S]*?<\/script>/gi` 在缺闭合时会从每个 `<` 往后扫到文末,
28
+ * 实测 40 万个未闭合 `<script>` 要 6 秒(卡死 host 事件循环)。
29
+ */
30
+ function stripBlocks(text, tag) {
31
+ const open = '<' + tag
32
+ const close = '</' + tag
33
+ const lower = text.toLowerCase()
34
+ let out = ''
35
+ let at = 0
36
+ for (;;) {
37
+ const start = lower.indexOf(open, at)
38
+ if (start === -1) return out + text.slice(at)
39
+ out += text.slice(at, start)
40
+ const end = lower.indexOf(close, start)
41
+ if (end === -1) return out /* 没有闭合:整段丢弃,不再往后扫 */
42
+ const gt = text.indexOf('>', end)
43
+ if (gt === -1) return out
44
+ at = gt + 1
45
+ }
46
+ }
47
+
48
+ /** HTML → 纯文本:去脚本、样式与标签,解常见实体。
49
+ *
50
+ * 这段处理的是**外部网页**(国务院通知),内容不受我们控制,所以两处做了防御:
51
+ * ① 先按长度截断,避免超大文档把主线程占满;
52
+ * ② `<[^<>]+>` 而不是 `<[^>]+>` —— 后者不排除 `<`,大量孤立 `<` 时会反复跨越大段文本,
53
+ * 实测 20 万字符要 12 秒。 */
22
54
  export function holidayHtmlToText(html) {
23
- return String(html === null || html === undefined ? '' : html)
24
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
25
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
55
+ const raw = String(html === null || html === undefined ? '' : html).slice(0, HOLIDAY_HTML_MAX)
56
+ return stripBlocks(stripBlocks(raw, 'script'), 'style')
26
57
  .replace(/<br\s*\/?>/gi, '\n')
27
58
  .replace(/<\/p>/gi, '\n')
28
- .replace(/<[^>]+>/g, '')
59
+ .replace(/<[^<>]+>/g, '')
29
60
  .replace(/&nbsp;/g, ' ')
30
61
  .replace(/&amp;/g, '&')
31
62
  .replace(/&lt;/g, '<')
@@ -49,7 +80,9 @@ const pad = (n) => (n < 10 ? '0' + String(n) : String(n))
49
80
  export function parseHolidayNotice(text, year) {
50
81
  const days = new Set()
51
82
  const flat = String(text === null || text === undefined ? '' : text)
52
- .replace(/<[^>]+>/g, ' ')
83
+ .slice(0, HOLIDAY_HTML_MAX)
84
+ /* `[^<>]` 而不是 `[^>]`:理由同 holidayHtmlToText —— 大量孤立 `<` 时会退化 */
85
+ .replace(/<[^<>]+>/g, ' ')
53
86
  .replace(/\s+/g, ' ')
54
87
  /* 只认含"放假"的句子;含"上班"的句子是调休上班日,不能算进空闲 */
55
88
  flat.split(/[。;;\n]/).forEach((sentence) => {
package/lib/index.js CHANGED
@@ -1626,7 +1626,9 @@ async function queryBalance(ctx, force, provider, model) {
1626
1626
  }
1627
1627
  const data = await response.json()
1628
1628
  const infos = Array.isArray(data.balance_infos) ? data.balance_infos : []
1629
- const picked = infos.filter((info) => info.currency === 'CNY')[0] ?? infos[0]
1629
+ /* 元素可能是 null(上游坏数据):在 filter 回调里直接读 info.currency 会抛 TypeError */
1630
+ const picked = infos.filter((info) => info !== null && typeof info === 'object' && info.currency === 'CNY')[0]
1631
+ ?? infos.filter((info) => info !== null && typeof info === 'object')[0]
1630
1632
  if (picked === undefined) {
1631
1633
  const empty = { ok: false, at: now, reason: 'no-balance-info', source: found.source }
1632
1634
  logHost(ctx, 'balance: 响应里没有 balance_infos')
@@ -4077,6 +4079,9 @@ async function handleApi(req, res, ctx) {
4077
4079
  reason: last.reason === undefined ? null : last.reason,
4078
4080
  from: last.from === undefined ? null : last.from,
4079
4081
  to: last.to === undefined ? null : last.to,
4082
+ /* 找不到 pnpm 时把「试过哪些路径」一并交给界面:排查这类问题时最费时间的就是
4083
+ 手工逐条 Test-Path,这里直接摊开。其它失败原因下是空数组。 */
4084
+ tried: Array.isArray(last.tried) === true ? last.tried : [],
4080
4085
  },
4081
4086
  })
4082
4087
  return
@@ -89,54 +89,237 @@ export function resolveSelfDir(moduleUrl) {
89
89
  }
90
90
 
91
91
  /**
92
- * 定位 pnpm 可执行文件。
92
+ * 取一个「可能是字符串」的注入值:类型不对时退回默认值。
93
+ * 传 `null` / 数字 / 对象都按「显式给了一个不可用的值」处理,不回退。
93
94
  *
94
- * 顺序:
95
- * 1. 环境变量 `DSH_DESKTOP_STATUSBAR_PNPM`:完整命令覆盖。可以是 JSON 数组
96
- * (`["node","/path/pnpm.mjs"]`,用别的 node 跑打包的 pnpm 或包一层脚本时用),
97
- * 也可以是单个可执行文件路径。
98
- * 2. 宿主随包提供的 pnpm:从 `process.execPath` 推出 resources 目录。
99
- * DSH 桌面端把 pnpm 放在 `resources/runtime/pnpm/bin/pnpm.mjs`,
100
- * 用 Electron 可执行文件加 `ELECTRON_RUN_AS_NODE=1` 启动即可当 node 用
101
- * (官方插件管理器走的也是这条路)。
102
- * 3. PATH 上的 `pnpm`(web 端 / CLI 运行时常走这条)。
95
+ * @param {unknown} value 注入值
96
+ * @param {unknown} fallback 默认值
97
+ * @returns {string}
98
+ */
99
+ function asText(value, fallback) {
100
+ if (value === undefined) return typeof fallback === 'string' ? fallback : ''
101
+ return typeof value === 'string' ? value : ''
102
+ }
103
+
104
+ /**
105
+ * 判断一段文本看起来是不是绝对路径。
103
106
  *
104
- * @returns {{command: string, args: string[], electron: boolean}|null}
107
+ * 不用 `node:path` 的 `isAbsolute()`:它按**当前进程**的平台判断,而这里允许测试
108
+ * 注入 `options.platform`,两者可能对不上。两种平台的写法都认:
109
+ * `C:\x`(盘符)、`\\server\share`(UNC)、`/x`(POSIX 根)。
110
+ *
111
+ * @param {string} text
112
+ * @returns {boolean}
113
+ */
114
+ function looksAbsolutePath(text) {
115
+ return /^[A-Za-z]:[\\/]/.test(text)
116
+ || text.startsWith('\\\\')
117
+ || text.startsWith('/')
118
+ }
119
+
120
+ /**
121
+ * 去掉路径结尾的分隔符;盘符根(`C:\`)原样保留 —— 去掉会变成 `C:`。
122
+ *
123
+ * @param {string} text
124
+ * @returns {string}
125
+ */
126
+ function trimTrailingSeparators(text) {
127
+ let out = text
128
+ while (out.length > 1 && /[\\/]$/.test(out) === true) {
129
+ if (/^[A-Za-z]:[\\/]$/.test(out) === true) break
130
+ out = out.slice(0, -1)
131
+ }
132
+ return out
133
+ }
134
+
135
+ /**
136
+ * 解析安装根下的 `resources` 目录。
137
+ *
138
+ * 优先级(与宿主自己的 `runtimeResources()` 取值顺序一致):
139
+ * 1. 显式注入的 `resourcesPath`(测试用,也可以是调用方认定的权威值);
140
+ * 2. `process.resourcesPath` —— Electron 打包版给的权威值,比推断更稳;
141
+ * 3. 由 `execPath` 推断:`dirname(execPath)/resources`(非 Electron 环境兜底)。
142
+ *
143
+ * @param {string} resourcesPath 已处理过默认值(可能是空字符串)
144
+ * @param {string} execPath 已处理过默认值(可能是空字符串)
145
+ * @returns {string|null} 推断不出来时返回 null
146
+ */
147
+ function resolveResourcesDir(resourcesPath, execPath) {
148
+ if (resourcesPath.length > 0 && looksAbsolutePath(resourcesPath) === true) {
149
+ return trimTrailingSeparators(resourcesPath)
150
+ }
151
+ if (execPath.length > 0) return trimTrailingSeparators(join(dirname(execPath), 'resources'))
152
+ return null
153
+ }
154
+
155
+ /**
156
+ * 定位 pnpm,返回**结构化结果**(永不抛异常),把「试过哪些候选」一并带回。
157
+ *
158
+ * 只做定位,不执行任何外部命令。
159
+ *
160
+ * 候选顺序(命中即返回,逐个记进 `tried`):
161
+ * 1. `DSH_DESKTOP_STATUSBAR_PNPM`:插件自己的覆盖口,优先级最高。可以是 JSON 数组
162
+ * (`["node","/path/pnpm.mjs"]`,用别的 node 跑打包的 pnpm 时用),也可以是
163
+ * 单个可执行文件路径。**不检查文件是否存在** —— 用户显式指定的入口就该照办。
164
+ * 2. `DSH_DESKTOP_PNPM_ENTRY`:宿主自己的口径(宿主 `runtimeResources()` 在开发
165
+ * 模式下读它)。排在文件候选之前,宿主以后挪位置也不会失配。
166
+ * 3. 宿主随包提供的 pnpm,按 `resources` 目录拼出来,三条候选依次尝试:
167
+ * - `runtime/primary-runtime/dependencies/pnpm/bin/pnpm.mjs`:**当前宿主布局**。
168
+ * 优先用同一套运行时里的 node(`runtime/primary-runtime/dependencies/node/bin/node.exe`)
169
+ * 去跑它,不依赖 Electron 的 `ELECTRON_RUN_AS_NODE`;那套 node 不在时退回宿主可执行文件
170
+ * (此时才需要 `ELECTRON_RUN_AS_NODE=1`)。
171
+ * - `runtime/pnpm/bin/pnpm.mjs`:更旧的宿主布局,保留兼容。
172
+ * - `app.asar.unpacked/dsh/node_modules/pnpm/bin/pnpm.mjs`:再旧的打包布局,保留兼容。
173
+ *
174
+ * 找不到时**不做 PATH 搜索**:Windows 上 PATH 里多半是 `pnpm.cmd`,spawn 它需要
175
+ * `shell: true`,等于把命令解释权交给 shell,风险面大于收益。真需要时请用户用
176
+ * `DSH_DESKTOP_STATUSBAR_PNPM` 直接指定(issue 里的绕过写法就是这么用的)。
177
+ *
178
+ * @param {{
179
+ * env?: {[key: string]: string|undefined},
180
+ * execPath?: string,
181
+ * resourcesPath?: string,
182
+ * exists?: (path: string) => boolean,
183
+ * electron?: boolean,
184
+ * platform?: string,
185
+ * }} [options] 全部可选;只有测试会传
186
+ * @returns {{
187
+ * ok: boolean,
188
+ * runner: {command: string, args: string[], electron: boolean}|null,
189
+ * source: 'custom-env'|'host-env'|'primary-runtime'|'runtime-legacy'|'asar-unpacked'|null,
190
+ * tried: Array<{source: string, path: string|null, note: string}>,
191
+ * }}
105
192
  */
106
- export function resolvePnpm() {
107
- const explicit = process.env.DSH_DESKTOP_STATUSBAR_PNPM
108
- if (typeof explicit === 'string' && explicit.trim().length > 0) {
109
- const text = explicit.trim()
110
- if (text.startsWith('[')) {
193
+ export function resolvePnpmDetailed(options) {
194
+ const opts = options === null || typeof options !== 'object' ? {} : options
195
+ /** 环境变量来源;默认 process.env。 */
196
+ const env = opts.env === undefined ? process.env : (opts.env === null ? {} : opts.env)
197
+ /** 宿主可执行文件;空字符串表示「拿不到」。 */
198
+ const execPath = asText(opts.execPath, process.execPath)
199
+ /** 注入的 resources 目录;未传时才回落到 process.resourcesPath。 */
200
+ const resourcesPath = asText(opts.resourcesPath, process.resourcesPath)
201
+ /** 是否跑在 Electron 里 —— 只有退回宿主可执行文件当 node 用时才需要它。 */
202
+ const electron = opts.electron === undefined
203
+ ? (typeof process.versions === 'object'
204
+ && process.versions !== null
205
+ && typeof process.versions.electron === 'string'
206
+ && process.versions.electron.length > 0)
207
+ : opts.electron === true
208
+ /** 平台;只用来决定同套运行时里的 node 叫 node.exe 还是 node。 */
209
+ const platform = asText(opts.platform, process.platform)
210
+
211
+ /** 每次尝试的记录:命中的也记,失败时一并带回给调用方写日志。 */
212
+ const tried = []
213
+ /** 记一条命中并直接返回成功结果。 */
214
+ const hit = (source, runner, path) => {
215
+ tried.push({ source, path: typeof path === 'string' ? path : null, note: '命中' })
216
+ return { ok: true, runner, source, tried }
217
+ }
218
+ /** 记一条未命中,继续试下一个候选。 */
219
+ const miss = (source, path, note) => {
220
+ tried.push({ source, path: typeof path === 'string' ? path : null, note })
221
+ }
222
+ /** 全部落空。 */
223
+ const fail = () => ({ ok: false, runner: null, source: null, tried })
224
+ /** 文件存在性检查;注入的实现抛错也当成「不存在」,保证本函数永不抛。 */
225
+ const exists = (target) => {
226
+ if (typeof opts.exists === 'function') {
227
+ try { return opts.exists(target) === true } catch (error) { return false }
228
+ }
229
+ try { return existsSync(target) === true } catch (error) { return false }
230
+ }
231
+
232
+ /* 1) 插件自己的覆盖口:完全不检查文件是否存在(用户显式指定的入口) */
233
+ const custom = asText(env.DSH_DESKTOP_STATUSBAR_PNPM, '')
234
+ if (custom.trim().length > 0) {
235
+ const text = custom.trim()
236
+ if (text.startsWith('[') === true) {
237
+ let parsed = null
111
238
  try {
112
- const parsed = JSON.parse(text)
113
- if (Array.isArray(parsed) === true && parsed.length > 0 && parsed.every((one) => typeof one === 'string')) {
114
- return { command: parsed[0], args: parsed.slice(1), electron: false }
239
+ const value = JSON.parse(text)
240
+ if (Array.isArray(value) === true && value.length > 0 && value.every((one) => typeof one === 'string') === true) {
241
+ parsed = value
115
242
  }
116
243
  } catch (error) {
117
- /* 不是合法数组就当单路径处理 */
244
+ /* 不是合法数组,落到下面的单路径处理 */
118
245
  }
246
+ if (parsed !== null) {
247
+ return hit('custom-env', { command: parsed[0], args: parsed.slice(1), electron: false }, custom)
248
+ }
249
+ /* `'[]'` 也走这里:空数组不够当命令用,退化成单路径(保持旧行为) */
250
+ miss('custom-env', custom, '不是合法 JSON 数组,按单路径处理')
119
251
  }
120
- return { command: text, args: [], electron: false }
252
+ return hit('custom-env', { command: text, args: [], electron: false }, custom)
121
253
  }
122
254
 
123
- const execPath = process.execPath
124
- if (typeof execPath === 'string' && execPath.length > 0) {
125
- const resources = join(dirname(execPath), 'resources')
126
- const candidates = [
127
- join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'),
128
- join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'),
129
- ]
130
- for (const candidate of candidates) {
131
- try {
132
- if (existsSync(candidate)) return { command: execPath, args: [candidate], electron: true }
133
- } catch (error) {
134
- /* 试下一个 */
135
- }
255
+ /* 2) 宿主自己的口径:只存在于开发模式,但插件照样优先认它 */
256
+ const hostEntry = asText(env.DSH_DESKTOP_PNPM_ENTRY, '')
257
+ if (hostEntry.trim().length > 0) {
258
+ const entry = hostEntry.trim()
259
+ if (execPath.length === 0) {
260
+ miss('host-env', hostEntry, '没有可用的宿主可执行文件')
261
+ } else if (exists(entry) === true) {
262
+ /* tried.path 记变量原值(契约口径),实际执行用去掉首尾空白的路径 */
263
+ return hit('host-env', { command: execPath, args: [entry], electron }, hostEntry)
264
+ } else {
265
+ miss('host-env', hostEntry, '不存在')
136
266
  }
137
267
  }
138
268
 
139
- return null
269
+ /* 3) 按 resources 目录拼出来的文件候选 */
270
+ const resources = resolveResourcesDir(resourcesPath, execPath)
271
+ if (resources === null) {
272
+ miss('primary-runtime', null, '无法推断 resources 目录')
273
+ return fail()
274
+ }
275
+
276
+ /* 3.1 当前宿主布局:优先用同一套运行时里的 node,不碰 ELECTRON_RUN_AS_NODE */
277
+ const primaryPnpm = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')
278
+ if (exists(primaryPnpm) === true) {
279
+ const primaryNode = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', platform === 'win32' ? 'node.exe' : 'node')
280
+ if (exists(primaryNode) === true) {
281
+ return hit('primary-runtime', { command: primaryNode, args: [primaryPnpm], electron: false }, primaryPnpm)
282
+ }
283
+ if (execPath.length > 0) {
284
+ return hit('primary-runtime', { command: execPath, args: [primaryPnpm], electron }, primaryPnpm)
285
+ }
286
+ miss('primary-runtime', primaryPnpm, '找到 pnpm,但同套运行时里的 node 与宿主可执行文件都不可用')
287
+ } else {
288
+ miss('primary-runtime', primaryPnpm, '不存在')
289
+ }
290
+
291
+ /* 3.2 / 3.3 旧布局:向后兼容,用宿主可执行文件加 ELECTRON_RUN_AS_NODE 去跑 */
292
+ const legacyCandidates = [
293
+ { source: 'runtime-legacy', file: join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') },
294
+ { source: 'asar-unpacked', file: join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') },
295
+ ]
296
+ for (const candidate of legacyCandidates) {
297
+ if (exists(candidate.file) !== true) {
298
+ miss(candidate.source, candidate.file, '不存在')
299
+ continue
300
+ }
301
+ if (execPath.length === 0) {
302
+ miss(candidate.source, candidate.file, '找到 pnpm,但没有可用的宿主可执行文件')
303
+ continue
304
+ }
305
+ return hit(candidate.source, { command: execPath, args: [candidate.file], electron }, candidate.file)
306
+ }
307
+
308
+ return fail()
309
+ }
310
+
311
+ /**
312
+ * 定位 pnpm 可执行文件(向后兼容的薄包装)。
313
+ *
314
+ * 内部走 `resolvePnpmDetailed()`,只关心「最终能不能跑」,不关心试过哪些候选。
315
+ * 现有测试与 `verify-self-update.js --online` 用的都是这个签名。
316
+ *
317
+ * @param {object} [options] 透传给 resolvePnpmDetailed(测试注入用)
318
+ * @returns {{command: string, args: string[], electron: boolean}|null}
319
+ */
320
+ export function resolvePnpm(options) {
321
+ const detail = resolvePnpmDetailed(options)
322
+ return detail.ok === true ? detail.runner : null
140
323
  }
141
324
 
142
325
  /**
@@ -231,9 +414,9 @@ export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0'
231
414
  * - `--save-exact`:清单里写死精确版本,避免 `^` 范围日后漂移。
232
415
  * - 发布年龄归零:见 RELEASE_AGE_OVERRIDE。
233
416
  *
234
- * 不加 `--no-frozen-lockfile`:pnpm 11.7.0 的 `add` 不认这个选项
235
- * (实测报 `Unknown option: 'frozen-lockfile'`)。`add` 本来就会更新锁文件,
236
- * 不需要它。
417
+ * 不加 `--no-frozen-lockfile`:pnpm 11 的 `add` 不认这个选项
418
+ * (实测报 `Unknown option: 'frozen-lockfile'`;宿主当前自带 pnpm 11.28.5,同样如此)。
419
+ * `add` 本来就会更新锁文件,不需要它。
237
420
  *
238
421
  * @param {string[]} base pnpm 自身的参数(例如打包 pnpm 的 .mjs 路径)
239
422
  * @param {string} target 安装目标 `包名@版本`
@@ -257,14 +440,30 @@ export async function runSelfUpdate(options) {
257
440
  刷新页面不算数 —— 插件包元数据(含 client.js 的 revision)在启动时缓存、直到重启才更新
258
441
  (见 @deepseek-ai/dsh-hmr 的已知限制与 dsh-client-modules 的说明)。
259
442
  不用「要不要重启」去猜,直接如实告诉界面。 */
260
- const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true }
443
+ const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true, tried: [] }
261
444
 
262
- const pnpm = resolvePnpm()
263
- if (pnpm === null) {
445
+ /* 注意:必须在找 profile 之前就报 pnpm-not-found —— 找不到 pnpm 时 profile
446
+ 目录是否存在无关紧要,先告诉用户真正的卡点。 */
447
+ const pnpm = resolvePnpmDetailed()
448
+ if (pnpm.ok !== true) {
264
449
  result.reason = 'pnpm-not-found'
450
+ /* 把试过的候选写进结果和日志:issue 里那次排查全靠手工逐条 Test-Path,
451
+ 以后用户/开发者看日志就能定位,不用再猜插件找的是哪条路径。 */
452
+ result.tried = pnpm.tried
453
+ .slice(0, 12)
454
+ .map((one) => {
455
+ const where = one.path === null ? '—' : one.path
456
+ const line = one.source + ': ' + where + '(' + one.note + ')'
457
+ return line.length > 300 ? line.slice(0, 300) + '…' : line
458
+ })
459
+ log('update: pnpm-not-found,尝试过:')
460
+ for (const line of result.tried) log(' - ' + line)
265
461
  return result
266
462
  }
267
463
 
464
+ const runner = pnpm.runner
465
+ log('update: pnpm 来源 ' + pnpm.source + ' → ' + runner.command + ' ' + runner.args.join(' ') + (runner.electron === true ? '(ELECTRON_RUN_AS_NODE)' : ''))
466
+
268
467
  const profileDir = findProfileDir(selfDir)
269
468
  result.profileDir = profileDir
270
469
  if (profileDir === null) {
@@ -285,13 +484,13 @@ export async function runSelfUpdate(options) {
285
484
  /* 精确版本,不用 @latest:后者在发布年龄策略下会静默装成旧的成熟版 */
286
485
  const target = SELF_NAME + '@' + options.version
287
486
  const env = { ...process.env }
288
- if (pnpm.electron === true) env.ELECTRON_RUN_AS_NODE = '1'
487
+ if (runner.electron === true) env.ELECTRON_RUN_AS_NODE = '1'
289
488
  /* CI=true:pnpm 在非交互环境下不会弹提示卡住(与官方插件管理器同一做法) */
290
489
  env.CI = 'true'
291
- const args = updateArgs(pnpm.args, target)
490
+ const args = updateArgs(runner.args, target)
292
491
 
293
492
  log('update: 开始安装 ' + target)
294
- const run = await runCommand(pnpm.command, args, {
493
+ const run = await runCommand(runner.command, args, {
295
494
  cwd: profileDir,
296
495
  env,
297
496
  timeoutMs: options.timeoutMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-desktop-statusbar",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "type": "module",
5
5
  "description": "把 DSH 桌面端对话区底部的统计行换成可配置状态栏:10 个字段可选可排序,按官方峰谷口径估算费用,支持自定义模型单价与账户余额。",
6
6
  "icon": "icon.svg",
package/tools/shot.cjs CHANGED
@@ -236,15 +236,19 @@ function pricingShot(locale) {
236
236
  教训:**下标是脆的**,加 hook 必须回来重数,并用 `node tools/shot.cjs` 实际生成一次确认。 */
237
237
  const presets = [];
238
238
  presets[4] = 'deepseek-flash';
239
- /* 浮层里的数字也要跟着币种走:预设草稿按语言挑人民币或美元那套 */
239
+ /* 浮层里的数字也要跟着币种走:预设草稿按语言挑人民币或美元那套。
240
+ **`currency` 必须一起写**:下拉按钮显示的正是 `draft.currency`(不是 config.currency),
241
+ 漏了它英文截图就会退回默认的 CNY —— 与整页英文界面自相矛盾(2026-10 实际踩过)。 */
240
242
  presets[5] = locale === 'en'
241
243
  ? {
242
244
  tiered: true,
245
+ currency: 'USD',
243
246
  peak: { input: '0.3', cacheRead: '0.006', cacheWrite: '0', output: '1.2' },
244
247
  offPeak: { input: '0.15', cacheRead: '0.003', cacheWrite: '0', output: '0.6' }
245
248
  }
246
249
  : {
247
250
  tiered: true,
251
+ currency: 'CNY',
248
252
  peak: { input: '2', cacheRead: '0.04', cacheWrite: '0', output: '8' },
249
253
  offPeak: { input: '1', cacheRead: '0.02', cacheWrite: '0', output: '4' }
250
254
  };