creditdaddy 1.1.1 → 1.3.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/src/providers.js CHANGED
@@ -1,181 +1,216 @@
1
- /**
2
- * 产品线注册表 — 按账号 provider 分发签到 / 积分 / 校验。
3
- *
4
- * 每个产品实现(checkin 可缺省:该产品不参与每日签到,如 ZCode):
5
- * checkin(account, ctx) → { status: checked-in|already|limited|no-activity|failed, … }
6
- * quota(account, ctx) → { total, used, remaining, exceeded, parts: [{ name, total, used, remaining, expiresAt, recurring }] }
7
- * verify(account) → { name?, uid?, email? }(添加账号时补全资料,失败抛错)
8
- * ctx.onRefresh(creds):产品在 token 刷新后回调,由调用方负责持久化。
9
- */
10
-
11
- import { productOf } from './constants.js';
12
- import { checkinOne as checkinQoder, fetchQuotaUsage, fetchUserinfo, fetchUsageDetail } from './qoderClient.js';
13
- import { checkinWorkbuddy, checkinWorkbuddyIntl, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
14
- import { fetchZcodeQuota } from './zcodeClient.js';
15
- import { fetchMirasimQuota, fetchMirasimProfile } from './mirasimClient.js';
16
- import { fetchCatpawQuota, fetchCatpawProfile } from './catpawClient.js';
17
- /** 从 userinfo 响应中挑一个可读的显示名 */
18
- export function displayNameFrom(ui) {
19
- const pick = [ui?.nickname, ui?.name, ui?.username, ui?.email]
20
- .map((v) => (typeof v === 'string' ? v.trim() : ''))
21
- .find(Boolean);
22
- return pick || null;
23
- }
24
-
25
- /** Qoder /api/v2/quota/usage → 统一积分结构;detail 为网页端逐资源包明细(可选,已按 uid 校验) */
26
- export function normalizeQoderQuota(q, detail = null) {
27
- const names = [['userQuota', '套餐额度', true], ['addOnQuota', '附加额度', false], ['orgResourcePackage', '组织资源包', false]];
28
- const fmtExpiry = (v) => (Number(v) > 0 && Number(v) < 253400000000000 ? new Date(Number(v)).toISOString() : null);
29
- let parts = names
30
- .map(([k, name, recurring]) => ({ x: q?.[k], name, recurring }))
31
- .filter(({ x }) => x && Number(x.total) > 0)
32
- .map(({ x, name, recurring }) => ({
33
- name, recurring,
34
- total: Number(x.total) || 0,
35
- used: Number(x.used) || 0,
36
- remaining: Number(x.remaining) || 0,
37
- expiresAt: fmtExpiry(q?.expiresAt),
38
- }));
39
- // 网页端明细可用时按「每个资源包一条」展开(含各自到期时间),比聚合值更精确
40
- const detailParts = detail ? detailQuotaParts(detail, fmtExpiry) : null;
41
- if (detailParts) parts = detailParts;
42
- const sum = (k) => Math.round(parts.reduce((s, p) => s + p[k], 0) * 100) / 100;
43
- return { total: sum('total'), used: sum('used'), remaining: sum('remaining'), parts, exceeded: Boolean(q?.isQuotaExceeded) };
44
- }
45
-
46
- /** 网页端用量明细的分组 → 统一 parts(组内为空 / 全 0 时返回 null 让调用方回落聚合数据) */
47
- const DETAIL_SOURCE_NAME = {
48
- PLAN: '套餐额度',
49
- RESOURCE_PACKAGE_SOURCE_BONUS: '获赠资源包',
50
- RESOURCE_PACKAGE_SOURCE_PURCHASE: '购买资源包',
51
- RESOURCE_PACKAGE_SOURCE_ORG: '组织资源包',
52
- };
53
- function detailQuotaParts(detail, fmtExpiry) {
54
- const groups = [['plan_quota', '套餐额度', true], ['resource_package_quota', '资源包', false], ['dedicated_resource_package_quota', '组织资源包', false]];
55
- const parts = [];
56
- for (const [key, fallbackName, recurring] of groups) {
57
- const rows = detail?.[key]?.quota_detail;
58
- if (!Array.isArray(rows)) continue;
59
- for (const r of rows) {
60
- const total = Number(r?.limit_value) || 0;
61
- if (total <= 0) continue;
62
- parts.push({
63
- name: DETAIL_SOURCE_NAME[r?.source] || fallbackName,
64
- recurring,
65
- total,
66
- used: Number(r?.used_value) || 0,
67
- remaining: Number(r?.remaining_value) || 0,
68
- expiresAt: fmtExpiry(r?.expires_at),
69
- });
70
- }
71
- }
72
- return parts.length ? parts : null;
73
- }
74
-
75
- /**
76
- * 网页端逐资源包明细(best-effort):无会话 Cookie 返回 null;
77
- * 会话失效(401/403)时清除 Cookie 并回调 ctx.onWebSessionExpired 提示重新登录,同样回落。
78
- * 明细里的 user_id 与账号 uid 不一致时丢弃(防止把别人的资源包显示到这个账号上)。
79
- */
80
- async function qoderWebDetail(account, ctx = {}) {
81
- try {
82
- const d = await fetchUsageDetail(account);
83
- if (!d) return null;
84
- if (account.uid && d.user_id && d.user_id !== account.uid) {
85
- ctx.log?.('网页会话与账号不匹配,忽略资源包明细');
86
- return null;
87
- }
88
- return d;
89
- } catch (e) {
90
- if (e.auth) {
91
- await clearQoderWebSession(account, ctx);
92
- ctx.log?.('Qoder 网页会话已失效,无法显示逐资源包明细(面板里重新登录一次即可)');
93
- }
94
- return null;
95
- }
96
- }
97
-
98
- /** 删除账号上的网页会话 Cookie(失效时),并通知桌面壳可以重新抓取 */
99
- async function clearQoderWebSession(account, ctx = {}) {
100
- const { withAccounts } = await import('./store.js');
101
- await withAccounts((list) => {
102
- const cur = list.find((a) => a.id === account.id);
103
- if (cur?.meta?.qoderWebSession) {
104
- const { qoderWebSession, ...meta } = cur.meta;
105
- cur.meta = meta;
106
- cur.updatedAt = new Date().toISOString();
107
- if (account.meta) delete account.meta.qoderWebSession;
108
- }
109
- });
110
- ctx.onWebSessionExpired?.();
111
- }
112
-
113
- const PRODUCTS = {
114
- qoder: {
115
- label: 'Qoder',
116
- checkin: (account) => checkinQoder(account),
117
- quota: async (account, ctx = {}) => normalizeQoderQuota(await fetchQuotaUsage(account), await qoderWebDetail(account, ctx)),
118
- verify: async (account) => {
119
- const ui = await fetchUserinfo(account);
120
- return { name: displayNameFrom(ui), uid: typeof ui?.id === 'string' ? ui.id : null, email: ui?.email || null };
121
- },
122
- },
123
- workbuddy: {
124
- label: 'WorkBuddy',
125
- // 国内版走 billing daily-checkin 签到;国际版没有签到接口,走「活跃领取」(免费档对话探测)
126
- checkin: (account, ctx) => (account.provider === 'workbuddy-intl' ? checkinWorkbuddyIntl(account, ctx) : checkinWorkbuddy(account, ctx)),
127
- quota: (account, ctx) => fetchWorkbuddyQuota(account, ctx),
128
- verify: async (account) => {
129
- const info = inspectToken(account.token);
130
- if (!info.provider) throw new Error('不是 WorkBuddy / CodeBuddy 的登录 token');
131
- if (info.provider !== account.provider) throw new Error(`token 属于${info.provider === 'workbuddy' ? '国内版' : '国际版'},与所选版本不符`);
132
- return { uid: info.uid, name: info.name };
133
- },
134
- },
135
- zcode: {
136
- label: 'ZCode',
137
- // 无 checkin:ZCode 积分来自活动领取(需阿里云验证码),不做后台自动签到
138
- quota: (account) => fetchZcodeQuota(account),
139
- verify: async () => {
140
- throw new Error('ZCode 账号请通过「本机导入」添加(需要客户端的完整登录快照才能查询额度与切换)');
141
- },
142
- },
143
- mirasim: {
144
- label: 'mirasim',
145
- // 无 checkin:mirasim 额度按 5h/7d 滚动窗口分配,不设签到。
146
- // token 约 1 小时过期:quota 内部 401 时自动用 refreshToken 续期,
147
- // ctx.onRefresh(refreshContext)回写账号库后,这里再把新凭据同步回 setting.json(若该账号是客户端当前登录)。
148
- quota: async (account, ctx = {}) => {
149
- const mirasimLocal = await import('./mirasimLocal.js');
150
- const wrapped = {
151
- ...ctx,
152
- onRefresh: async (creds) => {
153
- await ctx.onRefresh?.(creds);
154
- try {
155
- const synced = await mirasimLocal.writeMirasimAuth({ uid: account.uid, token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
156
- if (synced) ctx.log?.('已同步新 token 到 mirasim 客户端');
157
- } catch (e) { ctx.log?.('同步到 mirasim 客户端失败:' + e.message); }
158
- },
159
- };
160
- return fetchMirasimQuota(account, wrapped);
161
- },
162
- verify: async (account) => {
163
- const p = await fetchMirasimProfile(account.token);
164
- return { name: p.name || p.email, uid: p.id, email: p.email };
165
- },
166
- },
167
- catpaw: {
168
- label: '妙手',
169
- // 无 checkin:妙手(美团 CatPaw)按套餐发放 Credits,无每日签到活动。
170
- // 凭据由妙手客户端登录管理(无对外 refresh):401 时提示到客户端重新登录后重新本机导入。
171
- quota: (account) => fetchCatpawQuota(account),
172
- verify: async (account) => {
173
- const p = await fetchCatpawProfile(account.token);
174
- return { name: p.name, uid: p.userId, email: null };
175
- },
176
- },
177
- };
178
-
179
- export function productImpl(provider) {
180
- return PRODUCTS[productOf(provider)];
181
- }
1
+ /**
2
+ * 产品线注册表 — 按账号 provider 分发签到 / 积分 / 校验。
3
+ *
4
+ * 每个产品实现(checkin 可缺省:该产品不参与每日签到,如 ZCode):
5
+ * checkin(account, ctx) → { status: checked-in|already|limited|no-activity|failed, … }
6
+ * quota(account, ctx) → { total, used, remaining, exceeded, parts: [{ name, total, used, remaining, expiresAt, recurring }] }
7
+ * verify(account) → { name?, uid?, email? }(添加账号时补全资料,失败抛错)
8
+ * ctx.onRefresh(creds):产品在 token 刷新后回调,由调用方负责持久化。
9
+ */
10
+
11
+ import { productOf } from './constants.js';
12
+ import { checkinOne as checkinQoder, fetchQuotaUsage, fetchUserinfo, fetchUsageDetail } from './qoderClient.js';
13
+ import { checkinWorkbuddy, checkinWorkbuddyIntl, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
14
+ import { fetchZcodeQuota } from './zcodeClient.js';
15
+ import { fetchMirasimQuota, fetchMirasimProfile } from './mirasimClient.js';
16
+ import { fetchCatpawQuota, fetchCatpawProfile } from './catpawClient.js';
17
+ import { fetchTraeQuota, checkinTrae, verifyTraeAccount } from './traeClient.js';
18
+ import { fetchMiniMaxQuota, checkinMiniMax, verifyMiniMaxAccount } from './minimaxClient.js';
19
+ /** 从 userinfo 响应中挑一个可读的显示名 */
20
+ export function displayNameFrom(ui) {
21
+ const pick = [ui?.nickname, ui?.name, ui?.username, ui?.email]
22
+ .map((v) => (typeof v === 'string' ? v.trim() : ''))
23
+ .find(Boolean);
24
+ return pick || null;
25
+ }
26
+
27
+ /** Qoder /api/v2/quota/usage → 统一积分结构;detail 为网页端逐资源包明细(可选,已按 uid 校验) */
28
+ export function normalizeQoderQuota(q, detail = null) {
29
+ const names = [['userQuota', '套餐额度', true], ['addOnQuota', '附加额度', false], ['orgResourcePackage', '组织资源包', false]];
30
+ const fmtExpiry = (v) => (Number(v) > 0 && Number(v) < 253400000000000 ? new Date(Number(v)).toISOString() : null);
31
+ let parts = names
32
+ .map(([k, name, recurring]) => ({ x: q?.[k], name, recurring }))
33
+ .filter(({ x }) => x && Number(x.total) > 0)
34
+ .map(({ x, name, recurring }) => ({
35
+ name, recurring,
36
+ total: Number(x.total) || 0,
37
+ used: Number(x.used) || 0,
38
+ remaining: Number(x.remaining) || 0,
39
+ expiresAt: fmtExpiry(q?.expiresAt),
40
+ }));
41
+ // 网页端明细可用时按「每个资源包一条」展开(含各自到期时间),比聚合值更精确
42
+ const detailParts = detail ? detailQuotaParts(detail, fmtExpiry) : null;
43
+ if (detailParts) parts = detailParts;
44
+ const sum = (k) => Math.round(parts.reduce((s, p) => s + p[k], 0) * 100) / 100;
45
+ return { total: sum('total'), used: sum('used'), remaining: sum('remaining'), parts, exceeded: Boolean(q?.isQuotaExceeded) };
46
+ }
47
+
48
+ /** 网页端用量明细的分组 → 统一 parts(组内为空 / 全 0 时返回 null 让调用方回落聚合数据) */
49
+ const DETAIL_SOURCE_NAME = {
50
+ PLAN: '套餐额度',
51
+ RESOURCE_PACKAGE_SOURCE_BONUS: '获赠资源包',
52
+ RESOURCE_PACKAGE_SOURCE_PURCHASE: '购买资源包',
53
+ RESOURCE_PACKAGE_SOURCE_ORG: '组织资源包',
54
+ };
55
+ function detailQuotaParts(detail, fmtExpiry) {
56
+ const groups = [['plan_quota', '套餐额度', true], ['resource_package_quota', '资源包', false], ['dedicated_resource_package_quota', '组织资源包', false]];
57
+ const parts = [];
58
+ for (const [key, fallbackName, recurring] of groups) {
59
+ const rows = detail?.[key]?.quota_detail;
60
+ if (!Array.isArray(rows)) continue;
61
+ for (const r of rows) {
62
+ const total = Number(r?.limit_value) || 0;
63
+ if (total <= 0) continue;
64
+ parts.push({
65
+ name: DETAIL_SOURCE_NAME[r?.source] || fallbackName,
66
+ recurring,
67
+ total,
68
+ used: Number(r?.used_value) || 0,
69
+ remaining: Number(r?.remaining_value) || 0,
70
+ expiresAt: fmtExpiry(r?.expires_at),
71
+ });
72
+ }
73
+ }
74
+ return parts.length ? parts : null;
75
+ }
76
+
77
+ /**
78
+ * 网页端逐资源包明细(best-effort):无会话 Cookie 返回 null;
79
+ * 会话失效(401/403)时清除 Cookie 并回调 ctx.onWebSessionExpired 提示重新登录,同样回落。
80
+ * 明细里的 user_id 与账号 uid 不一致时丢弃(防止把别人的资源包显示到这个账号上)。
81
+ */
82
+ async function qoderWebDetail(account, ctx = {}) {
83
+ try {
84
+ const d = await fetchUsageDetail(account);
85
+ if (!d) return null;
86
+ if (account.uid && d.user_id && d.user_id !== account.uid) {
87
+ ctx.log?.('网页会话与账号不匹配,忽略资源包明细');
88
+ return null;
89
+ }
90
+ return d;
91
+ } catch (e) {
92
+ if (e.auth) {
93
+ await clearQoderWebSession(account, ctx);
94
+ ctx.log?.('Qoder 网页会话已失效,无法显示逐资源包明细(面板里重新登录一次即可)');
95
+ }
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** 删除账号上的网页会话 Cookie(失效时),并通知桌面壳可以重新抓取 */
101
+ async function clearQoderWebSession(account, ctx = {}) {
102
+ const { withAccounts } = await import('./store.js');
103
+ await withAccounts((list) => {
104
+ const cur = list.find((a) => a.id === account.id);
105
+ if (cur?.meta?.qoderWebSession) {
106
+ const { qoderWebSession, ...meta } = cur.meta;
107
+ cur.meta = meta;
108
+ cur.updatedAt = new Date().toISOString();
109
+ if (account.meta) delete account.meta.qoderWebSession;
110
+ }
111
+ });
112
+ ctx.onWebSessionExpired?.();
113
+ }
114
+
115
+ const PRODUCTS = {
116
+ qoder: {
117
+ label: 'Qoder',
118
+ checkin: (account) => checkinQoder(account),
119
+ quota: async (account, ctx = {}) => normalizeQoderQuota(await fetchQuotaUsage(account), await qoderWebDetail(account, ctx)),
120
+ verify: async (account) => {
121
+ const ui = await fetchUserinfo(account);
122
+ return { name: displayNameFrom(ui), uid: typeof ui?.id === 'string' ? ui.id : null, email: ui?.email || null };
123
+ },
124
+ },
125
+ workbuddy: {
126
+ label: 'WorkBuddy',
127
+ // 国内版走 billing daily-checkin 签到;国际版没有签到接口,走「活跃领取」(免费档对话探测)
128
+ checkin: (account, ctx) => (account.provider === 'workbuddy-intl' ? checkinWorkbuddyIntl(account, ctx) : checkinWorkbuddy(account, ctx)),
129
+ quota: (account, ctx) => fetchWorkbuddyQuota(account, ctx),
130
+ verify: async (account) => {
131
+ const info = inspectToken(account.token);
132
+ if (!info.provider) throw new Error('不是 WorkBuddy / CodeBuddy 的登录 token');
133
+ if (info.provider !== account.provider) throw new Error(`token 属于${info.provider === 'workbuddy' ? '国内版' : '国际版'},与所选版本不符`);
134
+ return { uid: info.uid, name: info.name };
135
+ },
136
+ },
137
+ zcode: {
138
+ label: 'ZCode',
139
+ // 无 checkin:ZCode 积分来自活动领取(需阿里云验证码),不做后台自动签到
140
+ quota: (account) => fetchZcodeQuota(account),
141
+ verify: async () => {
142
+ throw new Error('ZCode 账号请通过「本机导入」添加(需要客户端的完整登录快照才能查询额度与切换)');
143
+ },
144
+ },
145
+ mirasim: {
146
+ label: 'mirasim',
147
+ // 无 checkin:mirasim 额度按 5h/7d 滚动窗口分配,不设签到。
148
+ // token 约 1 小时过期:quota 内部 401 时自动用 refreshToken 续期,
149
+ // ctx.onRefresh(refreshContext)回写账号库后,这里再把新凭据同步回 setting.json(若该账号是客户端当前登录)。
150
+ quota: async (account, ctx = {}) => {
151
+ const mirasimLocal = await import('./mirasimLocal.js');
152
+ const wrapped = {
153
+ ...ctx,
154
+ onRefresh: async (creds) => {
155
+ await ctx.onRefresh?.(creds);
156
+ try {
157
+ const synced = await mirasimLocal.writeMirasimAuth({ uid: account.uid, token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
158
+ if (synced) ctx.log?.('已同步新 token 到 mirasim 客户端');
159
+ } catch (e) { ctx.log?.('同步到 mirasim 客户端失败:' + e.message); }
160
+ },
161
+ };
162
+ return fetchMirasimQuota(account, wrapped);
163
+ },
164
+ verify: async (account) => {
165
+ const p = await fetchMirasimProfile(account.token);
166
+ return { name: p.name || p.email, uid: p.id, email: p.email };
167
+ },
168
+ },
169
+ catpaw: {
170
+ label: '妙手',
171
+ // 无 checkin:妙手(美团 CatPaw)按套餐发放 Credits,无每日签到活动。
172
+ // 凭据由妙手客户端登录管理(无对外 refresh):401 时提示到客户端重新登录后重新本机导入。
173
+ quota: (account) => fetchCatpawQuota(account),
174
+ verify: async (account) => {
175
+ const p = await fetchCatpawProfile(account.token);
176
+ return { name: p.name, uid: p.userId, email: null };
177
+ },
178
+ },
179
+ trae: {
180
+ label: 'Trae',
181
+ // 每日签到发 150 通用积分(7 天有效)。凭据只来自本机 Trae 客户端的 storage.json,
182
+ // 无对外 refresh:token 约 14 天过期,失效后到 Trae 客户端重新登录再重新本机导入。
183
+ checkin: (account, ctx) => checkinTrae(account, ctx),
184
+ quota: (account) => fetchTraeQuota(account),
185
+ verify: (account) => verifyTraeAccount(account),
186
+ },
187
+ minimax: {
188
+ label: 'MiniMax',
189
+ // token 约 1 小时过期:checkin / quota 内部 401 时用 refreshToken 自动续期(withMiniMaxAuth)。
190
+ // 本机导入账号刷新前会重读 ~/.minimax auth.json 对齐客户端最新 refreshToken;
191
+ // ctx.onRefresh 回写账号库后,这里再安全回写 auth.json(仅客户端未运行时,见 minimaxLocal.writeMiniMaxAuth)。
192
+ // device 登录账号自持独立凭据链(无 meta.authRecordKey),writeMiniMaxAuth 对其 no-op,不与本机客户端互斥。
193
+ checkin: (account, ctx = {}) => checkinMiniMax(account, wrapMiniMaxRefresh(account, ctx)),
194
+ quota: (account, ctx = {}) => fetchMiniMaxQuota(account, wrapMiniMaxRefresh(account, ctx)),
195
+ verify: (account) => verifyMiniMaxAccount(account),
196
+ },
197
+ };
198
+
199
+ /** 包裹 MiniMax 的 ctx.onRefresh:回写账号库后,安全同步新凭据回本机 auth.json(仅本机导入账号且客户端未运行时) */
200
+ function wrapMiniMaxRefresh(account, ctx = {}) {
201
+ return {
202
+ ...ctx,
203
+ onRefresh: async (creds) => {
204
+ await ctx.onRefresh?.(creds);
205
+ try {
206
+ const { writeMiniMaxAuth } = await import('./minimaxLocal.js');
207
+ const synced = writeMiniMaxAuth({ ...account, ...creds });
208
+ if (synced) ctx.log?.('已同步新 token 到 MiniMax 客户端');
209
+ } catch {}
210
+ },
211
+ };
212
+ }
213
+
214
+ export function productImpl(provider) {
215
+ return PRODUCTS[productOf(provider)];
216
+ }
package/src/qoderApp.js CHANGED
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import crypto from 'node:crypto';
20
20
  import { spawn, execFile, execFileSync } from 'node:child_process';
21
21
  import { dataDir } from './store.js';
22
+ import { anyProcRunning } from './winProc.js';
22
23
  import { installedUmid, CLI_RISK_ENV } from './qoderUmid.js';
23
24
 
24
25
  const RISK_TTL_MS = 50 * 60 * 1000; // 客户端每 60±5 分钟刷新一次,这里保守取 50 分钟
@@ -364,10 +365,14 @@ function macKeychainPassword(service) {
364
365
 
365
366
  const keyCache = new Map();
366
367
 
367
- /** 取得某个 Electron userData 目录对应的 safeStorage 解密器 */
368
- async function safeStorageDecryptor(userData, appNames) {
368
+ /**
369
+ * 某个 Electron userData 目录对应的 safeStorage 编解码器。
370
+ * encrypt 用于把账号库里的登录快照写回客户端(切换账号),与 decrypt 同密钥同格式:
371
+ * Windows = 'v10' + nonce(12) + AES-256-GCM 密文 + tag(16);mac/linux = 'v10' + AES-128-CBC。
372
+ */
373
+ export async function safeStorageCodec(userData, appNames) {
369
374
  if (keyCache.has(userData)) return keyCache.get(userData);
370
- let decrypt;
375
+ let decrypt, encrypt;
371
376
  if (process.platform === 'win32') {
372
377
  const ls = JSON.parse(fs.readFileSync(path.join(userData, 'Local State'), 'utf8'));
373
378
  const enc = Buffer.from(ls?.os_crypt?.encrypted_key || '', 'base64');
@@ -381,6 +386,12 @@ async function safeStorageDecryptor(userData, appNames) {
381
386
  d.setAuthTag(tag);
382
387
  return Buffer.concat([d.update(data.subarray(15, data.length - 16)), d.final()]).toString('utf8');
383
388
  };
389
+ encrypt = (text) => {
390
+ const nonce = crypto.randomBytes(12);
391
+ const c = crypto.createCipheriv('aes-256-gcm', key, nonce);
392
+ const ct = Buffer.concat([c.update(text, 'utf8'), c.final()]);
393
+ return Buffer.concat([Buffer.from('v10'), nonce, ct, c.getAuthTag()]);
394
+ };
384
395
  } else {
385
396
  let password = 'peanuts', iterations = 1;
386
397
  if (process.platform === 'darwin') {
@@ -392,15 +403,21 @@ async function safeStorageDecryptor(userData, appNames) {
392
403
  iterations = 1003;
393
404
  }
394
405
  const key = crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
406
+ const iv = Buffer.alloc(16, ' ');
395
407
  decrypt = (data) => {
396
408
  const ver = data.subarray(0, 3).toString();
397
409
  if (ver !== 'v10') throw new Error(ver === 'v11' ? '需要系统密钥环(v11),暂不支持' : '未知的加密格式');
398
- const d = crypto.createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, ' '));
410
+ const d = crypto.createDecipheriv('aes-128-cbc', key, iv);
399
411
  return Buffer.concat([d.update(data.subarray(3)), d.final()]).toString('utf8');
400
412
  };
413
+ encrypt = (text) => {
414
+ const c = crypto.createCipheriv('aes-128-cbc', key, iv);
415
+ return Buffer.concat([Buffer.from('v10'), c.update(text, 'utf8'), c.final()]);
416
+ };
401
417
  }
402
- keyCache.set(userData, decrypt);
403
- return decrypt;
418
+ const codec = { decrypt, encrypt };
419
+ keyCache.set(userData, codec);
420
+ return codec;
404
421
  }
405
422
 
406
423
  /**
@@ -414,13 +431,14 @@ export async function readQoderAppAccounts() {
414
431
  const file = path.join(app.dataDir, 'auth.v1.dat');
415
432
  if (!exists(file)) continue;
416
433
  try {
417
- const decrypt = await safeStorageDecryptor(app.dataDir, ['Qoder App', app.label.includes('国内') ? 'Qoder CN' : 'Qoder']);
434
+ const { decrypt } = await safeStorageCodec(app.dataDir, ['Qoder App', app.label.includes('国内') ? 'Qoder CN' : 'Qoder']);
418
435
  const obj = JSON.parse(decrypt(fs.readFileSync(file)));
419
436
  if (typeof obj?.token !== 'string' || !obj.token) throw new Error('auth.v1.dat 中没有 token');
420
437
  accounts.push({
421
438
  provider: app.provider,
422
439
  source: `${app.label} 客户端`,
423
440
  file,
441
+ authJson: obj,
424
442
  token: obj.token,
425
443
  refreshToken: typeof obj.refreshToken === 'string' ? obj.refreshToken : null,
426
444
  expiresAt: typeof obj.expiresAt === 'string' ? obj.expiresAt : null,
@@ -436,3 +454,65 @@ export async function readQoderAppAccounts() {
436
454
  }
437
455
  return { accounts, errors };
438
456
  }
457
+
458
+ // ─── 切换本机 Qoder 登录账号 ───
459
+
460
+ const QODER_EXES = ['Qoder.exe', 'Qoder CN.exe'];
461
+
462
+ /** Qoder IDE(国际版 / 国内版)是否在运行 */
463
+ export function qoderRunning() {
464
+ // 走 winProc:这里曾经用 latin1 解 tasklist,中文 Windows 上恒为 true
465
+ return anyProcRunning(QODER_EXES);
466
+ }
467
+
468
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
469
+
470
+ /** 结束 Qoder 进程:先普通终止(给客户端落盘的机会),超时后 /F */
471
+ export function terminateQoder({ timeoutMs = 8000 } = {}) {
472
+ if (process.platform !== 'win32') return { closed: false, supported: false };
473
+ if (!qoderRunning()) return { closed: false, running: false };
474
+ for (const exe of QODER_EXES) {
475
+ try { execFileSync('taskkill.exe', ['/IM', exe, '/T'], { windowsHide: true, stdio: 'ignore' }); } catch {}
476
+ }
477
+ let deadline = Date.now() + timeoutMs;
478
+ while (Date.now() < deadline && qoderRunning()) nap(250);
479
+ if (qoderRunning()) {
480
+ for (const exe of QODER_EXES) {
481
+ try { execFileSync('taskkill.exe', ['/IM', exe, '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); } catch {}
482
+ }
483
+ deadline = Date.now() + 3000;
484
+ while (Date.now() < deadline && qoderRunning()) nap(250);
485
+ }
486
+ return { closed: !qoderRunning(), running: qoderRunning() };
487
+ }
488
+
489
+ /**
490
+ * 把本机 Qoder 客户端的登录换成目标账号(写回 auth.v1.dat)。
491
+ * force:由调用方先 terminateQoder 再进来(运行中的 Qoder 退出时会把内存里的旧登录覆盖回文件)。
492
+ */
493
+ export async function switchTo(account, { force = false } = {}) {
494
+ const auth = account.meta?.qoderAuth;
495
+ const file = account.meta?.qoderAuthFile;
496
+ if (!auth || !file) {
497
+ throw new Error(`账号「${account.name || account.id}」没有本机登录快照,无法切换:请在 Qoder 里登录该账号后到「添加账号 → 本机导入」导入一次`);
498
+ }
499
+ if (!fs.existsSync(file)) throw new Error(`Qoder 凭据文件不存在:${file}(客户端可能被卸载或换了数据目录)`);
500
+ if (!force && qoderRunning()) {
501
+ const e = new Error('Qoder 正在运行。切换会改写 Qoder 的登录文件,运行中的 Qoder 退出时会把旧登录写回去——退出后重试,或选择「关闭并强制切换」。');
502
+ e.qoderRunning = true;
503
+ throw e;
504
+ }
505
+ const { encrypt, decrypt } = await safeStorageCodec(path.dirname(file), ['Qoder App', account.provider === 'qoder-cn' ? 'Qoder CN' : 'Qoder']);
506
+ try {
507
+ if (JSON.parse(decrypt(fs.readFileSync(file))).token === auth.token) {
508
+ return { switched: false, alreadyActive: true };
509
+ }
510
+ } catch { /* 当前文件解不开就当作不同账号,继续写入 */ }
511
+ const buf = encrypt(JSON.stringify(auth));
512
+ const bak = file + '.creditdaddy.bak';
513
+ try { fs.copyFileSync(file, bak); } catch { /* 备份失败不阻断,下面原子写仍会成功 */ }
514
+ const tmp = file + '.tmp';
515
+ fs.writeFileSync(tmp, buf, { mode: 0o600 });
516
+ fs.renameSync(tmp, file);
517
+ return { switched: true, alreadyActive: false };
518
+ }