dsh-workbuddy-xdpool 1.7.3 → 1.7.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 +104 -0
- package/lib/bin.js +176 -29
- package/lib/client.js +165 -12
- package/lib/index.js +150 -28
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,110 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.7.5 — 模型列表会告诉你「现在用哪个不花钱」
|
|
8
|
+
|
|
9
|
+
> Hy4 preview 晚上 23 点到早上 8 点免费,Hy3 一直在免费期 —— 现在卡片上直接看得出来。
|
|
10
|
+
|
|
11
|
+
**你会看到**:模型列表里,**当前免费的排在最上面**并带标记:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
[免费] hy3 x0.00
|
|
15
|
+
auto
|
|
16
|
+
(23:00 起免费 · 活动至 10-31) hy4-preview x0.29
|
|
17
|
+
hy3-x x0.05
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
到了晚上 23 点,同一行会变成:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
[免费至 08:00 · 活动至 10-31] hy4-preview x0.29
|
|
24
|
+
[免费] hy3 x0.00
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**为什么需要专门做**:因为**上游接口不说这件事**。我拉了真实的国内版接口逐字段核对:
|
|
28
|
+
|
|
29
|
+
| 信息 | 上游提供吗 |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Hy3 限免 | ✅ 提供 —— `credits: "x0.00"`,倍率本身就是 0,我们一直在正确显示 |
|
|
32
|
+
| Hy4 preview **夜间**限免 | ❌ **不提供** —— 倍率恒为 `x0.29`,**没有任何时段字段** |
|
|
33
|
+
| Hy4 preview **新用户 14 天**额度 | ❌ **不提供** —— 积分包里也没有这类条目 |
|
|
34
|
+
|
|
35
|
+
所以夜间限免这条规则,只能由插件自己带。我把它写成一张**规则表**(`src/promo.ts`),包含「哪个平台、哪些 id 前缀、哪些小时、有效期到哪天」。
|
|
36
|
+
|
|
37
|
+
**几个刻意的设计**:
|
|
38
|
+
|
|
39
|
+
- **规则区分平台**。这是**国内版活动**,所以规则里带 `region: 'cn'`,**国际版完全不受影响** —— 国际版的 `hy4-preview-f` 显示「免费」是因为**上游倍率真的是 0**(客观事实),不是我们套了活动规则;
|
|
40
|
+
- **免费状态是「此刻」的,不是模型固有属性**。所以同一天里 `hy4-preview` 的标记会变 —— 这才是真实情况;
|
|
41
|
+
- **窗口外只给一个低调的虚线提示**(`23:00 起免费`),**不给「免费」徽章**。因为它此刻**确实要花积分**,标成免费会让你花错钱;
|
|
42
|
+
- **只有「此刻免费」的才置顶**。窗口外的模型不置顶,否则就是把一个要花钱的模型举到最前面,误导性更强;
|
|
43
|
+
- **标出活动截止日**(`活动至 10-31`)。方便你安排什么时候用 —— 而且活动一旦延期,改日期就能看到,不用翻更新日志;
|
|
44
|
+
- **规则带过期日期**,到 10 月 31 日之后**自动失效**,不需要有人记得来删代码;
|
|
45
|
+
- **`hy4-preview` 用前缀匹配**,因为上游有 `hy4-preview` 和 `hy4-preview-x` 两个 id,是同一款产品。
|
|
46
|
+
|
|
47
|
+
### 关于「新用户 14 天免费额度」——明确没做
|
|
48
|
+
|
|
49
|
+
你提到「新用户没用过 Hy4 preview,还能免费 14 天,要区分出来」。**这个我做不到,而且硬做会更糟**:
|
|
50
|
+
|
|
51
|
+
上游**完全不记录**「这个账户有没有用过 Hy4 preview」—— 模型目录里没有、积分包里也没有(我实测过,44 个积分包里没有一个试用额度条目)。
|
|
52
|
+
|
|
53
|
+
唯一能猜的办法是「插件自己记住第一次调用的时间」。但这有个致命问题:**它只知道「用本插件之后」的历史**。你之前要是在官方客户端用过 Hy4 preview,插件照样会以为你是新用户,然后给你标一个**假的「免费」** —— 而你会因此放心去用,结果**扣了积分**。
|
|
54
|
+
|
|
55
|
+
**给你一个假的「免费」,比不给你标记更糟**,因为它直接改变你的花钱决定。所以这条留白,不做推测。
|
|
56
|
+
|
|
57
|
+
如果你确实需要,可行的做法是**你自己确认**:卡片上显示的是客观事实(倍率 + 夜间窗口),你清楚自己是不是新用户,看一眼就知道该不该用。
|
|
58
|
+
|
|
59
|
+
### 验证
|
|
60
|
+
|
|
61
|
+
- 类型检查全绿,测试 **297 项全绿**(+19)。
|
|
62
|
+
- 新增 `tests/promo.test.ts`:23:00–08:00 窗口内/外的判定、跨午夜(23 点在窗口内、8 点不在)、窗口关闭时间(`免费至 08:00`)、下次开启时间(`23:00 起免费`)、同族 id(`hy4-preview` 与 `hy4-preview-x`)、零倍率模型全天免费且不误入夜间规则、**到期自动失效**(10/31 有效、11/1 失效)、**免费置顶只认「此刻」免费**、**活动截止日随状态带出**、**国际版不受活动影响**(但零倍率模型在两边都算免费)。
|
|
63
|
+
- **反向验证**:清空夜间小时 → 7 项变红;删掉过期日期 → 2 项变红;**移除平台过滤 → 「国际版不受影响」变红**。确认测试真的能抓住这三类缺陷。
|
|
64
|
+
- 用**两个平台的真实清单**(国内版 16 个 / 国际版 24 个)跑卡片渲染逻辑,在 12:00 与 23:30 核对:国内版 `hy4-preview` 正确显示活动徽章与截止日,**国际版完全不受活动规则影响**。
|
|
65
|
+
|
|
66
|
+
## 1.7.4 — 装在中文文件夹里的 WorkBuddy,终于找得到了
|
|
67
|
+
|
|
68
|
+
> 「腾讯」两个字,让插件整整看不见一个装好、登录着、正在运行的 WorkBuddy。
|
|
69
|
+
|
|
70
|
+
**你会看到**:插件卡片报「找不到 WorkBuddy 桌面端,无法取得密钥」,可你明明:程序装着、还开着、凭据文件就在那儿躺着。中文 Windows 上,只要**安装路径里带中文**(比如 `D:\ruanjian\anzhuang\腾讯\WorkBuddy\`)就会中招——国内用户把软件装进中文目录太常见了。
|
|
71
|
+
|
|
72
|
+
**为什么**:插件去注册表里问「WorkBuddy 装哪儿了」,用的是 `reg.exe`,而 `reg.exe` 按**系统控制台代码页**输出——中文系统就是 GBK(936)。我们这边却当成 UTF-8 去读。于是:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
实际路径:D:\ruanjian\anzhuang\腾讯\WorkBuddy\WorkBuddy.exe
|
|
76
|
+
读出来: D:\ruanjian\anzhuang\��Ѷ\WorkBuddy\WorkBuddy.exe ← 存的什么鬼
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`腾讯` 变成 `��Ѷ`,这个路径当然**不存在**。所有候选路径全部落空,插件就得出「没装」的结论。可笑的是它其实**探到了**注册表记录,只是**读成了乱码**。
|
|
80
|
+
|
|
81
|
+
这个坑和之前那个「装到非系统盘」是两回事——那个是找不到记录,这个是找到了但读不对。
|
|
82
|
+
|
|
83
|
+
**怎么修的**:
|
|
84
|
+
|
|
85
|
+
- **改走 PowerShell 读注册表**(主路径)。`Get-ItemProperty` 直接调注册表 API,**根本不过控制台代码页那一关**,中文路径原样拿到;
|
|
86
|
+
- **保留 `reg.exe` 作后备**(万一机器上 PowerShell 不可用),但**不再假定 UTF-8**:按字节读进来,逐个尝试各种编码,并且**用「这个路径是否真的存在」来判定哪个解码是对的**——这是唯一能区分「正确字节」和「看着像那么回事的乱码」的办法。
|
|
87
|
+
|
|
88
|
+
**顺带修了报告里提到的两点**:
|
|
89
|
+
|
|
90
|
+
- `DisplayIcon` 和 `InstallLocation` **两个字段都读**(不少安装包的 `InstallLocation` 是空的,只有 `DisplayIcon` 有值),并且清理 `",0` 后缀的逻辑保持不变;
|
|
91
|
+
- `doctor` **现在会把探测到的每一条路径原样列出来**,前面标 `✓`/`·` 表示存不存在。以前它压根不显示这些路径——而这恰恰是排查「装了却找不到」最该看的东西。**乱码路径现在一眼就能看见**,不用再靠猜。
|
|
92
|
+
|
|
93
|
+
### 验证
|
|
94
|
+
|
|
95
|
+
- 类型检查全绿,测试 **278 项全绿**(+2)。
|
|
96
|
+
- 新增 `tests/windows-registry-encoding.test.ts`:在本机**真实创建**一个含中文的目录(`D:\...\腾讯\WorkBuddy\WorkBuddy.exe`)并写进真实注册表,然后跑插件的真实探测逻辑,断言**拿到的路径与真实路径完全一致、且文件确实存在**。
|
|
97
|
+
- **反向验证**:把修复撤掉、退回「`reg.exe` + UTF-8」,含中文的路径**读不出来**(0 条),测试变红——确认这个测试真的能抓住这个 bug。
|
|
98
|
+
- 实测 `doctor` 输出:`✓ D:\DSH\_doc_check\腾讯\WorkBuddy\WorkBuddy.exe`,中文完整、存在性正确。
|
|
99
|
+
|
|
100
|
+
### 给中招的人(在你升级之前,或者万一还有别的编码)
|
|
101
|
+
|
|
102
|
+
临时指路,设完**完全退出 DSH 再启动**:
|
|
103
|
+
|
|
104
|
+
```powershell
|
|
105
|
+
[Environment]::SetEnvironmentVariable("WORKBUDDY_APP_EXECUTABLE",
|
|
106
|
+
"D:\ruanjian\anzhuang\腾讯\WorkBuddy\WorkBuddy.exe", "User")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
写进 `$DSH_HOME/.env` 也一样有效(不用注销,更省事)。
|
|
110
|
+
|
|
7
111
|
## 1.7.3 — 模型不会因为一次网络抖动就消失
|
|
8
112
|
|
|
9
113
|
> 开机那一秒网络还没起来,插件就把模型列表「降级」了,然后一整天都不再试第二次。
|
package/lib/bin.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
3
|
import { copyFile, mkdir, readFile, readdir, rename, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
3
4
|
import { homedir, platform } from "node:os";
|
|
4
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
6
|
import { execFile, execFileSync } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
8
8
|
import "@earendil-works/pi-ai";
|
|
9
9
|
import "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
@@ -1890,42 +1890,155 @@ function macosBundleExecutable(bundle) {
|
|
|
1890
1890
|
return join(bundle, "Contents", "MacOS", name);
|
|
1891
1891
|
}
|
|
1892
1892
|
/**
|
|
1893
|
+
* Encodings a Windows console child process may emit in, most likely first.
|
|
1894
|
+
*
|
|
1895
|
+
* `reg.exe` writes with the ACTIVE CODE PAGE, never UTF-8: a Chinese Windows
|
|
1896
|
+
* (code page 936) returns a path containing 「腾讯」 as GBK bytes. Decoding
|
|
1897
|
+
* those as UTF-8 mangles them into `��Ѷ`, every `existsSync` then fails, and the
|
|
1898
|
+
* plugin concludes no desktop app is installed while one is running fine.
|
|
1899
|
+
*
|
|
1900
|
+
* The list is ordered and each entry is TRIED rather than guessed: the first
|
|
1901
|
+
* decoding that yields usable text wins, and the decision is validated against
|
|
1902
|
+
* the filesystem rather than by sniffing bytes.
|
|
1903
|
+
*/
|
|
1904
|
+
const WINDOWS_CONSOLE_ENCODINGS = [
|
|
1905
|
+
"utf-8",
|
|
1906
|
+
"gbk",
|
|
1907
|
+
"big5",
|
|
1908
|
+
"shift_jis",
|
|
1909
|
+
"euc-kr",
|
|
1910
|
+
"windows-1252"
|
|
1911
|
+
];
|
|
1912
|
+
/**
|
|
1913
|
+
* Decode bytes from a Windows console child process.
|
|
1914
|
+
*
|
|
1915
|
+
* `accept` decides whether a decoding is usable — the callers pass "this text
|
|
1916
|
+
* contains a path that actually exists". Bytes that are already UTF-8 pass on
|
|
1917
|
+
* the first attempt, so the healthy case pays nothing.
|
|
1918
|
+
*/
|
|
1919
|
+
function decodeWindowsConsole(buf, accept) {
|
|
1920
|
+
for (const label of WINDOWS_CONSOLE_ENCODINGS) {
|
|
1921
|
+
let text;
|
|
1922
|
+
try {
|
|
1923
|
+
text = new TextDecoder(label, { fatal: false }).decode(buf);
|
|
1924
|
+
} catch {
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
if (accept(text)) return text;
|
|
1928
|
+
}
|
|
1929
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(buf);
|
|
1930
|
+
}
|
|
1931
|
+
/** Strip the `"<path>",<index>` decoration DisplayIcon carries. */
|
|
1932
|
+
function cleanIconPath(raw) {
|
|
1933
|
+
return raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
|
|
1934
|
+
}
|
|
1935
|
+
/** Registry locations the installer records its own product under. */
|
|
1936
|
+
const UNINSTALL_KEY_ROOTS = [
|
|
1937
|
+
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
|
1938
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
|
1939
|
+
"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
1940
|
+
];
|
|
1941
|
+
/**
|
|
1893
1942
|
* Windows install locations recorded by the app's own uninstaller.
|
|
1894
1943
|
*
|
|
1895
1944
|
* The registry is the authoritative answer: it survives a non-default drive, a
|
|
1896
1945
|
* renamed folder and a differently-named executable, none of which any fixed
|
|
1897
1946
|
* path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
|
|
1898
|
-
* and `D:\
|
|
1899
|
-
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see
|
|
1900
|
-
* plugin reported "the desktop app could not provide the key" for an app that was
|
|
1901
|
-
* installed and running.
|
|
1947
|
+
* and `D:\ruanjian\anzhuang\腾讯\WorkBuddy\WorkBuddy.exe` — layouts a
|
|
1948
|
+
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see.
|
|
1902
1949
|
*
|
|
1903
1950
|
* `DisplayIcon` is the field that actually carries the path (observed as
|
|
1904
1951
|
* `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
|
|
1905
1952
|
* installers, so both are read and either may contribute.
|
|
1906
1953
|
*
|
|
1954
|
+
* Two readers, because neither alone is enough:
|
|
1955
|
+
*
|
|
1956
|
+
* - **PowerShell first.** `Get-ItemProperty` talks to the registry API, so no
|
|
1957
|
+
* console code page is involved and non-ASCII paths come back intact. This
|
|
1958
|
+
* is the encoding-proof path.
|
|
1959
|
+
* - **`reg.exe` as a fallback**, for a machine where PowerShell is unavailable
|
|
1960
|
+
* (locked-down or stripped images). Its raw bytes are decoded through
|
|
1961
|
+
* {@link decodeWindowsConsole}, which is what the original code got wrong.
|
|
1962
|
+
*
|
|
1907
1963
|
* Returns [] on any failure — a missing registry key is the normal case on
|
|
1908
1964
|
* non-Windows, not an error.
|
|
1909
1965
|
*/
|
|
1910
1966
|
function windowsRegistryAppPaths() {
|
|
1911
1967
|
if (process.platform !== "win32") return [];
|
|
1912
|
-
const
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1968
|
+
const viaPowerShell = windowsRegistryAppPathsViaPowerShell();
|
|
1969
|
+
if (viaPowerShell.length > 0) return viaPowerShell;
|
|
1970
|
+
return windowsRegistryAppPathsViaReg();
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Registry read through PowerShell, immune to the console code page.
|
|
1974
|
+
*
|
|
1975
|
+
* `[Console]::OutputEncoding` is set to UTF-8 so the bytes Node receives are
|
|
1976
|
+
* UTF-8 regardless of the machine's code page. Values are printed one per line
|
|
1977
|
+
* and matched by the caller, so a Chinese path survives the round trip.
|
|
1978
|
+
*
|
|
1979
|
+
* Best-effort: any failure (no PowerShell, no keys, a policy restriction)
|
|
1980
|
+
* returns [], and the `reg.exe` reader takes over.
|
|
1981
|
+
*/
|
|
1982
|
+
function windowsRegistryAppPathsViaPowerShell() {
|
|
1983
|
+
const script = [
|
|
1984
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
1985
|
+
"try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}",
|
|
1986
|
+
"$roots=@('HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')",
|
|
1987
|
+
"foreach($r in $roots){",
|
|
1988
|
+
" Get-ItemProperty -Path $r | ForEach-Object {",
|
|
1989
|
+
" $dn=[string]$_.DisplayName; $di=[string]$_.DisplayIcon; $il=[string]$_.InstallLocation",
|
|
1990
|
+
" if($dn -match 'WorkBuddy|CodeBuddy' -or $di -match 'WorkBuddy|CodeBuddy'){",
|
|
1991
|
+
" if($di -ne ''){ Write-Output $di }",
|
|
1992
|
+
" if($il -ne ''){ Write-Output $il }",
|
|
1993
|
+
" }",
|
|
1994
|
+
" }",
|
|
1995
|
+
"}"
|
|
1996
|
+
].join("\n");
|
|
1997
|
+
let stdout;
|
|
1998
|
+
try {
|
|
1999
|
+
stdout = execFileSync("powershell", [
|
|
2000
|
+
"-NoProfile",
|
|
2001
|
+
"-NonInteractive",
|
|
2002
|
+
"-ExecutionPolicy",
|
|
2003
|
+
"Bypass",
|
|
2004
|
+
"-Command",
|
|
2005
|
+
script
|
|
2006
|
+
], {
|
|
2007
|
+
encoding: "utf8",
|
|
2008
|
+
timeout: 2e4,
|
|
2009
|
+
windowsHide: true,
|
|
2010
|
+
maxBuffer: 8388608
|
|
2011
|
+
});
|
|
2012
|
+
} catch {
|
|
2013
|
+
return [];
|
|
2014
|
+
}
|
|
1917
2015
|
const out = [];
|
|
1918
|
-
for (const
|
|
1919
|
-
|
|
2016
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
2017
|
+
const cleaned = cleanIconPath(line.trim());
|
|
2018
|
+
if (cleaned !== "") out.push(cleaned);
|
|
2019
|
+
}
|
|
2020
|
+
return out;
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Registry read through `reg.exe`, with console-code-page detection.
|
|
2024
|
+
*
|
|
2025
|
+
* Kept as the fallback for machines without PowerShell. The decode step is the
|
|
2026
|
+
* fix: the output is captured as BYTES and decoded with whichever encoding
|
|
2027
|
+
* yields a path that exists, instead of assuming UTF-8 and quietly producing
|
|
2028
|
+
* mojibake that no existence check can ever match.
|
|
2029
|
+
*/
|
|
2030
|
+
function windowsRegistryAppPathsViaReg() {
|
|
2031
|
+
const out = [];
|
|
2032
|
+
for (const root of UNINSTALL_KEY_ROOTS) {
|
|
2033
|
+
let listingBuf;
|
|
1920
2034
|
try {
|
|
1921
|
-
|
|
2035
|
+
listingBuf = execFileSync("reg", [
|
|
1922
2036
|
"query",
|
|
1923
2037
|
root,
|
|
1924
2038
|
"/s",
|
|
1925
2039
|
"/v",
|
|
1926
2040
|
"DisplayName"
|
|
1927
2041
|
], {
|
|
1928
|
-
encoding: "utf8",
|
|
1929
2042
|
timeout: 1e4,
|
|
1930
2043
|
windowsHide: true,
|
|
1931
2044
|
maxBuffer: 8388608
|
|
@@ -1933,26 +2046,35 @@ function windowsRegistryAppPaths() {
|
|
|
1933
2046
|
} catch {
|
|
1934
2047
|
continue;
|
|
1935
2048
|
}
|
|
1936
|
-
const keys =
|
|
2049
|
+
const keys = decodeWindowsConsole(listingBuf, (text) => /WorkBuddy|CodeBuddy/iu.test(text)).split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
|
|
1937
2050
|
for (const key of keys) {
|
|
1938
2051
|
const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
|
|
1939
2052
|
if (keyPath === void 0) continue;
|
|
1940
|
-
for (const name of ["DisplayIcon", "InstallLocation"])
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2053
|
+
for (const name of ["DisplayIcon", "InstallLocation"]) {
|
|
2054
|
+
let valueBuf;
|
|
2055
|
+
try {
|
|
2056
|
+
valueBuf = execFileSync("reg", [
|
|
2057
|
+
"query",
|
|
2058
|
+
keyPath,
|
|
2059
|
+
"/v",
|
|
2060
|
+
name
|
|
2061
|
+
], {
|
|
2062
|
+
timeout: 5e3,
|
|
2063
|
+
windowsHide: true
|
|
2064
|
+
});
|
|
2065
|
+
} catch {
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
const value = decodeWindowsConsole(valueBuf, (text) => {
|
|
2069
|
+
const candidate = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(text)?.[1]?.trim();
|
|
2070
|
+
if (candidate === void 0) return false;
|
|
2071
|
+
const path = cleanIconPath(candidate);
|
|
2072
|
+
return path !== "" && existsSync(path);
|
|
1950
2073
|
});
|
|
1951
2074
|
const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
|
|
1952
2075
|
if (raw === void 0 || raw === "") continue;
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
} catch {}
|
|
2076
|
+
out.push(cleanIconPath(raw));
|
|
2077
|
+
}
|
|
1956
2078
|
}
|
|
1957
2079
|
}
|
|
1958
2080
|
return out;
|
|
@@ -2221,7 +2343,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
|
2221
2343
|
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2222
2344
|
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2223
2345
|
constructor(sourcePath) {
|
|
2224
|
-
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed.`);
|
|
2346
|
+
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed — if one of them looks like mojibake (e.g. "??" where a Chinese folder name should be), the registry value could not be decoded on this machine; report that line and use the override above in the meantime.`);
|
|
2225
2347
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2226
2348
|
}
|
|
2227
2349
|
};
|
|
@@ -5111,6 +5233,31 @@ async function commandDoctor() {
|
|
|
5111
5233
|
imported = (await readdir2(accountDir())).filter((name) => name.endsWith(".json"));
|
|
5112
5234
|
} catch {}
|
|
5113
5235
|
lines.push(imported.length === 0 ? " (none)" : imported.map((name) => ` ✓ ${name}`).join("\n"));
|
|
5236
|
+
lines.push("");
|
|
5237
|
+
lines.push("WorkBuddy desktop app:");
|
|
5238
|
+
const override = process.env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim();
|
|
5239
|
+
if (override !== void 0 && override !== "") lines.push(` env ${WORKBUDDY_APP_EXECUTABLE_ENV} = ${override}`);
|
|
5240
|
+
let candidates = [];
|
|
5241
|
+
try {
|
|
5242
|
+
candidates = workbuddyAppExecutableCandidates();
|
|
5243
|
+
} catch (error) {
|
|
5244
|
+
lines.push(` ✗ candidate probe threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
5245
|
+
}
|
|
5246
|
+
let firstExisting;
|
|
5247
|
+
for (const candidate of candidates) {
|
|
5248
|
+
const exists = existsSync(candidate);
|
|
5249
|
+
if (exists && firstExisting === void 0) firstExisting = candidate;
|
|
5250
|
+
lines.push(` ${exists ? "✓" : "·"} ${candidate}`);
|
|
5251
|
+
}
|
|
5252
|
+
lines.push(` probed ${candidates.length} candidate path(s)`);
|
|
5253
|
+
if (firstExisting === void 0) {
|
|
5254
|
+
healthy = false;
|
|
5255
|
+
lines.push(" ✗ no WorkBuddy desktop executable found at any probed path.");
|
|
5256
|
+
lines.push(" If the app IS installed, set WORKBUDDY_APP_EXECUTABLE to its full .exe path");
|
|
5257
|
+
lines.push(" (or put that line in $DSH_HOME/.env) and restart DSH. A path shown above that");
|
|
5258
|
+
lines.push(" looks like mojibake (e.g. \"???\" for a Chinese folder) indicates the registry");
|
|
5259
|
+
lines.push(" value could not be decoded on this machine — please report it with the line.");
|
|
5260
|
+
} else lines.push(` ✓ using ${firstExisting}`);
|
|
5114
5261
|
console.log(lines.join("\n"));
|
|
5115
5262
|
return healthy ? 0 : 1;
|
|
5116
5263
|
}
|
package/lib/client.js
CHANGED
|
@@ -273,6 +273,12 @@ window.__ModuleLoader__.load({
|
|
|
273
273
|
.dsm-workbuddy-xdpool-model-id{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
274
274
|
.dsm-workbuddy-xdpool-model-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
|
|
275
275
|
.dsm-workbuddy-xdpool-model-meta-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:16px;background:rgba(174,179,187,.11);color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
276
|
+
/* "free from HH:00": quieter than the badge on purpose — the model costs
|
|
277
|
+
credits right now, so it must not read as a promo the user can spend against. */
|
|
278
|
+
.dsm-workbuddy-xdpool-model-meta-later{padding:1px 8px;border-radius:999px;font-size:11px;line-height:16px;border:1px dashed color-mix(in oklab, var(--dsw-alias-label-dimmed,#9aa0a8) 55%, transparent);color:var(--dsw-alias-label-tertiary,#9aa0a8)}
|
|
279
|
+
/* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
|
|
280
|
+
not a claim about the current price. */
|
|
281
|
+
.dsm-workbuddy-xdpool-model-meta-promo{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85}
|
|
276
282
|
.dsm-workbuddy-xdpool-model-cap{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
|
|
277
283
|
/* Model row: checkbox + image toggle + context-budget radios. */
|
|
278
284
|
.dsm-workbuddy-xdpool-model-off{opacity:.55}
|
|
@@ -366,6 +372,104 @@ window.__ModuleLoader__.load({
|
|
|
366
372
|
.dsm-workbuddy-xdpool-reserve-note-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
367
373
|
.dsm-workbuddy-xdpool-reserve-note-bad{color:var(--dsw-alias-state-error-primary,#ef4444);font-weight:600}
|
|
368
374
|
`.trim();
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/promo.ts
|
|
377
|
+
/**
|
|
378
|
+
* Promotions in force.
|
|
379
|
+
*
|
|
380
|
+
* Source: the WorkBuddy announcement extending Hy3's free tier and
|
|
381
|
+
* Hy4-preview's night window to **2026-10-31**. Hy3 needs no entry here (its
|
|
382
|
+
* CN price is already zero upstream); only Hy4-preview's time window does,
|
|
383
|
+
* because the gateway charges for it around the clock.
|
|
384
|
+
*
|
|
385
|
+
* When a promotion is extended or a new one starts, edit this table. When one
|
|
386
|
+
* ends, either delete the row or let `until` lapse — both stop the badge.
|
|
387
|
+
*/
|
|
388
|
+
const PROMO_RULES = [{
|
|
389
|
+
idPrefix: "hy4-preview",
|
|
390
|
+
region: "cn",
|
|
391
|
+
hours: [
|
|
392
|
+
23,
|
|
393
|
+
0,
|
|
394
|
+
1,
|
|
395
|
+
2,
|
|
396
|
+
3,
|
|
397
|
+
4,
|
|
398
|
+
5,
|
|
399
|
+
6,
|
|
400
|
+
7
|
|
401
|
+
],
|
|
402
|
+
until: "2026-10-31",
|
|
403
|
+
label: "night"
|
|
404
|
+
}];
|
|
405
|
+
/** `YYYY-MM-DD` for a Date in LOCAL time (the promotions are announced locally). */
|
|
406
|
+
function localDayKey(date) {
|
|
407
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
408
|
+
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
|
|
409
|
+
}
|
|
410
|
+
/** Whether a rule is still in force on `date`. */
|
|
411
|
+
function ruleActive(rule, date) {
|
|
412
|
+
return localDayKey(date) <= rule.until;
|
|
413
|
+
}
|
|
414
|
+
/** The first hour the window reopens, searching forward from `hour`. */
|
|
415
|
+
function nextWindowHour(rule, hour) {
|
|
416
|
+
for (let step = 1; step <= 24; step += 1) {
|
|
417
|
+
const candidate = (hour + step) % 24;
|
|
418
|
+
if (rule.hours.includes(candidate)) return candidate;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** The hour the CURRENT window closes, searching forward from `hour`. */
|
|
422
|
+
function windowEndHour(rule, hour) {
|
|
423
|
+
for (let step = 1; step <= 24; step += 1) {
|
|
424
|
+
const candidate = (hour + step) % 24;
|
|
425
|
+
if (!rule.hours.includes(candidate)) return candidate;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The promotion status of one model at `now`, for one region.
|
|
430
|
+
*
|
|
431
|
+
* `now` is injected so the behaviour is testable without freezing the clock —
|
|
432
|
+
* a time-dependent badge that can only be tested by waiting is a badge nobody
|
|
433
|
+
* tests. `region` is required for the same reason the rules carry one: these
|
|
434
|
+
* campaigns are regional, and applying a domestic rule to the international
|
|
435
|
+
* roster would invent a discount that gateway does not offer.
|
|
436
|
+
*/
|
|
437
|
+
function promoStatusFor(model, now = /* @__PURE__ */ new Date(), region = "cn") {
|
|
438
|
+
if (model.multiplier === 0) return { kind: "free" };
|
|
439
|
+
for (const rule of PROMO_RULES) {
|
|
440
|
+
if (rule.region !== region) continue;
|
|
441
|
+
if (!model.id.startsWith(rule.idPrefix)) continue;
|
|
442
|
+
if (!ruleActive(rule, now)) continue;
|
|
443
|
+
const hour = now.getHours();
|
|
444
|
+
if (rule.hours.includes(hour)) {
|
|
445
|
+
const untilHour = windowEndHour(rule, hour);
|
|
446
|
+
return {
|
|
447
|
+
kind: "night",
|
|
448
|
+
label: rule.label,
|
|
449
|
+
untilHour: untilHour ?? hour,
|
|
450
|
+
promoUntil: rule.until
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const nextHour = nextWindowHour(rule, hour);
|
|
454
|
+
if (nextHour !== void 0) return {
|
|
455
|
+
kind: "night-later",
|
|
456
|
+
label: rule.label,
|
|
457
|
+
nextHour,
|
|
458
|
+
promoUntil: rule.until
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Whether a model is free RIGHT NOW, for sorting.
|
|
464
|
+
*
|
|
465
|
+
* Only the two "currently free" kinds count. `night-later` deliberately does
|
|
466
|
+
* NOT sort to the top: it costs credits at this moment, and floating a
|
|
467
|
+
* credit-priced model above cheaper ones would misrepresent the list.
|
|
468
|
+
*/
|
|
469
|
+
function isFreeNow(model, now = /* @__PURE__ */ new Date(), region = "cn") {
|
|
470
|
+
const status = promoStatusFor(model, now, region);
|
|
471
|
+
return status?.kind === "free" || status?.kind === "night";
|
|
472
|
+
}
|
|
369
473
|
//#endregion
|
|
370
474
|
//#region src/client/PoolCard.tsx
|
|
371
475
|
/**
|
|
@@ -546,17 +650,23 @@ window.__ModuleLoader__.load({
|
|
|
546
650
|
/**
|
|
547
651
|
* The promo badge for one model, or undefined when it has none.
|
|
548
652
|
*
|
|
549
|
-
*
|
|
550
|
-
* sends a literal `free` tag: the global roster marks its zero-cost models
|
|
551
|
-
* (`hy3`, `hy4-preview-f`, `deepseek-v4.1-flash`) as `credits: "x0.00"`, so
|
|
552
|
-
* waiting for a tag meant the badge NEVER appeared — including on models that
|
|
553
|
-
* genuinely cost nothing.
|
|
653
|
+
* Two sources, because the gateway models one of them and not the other:
|
|
554
654
|
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
655
|
+
* - `free` comes from the CREDIT MULTIPLIER. Neither gateway ever sends a
|
|
656
|
+
* literal `free` tag, but it does price its free models at `credits: "x0.00"`
|
|
657
|
+
* (CN `hy3`), so the multiplier is the honest signal.
|
|
658
|
+
* - the NIGHT window comes from {@link promoStatusFor}, because the gateway
|
|
659
|
+
* charges `x0.29` for `hy4-preview` around the clock and has no field for a
|
|
660
|
+
* time-of-day rule. See that module for why the 14-day newcomer allowance is
|
|
661
|
+
* deliberately not modelled.
|
|
662
|
+
*
|
|
663
|
+
* `region` is threaded through because the campaigns are REGIONAL: the
|
|
664
|
+
* Hy3 / Hy4-preview extension is a domestic promotion, so a global roster must
|
|
665
|
+
* not pick up a discount that gateway does not offer.
|
|
557
666
|
*/
|
|
558
|
-
function tagFor(model) {
|
|
667
|
+
function tagFor(model, now, region) {
|
|
559
668
|
const tags = model.tags ?? [];
|
|
669
|
+
if (promoStatusFor(model, now, region)?.kind === "night") return "night";
|
|
560
670
|
if (tags.includes("free") || model.multiplier === 0) return "free";
|
|
561
671
|
if (tags.includes("limited-free")) return "limited";
|
|
562
672
|
if (tags.includes("night-discount")) return "night";
|
|
@@ -837,6 +947,17 @@ window.__ModuleLoader__.load({
|
|
|
837
947
|
const modelsEditable = settingsWritable;
|
|
838
948
|
const modelsDirty = draft !== void 0 && status !== void 0 && draftIsDirty(status, draft);
|
|
839
949
|
const enabledCount = Object.values(modelDraft).filter((entry) => entry.enabled).length;
|
|
950
|
+
/**
|
|
951
|
+
* One clock reading per render, shared by the sort and every row.
|
|
952
|
+
*
|
|
953
|
+
* Taken once so the list cannot disagree with itself: calling `new Date()`
|
|
954
|
+
* per row would let a model be "free" for the badge and "not free" for the
|
|
955
|
+
* sort if the window boundary happened to fall between two rows.
|
|
956
|
+
*
|
|
957
|
+
* A promotion boundary is not a re-render trigger — the card refreshes on its
|
|
958
|
+
* own poll, which is frequent enough for a badge that changes twice a day.
|
|
959
|
+
*/
|
|
960
|
+
const now = /* @__PURE__ */ new Date();
|
|
840
961
|
const toggleModel = (id) => {
|
|
841
962
|
if (status === void 0) return;
|
|
842
963
|
const base = draft ?? draftFromStatus(status);
|
|
@@ -1510,8 +1631,9 @@ window.__ModuleLoader__.load({
|
|
|
1510
1631
|
})]
|
|
1511
1632
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1512
1633
|
className: "dsm-workbuddy-xdpool-model-list",
|
|
1513
|
-
children: status?.models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
|
|
1634
|
+
children: [...status?.models ?? []].sort((a, b) => Number(isFreeNow(b, now, activeRegion)) - Number(isFreeNow(a, now, activeRegion))).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
|
|
1514
1635
|
model,
|
|
1636
|
+
region: activeRegion,
|
|
1515
1637
|
t,
|
|
1516
1638
|
draft: modelDraft[model.id] ?? {
|
|
1517
1639
|
enabled: model.enabled,
|
|
@@ -1865,9 +1987,26 @@ window.__ModuleLoader__.load({
|
|
|
1865
1987
|
* context radios stay disabled rather than pretending an edit took hold. The
|
|
1866
1988
|
* draft lives in the parent, so this component only ever reports intent.
|
|
1867
1989
|
*/
|
|
1868
|
-
function ModelRow({ model, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
1869
|
-
const
|
|
1870
|
-
const
|
|
1990
|
+
function ModelRow({ model, region, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
1991
|
+
const now = /* @__PURE__ */ new Date();
|
|
1992
|
+
const tag = tagFor(model, now, region);
|
|
1993
|
+
const promo = promoStatusFor(model, now, region);
|
|
1994
|
+
const tagText = tag === "free" ? t?.("row.free") ?? "free" : tag === "limited" ? t?.("row.limitedFree") ?? "limited free" : tag === "night" ? t?.("row.nightFreeNow", { time: `${String(promo?.kind === "night" ? promo.untilHour : 0).padStart(2, "0")}:00` }) ?? "free until 08:00" : null;
|
|
1995
|
+
/**
|
|
1996
|
+
* The "cheaper later" hint: the model is on a promotion but the window is
|
|
1997
|
+
* closed. Deliberately NOT a "free" badge — it costs credits at this moment,
|
|
1998
|
+
* and telling the user otherwise would change what they spend.
|
|
1999
|
+
*/
|
|
2000
|
+
const laterHint = promo?.kind === "night-later" ? t?.("row.nightFreeLater", { time: `${String(promo.nextHour).padStart(2, "0")}:00` }) ?? `free from ${String(promo.nextHour).padStart(2, "0")}:00` : null;
|
|
2001
|
+
/**
|
|
2002
|
+
* How long the campaign itself runs, e.g. "活动至 10-31".
|
|
2003
|
+
*
|
|
2004
|
+
* Shown on both the free and the not-yet-free row, because the useful question
|
|
2005
|
+
* is not only "is it free now" but "until when is this offer good at all" —
|
|
2006
|
+
* an extension changes that date, and a user planning around the promotion
|
|
2007
|
+
* needs it visible rather than buried in a changelog.
|
|
2008
|
+
*/
|
|
2009
|
+
const promoUntil = promo === void 0 || promo.kind === "free" ? null : t?.("row.promoUntil", { date: promo.promoUntil.slice(5).replace("-", "-") }) ?? `promo until ${promo.promoUntil}`;
|
|
1871
2010
|
const native = model.nativeContextWindow;
|
|
1872
2011
|
const capped = native > DEFAULT_CONTEXT_BUDGET;
|
|
1873
2012
|
const currentBudget = draft.budget ?? native;
|
|
@@ -1939,6 +2078,14 @@ window.__ModuleLoader__.load({
|
|
|
1939
2078
|
className: "dsm-workbuddy-xdpool-model-meta-tag",
|
|
1940
2079
|
children: tagText
|
|
1941
2080
|
}),
|
|
2081
|
+
laterHint === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2082
|
+
className: "dsm-workbuddy-xdpool-model-meta-later",
|
|
2083
|
+
children: laterHint
|
|
2084
|
+
}),
|
|
2085
|
+
promoUntil === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2086
|
+
className: "dsm-workbuddy-xdpool-model-meta-promo",
|
|
2087
|
+
children: promoUntil
|
|
2088
|
+
}),
|
|
1942
2089
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1943
2090
|
className: "dsm-workbuddy-xdpool-model-cap",
|
|
1944
2091
|
children: t?.("row.modelOutput", { size: formatCapacity(model.maxOutputTokens) }) ?? `out ${formatCapacity(model.maxOutputTokens)}`
|
|
@@ -2094,6 +2241,9 @@ window.__ModuleLoader__.load({
|
|
|
2094
2241
|
"row.free": "free",
|
|
2095
2242
|
"row.limitedFree": "limited free",
|
|
2096
2243
|
"row.nightDiscount": "night",
|
|
2244
|
+
"row.nightFreeNow": "free until {time}",
|
|
2245
|
+
"row.nightFreeLater": "free from {time}",
|
|
2246
|
+
"row.promoUntil": "promo to {date}",
|
|
2097
2247
|
"row.imageCapable": "image input",
|
|
2098
2248
|
"row.rate": "{rate}x credits",
|
|
2099
2249
|
"row.accountsRescan": "Detect accounts again",
|
|
@@ -2239,6 +2389,9 @@ window.__ModuleLoader__.load({
|
|
|
2239
2389
|
"row.free": "免费",
|
|
2240
2390
|
"row.limitedFree": "限量免费",
|
|
2241
2391
|
"row.nightDiscount": "夜间",
|
|
2392
|
+
"row.nightFreeNow": "免费至 {time}",
|
|
2393
|
+
"row.nightFreeLater": "{time} 起免费",
|
|
2394
|
+
"row.promoUntil": "活动至 {date}",
|
|
2242
2395
|
"row.imageCapable": "图片输入",
|
|
2243
2396
|
"row.rate": "{rate}x 积分",
|
|
2244
2397
|
"row.accountsRescan": "重新检测账号",
|
package/lib/index.js
CHANGED
|
@@ -1997,42 +1997,155 @@ function macosBundleExecutable(bundle) {
|
|
|
1997
1997
|
return join(bundle, "Contents", "MacOS", name);
|
|
1998
1998
|
}
|
|
1999
1999
|
/**
|
|
2000
|
+
* Encodings a Windows console child process may emit in, most likely first.
|
|
2001
|
+
*
|
|
2002
|
+
* `reg.exe` writes with the ACTIVE CODE PAGE, never UTF-8: a Chinese Windows
|
|
2003
|
+
* (code page 936) returns a path containing 「腾讯」 as GBK bytes. Decoding
|
|
2004
|
+
* those as UTF-8 mangles them into `��Ѷ`, every `existsSync` then fails, and the
|
|
2005
|
+
* plugin concludes no desktop app is installed while one is running fine.
|
|
2006
|
+
*
|
|
2007
|
+
* The list is ordered and each entry is TRIED rather than guessed: the first
|
|
2008
|
+
* decoding that yields usable text wins, and the decision is validated against
|
|
2009
|
+
* the filesystem rather than by sniffing bytes.
|
|
2010
|
+
*/
|
|
2011
|
+
const WINDOWS_CONSOLE_ENCODINGS = [
|
|
2012
|
+
"utf-8",
|
|
2013
|
+
"gbk",
|
|
2014
|
+
"big5",
|
|
2015
|
+
"shift_jis",
|
|
2016
|
+
"euc-kr",
|
|
2017
|
+
"windows-1252"
|
|
2018
|
+
];
|
|
2019
|
+
/**
|
|
2020
|
+
* Decode bytes from a Windows console child process.
|
|
2021
|
+
*
|
|
2022
|
+
* `accept` decides whether a decoding is usable — the callers pass "this text
|
|
2023
|
+
* contains a path that actually exists". Bytes that are already UTF-8 pass on
|
|
2024
|
+
* the first attempt, so the healthy case pays nothing.
|
|
2025
|
+
*/
|
|
2026
|
+
function decodeWindowsConsole(buf, accept) {
|
|
2027
|
+
for (const label of WINDOWS_CONSOLE_ENCODINGS) {
|
|
2028
|
+
let text;
|
|
2029
|
+
try {
|
|
2030
|
+
text = new TextDecoder(label, { fatal: false }).decode(buf);
|
|
2031
|
+
} catch {
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
if (accept(text)) return text;
|
|
2035
|
+
}
|
|
2036
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(buf);
|
|
2037
|
+
}
|
|
2038
|
+
/** Strip the `"<path>",<index>` decoration DisplayIcon carries. */
|
|
2039
|
+
function cleanIconPath(raw) {
|
|
2040
|
+
return raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
|
|
2041
|
+
}
|
|
2042
|
+
/** Registry locations the installer records its own product under. */
|
|
2043
|
+
const UNINSTALL_KEY_ROOTS = [
|
|
2044
|
+
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
|
2045
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
|
2046
|
+
"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
2047
|
+
];
|
|
2048
|
+
/**
|
|
2000
2049
|
* Windows install locations recorded by the app's own uninstaller.
|
|
2001
2050
|
*
|
|
2002
2051
|
* The registry is the authoritative answer: it survives a non-default drive, a
|
|
2003
2052
|
* renamed folder and a differently-named executable, none of which any fixed
|
|
2004
2053
|
* path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
|
|
2005
|
-
* and `D:\
|
|
2006
|
-
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see
|
|
2007
|
-
* plugin reported "the desktop app could not provide the key" for an app that was
|
|
2008
|
-
* installed and running.
|
|
2054
|
+
* and `D:\ruanjian\anzhuang\腾讯\WorkBuddy\WorkBuddy.exe` — layouts a
|
|
2055
|
+
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see.
|
|
2009
2056
|
*
|
|
2010
2057
|
* `DisplayIcon` is the field that actually carries the path (observed as
|
|
2011
2058
|
* `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
|
|
2012
2059
|
* installers, so both are read and either may contribute.
|
|
2013
2060
|
*
|
|
2061
|
+
* Two readers, because neither alone is enough:
|
|
2062
|
+
*
|
|
2063
|
+
* - **PowerShell first.** `Get-ItemProperty` talks to the registry API, so no
|
|
2064
|
+
* console code page is involved and non-ASCII paths come back intact. This
|
|
2065
|
+
* is the encoding-proof path.
|
|
2066
|
+
* - **`reg.exe` as a fallback**, for a machine where PowerShell is unavailable
|
|
2067
|
+
* (locked-down or stripped images). Its raw bytes are decoded through
|
|
2068
|
+
* {@link decodeWindowsConsole}, which is what the original code got wrong.
|
|
2069
|
+
*
|
|
2014
2070
|
* Returns [] on any failure — a missing registry key is the normal case on
|
|
2015
2071
|
* non-Windows, not an error.
|
|
2016
2072
|
*/
|
|
2017
2073
|
function windowsRegistryAppPaths() {
|
|
2018
2074
|
if (process.platform !== "win32") return [];
|
|
2019
|
-
const
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2075
|
+
const viaPowerShell = windowsRegistryAppPathsViaPowerShell();
|
|
2076
|
+
if (viaPowerShell.length > 0) return viaPowerShell;
|
|
2077
|
+
return windowsRegistryAppPathsViaReg();
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Registry read through PowerShell, immune to the console code page.
|
|
2081
|
+
*
|
|
2082
|
+
* `[Console]::OutputEncoding` is set to UTF-8 so the bytes Node receives are
|
|
2083
|
+
* UTF-8 regardless of the machine's code page. Values are printed one per line
|
|
2084
|
+
* and matched by the caller, so a Chinese path survives the round trip.
|
|
2085
|
+
*
|
|
2086
|
+
* Best-effort: any failure (no PowerShell, no keys, a policy restriction)
|
|
2087
|
+
* returns [], and the `reg.exe` reader takes over.
|
|
2088
|
+
*/
|
|
2089
|
+
function windowsRegistryAppPathsViaPowerShell() {
|
|
2090
|
+
const script = [
|
|
2091
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
2092
|
+
"try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}",
|
|
2093
|
+
"$roots=@('HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')",
|
|
2094
|
+
"foreach($r in $roots){",
|
|
2095
|
+
" Get-ItemProperty -Path $r | ForEach-Object {",
|
|
2096
|
+
" $dn=[string]$_.DisplayName; $di=[string]$_.DisplayIcon; $il=[string]$_.InstallLocation",
|
|
2097
|
+
" if($dn -match 'WorkBuddy|CodeBuddy' -or $di -match 'WorkBuddy|CodeBuddy'){",
|
|
2098
|
+
" if($di -ne ''){ Write-Output $di }",
|
|
2099
|
+
" if($il -ne ''){ Write-Output $il }",
|
|
2100
|
+
" }",
|
|
2101
|
+
" }",
|
|
2102
|
+
"}"
|
|
2103
|
+
].join("\n");
|
|
2104
|
+
let stdout;
|
|
2105
|
+
try {
|
|
2106
|
+
stdout = execFileSync("powershell", [
|
|
2107
|
+
"-NoProfile",
|
|
2108
|
+
"-NonInteractive",
|
|
2109
|
+
"-ExecutionPolicy",
|
|
2110
|
+
"Bypass",
|
|
2111
|
+
"-Command",
|
|
2112
|
+
script
|
|
2113
|
+
], {
|
|
2114
|
+
encoding: "utf8",
|
|
2115
|
+
timeout: 2e4,
|
|
2116
|
+
windowsHide: true,
|
|
2117
|
+
maxBuffer: 8388608
|
|
2118
|
+
});
|
|
2119
|
+
} catch {
|
|
2120
|
+
return [];
|
|
2121
|
+
}
|
|
2024
2122
|
const out = [];
|
|
2025
|
-
for (const
|
|
2026
|
-
|
|
2123
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
2124
|
+
const cleaned = cleanIconPath(line.trim());
|
|
2125
|
+
if (cleaned !== "") out.push(cleaned);
|
|
2126
|
+
}
|
|
2127
|
+
return out;
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Registry read through `reg.exe`, with console-code-page detection.
|
|
2131
|
+
*
|
|
2132
|
+
* Kept as the fallback for machines without PowerShell. The decode step is the
|
|
2133
|
+
* fix: the output is captured as BYTES and decoded with whichever encoding
|
|
2134
|
+
* yields a path that exists, instead of assuming UTF-8 and quietly producing
|
|
2135
|
+
* mojibake that no existence check can ever match.
|
|
2136
|
+
*/
|
|
2137
|
+
function windowsRegistryAppPathsViaReg() {
|
|
2138
|
+
const out = [];
|
|
2139
|
+
for (const root of UNINSTALL_KEY_ROOTS) {
|
|
2140
|
+
let listingBuf;
|
|
2027
2141
|
try {
|
|
2028
|
-
|
|
2142
|
+
listingBuf = execFileSync("reg", [
|
|
2029
2143
|
"query",
|
|
2030
2144
|
root,
|
|
2031
2145
|
"/s",
|
|
2032
2146
|
"/v",
|
|
2033
2147
|
"DisplayName"
|
|
2034
2148
|
], {
|
|
2035
|
-
encoding: "utf8",
|
|
2036
2149
|
timeout: 1e4,
|
|
2037
2150
|
windowsHide: true,
|
|
2038
2151
|
maxBuffer: 8388608
|
|
@@ -2040,26 +2153,35 @@ function windowsRegistryAppPaths() {
|
|
|
2040
2153
|
} catch {
|
|
2041
2154
|
continue;
|
|
2042
2155
|
}
|
|
2043
|
-
const keys =
|
|
2156
|
+
const keys = decodeWindowsConsole(listingBuf, (text) => /WorkBuddy|CodeBuddy/iu.test(text)).split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
|
|
2044
2157
|
for (const key of keys) {
|
|
2045
2158
|
const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
|
|
2046
2159
|
if (keyPath === void 0) continue;
|
|
2047
|
-
for (const name of ["DisplayIcon", "InstallLocation"])
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2160
|
+
for (const name of ["DisplayIcon", "InstallLocation"]) {
|
|
2161
|
+
let valueBuf;
|
|
2162
|
+
try {
|
|
2163
|
+
valueBuf = execFileSync("reg", [
|
|
2164
|
+
"query",
|
|
2165
|
+
keyPath,
|
|
2166
|
+
"/v",
|
|
2167
|
+
name
|
|
2168
|
+
], {
|
|
2169
|
+
timeout: 5e3,
|
|
2170
|
+
windowsHide: true
|
|
2171
|
+
});
|
|
2172
|
+
} catch {
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
const value = decodeWindowsConsole(valueBuf, (text) => {
|
|
2176
|
+
const candidate = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(text)?.[1]?.trim();
|
|
2177
|
+
if (candidate === void 0) return false;
|
|
2178
|
+
const path = cleanIconPath(candidate);
|
|
2179
|
+
return path !== "" && existsSync(path);
|
|
2057
2180
|
});
|
|
2058
2181
|
const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
|
|
2059
2182
|
if (raw === void 0 || raw === "") continue;
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
} catch {}
|
|
2183
|
+
out.push(cleanIconPath(raw));
|
|
2184
|
+
}
|
|
2063
2185
|
}
|
|
2064
2186
|
}
|
|
2065
2187
|
return out;
|
|
@@ -2328,7 +2450,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
|
2328
2450
|
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2329
2451
|
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2330
2452
|
constructor(sourcePath) {
|
|
2331
|
-
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed.`);
|
|
2453
|
+
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed — if one of them looks like mojibake (e.g. "??" where a Chinese folder name should be), the registry value could not be decoded on this machine; report that line and use the override above in the meantime.`);
|
|
2332
2454
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2333
2455
|
}
|
|
2334
2456
|
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "1.7.
|
|
5
|
+
"version": "1.7.5",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|