dsh-connect-trae 3.5.0 → 3.6.0
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 +87 -0
- package/README.md +7 -0
- package/lib/client.js +464 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +525 -30
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.6.0 (2026-10-16)
|
|
4
|
+
|
|
5
|
+
> **多个账号可以并存了。** 本版新增**账号导入 / 导出**:不再需要每次把某台机器的登录凭据
|
|
6
|
+
> 手工写进默认路径来顶掉上一个账号 —— 导出一份 JSON、在另一处导入即可。同时补上
|
|
7
|
+
> `#39` 那条诊断:上游没下发 function 配置时,报错不再读起来像本地故障。
|
|
8
|
+
|
|
9
|
+
这是一次 **minor** 升级(新增用户可见的界面与能力)。
|
|
10
|
+
|
|
11
|
+
### 新增
|
|
12
|
+
|
|
13
|
+
- **账号导入 / 导出(issue #37)**:账号区新增一行「导入 / 导出账号」,
|
|
14
|
+
- **导出**:勾选若干账号 → 下载一个 JSON 文件(`trae-accounts-YYYY-MM-DD.json`);
|
|
15
|
+
- **导入**:选择文件 → **脱敏预览**(只显示账号标识与「是否有 Token」,不显示凭据)→ 勾选 → 导入;
|
|
16
|
+
- 文件格式与同族 `dsh-connect-workbuddy` **互通**(顶层 snake_case:`access_token` / `refresh_token` /
|
|
17
|
+
`uid` / `nickname` / `domain` / `expiresAt`(毫秒) / `refreshExpiresAt` / `auth_raw`),
|
|
18
|
+
所以两边导出的文件可以互相导入。
|
|
19
|
+
- **顺带把存储升级成多账号**:本插件原先每个区域只保存**一个**凭据,导入的第二个账号无处可放。
|
|
20
|
+
现改为**每区域一个文件保存凭据数组**(格式版本 1→2),**旧文件仍可读**、在下一次写入时自动升级。
|
|
21
|
+
- 导入按钮在**未登录时也可用** —— 一台从没登录过的机器,正是最需要导入别处导出的文件的时候。
|
|
22
|
+
|
|
23
|
+
> **区域门禁是正向匹配**:导入文件里的 `domain` 是文件作者可控的值,而本插件「认不出就当作国内版」
|
|
24
|
+
> 的规则对**上游下发**的数据是对的、对**导入文件**是危险的(伪造域名会被塞进国内版)。
|
|
25
|
+
> 因此导入走一条**只认正向识别**的通道:空值、认不出的值、以及明确属于另一区域的值一律**跳过并计数**,
|
|
26
|
+
> 绝不静默落库。国内版与国际版是两个并行的供应商栈,跨区导入会导致跨区计费。
|
|
27
|
+
|
|
28
|
+
### 修复
|
|
29
|
+
|
|
30
|
+
- **修复一个会导致账号丢失的写入缺陷(本次实现导入功能时发现)**:本插件的刷新路径原先把
|
|
31
|
+
「刚刷新好的那一个凭据」当作**整个文件**写回。在单凭据时代这没问题;一旦一个区域能保存多个账号
|
|
32
|
+
(正是导入带来的),**刷新一个账号就会把同区域的其他账号全部删掉**。
|
|
33
|
+
已改为**读-改-写**:只替换被刷新账号自己那一条,其余条目原样保留。回归测试专门钉住了这一点。
|
|
34
|
+
|
|
35
|
+
- **上游未下发 function 配置时,报错读起来不再像本地故障(issue #39)**:
|
|
36
|
+
- 报告者遇到的现象是每次调用都返回 `2001 no function config found for appId=…`,
|
|
37
|
+
他据此排查了一圈本地凭据与插件版本,最后才发现问题在服务端 —— 上游的
|
|
38
|
+
`get_detail_param` 当时对**每个** SOLO function 都返回空的 `config_info_list`,随后**自行恢复**。
|
|
39
|
+
- 现在(a)`2001` 有了明确文案:**「上游尚未下发本通道的 function 配置(这是上游侧的状态,
|
|
40
|
+
不是本地凭据或配置问题)」**;(b)当**所有** function 都返回 200 却一条配置都没有时,
|
|
41
|
+
目录读取会抛出专用的 `TraeEmptySoloDirectory`,把「上游没下发」与「所有 function 都请求失败」
|
|
42
|
+
区分开。两者都不暗示需要重新登录,因为这个条件是**会自行恢复**的。
|
|
43
|
+
|
|
44
|
+
### 测试
|
|
45
|
+
|
|
46
|
+
- 新增 4 个测试文件、共 34 例:导入导出格式(解析 / 脱敏预览 / 往返 / 区域门禁)、
|
|
47
|
+
导入路由(HTTP 边界上的伪造区域拒绝、导入前重新解析文件而非信任预览)、
|
|
48
|
+
存储升级与**刷新不吞账号**、以及端到端的「一处导出 → 另一处导入」。
|
|
49
|
+
- 区域门禁做了**变异验证**:把门禁改成 fail-open(正是同族插件 `#42` 修过的那个缺陷)后,
|
|
50
|
+
格式层与路由层**各有用例变红**;把毫秒单位改成错误的换算同样被抓住。
|
|
51
|
+
- 全仓 **655** 例测试通过,类型检查通过。
|
|
52
|
+
|
|
53
|
+
## 3.5.1 (2026-10-16)
|
|
54
|
+
|
|
55
|
+
> **DeepSeek-V4 四个模型行不再必然报 4001。** 本版修一处 wire 名单合并的方向错误:
|
|
56
|
+
> 同名的多行里,旧代码让**最后**一行取胜,于是一个元数据残缺的小写尾行抢走了展示名,
|
|
57
|
+
> 桥接层把 `config_name` 改写成了上游不认的小写拼写。改为**每张映射表先到先得**后,
|
|
58
|
+
> 规范行留在原位,不再发生任何改写。
|
|
59
|
+
|
|
60
|
+
### 修复
|
|
61
|
+
|
|
62
|
+
- **DeepSeek-V4 四行(`DeepSeek-V4-Flash` / `-Flash-Official` / `-Pro` / `-Pro-Official`)选中发送必报 4001**(issue #38):
|
|
63
|
+
- **上游名单的形状**:CN 区 wire(`get_detail_param`)把同一族列了**三行**,共享同一个展示名,其中尾行是
|
|
64
|
+
元数据残缺的小写行 —— `DeepSeek-V4-Flash` 展示名同时对应 `DeepSeek-V4-Flash-Official`、
|
|
65
|
+
`DeepSeek-V4-Flash`、`deepseek-v4-flash` 三个 id;`DeepSeek-V4-Pro` 完全同构。
|
|
66
|
+
- **改坏它的那一步**:`mergeTraeModelSources` 用 `wireByName.set(...)` / `wireById.set(...)`
|
|
67
|
+
**无条件写入**,所以后到的那一行会覆盖先到的同名键。尾行的 `name` 正好是小写 id,
|
|
68
|
+
于是 `displayKey("DeepSeek-V4-Flash")` 被它抢占,桥接层据此把 `config_name` 改写成
|
|
69
|
+
`deepseek-v4-flash`,上游回 `4001 the param is invalid`。
|
|
70
|
+
- **为什么 `-Official` 行也一起废**:`TraeSoloBridge` 在 catalog 取不到 `wireConfigName` 时
|
|
71
|
+
会退回 `wireResolver(entry?.name)`,而 `entry.name` 同样是 `DeepSeek-V4-Flash`,
|
|
72
|
+
于是又落到那一行上 —— **两行一起废**。
|
|
73
|
+
- **修法**:两张映射表都改成**先到先得**(`if (!map.has(key)) map.set(...)`)。
|
|
74
|
+
`displayKey("DeepSeek-V4-Flash")` 于是命中 id 完全相同的规范行,该行的 `config_name`
|
|
75
|
+
本就是 `DeepSeek-V4-Flash`(与展示名一致),**不再发生改写**。
|
|
76
|
+
- **本机 CN 实测**(插件真实请求形状 `{messages, model, config_name, function, stream}`):
|
|
77
|
+
- 修前:`DeepSeek-V4-Flash` → 发出 `deepseek-v4-flash` → 流内 `4001`;`DeepSeek-V4-Pro` 同;
|
|
78
|
+
- 修后:四行发出 `DeepSeek-V4-Flash` / `DeepSeek-V4-Pro`(`-Official` 两行发出自身 id)→ **HTTP 200 + 完整正文**;
|
|
79
|
+
- 对照探测:大写能通、小写必 `4001`,`glm-5.2` 作基线正常 —— 证实「号码牌本身是好的,是被改写坏的」。
|
|
80
|
+
|
|
81
|
+
> 注意:`4001` 是**流内** `event:error`(外层 HTTP 仍是 200),所以只看状态码会把它误判成成功。
|
|
82
|
+
|
|
83
|
+
### 测试
|
|
84
|
+
|
|
85
|
+
- 新增 2 例回归:`mergeTraeModelSources` 在「同名 + 尾行小写」的 wire 上必须让规范行取胜;
|
|
86
|
+
以及「按展示名 join」在改为先到先得后仍然生效(防止过度修正)。
|
|
87
|
+
两例在旧代码上均失败、在新代码上通过。
|
|
88
|
+
- 全仓 **619** 例测试通过。
|
|
89
|
+
|
|
3
90
|
## 3.5.0 (2026-10-14)
|
|
4
91
|
|
|
5
92
|
> **积分读数不再只有一个固定位置。** 本版向同族 `dsh-connect-workbuddy` 学来它的 issue #62
|
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
- **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
|
|
43
43
|
- **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。**你的选择会被记住并长期生效**:插件不会因为某个账号额度用尽、或检测到另一个可用账号就自作主张替你切换(换账号意味着换人计费)。所以当绑定的账号额度耗尽时,卡片会**明确告诉你**,并把本机其他还有额度的账号连同其可用积分数列出来,由你一键切换——而不是让面板一直空着、也不自动把你切走。「刷新账号与 Token」每次都会回报结果(找到几个账号、当前仍绑定谁),不会再「点了没反应」。下拉里显示的是产品名(`Trae` / `TRAE SOLO` / 国际版),便于分辨凭据来自哪个安装。
|
|
44
44
|
- **跟随 Trae 当前登录的账号** —— 你在 Trae 桌面端**退出并换登录**后,卡片会检测到「插件绑定的账号」与「Trae 现在登录的账号」不一致,并给出一行提示与**一键切换**(复用账号下拉的同一次写入,不引入第二条切换路径)。想让插件**自动跟随**,可打开设置卡片里的「跟随 Trae 当前登录的账号」开关:此后每次配置生效都会把 `accounts.<region>` 重绑到 App 当前登录的账号。**该开关默认关闭**——已有配置保持原来的严格绑定,自动换绑意味着换人计费,必须由你主动勾选;未勾选时上面那行提示就是你的手动通道。(issue #29)
|
|
45
|
+
- **账号导入 / 导出** —— 不用再为了用第二个账号而把凭据手工写进默认路径、顶掉上一个。在卡片账号区的「导入 / 导出账号」里:**导出**勾选若干账号下载一份 JSON;**导入**选择文件后先看**脱敏预览**(只显示账号标识与「是否有 Token」,不含凭据本身)再勾选导入。文件格式与同族 `dsh-connect-workbuddy` **互通**,两边导出的文件可以互相导入。导入按账号去重(同一账号再导入即覆盖更新),**认不出所属区域或因缺 Token 的记录会被跳过并计数**——国内版与国际版是两条并行计费通道,跨区导入会导致跨区计费,所以这里只认同能**正向识别**的区域。导出文件里是**有效 Token,请当作密码保管**。(issue #37)
|
|
45
46
|
- **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
|
|
46
47
|
- **积分临近到期会提醒** —— 剩余额度只回答「还有多少」,回答不了「**哪一笔先到期**」;提醒条按到期紧迫程度逐级加重(**7 天 → 24 小时 → 已过期**),写明金额、套餐数和最早到期时间,避免只盯总额时悄悄丢掉最先到期的额度。国内版在用量页与输入框积分面板两处都显示(国际版是订阅制,没有逐包到期数据)。
|
|
47
48
|
- **一键测试启用模型** —— 给**已启用**的每个模型发一条最小消息(一句 `ping`、不带工具),逐个告诉你能不能真的应答:**可用** / **需要更高档位**(Trae `1005`,你的套餐不覆盖)/ **上游不提供**(`4001`,SOLO 通道没有这个模型)/ **被限流**(`4011`,Trae 限了这条通道)/ **登录凭据被拒** / **本次未测出**。最后一项是一等公民:超时、断连、代理、5xx **一律不算「不可用」**——只有上游明确的拒绝才算负面结论。**这是插件里唯一消耗额度的操作**(签到改变的是账号状态,不消耗),只在你点击时运行,结果只留在当前会话里不写入设置。
|
|
@@ -114,6 +115,12 @@ Trae does not serve this model under the SOLO function the request used
|
|
|
114
115
|
|
|
115
116
|
即「上游不提供该模型」被如实说明,而不是静默失败或像网络故障。**官方把它们开放到 SOLO 通道后,插件会自动可用**(判据就是能不能拿到调用 id),无需升级插件。
|
|
116
117
|
|
|
118
|
+
> **同一条报错的另一种成因(3.5.1 已修,issue #38)**:`DeepSeek-V4` 四行曾报**同样的 4001**,但成因相反 ——
|
|
119
|
+
> 它们在上游名单里**本来可以调用**,只是 CN 区的 wire 把同一族列了三行且共享展示名,旧代码让**最后**一行
|
|
120
|
+
> (元数据残缺的小写行 `deepseek-v4-flash`)抢走了展示名键,桥接层据此把 `config_name` 改写成小写拼写,
|
|
121
|
+
> 上游拒收。**判别方法**:看这个模型在上游名单里有没有调用 id —— 有而报 4001,就是这一类。
|
|
122
|
+
> 3.5.1 起两张映射表都改为先到先得,不再发生改写,四行已实测恢复正常。
|
|
123
|
+
|
|
117
124
|
> **为什么现在会显示(2026-10-02 起的策略)**:此前这类「目录里有、但拿不到调用 id」的模型会被**直接隐藏**,理由是「放出来只会报错」。但可调用名单是**按账号档位**下发的——在免费账号上测不到,不等于付费账号用不了(issue #19 的报告人就是 Pro:他的账号能调用本机免费账号被门禁挡住的模型)。**隐藏用户自己 Trae IDE 里有的模型,比让他看到一条明确的错误更糟**,所以改为:全部呈现 + 如实报错。
|
|
118
125
|
|
|
119
126
|
> `deepseek-v4.1-flash` 曾在此列——`docs/DS41_CALLABILITY.md`(2026-09-15 取证)实测 8 个 SOLO function
|
package/lib/client.js
CHANGED
|
@@ -48,6 +48,17 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
* account row.
|
|
49
49
|
*/
|
|
50
50
|
const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
|
|
51
|
+
/**
|
|
52
|
+
* Plugin-owned account transfer endpoint: batch export and import of a region's
|
|
53
|
+
* stored credentials (issue #37).
|
|
54
|
+
*
|
|
55
|
+
* One route with an `action` query parameter (`export` | `preview` | `import`),
|
|
56
|
+
* mirroring the sibling `dsh-connect-workbuddy` plugin's transfer route so the
|
|
57
|
+
* two cards behave the same way for a user who has both installed. The vault
|
|
58
|
+
* document never leaves the Host: the browser receives parsed answers, and a
|
|
59
|
+
* preview carries display fields only — never token material.
|
|
60
|
+
*/
|
|
61
|
+
const TRAE_TRANSFER_PATH = "/plugins/dsh-connect-trae/transfer";
|
|
51
62
|
/** Query parameter naming the region a card request addresses. */
|
|
52
63
|
const TRAE_REGION_PARAM = "region";
|
|
53
64
|
/** Every region, in card tab order. */
|
|
@@ -60,6 +71,18 @@ window.__ModuleLoader__.load({
|
|
|
60
71
|
return `${path}?${TRAE_REGION_PARAM}=${region}`;
|
|
61
72
|
}
|
|
62
73
|
/**
|
|
74
|
+
* Address one region's transfer route with its step, e.g.
|
|
75
|
+
* `/plugins/dsh-connect-trae/transfer?region=cn&action=export`.
|
|
76
|
+
*
|
|
77
|
+
* Separate from {@link withTraeRegion} because this route carries a second
|
|
78
|
+
* query parameter: a caller that appended `&action=` by hand would be one typo
|
|
79
|
+
* away from a 400, which is exactly the failure the sibling workbuddy plugin
|
|
80
|
+
* hit (#42) when five requests dropped that parameter.
|
|
81
|
+
*/
|
|
82
|
+
function withTraeTransferAction(path, region, action) {
|
|
83
|
+
return `${path}?${TRAE_REGION_PARAM}=${region}&action=${action}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
63
86
|
* Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
|
|
64
87
|
* volatile-marked settings field as (see {@link unwrapVolatileDeep}).
|
|
65
88
|
*/
|
|
@@ -482,6 +505,32 @@ window.__ModuleLoader__.load({
|
|
|
482
505
|
.dsm-trae-expiry-week{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
483
506
|
.dsm-trae-expiry-lapsed{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-tertiary,#999)}
|
|
484
507
|
.dsm-trae-expiry-detail{font-size:11px;line-height:16px;opacity:.8}
|
|
508
|
+
/* Account import / export (issue #37). A sub-block of the accounts section:
|
|
509
|
+
the transfer row, plus the two dialogs it opens in place. The file input is
|
|
510
|
+
hidden rather than absent — the button opens the system picker, and the input
|
|
511
|
+
must stay mounted to be clicked. */
|
|
512
|
+
.dsm-trae-transfer{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.2))}
|
|
513
|
+
.dsm-trae-transfer-label{margin:0 0 6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,GrayText)}
|
|
514
|
+
.dsm-trae-transfer-actions{display:flex;flex-wrap:wrap;gap:8px}
|
|
515
|
+
.dsm-trae-transfer-file{display:none}
|
|
516
|
+
.dsm-trae-transfer-status{margin:6px 0 0;font-size:11px;line-height:16px}
|
|
517
|
+
.dsm-trae-transfer-ok{color:var(--dsw-alias-state-success-primary,#16a34a)}
|
|
518
|
+
.dsm-trae-transfer-error{color:var(--dsw-alias-state-error-primary,#dc2626)}
|
|
519
|
+
.dsm-trae-transfer-dialog{
|
|
520
|
+
margin-top:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
|
|
521
|
+
border-radius:8px;font-size:12px;line-height:18px;
|
|
522
|
+
}
|
|
523
|
+
.dsm-trae-transfer-dialog>p{margin:0 0 6px}
|
|
524
|
+
.dsm-trae-transfer-list{list-style:none;margin:6px 0;padding:0;max-height:180px;overflow:auto}
|
|
525
|
+
.dsm-trae-transfer-list label{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer}
|
|
526
|
+
.dsm-trae-transfer-list input{flex:none}
|
|
527
|
+
.dsm-trae-transfer-dialog-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
|
|
528
|
+
/* A text-button rather than a boxed one: "select all" is a shortcut inside the
|
|
529
|
+
dialog, and a third button would compete with the two that act. */
|
|
530
|
+
.dsm-trae-transfer-link{
|
|
531
|
+
margin-top:6px;padding:0;border:0;background:none;cursor:pointer;
|
|
532
|
+
color:var(--dsw-alias-brand-primary,#2563eb);font-size:11px;line-height:16px;
|
|
533
|
+
}
|
|
485
534
|
.dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
|
|
486
535
|
/* Verdict badge. Only a real refusal is coloured as a warning; an unknown
|
|
487
536
|
verdict stays neutral because an inconclusive probe is not evidence against
|
|
@@ -785,6 +834,368 @@ window.__ModuleLoader__.load({
|
|
|
785
834
|
});
|
|
786
835
|
}
|
|
787
836
|
//#endregion
|
|
837
|
+
//#region src/client/AccountTransfer.tsx
|
|
838
|
+
/**
|
|
839
|
+
* The card's account-transfer section: batch export and import of a region's
|
|
840
|
+
* stored credentials, in the file format shared with sibling credential
|
|
841
|
+
* managers for the same upstream (the `dsh-connect-workbuddy` family).
|
|
842
|
+
*
|
|
843
|
+
* Modelled on that plugin's `AccountTransfer`, deliberately: the two plugins
|
|
844
|
+
* share one external-presentation language, and a user who has imported
|
|
845
|
+
* accounts in one should recognise the flow in the other. The shape is a single
|
|
846
|
+
* section owning all of its state (busy / result line / error), with the export
|
|
847
|
+
* dialog (pick accounts → download JSON) and the import dialog (pick file →
|
|
848
|
+
* desensitised preview → tick → import) split the same way.
|
|
849
|
+
*
|
|
850
|
+
* Two properties are load-bearing and must not be relaxed:
|
|
851
|
+
* - The preview carries display fields and a `hasToken` boolean only. Tokens
|
|
852
|
+
* never enter the browser at all until the user explicitly exports a chosen
|
|
853
|
+
* account, and the Host re-parses the file on import rather than trusting
|
|
854
|
+
* anything this component says about it.
|
|
855
|
+
* - Import is offered even when the region has NO account: a machine that has
|
|
856
|
+
* never signed in is exactly where importing someone else's export matters
|
|
857
|
+
* most. Only EXPORT needs an existing account to select.
|
|
858
|
+
*
|
|
859
|
+
* The download uses `<a download>` on a Blob because the card runs inside the
|
|
860
|
+
* settings iframe and has no host save dialog; a same-origin anchor download is
|
|
861
|
+
* the reliable path there.
|
|
862
|
+
*
|
|
863
|
+
* @module dsh-connect-trae/client/AccountTransfer
|
|
864
|
+
*/
|
|
865
|
+
/** How long the veil may wait for the card's re-read before it comes down. */
|
|
866
|
+
const REFRESH_SETTLE_TIMEOUT_MS = 5e3;
|
|
867
|
+
/** Build the default export file name: trae-accounts-YYYY-MM-DD.json. */
|
|
868
|
+
function exportFileName() {
|
|
869
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
870
|
+
const now = /* @__PURE__ */ new Date();
|
|
871
|
+
return `trae-accounts-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}.json`;
|
|
872
|
+
}
|
|
873
|
+
/** Download a JSON value as a local file (no host save dialog inside the iframe). */
|
|
874
|
+
function downloadJson(fileName, data) {
|
|
875
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
876
|
+
const url = URL.createObjectURL(blob);
|
|
877
|
+
const anchor = document.createElement("a");
|
|
878
|
+
anchor.href = url;
|
|
879
|
+
anchor.download = fileName;
|
|
880
|
+
document.body.appendChild(anchor);
|
|
881
|
+
anchor.click();
|
|
882
|
+
anchor.remove();
|
|
883
|
+
URL.revokeObjectURL(url);
|
|
884
|
+
}
|
|
885
|
+
/** Resolve once `value` settles, or after `timeoutMs` — whichever comes first. */
|
|
886
|
+
async function settlesWithin(value, timeoutMs) {
|
|
887
|
+
await Promise.race([Promise.resolve(value).catch(() => void 0), new Promise((resolve) => {
|
|
888
|
+
setTimeout(resolve, timeoutMs);
|
|
889
|
+
})]);
|
|
890
|
+
}
|
|
891
|
+
function AccountTransfer({ t, region, accounts, onChanged }) {
|
|
892
|
+
const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
|
|
893
|
+
const [dialog, setDialog] = (0, react.useState)(void 0);
|
|
894
|
+
const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
895
|
+
const [preview, setPreview] = (0, react.useState)(void 0);
|
|
896
|
+
const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
897
|
+
const fileInput = (0, react.useRef)(null);
|
|
898
|
+
/**
|
|
899
|
+
* The text of the file the user just picked, held here rather than re-read
|
|
900
|
+
* from the input at import time: the picker is a bare system dialog that
|
|
901
|
+
* closes immediately, so between preview and confirm no dialog holds the
|
|
902
|
+
* File. Re-reading `fileInput.current.files` would also break the moment
|
|
903
|
+
* anything clears the input — and it must be cleared after every pick, or
|
|
904
|
+
* choosing the SAME file twice fires no change event.
|
|
905
|
+
*/
|
|
906
|
+
const importText = (0, react.useRef)("");
|
|
907
|
+
const mounted = (0, react.useRef)(true);
|
|
908
|
+
(0, react.useEffect)(() => {
|
|
909
|
+
mounted.current = true;
|
|
910
|
+
return () => {
|
|
911
|
+
mounted.current = false;
|
|
912
|
+
};
|
|
913
|
+
}, []);
|
|
914
|
+
const closeDialog = () => {
|
|
915
|
+
setDialog(void 0);
|
|
916
|
+
setSelected(/* @__PURE__ */ new Set());
|
|
917
|
+
setPreview(void 0);
|
|
918
|
+
setImportSelected(/* @__PURE__ */ new Set());
|
|
919
|
+
};
|
|
920
|
+
const runExport = async () => {
|
|
921
|
+
if (selected.size === 0) return;
|
|
922
|
+
setPhase({ kind: "exporting" });
|
|
923
|
+
try {
|
|
924
|
+
const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "export"), {
|
|
925
|
+
method: "POST",
|
|
926
|
+
headers: {
|
|
927
|
+
"content-type": "application/json",
|
|
928
|
+
accept: "application/json"
|
|
929
|
+
},
|
|
930
|
+
credentials: "same-origin",
|
|
931
|
+
body: JSON.stringify({ accountIds: [...selected] })
|
|
932
|
+
});
|
|
933
|
+
const body = await response.json();
|
|
934
|
+
if (!mounted.current) return;
|
|
935
|
+
if (!response.ok || !Array.isArray(body.accounts)) {
|
|
936
|
+
setPhase({
|
|
937
|
+
kind: "error",
|
|
938
|
+
message: body.error ?? `HTTP ${response.status}`
|
|
939
|
+
});
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const fileName = exportFileName();
|
|
943
|
+
downloadJson(fileName, body.accounts);
|
|
944
|
+
closeDialog();
|
|
945
|
+
setPhase({
|
|
946
|
+
kind: "done",
|
|
947
|
+
message: t("row.transferExported", {
|
|
948
|
+
count: body.accounts.length,
|
|
949
|
+
fileName
|
|
950
|
+
})
|
|
951
|
+
});
|
|
952
|
+
} catch (error) {
|
|
953
|
+
if (mounted.current) setPhase({
|
|
954
|
+
kind: "error",
|
|
955
|
+
message: error instanceof Error ? error.message : String(error)
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
const readImportFile = async (file) => {
|
|
960
|
+
setPhase({ kind: "importing" });
|
|
961
|
+
try {
|
|
962
|
+
const text = await file.text();
|
|
963
|
+
importText.current = text;
|
|
964
|
+
const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "preview"), {
|
|
965
|
+
method: "POST",
|
|
966
|
+
headers: {
|
|
967
|
+
"content-type": "application/json",
|
|
968
|
+
accept: "application/json"
|
|
969
|
+
},
|
|
970
|
+
credentials: "same-origin",
|
|
971
|
+
body: JSON.stringify({ text })
|
|
972
|
+
});
|
|
973
|
+
const body = await response.json();
|
|
974
|
+
if (!mounted.current) return;
|
|
975
|
+
if (!response.ok || !Array.isArray(body.accounts)) {
|
|
976
|
+
setPhase({
|
|
977
|
+
kind: "error",
|
|
978
|
+
message: body.error ?? `HTTP ${response.status}`
|
|
979
|
+
});
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
setPreview(body);
|
|
983
|
+
setImportSelected(new Set(body.accounts.filter((entry) => entry.hasToken).map((entry) => entry.index)));
|
|
984
|
+
setPhase({ kind: "idle" });
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (mounted.current) setPhase({
|
|
987
|
+
kind: "error",
|
|
988
|
+
message: error instanceof Error ? error.message : String(error)
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
const runImport = async () => {
|
|
993
|
+
if (preview === void 0 || importSelected.size === 0 || importText.current === "") return;
|
|
994
|
+
setPhase({ kind: "importing" });
|
|
995
|
+
try {
|
|
996
|
+
const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "import"), {
|
|
997
|
+
method: "POST",
|
|
998
|
+
headers: {
|
|
999
|
+
"content-type": "application/json",
|
|
1000
|
+
accept: "application/json"
|
|
1001
|
+
},
|
|
1002
|
+
credentials: "same-origin",
|
|
1003
|
+
body: JSON.stringify({
|
|
1004
|
+
text: importText.current,
|
|
1005
|
+
indexes: [...importSelected]
|
|
1006
|
+
})
|
|
1007
|
+
});
|
|
1008
|
+
const body = await response.json();
|
|
1009
|
+
if (!mounted.current) return;
|
|
1010
|
+
if (!response.ok || typeof body.imported !== "number") {
|
|
1011
|
+
setPhase({
|
|
1012
|
+
kind: "error",
|
|
1013
|
+
message: body.error ?? `HTTP ${response.status}`
|
|
1014
|
+
});
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
closeDialog();
|
|
1018
|
+
const message = t("row.transferImported", {
|
|
1019
|
+
imported: body.imported,
|
|
1020
|
+
skipped: body.skipped ?? 0
|
|
1021
|
+
});
|
|
1022
|
+
setPhase({
|
|
1023
|
+
kind: "refreshing",
|
|
1024
|
+
message
|
|
1025
|
+
});
|
|
1026
|
+
await settlesWithin(onChanged?.(), REFRESH_SETTLE_TIMEOUT_MS);
|
|
1027
|
+
if (!mounted.current) return;
|
|
1028
|
+
setPhase({
|
|
1029
|
+
kind: "done",
|
|
1030
|
+
message
|
|
1031
|
+
});
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (mounted.current) setPhase({
|
|
1034
|
+
kind: "error",
|
|
1035
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
const busy = phase.kind === "exporting" || phase.kind === "importing" || phase.kind === "refreshing";
|
|
1040
|
+
const veilMessage = phase.kind === "exporting" ? t("row.transferExporting") : phase.kind === "importing" || phase.kind === "refreshing" ? t("row.transferImporting") : "";
|
|
1041
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1042
|
+
className: "dsm-trae-transfer",
|
|
1043
|
+
"aria-label": t("row.transferTitle"),
|
|
1044
|
+
children: [
|
|
1045
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
|
|
1046
|
+
visible: busy,
|
|
1047
|
+
message: veilMessage,
|
|
1048
|
+
...busy ? { hint: t("row.transferVeilHint") } : {}
|
|
1049
|
+
}),
|
|
1050
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1051
|
+
className: "dsm-trae-transfer-label",
|
|
1052
|
+
children: t("row.transferTitle")
|
|
1053
|
+
}),
|
|
1054
|
+
phase.kind === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1055
|
+
className: "dsm-trae-transfer-status dsm-trae-transfer-ok",
|
|
1056
|
+
children: phase.message
|
|
1057
|
+
}) : phase.kind === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1058
|
+
className: "dsm-trae-transfer-status dsm-trae-transfer-error",
|
|
1059
|
+
children: t("row.transferError", { message: phase.message })
|
|
1060
|
+
}) : null,
|
|
1061
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1062
|
+
className: "dsm-trae-transfer-actions",
|
|
1063
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1064
|
+
type: "button",
|
|
1065
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1066
|
+
disabled: busy || accounts.length === 0,
|
|
1067
|
+
onClick: () => {
|
|
1068
|
+
setSelected(/* @__PURE__ */ new Set());
|
|
1069
|
+
setDialog("export");
|
|
1070
|
+
},
|
|
1071
|
+
children: t("row.transferExport")
|
|
1072
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1073
|
+
type: "button",
|
|
1074
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1075
|
+
disabled: busy,
|
|
1076
|
+
onClick: () => {
|
|
1077
|
+
fileInput.current?.click();
|
|
1078
|
+
},
|
|
1079
|
+
children: t("row.transferImport")
|
|
1080
|
+
})]
|
|
1081
|
+
}),
|
|
1082
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1083
|
+
ref: fileInput,
|
|
1084
|
+
type: "file",
|
|
1085
|
+
accept: "application/json,.json",
|
|
1086
|
+
className: "dsm-trae-transfer-file",
|
|
1087
|
+
onChange: (event) => {
|
|
1088
|
+
const file = event.target.files?.[0];
|
|
1089
|
+
event.target.value = "";
|
|
1090
|
+
if (file !== void 0) {
|
|
1091
|
+
setPreview(void 0);
|
|
1092
|
+
setImportSelected(/* @__PURE__ */ new Set());
|
|
1093
|
+
setDialog("import");
|
|
1094
|
+
readImportFile(file);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}),
|
|
1098
|
+
dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1099
|
+
className: "dsm-trae-transfer-dialog",
|
|
1100
|
+
role: "dialog",
|
|
1101
|
+
"aria-label": t("row.transferExport"),
|
|
1102
|
+
children: [
|
|
1103
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferExportPick", { total: accounts.length }) }),
|
|
1104
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1105
|
+
className: "dsm-trae-hint",
|
|
1106
|
+
children: t("row.transferHint")
|
|
1107
|
+
}),
|
|
1108
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
1109
|
+
className: "dsm-trae-transfer-list",
|
|
1110
|
+
children: accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1111
|
+
type: "checkbox",
|
|
1112
|
+
checked: selected.has(account.id),
|
|
1113
|
+
onChange: (event) => {
|
|
1114
|
+
const next = new Set(selected);
|
|
1115
|
+
if (event.target.checked) next.add(account.id);
|
|
1116
|
+
else next.delete(account.id);
|
|
1117
|
+
setSelected(next);
|
|
1118
|
+
}
|
|
1119
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: account.accountName })] }) }, account.id))
|
|
1120
|
+
}),
|
|
1121
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1122
|
+
className: "dsm-trae-transfer-dialog-actions",
|
|
1123
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1124
|
+
type: "button",
|
|
1125
|
+
className: "dsm-btn",
|
|
1126
|
+
disabled: selected.size === 0,
|
|
1127
|
+
onClick: () => {
|
|
1128
|
+
runExport();
|
|
1129
|
+
},
|
|
1130
|
+
children: t("row.transferExportConfirm", { count: selected.size })
|
|
1131
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1132
|
+
type: "button",
|
|
1133
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1134
|
+
onClick: closeDialog,
|
|
1135
|
+
children: t("row.transferCancel")
|
|
1136
|
+
})]
|
|
1137
|
+
}),
|
|
1138
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1139
|
+
type: "button",
|
|
1140
|
+
className: "dsm-trae-transfer-link",
|
|
1141
|
+
onClick: () => setSelected(new Set(accounts.map((account) => account.id))),
|
|
1142
|
+
children: t("row.transferSelectAll")
|
|
1143
|
+
})
|
|
1144
|
+
]
|
|
1145
|
+
}) : null,
|
|
1146
|
+
dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1147
|
+
className: "dsm-trae-transfer-dialog",
|
|
1148
|
+
role: "dialog",
|
|
1149
|
+
"aria-label": t("row.transferImport"),
|
|
1150
|
+
children: preview === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferReading") }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1151
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferImportPick", { total: preview.total }) }),
|
|
1152
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
1153
|
+
className: "dsm-trae-transfer-list",
|
|
1154
|
+
children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1155
|
+
type: "checkbox",
|
|
1156
|
+
checked: importSelected.has(entry.index),
|
|
1157
|
+
disabled: !entry.hasToken,
|
|
1158
|
+
onChange: (event) => {
|
|
1159
|
+
const next = new Set(importSelected);
|
|
1160
|
+
if (event.target.checked) next.add(entry.index);
|
|
1161
|
+
else next.delete(entry.index);
|
|
1162
|
+
setImportSelected(next);
|
|
1163
|
+
}
|
|
1164
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
|
|
1165
|
+
entry.nickname || entry.uid || t("row.transferUnnamed"),
|
|
1166
|
+
entry.email === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1167
|
+
className: "dsm-trae-hint",
|
|
1168
|
+
children: [" · ", entry.email]
|
|
1169
|
+
}),
|
|
1170
|
+
entry.hasToken ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1171
|
+
className: "dsm-trae-hint",
|
|
1172
|
+
children: [" · ", t("row.transferNoToken")]
|
|
1173
|
+
})
|
|
1174
|
+
] })] }) }, entry.index))
|
|
1175
|
+
}),
|
|
1176
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1177
|
+
className: "dsm-trae-transfer-dialog-actions",
|
|
1178
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1179
|
+
type: "button",
|
|
1180
|
+
className: "dsm-btn",
|
|
1181
|
+
disabled: importSelected.size === 0,
|
|
1182
|
+
onClick: () => {
|
|
1183
|
+
runImport();
|
|
1184
|
+
},
|
|
1185
|
+
children: t("row.transferImportConfirm", { count: importSelected.size })
|
|
1186
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1187
|
+
type: "button",
|
|
1188
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1189
|
+
onClick: closeDialog,
|
|
1190
|
+
children: t("row.transferCancel")
|
|
1191
|
+
})]
|
|
1192
|
+
})
|
|
1193
|
+
] })
|
|
1194
|
+
}) : null
|
|
1195
|
+
]
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
//#endregion
|
|
788
1199
|
//#region src/client/plan-text.ts
|
|
789
1200
|
/**
|
|
790
1201
|
* The international region's "what plan is this account on" text.
|
|
@@ -2246,6 +2657,21 @@ window.__ModuleLoader__.load({
|
|
|
2246
2657
|
className: "dsm-trae-usage-note",
|
|
2247
2658
|
role: "status",
|
|
2248
2659
|
children: rescanNote
|
|
2660
|
+
}),
|
|
2661
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
|
|
2662
|
+
t,
|
|
2663
|
+
region: activeRegion,
|
|
2664
|
+
accounts: status.status === "signed-in" ? status.accounts.map((account) => ({
|
|
2665
|
+
id: account.id,
|
|
2666
|
+
accountName: account.accountName
|
|
2667
|
+
})) : [],
|
|
2668
|
+
onChanged: async () => {
|
|
2669
|
+
const fresh = await refreshUsage(activeRegion);
|
|
2670
|
+
if (fresh !== void 0) setStatusByRegion((current) => ({
|
|
2671
|
+
...current,
|
|
2672
|
+
[activeRegion]: fresh
|
|
2673
|
+
}));
|
|
2674
|
+
}
|
|
2249
2675
|
})
|
|
2250
2676
|
]
|
|
2251
2677
|
}),
|
|
@@ -3225,6 +3651,25 @@ window.__ModuleLoader__.load({
|
|
|
3225
3651
|
"row.refreshing": "Refreshing…",
|
|
3226
3652
|
"row.accountsTitle": "Trae account",
|
|
3227
3653
|
"row.accountsHint": "Sign-ins detected on this machine. Tokens are never shown or saved.",
|
|
3654
|
+
"row.transferTitle": "Import & export accounts",
|
|
3655
|
+
"row.transferExport": "Export selected",
|
|
3656
|
+
"row.transferImport": "Import from file",
|
|
3657
|
+
"row.transferCancel": "Cancel",
|
|
3658
|
+
"row.transferSelectAll": "Select all",
|
|
3659
|
+
"row.transferExportPick": "Choose which of the {total} accounts to export.",
|
|
3660
|
+
"row.transferExportConfirm": "Export {count}",
|
|
3661
|
+
"row.transferExported": "Exported {count} account(s) to {fileName}.",
|
|
3662
|
+
"row.transferExporting": "Preparing the export…",
|
|
3663
|
+
"row.transferImportPick": "This file holds {total} record(s). Untick anything you do not want to add.",
|
|
3664
|
+
"row.transferImportConfirm": "Import {count}",
|
|
3665
|
+
"row.transferImported": "Imported {imported} account(s), skipped {skipped}.",
|
|
3666
|
+
"row.transferImporting": "Importing…",
|
|
3667
|
+
"row.transferReading": "Reading the file…",
|
|
3668
|
+
"row.transferHint": "The exported file contains live tokens — treat it as a password. Records belonging to the other region are skipped.",
|
|
3669
|
+
"row.transferError": "Import/export failed: {message}",
|
|
3670
|
+
"row.transferVeilHint": "The account list refreshes when this finishes.",
|
|
3671
|
+
"row.transferUnnamed": "Unnamed account",
|
|
3672
|
+
"row.transferNoToken": "no token, will be skipped",
|
|
3228
3673
|
"row.accountsRescan": "Detect accounts again",
|
|
3229
3674
|
"row.accountsScanning": "Detecting…",
|
|
3230
3675
|
"row.accountsNone": "This region reports no usable account.",
|
|
@@ -3372,6 +3817,25 @@ window.__ModuleLoader__.load({
|
|
|
3372
3817
|
"row.refreshing": "正在刷新…",
|
|
3373
3818
|
"row.accountsTitle": "Trae 账号",
|
|
3374
3819
|
"row.accountsHint": "本机检测到的登录账号;Token 不会显示或保存。",
|
|
3820
|
+
"row.transferTitle": "导入 / 导出账号",
|
|
3821
|
+
"row.transferExport": "导出所选",
|
|
3822
|
+
"row.transferImport": "从文件导入",
|
|
3823
|
+
"row.transferCancel": "取消",
|
|
3824
|
+
"row.transferSelectAll": "全选",
|
|
3825
|
+
"row.transferExportPick": "从这 {total} 个账号中选择要导出的。",
|
|
3826
|
+
"row.transferExportConfirm": "导出 {count} 个",
|
|
3827
|
+
"row.transferExported": "已导出 {count} 个账号到 {fileName}。",
|
|
3828
|
+
"row.transferExporting": "正在准备导出…",
|
|
3829
|
+
"row.transferImportPick": "该文件包含 {total} 条记录,取消勾选不想添加的。",
|
|
3830
|
+
"row.transferImportConfirm": "导入 {count} 个",
|
|
3831
|
+
"row.transferImported": "已导入 {imported} 个账号,跳过 {skipped} 个。",
|
|
3832
|
+
"row.transferImporting": "正在导入…",
|
|
3833
|
+
"row.transferReading": "正在读取文件…",
|
|
3834
|
+
"row.transferHint": "导出的文件里是有效 Token,请当作密码保管。属于另一区域的记录会被跳过。",
|
|
3835
|
+
"row.transferError": "导入/导出失败:{message}",
|
|
3836
|
+
"row.transferVeilHint": "完成后账号列表会自动刷新。",
|
|
3837
|
+
"row.transferUnnamed": "未命名账号",
|
|
3838
|
+
"row.transferNoToken": "无 Token,将被跳过",
|
|
3375
3839
|
"row.accountsRescan": "重新检测账号",
|
|
3376
3840
|
"row.accountsScanning": "正在检测…",
|
|
3377
3841
|
"row.accountsNone": "该区域没有可列出的账号。",
|
package/lib/index.d.ts
CHANGED
|
@@ -698,6 +698,22 @@ declare class TraeCredentialStore {
|
|
|
698
698
|
* fallback, for the same reason {@link current} refuses to fall back.
|
|
699
699
|
*/
|
|
700
700
|
credentialOf(accountId: string): Promise<TraeCredential | undefined>;
|
|
701
|
+
/**
|
|
702
|
+
* Store imported credentials (issue #37), replacing any entry with the same
|
|
703
|
+
* account id and leaving every other entry alone.
|
|
704
|
+
*
|
|
705
|
+
* Why an additive write rather than the refresh path: an imported credential
|
|
706
|
+
* has NO desktop file behind it, so unlike a refresh result it cannot be
|
|
707
|
+
* rediscovered from the machine. If it were written the way a refresh is —
|
|
708
|
+
* as the whole document — importing account B would delete account A, and a
|
|
709
|
+
* later refresh would delete both. `readAll`'s desktop-first preference is
|
|
710
|
+
* therefore also not enough on its own: these entries survive only because
|
|
711
|
+
* `readOwns` reads them back and the dedupe keeps the longest-lived copy.
|
|
712
|
+
*
|
|
713
|
+
* Returns the account ids actually written, so the caller can report how many
|
|
714
|
+
* landed without re-reading the store.
|
|
715
|
+
*/
|
|
716
|
+
importAccounts(credentials: readonly Omit<TraeCredential, 'source'>[]): Promise<string[]>;
|
|
701
717
|
resolve(): Promise<TraeCredential>;
|
|
702
718
|
status(): Promise<{
|
|
703
719
|
state: 'signed-in' | 'signed-out';
|
package/lib/index.js
CHANGED
|
@@ -222,10 +222,8 @@ function displayKey(name) {
|
|
|
222
222
|
function mergeTraeModelSources(remote, wire) {
|
|
223
223
|
const wireByName = /* @__PURE__ */ new Map();
|
|
224
224
|
const wireById = /* @__PURE__ */ new Map();
|
|
225
|
-
for (const model of wire)
|
|
226
|
-
|
|
227
|
-
wireById.set(displayKey(model.id), model);
|
|
228
|
-
}
|
|
225
|
+
for (const model of wire) if (!wireById.has(displayKey(model.id))) wireById.set(displayKey(model.id), model);
|
|
226
|
+
for (const model of wire) if (!wireByName.has(displayKey(model.name))) wireByName.set(displayKey(model.name), model);
|
|
229
227
|
const result = [];
|
|
230
228
|
for (const model of remote) {
|
|
231
229
|
const wireModel = wireById.get(displayKey(model.id)) ?? wireByName.get(displayKey(model.name));
|
|
@@ -831,6 +829,24 @@ const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
|
|
|
831
829
|
* account row.
|
|
832
830
|
*/
|
|
833
831
|
const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
|
|
832
|
+
/**
|
|
833
|
+
* Plugin-owned account transfer endpoint: batch export and import of a region's
|
|
834
|
+
* stored credentials (issue #37).
|
|
835
|
+
*
|
|
836
|
+
* One route with an `action` query parameter (`export` | `preview` | `import`),
|
|
837
|
+
* mirroring the sibling `dsh-connect-workbuddy` plugin's transfer route so the
|
|
838
|
+
* two cards behave the same way for a user who has both installed. The vault
|
|
839
|
+
* document never leaves the Host: the browser receives parsed answers, and a
|
|
840
|
+
* preview carries display fields only — never token material.
|
|
841
|
+
*/
|
|
842
|
+
const TRAE_TRANSFER_PATH = "/plugins/dsh-connect-trae/transfer";
|
|
843
|
+
/** Read the transfer action off a request URL; unknown/absent means undefined. */
|
|
844
|
+
function transferActionOf(url) {
|
|
845
|
+
const at = url.indexOf("?");
|
|
846
|
+
if (at === -1) return void 0;
|
|
847
|
+
const value = new URLSearchParams(url.slice(at + 1)).get("action");
|
|
848
|
+
return value === "export" || value === "preview" || value === "import" ? value : void 0;
|
|
849
|
+
}
|
|
834
850
|
/** Query parameter naming the region a card request addresses. */
|
|
835
851
|
const TRAE_REGION_PARAM = "region";
|
|
836
852
|
/** Every region, in card tab order. */
|
|
@@ -1583,7 +1599,24 @@ function regionOfCredential(credential) {
|
|
|
1583
1599
|
//#region src/auth.ts
|
|
1584
1600
|
/** Host used for CLI tokens, which carry no host claim of their own. */
|
|
1585
1601
|
const CLI_DEFAULT_HOST = "https://api.trae.cn";
|
|
1586
|
-
|
|
1602
|
+
/**
|
|
1603
|
+
* On-disk format of the plugin-owned per-region file.
|
|
1604
|
+
*
|
|
1605
|
+
* Version 1 held exactly ONE credential (`{ version, credential }`), which was
|
|
1606
|
+
* enough while every account came from a Trae install the plugin can discover
|
|
1607
|
+
* itself. Import (issue #37) breaks that assumption: a user can hold several
|
|
1608
|
+
* accounts as transferable files, and they have to live somewhere. Version 2
|
|
1609
|
+
* keeps the same per-region file but stores `{ version, credentials: [...] }`
|
|
1610
|
+
* instead, so the file stays the unit of region isolation (see
|
|
1611
|
+
* {@link traeOwnAuthPath}) while holding as many accounts as were imported.
|
|
1612
|
+
*
|
|
1613
|
+
* Version 1 remains READ (see {@link parseOwn}), so an existing install keeps
|
|
1614
|
+
* working and upgrades itself the next time a credential is written; it is
|
|
1615
|
+
* never written again.
|
|
1616
|
+
*/
|
|
1617
|
+
const OWN_VERSION = 2;
|
|
1618
|
+
/** The superseded single-credential layout, still read for migration. */
|
|
1619
|
+
const OWN_VERSION_SINGLE = 1;
|
|
1587
1620
|
const TRAE_AUTH_FILENAME = ".trae-auth.json";
|
|
1588
1621
|
/** Prefix of the plugin-owned per-region credential copies. */
|
|
1589
1622
|
const TRAE_OWN_PREFIX = ".trae-auth";
|
|
@@ -1646,27 +1679,68 @@ function traeAccountId(credential) {
|
|
|
1646
1679
|
const stable = `${credential.edition}\0${credential.userId || credential.accountName || "unknown"}`;
|
|
1647
1680
|
return createHash("sha256").update(stable).digest("hex").slice(0, 24);
|
|
1648
1681
|
}
|
|
1682
|
+
/** One stored credential, validated. Returns undefined for a malformed row. */
|
|
1683
|
+
function credentialOfStored(stored) {
|
|
1684
|
+
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return void 0;
|
|
1685
|
+
const record = stored;
|
|
1686
|
+
const edition = record["edition"];
|
|
1687
|
+
if (edition !== "cn" && edition !== "sg" && edition !== "solo" && edition !== "solo-sg") return void 0;
|
|
1688
|
+
return normalizeTraeCredential({
|
|
1689
|
+
token: record["accessToken"],
|
|
1690
|
+
refreshToken: record["refreshToken"],
|
|
1691
|
+
userId: record["userId"],
|
|
1692
|
+
host: record["host"],
|
|
1693
|
+
userRegion: record["userRegion"],
|
|
1694
|
+
account: record["accountName"] === void 0 ? void 0 : { username: record["accountName"] },
|
|
1695
|
+
expiredAt: record["expiresAtMs"],
|
|
1696
|
+
refreshExpiredAt: record["refreshExpiredAtMs"]
|
|
1697
|
+
}, edition, "dsh");
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Read the plugin-owned file into its stored credentials.
|
|
1701
|
+
*
|
|
1702
|
+
* Accepts BOTH layouts: version 2 (`credentials: [...]`, the import-capable
|
|
1703
|
+
* shape) and the superseded version 1 (`credential: {...}`). A version-1 file
|
|
1704
|
+
* keeps working until the next write upgrades it, so no migration step is
|
|
1705
|
+
* needed and an older install's file is never rejected. A malformed row is
|
|
1706
|
+
* dropped rather than failing the whole file — one bad imported account must
|
|
1707
|
+
* not hide the rest.
|
|
1708
|
+
*/
|
|
1649
1709
|
function parseOwn(text) {
|
|
1650
1710
|
try {
|
|
1651
1711
|
const document = JSON.parse(text);
|
|
1652
|
-
if (document.version
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
userRegion: stored["userRegion"],
|
|
1662
|
-
account: stored["accountName"] === void 0 ? void 0 : { username: stored["accountName"] },
|
|
1663
|
-
expiredAt: stored["expiresAtMs"],
|
|
1664
|
-
refreshExpiredAt: stored["refreshExpiresAtMs"]
|
|
1665
|
-
}, edition, "dsh");
|
|
1712
|
+
if (document.version === OWN_VERSION) {
|
|
1713
|
+
if (!Array.isArray(document.credentials)) return [];
|
|
1714
|
+
return document.credentials.map(credentialOfStored).filter((value) => value !== void 0);
|
|
1715
|
+
}
|
|
1716
|
+
if (document.version === OWN_VERSION_SINGLE) {
|
|
1717
|
+
const single = credentialOfStored(document.credential);
|
|
1718
|
+
return single === void 0 ? [] : [single];
|
|
1719
|
+
}
|
|
1720
|
+
return [];
|
|
1666
1721
|
} catch {
|
|
1667
|
-
return;
|
|
1722
|
+
return [];
|
|
1668
1723
|
}
|
|
1669
1724
|
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Write the plugin-owned file: every credential of the region, in the version-2
|
|
1727
|
+
* array layout.
|
|
1728
|
+
*
|
|
1729
|
+
* One writer for both paths that persist to this file (a refresh, and an
|
|
1730
|
+
* import) so the read-modify-write in `refreshNow` and the additive write in
|
|
1731
|
+
* {@link TraeCredentialStore.importAccounts} cannot drift apart. Always writes
|
|
1732
|
+
* `OWN_VERSION`, which is what upgrades a version-1 file on its first write.
|
|
1733
|
+
*/
|
|
1734
|
+
async function writeOwn(path, credentials) {
|
|
1735
|
+
const document = {
|
|
1736
|
+
version: OWN_VERSION,
|
|
1737
|
+
credentials: credentials.map((credential) => ({ ...credential }))
|
|
1738
|
+
};
|
|
1739
|
+
await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
|
|
1740
|
+
mode: 384,
|
|
1741
|
+
dirMode: 448
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1670
1744
|
var TraeCredentialStore = class {
|
|
1671
1745
|
storagePathOverride;
|
|
1672
1746
|
edition;
|
|
@@ -1834,6 +1908,46 @@ var TraeCredentialStore = class {
|
|
|
1834
1908
|
if (accountId === "") return void 0;
|
|
1835
1909
|
return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
|
|
1836
1910
|
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Store imported credentials (issue #37), replacing any entry with the same
|
|
1913
|
+
* account id and leaving every other entry alone.
|
|
1914
|
+
*
|
|
1915
|
+
* Why an additive write rather than the refresh path: an imported credential
|
|
1916
|
+
* has NO desktop file behind it, so unlike a refresh result it cannot be
|
|
1917
|
+
* rediscovered from the machine. If it were written the way a refresh is —
|
|
1918
|
+
* as the whole document — importing account B would delete account A, and a
|
|
1919
|
+
* later refresh would delete both. `readAll`'s desktop-first preference is
|
|
1920
|
+
* therefore also not enough on its own: these entries survive only because
|
|
1921
|
+
* `readOwns` reads them back and the dedupe keeps the longest-lived copy.
|
|
1922
|
+
*
|
|
1923
|
+
* Returns the account ids actually written, so the caller can report how many
|
|
1924
|
+
* landed without re-reading the store.
|
|
1925
|
+
*/
|
|
1926
|
+
async importAccounts(credentials) {
|
|
1927
|
+
if (credentials.length === 0) return [];
|
|
1928
|
+
const ownPath = this.ownAuthPath();
|
|
1929
|
+
const written = [];
|
|
1930
|
+
await withFileLock(ownPath, async () => {
|
|
1931
|
+
const existing = await readFile(ownPath, "utf8").then(parseOwn, () => []);
|
|
1932
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1933
|
+
for (const item of existing) byId.set(traeAccountId(item), item);
|
|
1934
|
+
for (const credential of credentials) {
|
|
1935
|
+
const stored = {
|
|
1936
|
+
...credential,
|
|
1937
|
+
source: "dsh"
|
|
1938
|
+
};
|
|
1939
|
+
const id = traeAccountId(stored);
|
|
1940
|
+
byId.set(id, stored);
|
|
1941
|
+
written.push(id);
|
|
1942
|
+
}
|
|
1943
|
+
await writeOwn(ownPath, [...byId.values()]);
|
|
1944
|
+
});
|
|
1945
|
+
if (written.length > 0) {
|
|
1946
|
+
this.accountId = written[written.length - 1];
|
|
1947
|
+
this.inflight = void 0;
|
|
1948
|
+
}
|
|
1949
|
+
return written;
|
|
1950
|
+
}
|
|
1837
1951
|
async resolve() {
|
|
1838
1952
|
const credential = await this.current();
|
|
1839
1953
|
if (credential === void 0) throw new Error(`trae: no signed-in account found (${this.candidates().map((item) => item.path).join(" or ")})`);
|
|
@@ -1978,8 +2092,7 @@ var TraeCredentialStore = class {
|
|
|
1978
2092
|
async readOwns() {
|
|
1979
2093
|
const copies = [];
|
|
1980
2094
|
for (const path of this.ownCandidates()) try {
|
|
1981
|
-
|
|
1982
|
-
if (parsed !== void 0) copies.push(parsed);
|
|
2095
|
+
copies.push(...parseOwn(await readFile(path, "utf8")));
|
|
1983
2096
|
} catch {}
|
|
1984
2097
|
return copies;
|
|
1985
2098
|
}
|
|
@@ -2001,13 +2114,11 @@ var TraeCredentialStore = class {
|
|
|
2001
2114
|
};
|
|
2002
2115
|
const ownPath = this.ownAuthPath();
|
|
2003
2116
|
await withFileLock(ownPath, async () => {
|
|
2004
|
-
await
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
dirMode: 448
|
|
2010
|
-
});
|
|
2117
|
+
const existing = await readFile(ownPath, "utf8").then(parseOwn, () => []);
|
|
2118
|
+
const refreshedId = traeAccountId(refreshed);
|
|
2119
|
+
const entries = existing.filter((item) => traeAccountId(item) !== refreshedId);
|
|
2120
|
+
entries.push(refreshed);
|
|
2121
|
+
await writeOwn(ownPath, entries);
|
|
2011
2122
|
});
|
|
2012
2123
|
return refreshed;
|
|
2013
2124
|
} catch (error) {
|
|
@@ -2928,6 +3039,23 @@ function wireCreditMultiplier(config) {
|
|
|
2928
3039
|
if (typeof data !== "object" || data === null) return void 0;
|
|
2929
3040
|
return finitePositive$1(data["rate"]);
|
|
2930
3041
|
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Thrown by {@link TraeSoloUpstreamClient.fetchModels} when every requested
|
|
3044
|
+
* directory function answered HTTP 200 but with an EMPTY `config_info_list`
|
|
3045
|
+
* (no rows at all), so the aggregate roster is empty. This is issue #39's root
|
|
3046
|
+
* signal: the upstream has published NO SOLO function config for this account /
|
|
3047
|
+
* route. It is a server-side, usually transient gap — the reporter's roster
|
|
3048
|
+
* returned empty and later self-resolved with rows per function — NOT a local
|
|
3049
|
+
* credential, plugin or account fault, and not something to re-login for. The
|
|
3050
|
+
* caller can catch this type to tell "the upstream published no config" apart
|
|
3051
|
+
* from "all directory functions errored" (which surfaces as a generic failure).
|
|
3052
|
+
*/
|
|
3053
|
+
var TraeEmptySoloDirectory = class extends Error {
|
|
3054
|
+
name = "TraeEmptySoloDirectory";
|
|
3055
|
+
constructor() {
|
|
3056
|
+
super("Trae’s server returned NO SOLO function config (an empty directory for every requested function) — an upstream-side gap that usually clears on its own; retry later instead of re-checking credentials or the plugin");
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
2931
3059
|
var TraeSoloUpstreamClient = class {
|
|
2932
3060
|
options;
|
|
2933
3061
|
fetchImpl;
|
|
@@ -2984,7 +3112,10 @@ var TraeSoloUpstreamClient = class {
|
|
|
2984
3112
|
this.collectModels(list, directoryFunction, byId);
|
|
2985
3113
|
}
|
|
2986
3114
|
const models = [...byId.values()];
|
|
2987
|
-
if (models.length === 0)
|
|
3115
|
+
if (models.length === 0) {
|
|
3116
|
+
if (failures.length === 0) throw new TraeEmptySoloDirectory();
|
|
3117
|
+
throw new Error(`Trae SOLO models response contained no models (${failures.join("; ")})`);
|
|
3118
|
+
}
|
|
2988
3119
|
return models;
|
|
2989
3120
|
}
|
|
2990
3121
|
/** Merge one function's config list into the shared catalogue (first wins). */
|
|
@@ -3210,9 +3341,20 @@ function decodeTraeEvent(event) {
|
|
|
3210
3341
|
* two different statements, and a user hits the second without touching the
|
|
3211
3342
|
* first (issue #33).
|
|
3212
3343
|
* - `4023` — the model name is unknown on the route that was asked.
|
|
3344
|
+
* - `2001` — the upstream has not published its function config for the route
|
|
3345
|
+
* that was asked. Evidence is issue #39: on a live CN account every SOLO
|
|
3346
|
+
* function's `get_detail_param` answered HTTP 200 with `config_info_list:
|
|
3347
|
+
* []` (an empty directory), and each chat call failed with
|
|
3348
|
+
* `{"code":2001,"message":"no function config found for appId=...,
|
|
3349
|
+
* function=solo_work_remote"}` (and the variant `[LLMUtilsChat.resolveByUsage]
|
|
3350
|
+
* function is empty, cannot resolve model by usage=`). The roster came back
|
|
3351
|
+
* empty and then self-resolved later with rows per function, so this is a
|
|
3352
|
+
* TRANSIENT server-side gap the user can wait out or retry — never a local
|
|
3353
|
+
* credential, plugin or account fault, and not something to re-login for.
|
|
3213
3354
|
*/
|
|
3214
3355
|
const TRAE_ERROR_HINTS = {
|
|
3215
3356
|
1005: "Trae requires a paid plan for this model; the current account's plan does not cover it",
|
|
3357
|
+
2001: "Trae has not published its function config for this route (an upstream condition, not a local credential or config problem)",
|
|
3216
3358
|
4001: "Trae does not serve this model under the SOLO function the request used (config_name rejected)",
|
|
3217
3359
|
4008: "Trae reports the account's SOLO quota is exhausted for this request",
|
|
3218
3360
|
4011: "Trae throttled the SOLO channel for this account (a rate limit, not the credit balance)",
|
|
@@ -4798,6 +4940,268 @@ var TraeUsageClient = class {
|
|
|
4798
4940
|
}
|
|
4799
4941
|
};
|
|
4800
4942
|
//#endregion
|
|
4943
|
+
//#region src/transfer.ts
|
|
4944
|
+
/**
|
|
4945
|
+
* Account transfer: the export / import file format shared with sibling
|
|
4946
|
+
* credential managers for the same upstream (dsh-connect-workbuddy ships the
|
|
4947
|
+
* identical contract so export files interoperate).
|
|
4948
|
+
*
|
|
4949
|
+
* 参考:通行实现 —— 文件是一个 JSON 数组,每项为一条凭据记录,顶层字段
|
|
4950
|
+
* snake_case(`access_token` / `refresh_token` / `uid` / `nickname` /
|
|
4951
|
+
* `email` / `domain` / `expiresAt`(ms) / `refreshExpiresAt`(ms) /
|
|
4952
|
+
* `auth_raw`),导入按 account id 去重(同 id 覆盖),缺 `access_token`
|
|
4953
|
+
* 的记录跳过;预览只回脱敏字段(含 `hasToken` 布尔量),不回明文。解析
|
|
4954
|
+
* 函数不依赖文件系统,便于无 UI 环境单测;落库由 Host 侧的 store 完成。
|
|
4955
|
+
*
|
|
4956
|
+
* 互通 / 单位边界:`expiresAt` 一族按契约是**毫秒**。workbuddy 的 vault 文档
|
|
4957
|
+
* 存的是秒,需要在导出/导入边界换算;trae 的 `TraeCredential.expiresAtMs`
|
|
4958
|
+
* (见 auth.ts 的 `timeToMs`)**本身就是毫秒**,所以这里**不做任何
|
|
4959
|
+
* 乘除**——刻意与 workbuddy 不同,读者对比两文件时不要误以为换算被漏掉了。
|
|
4960
|
+
* 导出时 `auth_raw` 直接携带原始认证文档(桌面 storage 文档或 `{auth,
|
|
4961
|
+
* account}` 双键块),供写入桌面认证文件的工具原样消费。
|
|
4962
|
+
*
|
|
4963
|
+
* @module dsh-connect-trae/transfer
|
|
4964
|
+
*/
|
|
4965
|
+
/**
|
|
4966
|
+
* Parse transfer-file text: a JSON array, every element an object.
|
|
4967
|
+
*
|
|
4968
|
+
* The error strings carry the position (1-based item number) because a file
|
|
4969
|
+
* from another tool can be big, and "第 N 项" is what lets the user find the
|
|
4970
|
+
* broken record in their editor.
|
|
4971
|
+
*/
|
|
4972
|
+
function parseTransferFile(text) {
|
|
4973
|
+
if (text.trim() === "") throw new Error("transfer file is empty");
|
|
4974
|
+
let parsed;
|
|
4975
|
+
try {
|
|
4976
|
+
parsed = JSON.parse(text);
|
|
4977
|
+
} catch (error) {
|
|
4978
|
+
throw new Error("transfer file is not valid JSON: " + (error instanceof Error ? error.message : String(error)));
|
|
4979
|
+
}
|
|
4980
|
+
if (!Array.isArray(parsed)) throw new Error("transfer file must be a JSON array of account records");
|
|
4981
|
+
for (const [index, item] of parsed.entries()) if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("transfer file item " + (index + 1) + " is not an account object");
|
|
4982
|
+
return parsed;
|
|
4983
|
+
}
|
|
4984
|
+
/** One string field of a record, tolerating the sibling format's odd shapes. */
|
|
4985
|
+
function displayString(value) {
|
|
4986
|
+
if (typeof value === "string") return value;
|
|
4987
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
4988
|
+
return "";
|
|
4989
|
+
}
|
|
4990
|
+
/** Whether a record carries a usable token (a non-empty string). */
|
|
4991
|
+
function hasToken(record) {
|
|
4992
|
+
const token = record["access_token"];
|
|
4993
|
+
return typeof token === "string" && token !== "";
|
|
4994
|
+
}
|
|
4995
|
+
/**
|
|
4996
|
+
* The transfer file's desensitized preview: display fields only.
|
|
4997
|
+
*
|
|
4998
|
+
* Fields come from EXTERNAL files, so a display field may hold an object or an
|
|
4999
|
+
* array (a sibling tool can store the desktop app's encrypted-wrapper nickname
|
|
5000
|
+
* verbatim); every preview field is normalized to a string or emptied rather
|
|
5001
|
+
* than letting a non-renderable value reach the card.
|
|
5002
|
+
*/
|
|
5003
|
+
function previewTransferFile(text) {
|
|
5004
|
+
const records = parseTransferFile(text);
|
|
5005
|
+
return {
|
|
5006
|
+
accounts: records.map((record, index) => ({
|
|
5007
|
+
index,
|
|
5008
|
+
uid: displayString(record["uid"]),
|
|
5009
|
+
nickname: displayString(record["nickname"]),
|
|
5010
|
+
email: displayString(record["email"]),
|
|
5011
|
+
hasToken: hasToken(record)
|
|
5012
|
+
})),
|
|
5013
|
+
total: records.length
|
|
5014
|
+
};
|
|
5015
|
+
}
|
|
5016
|
+
/**
|
|
5017
|
+
* Positive-match region claim for an IMPORTED record: the record's own
|
|
5018
|
+
* `userRegion` claim wins, the host suffix is the fallback, and anything
|
|
5019
|
+
* unrecognised (empty, misspelt, or other-region) yields `undefined` — never a
|
|
5020
|
+
* default.
|
|
5021
|
+
*
|
|
5022
|
+
* This is the security-critical difference from `regionOfCredential`, which
|
|
5023
|
+
* classifies anything unrecognised as `cn` as a last resort (via the edition
|
|
5024
|
+
* label). That fallback is right for upstream-sourced data, whose host/region
|
|
5025
|
+
* came out of the real Trae install, but wrong for an import whose values are
|
|
5026
|
+
* simply whatever the file's author wrote: a spoofed or misspelt `domain`
|
|
5027
|
+
* would otherwise be admitted into the CN vault and cross-bill the CN provider
|
|
5028
|
+
* stack with a credential that belongs to no real CN account. So this helper
|
|
5029
|
+
* reuses `regionOfUserRegion` / `regionOfHost` — both of which already return
|
|
5030
|
+
* `undefined` on unrecognised input — and deliberately does NOT consult the
|
|
5031
|
+
* edition fallback.
|
|
5032
|
+
*/
|
|
5033
|
+
function knownRegionOfCredential(credential) {
|
|
5034
|
+
return regionOfUserRegion(credential.userRegion) ?? regionOfHost(credential.host);
|
|
5035
|
+
}
|
|
5036
|
+
/**
|
|
5037
|
+
* Project one credential into the shared transfer record shape.
|
|
5038
|
+
*
|
|
5039
|
+
* `expiresAt` / `refreshExpiresAt` are emitted in epoch MILLISECONDS — the
|
|
5040
|
+
* transfer contract's unit. Unlike workbuddy — whose vault stored seconds and
|
|
5041
|
+
* had to convert here — trae's `expiresAtMs` is already milliseconds, so the
|
|
5042
|
+
* value is carried through verbatim (documented above; do not rescale).
|
|
5043
|
+
* `auth_raw` carries the original auth document so a tool that writes desktop
|
|
5044
|
+
* auth files can consume it verbatim.
|
|
5045
|
+
*/
|
|
5046
|
+
function traeTransferRecordOf(credential, authRawDocument) {
|
|
5047
|
+
return {
|
|
5048
|
+
access_token: credential.accessToken,
|
|
5049
|
+
...credential.refreshToken !== void 0 && credential.refreshToken !== "" ? { refresh_token: credential.refreshToken } : {},
|
|
5050
|
+
...credential.userId !== "" ? { uid: credential.userId } : {},
|
|
5051
|
+
...credential.accountName !== void 0 ? { nickname: credential.accountName } : {},
|
|
5052
|
+
...credential.host !== "" ? { domain: credential.host } : {},
|
|
5053
|
+
...credential.expiresAtMs > 0 ? { expiresAt: credential.expiresAtMs } : {},
|
|
5054
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: credential.refreshExpiresAtMs },
|
|
5055
|
+
auth_raw: authRawDocument
|
|
5056
|
+
};
|
|
5057
|
+
}
|
|
5058
|
+
/** The `userId` claim from a record: the flat `uid`, else recovered from `auth_raw`. */
|
|
5059
|
+
function userIdOfRaw(record) {
|
|
5060
|
+
const flat = displayString(record["uid"]);
|
|
5061
|
+
if (flat !== "") return flat;
|
|
5062
|
+
const root = rawRoot(record["auth_raw"]);
|
|
5063
|
+
if (root === void 0) return "";
|
|
5064
|
+
return displayString(root["userId"]) ?? "";
|
|
5065
|
+
}
|
|
5066
|
+
/** The `accountName` claim: the flat `nickname`, else recovered from `auth_raw`. */
|
|
5067
|
+
function accountNameOfRaw(record) {
|
|
5068
|
+
const flat = displayString(record["nickname"]);
|
|
5069
|
+
if (flat !== "") return flat;
|
|
5070
|
+
const root = rawRoot(record["auth_raw"]);
|
|
5071
|
+
if (root === void 0) return void 0;
|
|
5072
|
+
const fromAccount = displayString((typeof root["account"] === "object" && root["account"] !== null ? root["account"] : void 0)?.["username"]);
|
|
5073
|
+
if (fromAccount !== "") return fromAccount;
|
|
5074
|
+
return displayString(root["username"]);
|
|
5075
|
+
}
|
|
5076
|
+
/** The raw `userRegion` claim string from `auth_raw` (object `{region}` or bare value). */
|
|
5077
|
+
function userRegionOfRaw(raw) {
|
|
5078
|
+
const root = rawRoot(raw);
|
|
5079
|
+
if (root === void 0) return void 0;
|
|
5080
|
+
const value = root["userRegion"];
|
|
5081
|
+
const claim = typeof value === "object" && value !== null && !Array.isArray(value) ? value["region"] : value;
|
|
5082
|
+
return typeof claim === "string" && claim.trim() !== "" ? claim.trim() : void 0;
|
|
5083
|
+
}
|
|
5084
|
+
/**
|
|
5085
|
+
* A record's `auth_raw` root, tolerating both the nested `{auth, account}` block
|
|
5086
|
+
* this plugin exports and a raw desktop storage document: `account` may be a
|
|
5087
|
+
* sub-object (the block shape) or absent (flat document shape).
|
|
5088
|
+
*/
|
|
5089
|
+
function rawRoot(raw) {
|
|
5090
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
5091
|
+
return raw;
|
|
5092
|
+
}
|
|
5093
|
+
/**
|
|
5094
|
+
* Edition derived from an imported record's `domain` host, positively.
|
|
5095
|
+
*
|
|
5096
|
+
* The shared transfer format has no edition field (only `domain`), so a
|
|
5097
|
+
* re-import must re-derive the routing label from the host. Only the two base
|
|
5098
|
+
* editions are ever assigned — a flat `domain` + `uid` cannot distinguish the
|
|
5099
|
+
* SOLO variants — so this is inherently lossy for SOLO accounts; that is
|
|
5100
|
+
* acceptable because the region gate (see `mergeTransferRecords`) never
|
|
5101
|
+
* consults edition, and the account id's edition component only affects whether
|
|
5102
|
+
* a re-imported account converges on the same stored id (which it does as long
|
|
5103
|
+
* as the host is positively classified).
|
|
5104
|
+
*/
|
|
5105
|
+
function editionOfDomain(host) {
|
|
5106
|
+
const region = regionOfHost(host);
|
|
5107
|
+
if (region === "ai") return "sg";
|
|
5108
|
+
if (region === "cn") return "cn";
|
|
5109
|
+
}
|
|
5110
|
+
/**
|
|
5111
|
+
* Project one transfer record into a credential, or undefined when the record
|
|
5112
|
+
* carries no token (the importer's skip rule).
|
|
5113
|
+
*
|
|
5114
|
+
* `userId` empty is tolerated at THIS layer because a token-only record still
|
|
5115
|
+
* describes a usable credential; the Host's region gate decides whether it is
|
|
5116
|
+
* representable, and the store's account id (`edition \0 userId || accountName`)
|
|
5117
|
+
* decides whether it is addressable. An unopenable `auth_raw` is ignored — the
|
|
5118
|
+
* flat fields are the contract, `auth_raw` is extra fidelity for the write-back
|
|
5119
|
+
* path.
|
|
5120
|
+
*
|
|
5121
|
+
* `edition` is restored positively from the domain host (see `editionOfDomain`),
|
|
5122
|
+
* defaulting to `cn` ONLY as a neutral placeholder when the host is not
|
|
5123
|
+
* classifiable — this is safe because the region gate never consults edition,
|
|
5124
|
+
* and such a record is always `skipped` there before edition could matter.
|
|
5125
|
+
*/
|
|
5126
|
+
function credentialOfTransferRecord(record) {
|
|
5127
|
+
const accessToken = record["access_token"];
|
|
5128
|
+
if (typeof accessToken !== "string" || accessToken === "") return void 0;
|
|
5129
|
+
const refreshToken = typeof record["refresh_token"] === "string" ? record["refresh_token"] : "";
|
|
5130
|
+
const expiresAtMs = typeof record["expiresAt"] === "number" && Number.isFinite(record["expiresAt"]) && record["expiresAt"] > 0 ? record["expiresAt"] : 0;
|
|
5131
|
+
const refreshExpiresAtMs = typeof record["refreshExpiresAt"] === "number" && Number.isFinite(record["refreshExpiresAt"]) && record["refreshExpiresAt"] > 0 ? record["refreshExpiresAt"] : void 0;
|
|
5132
|
+
const userId = userIdOfRaw(record);
|
|
5133
|
+
const accountName = accountNameOfRaw(record);
|
|
5134
|
+
const host = typeof record["domain"] === "string" ? record["domain"] : "";
|
|
5135
|
+
const edition = editionOfDomain(host) ?? "cn";
|
|
5136
|
+
const userRegion = userRegionOfRaw(record["auth_raw"]);
|
|
5137
|
+
return {
|
|
5138
|
+
accessToken,
|
|
5139
|
+
...refreshToken === "" ? {} : { refreshToken },
|
|
5140
|
+
userId,
|
|
5141
|
+
...accountName === void 0 ? {} : { accountName },
|
|
5142
|
+
host,
|
|
5143
|
+
...userRegion === void 0 ? {} : { userRegion },
|
|
5144
|
+
expiresAtMs,
|
|
5145
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
5146
|
+
edition
|
|
5147
|
+
};
|
|
5148
|
+
}
|
|
5149
|
+
/** Default gate so the merge is secure even when the caller omits the judge. */
|
|
5150
|
+
const defaultRegionJudge = knownRegionOfCredential;
|
|
5151
|
+
/**
|
|
5152
|
+
* Pure merge: fold selected records into a CHANGES map keyed by account id.
|
|
5153
|
+
*
|
|
5154
|
+
* Mirrors the sibling merger's contract: a record with the same account id
|
|
5155
|
+
* OVERWRITES the stored entry (the imported tokens replace what is stored —
|
|
5156
|
+
* re-importing a re-login must refresh it; the write layer stores the record
|
|
5157
|
+
* atomically, so "overwrite" needs no knowledge of the incumbent), and a
|
|
5158
|
+
* record without a token is skipped. The region gate lives HERE rather than in
|
|
5159
|
+
* the route so it is testable without a Host.
|
|
5160
|
+
*
|
|
5161
|
+
* The region gate is a POSITIVE match: a record is kept only when its OWN
|
|
5162
|
+
* region claim is positively known to equal the target region. `regionOfCredential`
|
|
5163
|
+
* classifies anything unrecognised as `cn`, which is fine for upstream-sourced
|
|
5164
|
+
* data but WRONG for an imported file whose host/region are author-controlled —
|
|
5165
|
+
* a spoofed or misspelt value would otherwise be admitted into the CN vault.
|
|
5166
|
+
* Here the injected `knownRegionOfCredential` (defaulting to the exported
|
|
5167
|
+
* positive-match helper, which never consults the edition fallback) returns
|
|
5168
|
+
* `undefined` for an empty, unrecognised, or other-region claim, and such a
|
|
5169
|
+
* record counts as `skipped` rather than being silently imported cross-region.
|
|
5170
|
+
*
|
|
5171
|
+
* The caller writes one store file per map entry, so a selection that is
|
|
5172
|
+
* already stored rewrites those files with identical content — idempotent,
|
|
5173
|
+
* and the steady state of importing the same file twice.
|
|
5174
|
+
*/
|
|
5175
|
+
function mergeTransferRecords(records, indexes, region, knownRegionOfCredential = defaultRegionJudge) {
|
|
5176
|
+
const changes = /* @__PURE__ */ new Map();
|
|
5177
|
+
let imported = 0;
|
|
5178
|
+
let skipped = 0;
|
|
5179
|
+
for (const index of indexes) {
|
|
5180
|
+
const record = records[index];
|
|
5181
|
+
if (record === void 0) {
|
|
5182
|
+
skipped += 1;
|
|
5183
|
+
continue;
|
|
5184
|
+
}
|
|
5185
|
+
const credential = credentialOfTransferRecord(record);
|
|
5186
|
+
if (credential === void 0) {
|
|
5187
|
+
skipped += 1;
|
|
5188
|
+
continue;
|
|
5189
|
+
}
|
|
5190
|
+
if (knownRegionOfCredential(credential) !== region) {
|
|
5191
|
+
skipped += 1;
|
|
5192
|
+
continue;
|
|
5193
|
+
}
|
|
5194
|
+
const id = traeAccountId(credential);
|
|
5195
|
+
changes.set(id, credential);
|
|
5196
|
+
imported += 1;
|
|
5197
|
+
}
|
|
5198
|
+
return {
|
|
5199
|
+
changes,
|
|
5200
|
+
imported,
|
|
5201
|
+
skipped
|
|
5202
|
+
};
|
|
5203
|
+
}
|
|
5204
|
+
//#endregion
|
|
4801
5205
|
//#region src/web-status.ts
|
|
4802
5206
|
/**
|
|
4803
5207
|
* The upstream's business code for "该设备今日已参与签到" — this DEVICE already
|
|
@@ -4882,6 +5286,35 @@ async function readAccountBody(req) {
|
|
|
4882
5286
|
if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("body must carry an accountId string");
|
|
4883
5287
|
return accountId.trim();
|
|
4884
5288
|
}
|
|
5289
|
+
/**
|
|
5290
|
+
* Read a transfer-file POST body: the raw file TEXT plus the record indexes the
|
|
5291
|
+
* user selected, or (for `export`) the account ids to include.
|
|
5292
|
+
*
|
|
5293
|
+
* The ceiling is much larger than the other readers' because this body IS a
|
|
5294
|
+
* user-chosen file rather than a couple of ids — a credential export from a
|
|
5295
|
+
* sibling tool can legitimately be hundreds of kilobytes. It is still bounded so
|
|
5296
|
+
* a malformed or hostile request cannot make the Host buffer without limit.
|
|
5297
|
+
*/
|
|
5298
|
+
async function readTransferBody(req) {
|
|
5299
|
+
const chunks = [];
|
|
5300
|
+
let size = 0;
|
|
5301
|
+
for await (const chunk of req) {
|
|
5302
|
+
const buffer = chunk;
|
|
5303
|
+
size += buffer.byteLength;
|
|
5304
|
+
if (size > 4194304) throw new Error("request body too large");
|
|
5305
|
+
chunks.push(buffer);
|
|
5306
|
+
}
|
|
5307
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
5308
|
+
if (text === "") return {};
|
|
5309
|
+
let parsed;
|
|
5310
|
+
try {
|
|
5311
|
+
parsed = JSON.parse(text);
|
|
5312
|
+
} catch {
|
|
5313
|
+
throw new Error("invalid JSON body");
|
|
5314
|
+
}
|
|
5315
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("body must be a JSON object");
|
|
5316
|
+
return parsed;
|
|
5317
|
+
}
|
|
4885
5318
|
/** Loopback browser origins only; other devices are refused until trusted origins exist. */
|
|
4886
5319
|
function loopbackOrigin(req) {
|
|
4887
5320
|
const origin = req.headers.origin;
|
|
@@ -5252,6 +5685,67 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5252
5685
|
}
|
|
5253
5686
|
}
|
|
5254
5687
|
});
|
|
5688
|
+
/**
|
|
5689
|
+
* Batch credential export / import (issue #37).
|
|
5690
|
+
*
|
|
5691
|
+
* The Host owns every byte of the credential store; the browser only receives
|
|
5692
|
+
* parsed answers. `preview` necessarily returns identifiers the card shows
|
|
5693
|
+
* (uid / nickname / email) plus a `hasToken` boolean — never a token — and
|
|
5694
|
+
* `import` re-parses the file server-side rather than trusting the preview,
|
|
5695
|
+
* so a tampered browser cannot smuggle in a record the preview never showed.
|
|
5696
|
+
*/
|
|
5697
|
+
const disposeTransfer = ctx.webServer.register({
|
|
5698
|
+
kind: "exact",
|
|
5699
|
+
path: TRAE_TRANSFER_PATH,
|
|
5700
|
+
handler: async (req, res) => {
|
|
5701
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
5702
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5703
|
+
const region = requestRegion(req, res);
|
|
5704
|
+
if (region === void 0) return;
|
|
5705
|
+
const action = transferActionOf(req.url ?? "");
|
|
5706
|
+
if (action === void 0) return json(res, 400, { error: "unknown transfer action" });
|
|
5707
|
+
try {
|
|
5708
|
+
const body = await readTransferBody(req);
|
|
5709
|
+
if (action === "export") {
|
|
5710
|
+
const wanted = new Set(Array.isArray(body["accountIds"]) ? body["accountIds"].filter((id) => typeof id === "string") : []);
|
|
5711
|
+
const store = deps.store(region);
|
|
5712
|
+
const records = [];
|
|
5713
|
+
for (const account of await store.accounts()) {
|
|
5714
|
+
if (!wanted.has(account.id)) continue;
|
|
5715
|
+
const credential = await store.credentialOf(account.id).catch(() => void 0);
|
|
5716
|
+
if (credential === void 0) continue;
|
|
5717
|
+
records.push(traeTransferRecordOf(credential, {
|
|
5718
|
+
auth: {
|
|
5719
|
+
accessToken: credential.accessToken,
|
|
5720
|
+
...credential.refreshToken === void 0 ? {} : { refreshToken: credential.refreshToken },
|
|
5721
|
+
expiresAt: credential.expiresAtMs > 0 ? Math.floor(credential.expiresAtMs / 1e3) : 0,
|
|
5722
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: Math.floor(credential.refreshExpiresAtMs / 1e3) },
|
|
5723
|
+
host: credential.host,
|
|
5724
|
+
...credential.userRegion === void 0 ? {} : { userRegion: credential.userRegion }
|
|
5725
|
+
},
|
|
5726
|
+
account: {
|
|
5727
|
+
userId: credential.userId,
|
|
5728
|
+
...credential.accountName === void 0 ? {} : { username: credential.accountName },
|
|
5729
|
+
edition: credential.edition
|
|
5730
|
+
}
|
|
5731
|
+
}));
|
|
5732
|
+
}
|
|
5733
|
+
return json(res, 200, { accounts: records });
|
|
5734
|
+
}
|
|
5735
|
+
const text = typeof body["text"] === "string" ? body["text"] : "";
|
|
5736
|
+
if (action === "preview") return json(res, 200, previewTransferFile(text));
|
|
5737
|
+
const indexes = Array.isArray(body["indexes"]) ? body["indexes"].filter((index) => typeof index === "number" && Number.isInteger(index)) : [];
|
|
5738
|
+
const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes, region);
|
|
5739
|
+
if (changes.size > 0) await deps.store(region).importAccounts([...changes.values()]);
|
|
5740
|
+
json(res, 200, {
|
|
5741
|
+
imported,
|
|
5742
|
+
skipped
|
|
5743
|
+
});
|
|
5744
|
+
} catch (error) {
|
|
5745
|
+
json(res, 400, { error: safeMessage(error) });
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
});
|
|
5255
5749
|
const disposeRefresh = ctx.webServer.register({
|
|
5256
5750
|
kind: "exact",
|
|
5257
5751
|
path: TRAE_MODELS_REFRESH_PATH,
|
|
@@ -5389,6 +5883,7 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5389
5883
|
return () => {
|
|
5390
5884
|
disposeCheckin();
|
|
5391
5885
|
disposeRefresh();
|
|
5886
|
+
disposeTransfer();
|
|
5392
5887
|
disposeModelsTest();
|
|
5393
5888
|
disposeAccounts();
|
|
5394
5889
|
disposeAccountCredits();
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-trae",
|
|
3
3
|
"displayName": "DSH Connect Trae",
|
|
4
4
|
"description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.6.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|