creditdaddy 0.9.5 → 1.2.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/LICENSE +21 -21
- package/README.md +56 -8
- package/bin/creditdaddy.js +209 -191
- package/package.json +4 -3
- package/src/accounts.js +161 -143
- package/src/authDevice.js +205 -205
- package/src/catpawClient.js +103 -0
- package/src/catpawLocal.js +229 -0
- package/src/checkin.js +357 -230
- package/src/constants.js +97 -81
- package/src/daemon.js +935 -658
- package/src/localDetect.js +147 -147
- package/src/logger.js +39 -1
- package/src/mirasimClient.js +189 -189
- package/src/mirasimLocal.js +386 -307
- package/src/panel.html +765 -214
- package/src/providers.js +190 -100
- package/src/qoderApp.js +524 -332
- package/src/qoderClient.js +237 -199
- package/src/qoderUmid.js +198 -198
- package/src/store.js +238 -222
- package/src/tenrouter.js +263 -221
- package/src/tenrouterAccounts.js +163 -0
- package/src/traeClient.js +258 -0
- package/src/traeLocal.js +337 -0
- package/src/transfer.js +160 -141
- package/src/usageSync.js +505 -319
- package/src/workbuddyAuth.js +127 -126
- package/src/workbuddyClient.js +302 -302
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -96
- package/src/zcodeClient.js +580 -529
- package/src/zcodeGateway.js +570 -0
- package/src/zcodeLocal.js +334 -319
- package/src/zcodePlanShape.json +29 -0
- package/src/zcrypto.js +160 -160
package/src/qoderApp.js
CHANGED
|
@@ -1,332 +1,524 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本机 Qoder 客户端(Qoder App,Electron)集成 — 全部在本机完成,不依赖第三方包。
|
|
3
|
-
*
|
|
4
|
-
* 1. 安装探测:国际版 Qoder / 国内版 Qoder CN 的程序目录、数据目录、版本号
|
|
5
|
-
* 2. 设备风控身份:调用客户端自带的 umid/runtime-info 生成
|
|
6
|
-
* Cosy-MachineToken / Cosy-MachineCode / Cosy-MachineType。
|
|
7
|
-
* 国际版服务端只对带风控身份的请求下发「每天领 100 Credits」活动(实测)。
|
|
8
|
-
* 3. 登录凭据读取:解密客户端数据目录下的 auth.v1.dat(Electron safeStorage)
|
|
9
|
-
* Windows: "v10" + nonce(12) + AES-256-GCM(密文+tag),密钥 = DPAPI 解开 Local State 的 os_crypt.encrypted_key
|
|
10
|
-
* macOS: "v10" + AES-128-CBC,密钥 = PBKDF2(钥匙串 "<App> Safe Storage", 'saltysalt', 1003)
|
|
11
|
-
* Linux: "v10" + AES-128-CBC,密钥 = PBKDF2('peanuts', 'saltysalt', 1)(v11 需系统密钥环,不支持)
|
|
12
|
-
*
|
|
13
|
-
* 协议细节来自本机 Qoder App 0.2.x 的客户端代码(campaignMainService / nativeRiskIdentityMainService)。
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import fs from 'node:fs';
|
|
17
|
-
import os from 'node:os';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import crypto from 'node:crypto';
|
|
20
|
-
import { spawn, execFile } from 'node:child_process';
|
|
21
|
-
import { dataDir } from './store.js';
|
|
22
|
-
import { installedUmid, CLI_RISK_ENV } from './qoderUmid.js';
|
|
23
|
-
|
|
24
|
-
const RISK_TTL_MS = 50 * 60 * 1000; // 客户端每 60±5 分钟刷新一次,这里保守取 50 分钟
|
|
25
|
-
const RISK_TIMEOUT_MS = 25_000; // 与客户端一致
|
|
26
|
-
const DEFAULT_CLIENT_VERSION = '0.2.5';
|
|
27
|
-
|
|
28
|
-
const VARIANTS = [
|
|
29
|
-
{ provider: 'qoder', label: 'Qoder 国际版', installName: 'Qoder', appId: 'com.qoder.app', riskEnv: 3 },
|
|
30
|
-
{ provider: 'qoder-cn', label: 'Qoder 国内版', installName: 'Qoder CN', appId: 'com.qodercn.app', riskEnv: 0 },
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 本机 Qoder 客户端(Qoder App,Electron)集成 — 全部在本机完成,不依赖第三方包。
|
|
3
|
+
*
|
|
4
|
+
* 1. 安装探测:国际版 Qoder / 国内版 Qoder CN 的程序目录、数据目录、版本号
|
|
5
|
+
* 2. 设备风控身份:调用客户端自带的 umid/runtime-info 生成
|
|
6
|
+
* Cosy-MachineToken / Cosy-MachineCode / Cosy-MachineType。
|
|
7
|
+
* 国际版服务端只对带风控身份的请求下发「每天领 100 Credits」活动(实测)。
|
|
8
|
+
* 3. 登录凭据读取:解密客户端数据目录下的 auth.v1.dat(Electron safeStorage)
|
|
9
|
+
* Windows: "v10" + nonce(12) + AES-256-GCM(密文+tag),密钥 = DPAPI 解开 Local State 的 os_crypt.encrypted_key
|
|
10
|
+
* macOS: "v10" + AES-128-CBC,密钥 = PBKDF2(钥匙串 "<App> Safe Storage", 'saltysalt', 1003)
|
|
11
|
+
* Linux: "v10" + AES-128-CBC,密钥 = PBKDF2('peanuts', 'saltysalt', 1)(v11 需系统密钥环,不支持)
|
|
12
|
+
*
|
|
13
|
+
* 协议细节来自本机 Qoder App 0.2.x 的客户端代码(campaignMainService / nativeRiskIdentityMainService)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { spawn, execFile, execFileSync } from 'node:child_process';
|
|
21
|
+
import { dataDir } from './store.js';
|
|
22
|
+
import { installedUmid, CLI_RISK_ENV } from './qoderUmid.js';
|
|
23
|
+
|
|
24
|
+
const RISK_TTL_MS = 50 * 60 * 1000; // 客户端每 60±5 分钟刷新一次,这里保守取 50 分钟
|
|
25
|
+
const RISK_TIMEOUT_MS = 25_000; // 与客户端一致
|
|
26
|
+
const DEFAULT_CLIENT_VERSION = '0.2.5';
|
|
27
|
+
|
|
28
|
+
const VARIANTS = [
|
|
29
|
+
{ provider: 'qoder', label: 'Qoder 国际版', installName: 'Qoder', appId: 'com.qoder.app', riskEnv: 3 },
|
|
30
|
+
{ provider: 'qoder-cn', label: 'Qoder 国内版', installName: 'Qoder CN', appId: 'com.qodercn.app', riskEnv: 0 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
34
|
+
|
|
35
|
+
const versionParts = (s) => s.split('.').map((n) => Number(n) || 0);
|
|
36
|
+
function compareVersionDesc(a, b) {
|
|
37
|
+
const x = versionParts(a), y = versionParts(b);
|
|
38
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
39
|
+
if ((x[i] || 0) !== (y[i] || 0)) return (y[i] || 0) - (x[i] || 0);
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 一个安装目录下的 resources 候选:0.3+ 的启动器把实际运行的版本放在
|
|
46
|
+
* .qoder-versions\<ver>\resources(按版本从新到旧),顶层 resources 可能只是首装时的旧版残留。
|
|
47
|
+
*/
|
|
48
|
+
function resourceDirsIn(installDir) {
|
|
49
|
+
const root = path.join(installDir, '.qoder-versions');
|
|
50
|
+
let versions = [];
|
|
51
|
+
try {
|
|
52
|
+
versions = fs.readdirSync(root, { withFileTypes: true })
|
|
53
|
+
.filter((e) => e.isDirectory() && /^\d+(\.\d+)*$/.test(e.name))
|
|
54
|
+
.map((e) => e.name)
|
|
55
|
+
.sort(compareVersionDesc);
|
|
56
|
+
} catch {}
|
|
57
|
+
return [...versions.map((ver) => path.join(root, ver, 'resources')), path.join(installDir, 'resources')];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 卸载注册表里登记的安装目录(自定义安装路径)。DisplayName 形如「Qoder 0.4.1」/「Qoder CN 0.4.1」,
|
|
62
|
+
* 不匹配「Qoder IDE」(IDE 不带 runtime-info)。走 PowerShell 以正确读出中文路径;结果缓存 5 分钟。
|
|
63
|
+
*/
|
|
64
|
+
let registryCache = { at: 0, entries: null };
|
|
65
|
+
function registryInstallDirs(v) {
|
|
66
|
+
if (process.platform !== 'win32') return [];
|
|
67
|
+
if (!registryCache.entries || Date.now() - registryCache.at > 5 * 60_000) {
|
|
68
|
+
let entries = [];
|
|
69
|
+
try {
|
|
70
|
+
const script = "$ProgressPreference='SilentlyContinue';[Console]::OutputEncoding=[Text.Encoding]::UTF8;"
|
|
71
|
+
+ "Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue"
|
|
72
|
+
+ " | Where-Object { $_.DisplayName -like 'Qoder*' }"
|
|
73
|
+
+ ' | Select-Object DisplayName,InstallLocation,UninstallString,DisplayIcon | ConvertTo-Json -Compress';
|
|
74
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')],
|
|
75
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
76
|
+
if (out) entries = [].concat(JSON.parse(out));
|
|
77
|
+
} catch {}
|
|
78
|
+
registryCache = { at: Date.now(), entries };
|
|
79
|
+
}
|
|
80
|
+
const nameRe = new RegExp(`^${v.installName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s+v?\\d[\\d.]*)?$`, 'i');
|
|
81
|
+
const exeDir = (s) => {
|
|
82
|
+
const m = String(s || '').match(/^\s*"([^"]+)"|^\s*([^,]+?\.exe)/i);
|
|
83
|
+
return m ? path.dirname(m[1] || m[2]) : null;
|
|
84
|
+
};
|
|
85
|
+
const dirs = [];
|
|
86
|
+
for (const e of registryCache.entries) {
|
|
87
|
+
if (!nameRe.test(String(e?.DisplayName || '').trim())) continue;
|
|
88
|
+
const dir = (e.InstallLocation && String(e.InstallLocation).trim()) || exeDir(e.UninstallString) || exeDir(e.DisplayIcon);
|
|
89
|
+
if (dir) dirs.push(dir.replace(/[\\/]+$/, ''));
|
|
90
|
+
}
|
|
91
|
+
return dirs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function candidateResourceDirs(v, { registry = false } = {}) {
|
|
95
|
+
const home = os.homedir();
|
|
96
|
+
if (process.platform === 'win32') {
|
|
97
|
+
const local = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
98
|
+
const installDirs = [
|
|
99
|
+
path.join(local, 'Programs', v.installName),
|
|
100
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', v.installName),
|
|
101
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', v.installName),
|
|
102
|
+
...(registry ? registryInstallDirs(v) : []),
|
|
103
|
+
];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const unique = installDirs.filter((d) => {
|
|
106
|
+
const k = path.resolve(d).toLowerCase();
|
|
107
|
+
if (seen.has(k)) return false;
|
|
108
|
+
seen.add(k);
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
return [
|
|
112
|
+
...unique.flatMap(resourceDirsIn),
|
|
113
|
+
// AppX 风格(微软商店版 / MSIX):Qoder_*.msix 通常在 Start Menu 快捷方式指向的位置
|
|
114
|
+
path.join(local, 'Microsoft', 'WindowsApps', v.installName),
|
|
115
|
+
// Squirrel 更新目录:Electron 应用的临时升级包
|
|
116
|
+
path.join(local, 'electron', 'updates', v.installName),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
if (process.platform === 'darwin') {
|
|
120
|
+
return [
|
|
121
|
+
`/Applications/${v.installName}.app/Contents/Resources`,
|
|
122
|
+
path.join(home, 'Applications', `${v.installName}.app`, 'Contents', 'Resources'),
|
|
123
|
+
// 可能是 qodercn.app 或 qoder.app
|
|
124
|
+
`/Applications/Qoder.app/Contents/Resources`,
|
|
125
|
+
`/Applications/Qoder\\ CN.app/Contents/Resources`,
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
const slug = v.installName.toLowerCase().replace(/\s+/g, '-');
|
|
129
|
+
return [`/opt/${v.installName}/resources`, `/opt/${slug}/resources`, `/usr/lib/${slug}/resources`, '/snap/qoder/current/resources'];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function userDataDir(v) {
|
|
133
|
+
const home = os.homedir();
|
|
134
|
+
const name = `${v.appId}.stable`;
|
|
135
|
+
if (process.platform === 'win32') {
|
|
136
|
+
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), name);
|
|
137
|
+
}
|
|
138
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', name);
|
|
139
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readVersion(resourcesDir) {
|
|
143
|
+
try {
|
|
144
|
+
const m = JSON.parse(fs.readFileSync(path.join(resourcesDir, 'build-manifest.json'), 'utf8'));
|
|
145
|
+
if (typeof m.productVersion === 'string') return m.productVersion;
|
|
146
|
+
} catch {}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const RUNTIME_INFO_EXE = process.platform === 'win32' ? 'runtime-info.exe' : 'runtime-info';
|
|
151
|
+
const runtimeInfoIn = (resources) => (exists(path.join(resources, 'umid', RUNTIME_INFO_EXE)) ? path.join(resources, 'umid', RUNTIME_INFO_EXE) : null);
|
|
152
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
153
|
+
|
|
154
|
+
/** 在候选里选 resources:优先带 runtime-info 的(风控身份靠它),否则第一个存在的 */
|
|
155
|
+
function pickResources(dirs) {
|
|
156
|
+
const existing = dirs.filter(isDir);
|
|
157
|
+
return existing.find((d) => runtimeInfoIn(d)) || existing[0] || null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 探测本机已安装的 Qoder 客户端(国际版 / 国内版) */
|
|
161
|
+
export function detectQoderApps() {
|
|
162
|
+
return VARIANTS.map((v) => {
|
|
163
|
+
let resources = pickResources(candidateResourceDirs(v));
|
|
164
|
+
// 默认位置没找到可用的 runtime-info → 再查卸载注册表里的实际安装目录(自定义路径)
|
|
165
|
+
if (process.platform === 'win32' && !(resources && runtimeInfoIn(resources))) {
|
|
166
|
+
resources = pickResources(candidateResourceDirs(v, { registry: true })) || resources;
|
|
167
|
+
}
|
|
168
|
+
const runtimeInfo = resources ? runtimeInfoIn(resources) : null;
|
|
169
|
+
const data = userDataDir(v);
|
|
170
|
+
return {
|
|
171
|
+
provider: v.provider,
|
|
172
|
+
label: v.label,
|
|
173
|
+
installed: Boolean(resources),
|
|
174
|
+
resourcesDir: resources,
|
|
175
|
+
version: resources ? readVersion(resources) : null,
|
|
176
|
+
runtimeInfo,
|
|
177
|
+
dataDir: data,
|
|
178
|
+
dataExists: exists(data),
|
|
179
|
+
signedIn: exists(path.join(data, 'auth.v1.dat')),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── 设备风控身份 ───
|
|
185
|
+
|
|
186
|
+
/** 与客户端一致的 Cosy-MachineOS 格式:x86_64_win32 / aarch64_darwin … */
|
|
187
|
+
export function machineOs() {
|
|
188
|
+
const a = process.arch === 'arm64' ? 'aarch64' : process.arch === 'x64' ? 'x86_64' : process.arch;
|
|
189
|
+
return `${a}_${process.platform}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 与客户端一致的主机名清洗:可打印 ASCII,超长截断并附短哈希 */
|
|
193
|
+
export function machineHostname(raw = os.hostname()) {
|
|
194
|
+
const e = String(raw || '').trim();
|
|
195
|
+
if (!e) return undefined;
|
|
196
|
+
const clip = (t) => {
|
|
197
|
+
if (t.length <= 96) return t;
|
|
198
|
+
const h = crypto.createHash('sha256').update(t, 'utf8').digest('hex').slice(0, 8);
|
|
199
|
+
const head = t.slice(0, 96 - 8 - 1).replace(/[-\s]+$/u, '');
|
|
200
|
+
return head ? `${head}-${h}` : `unknown-${h}`;
|
|
201
|
+
};
|
|
202
|
+
if (/^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/u.test(e)) return clip(e);
|
|
203
|
+
const h = crypto.createHash('sha256').update(e, 'utf8').digest('hex').slice(0, 8);
|
|
204
|
+
const n = e.replace(/[^\x21-\x7e]+/gu, '-').replace(/-{2,}/gu, '-').replace(/^-+|-+$/gu, '');
|
|
205
|
+
return clip(n ? `${n}-${h}` : `unknown-${h}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
209
|
+
|
|
210
|
+
/** 机器 ID:优先复用客户端的 auth.machine-id,否则在 CreditDaddy 数据目录持久化一个 */
|
|
211
|
+
export function machineId(provider) {
|
|
212
|
+
const v = VARIANTS.find((x) => x.provider === provider) || VARIANTS[0];
|
|
213
|
+
for (const file of [path.join(userDataDir(v), 'auth.machine-id'), path.join(dataDir(), 'machine-id')]) {
|
|
214
|
+
try {
|
|
215
|
+
const id = fs.readFileSync(file, 'utf8').trim();
|
|
216
|
+
if (UUID_RE.test(id)) return id;
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
const id = crypto.randomUUID();
|
|
220
|
+
try {
|
|
221
|
+
fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
|
|
222
|
+
fs.writeFileSync(path.join(dataDir(), 'machine-id'), id, { mode: 0o600 });
|
|
223
|
+
} catch {}
|
|
224
|
+
return id;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 客户端版本号(用于 Cosy-Version) */
|
|
228
|
+
export function clientVersion(provider) {
|
|
229
|
+
const apps = detectQoderApps();
|
|
230
|
+
const app = apps.find((a) => a.provider === provider && a.version) || apps.find((a) => a.version);
|
|
231
|
+
return app?.version || DEFAULT_CLIENT_VERSION;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runRuntimeInfo(exe, env, account) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const withStdin = process.platform === 'darwin' || process.platform === 'win32';
|
|
237
|
+
const child = spawn(exe, withStdin ? [String(env), '--account-stdin'] : [String(env)], {
|
|
238
|
+
stdio: [withStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
239
|
+
windowsHide: true,
|
|
240
|
+
});
|
|
241
|
+
let out = '';
|
|
242
|
+
let settled = false;
|
|
243
|
+
const done = (err, val) => {
|
|
244
|
+
if (settled) return;
|
|
245
|
+
settled = true;
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
if (child.exitCode === null) child.kill();
|
|
248
|
+
err ? reject(err) : resolve(val);
|
|
249
|
+
};
|
|
250
|
+
const timer = setTimeout(() => done(new Error('runtime-info 超时')), RISK_TIMEOUT_MS);
|
|
251
|
+
const tryParse = () => {
|
|
252
|
+
const nl = out.indexOf('\n');
|
|
253
|
+
if (nl < 0) return false;
|
|
254
|
+
try {
|
|
255
|
+
const j = JSON.parse(out.slice(0, nl));
|
|
256
|
+
const pick = (k) => {
|
|
257
|
+
const s = typeof j[k] === 'string' ? j[k].trim() : '';
|
|
258
|
+
if (!s || s.length > 4096) throw new Error(`runtime-info 输出缺少 ${k}`);
|
|
259
|
+
return s;
|
|
260
|
+
};
|
|
261
|
+
done(null, { machineToken: pick('machineToken'), machineCode: pick('machineCode'), machineType: pick('machineType') });
|
|
262
|
+
} catch (e) { done(e); }
|
|
263
|
+
return true;
|
|
264
|
+
};
|
|
265
|
+
child.stdout.on('data', (d) => {
|
|
266
|
+
out += d.toString('utf8');
|
|
267
|
+
if (out.length > 1024 * 1024) return done(new Error('runtime-info 输出过大'));
|
|
268
|
+
tryParse();
|
|
269
|
+
});
|
|
270
|
+
child.stderr.on('data', () => {});
|
|
271
|
+
child.once('error', (e) => done(new Error(`runtime-info 启动失败:${e.message}`)));
|
|
272
|
+
child.once('close', (code) => {
|
|
273
|
+
if (!tryParse()) done(new Error(`runtime-info 退出码 ${code}`));
|
|
274
|
+
});
|
|
275
|
+
if (withStdin) {
|
|
276
|
+
child.stdin.on('error', () => {});
|
|
277
|
+
child.stdin.end(JSON.stringify({ account }) + '\n');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const riskCache = new Map(); // `${provider}:${uid}` → { value, at } | { promise }
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 风控身份来源:优先本机 Qoder 客户端自带的 runtime-info(国际版 env=3 / 国内版 env=0,缺失时用另一版本的),
|
|
286
|
+
* 其次是从官方 qodercli 提取的设备身份组件(Linux / fnOS,见 qoderUmid.js,env 与 qodercli 一致)。
|
|
287
|
+
*/
|
|
288
|
+
function riskRunner(provider) {
|
|
289
|
+
const apps = detectQoderApps().filter((a) => a.runtimeInfo);
|
|
290
|
+
const app = apps.find((a) => a.provider === provider) || apps[0];
|
|
291
|
+
if (app) return { exe: app.runtimeInfo, env: (VARIANTS.find((v) => v.provider === provider) || VARIANTS[0]).riskEnv, source: 'app' };
|
|
292
|
+
const cli = installedUmid() || bundledCliUmid(provider);
|
|
293
|
+
if (cli) return { exe: cli.path, env: CLI_RISK_ENV[provider] ?? CLI_RISK_ENV.qoder, source: 'cli' };
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Windows:客户端自带的 qodercli 会把设备身份组件解压到 ~\.qoder\.bin\umid-win32-<arch>-*\(国内版 ~\.qoder-cn) */
|
|
298
|
+
function bundledCliUmid(provider) {
|
|
299
|
+
if (process.platform !== 'win32') return null;
|
|
300
|
+
const homes = provider === 'qoder-cn' ? ['.qoder-cn', '.qoder'] : ['.qoder', '.qoder-cn'];
|
|
301
|
+
for (const h of homes) {
|
|
302
|
+
const bin = path.join(os.homedir(), h, '.bin');
|
|
303
|
+
let dirs = [];
|
|
304
|
+
try { dirs = fs.readdirSync(bin).filter((n) => n.startsWith('umid-win32-')); } catch {}
|
|
305
|
+
for (const d of dirs) {
|
|
306
|
+
const exe = path.join(bin, d, RUNTIME_INFO_EXE);
|
|
307
|
+
if (exists(exe)) return { path: exe };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 获取某账号的设备风控身份;本机既没有 Qoder 客户端也没装设备身份组件时返回 null。 */
|
|
314
|
+
export async function getRiskIdentity(provider, uid) {
|
|
315
|
+
if (!uid) return null;
|
|
316
|
+
const key = `${provider}:${uid}`;
|
|
317
|
+
const hit = riskCache.get(key);
|
|
318
|
+
if (hit?.value && Date.now() - hit.at < RISK_TTL_MS) return hit.value;
|
|
319
|
+
if (hit?.promise) return hit.promise;
|
|
320
|
+
|
|
321
|
+
const runner = riskRunner(provider);
|
|
322
|
+
if (!runner) return null;
|
|
323
|
+
|
|
324
|
+
const promise = runRuntimeInfo(runner.exe, runner.env, uid)
|
|
325
|
+
.then((value) => { riskCache.set(key, { value, at: Date.now() }); return value; })
|
|
326
|
+
.catch((e) => { riskCache.delete(key); throw e; });
|
|
327
|
+
riskCache.set(key, { promise });
|
|
328
|
+
return promise;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function riskIdentityAvailable() {
|
|
332
|
+
return Boolean(riskRunner('qoder'));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 风控身份来源:'app'(Qoder 客户端)/ 'cli'(qodercli 设备身份组件)/ null */
|
|
336
|
+
export function riskIdentitySource() {
|
|
337
|
+
return riskRunner('qoder')?.source || null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Electron safeStorage 解密 ───
|
|
341
|
+
|
|
342
|
+
function dpapiUnprotect(buf) {
|
|
343
|
+
const script = 'Add-Type -AssemblyName System.Security;'
|
|
344
|
+
+ '$b=[Convert]::FromBase64String([Console]::In.ReadToEnd());'
|
|
345
|
+
+ "[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'))";
|
|
346
|
+
// 使用 -EncodedCommand 传入 UTF-16LE Base64,避免命令行参数中包含敏感 API 关键字被杀毒软件误报
|
|
347
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
348
|
+
return new Promise((resolve, reject) => {
|
|
349
|
+
const child = execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
|
|
350
|
+
{ windowsHide: true, timeout: 20_000 }, (err, stdout) => {
|
|
351
|
+
if (err) return reject(new Error('DPAPI 解密失败:' + err.message));
|
|
352
|
+
resolve(Buffer.from(String(stdout).trim(), 'base64'));
|
|
353
|
+
});
|
|
354
|
+
child.stdin.end(buf.toString('base64'));
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function macKeychainPassword(service) {
|
|
359
|
+
return new Promise((resolve) => {
|
|
360
|
+
execFile('security', ['find-generic-password', '-w', '-s', service], { timeout: 20_000 },
|
|
361
|
+
(err, stdout) => resolve(err ? null : String(stdout).trim()));
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const keyCache = new Map();
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 某个 Electron userData 目录对应的 safeStorage 编解码器。
|
|
369
|
+
* encrypt 用于把账号库里的登录快照写回客户端(切换账号),与 decrypt 同密钥同格式:
|
|
370
|
+
* Windows = 'v10' + nonce(12) + AES-256-GCM 密文 + tag(16);mac/linux = 'v10' + AES-128-CBC。
|
|
371
|
+
*/
|
|
372
|
+
export async function safeStorageCodec(userData, appNames) {
|
|
373
|
+
if (keyCache.has(userData)) return keyCache.get(userData);
|
|
374
|
+
let decrypt, encrypt;
|
|
375
|
+
if (process.platform === 'win32') {
|
|
376
|
+
const ls = JSON.parse(fs.readFileSync(path.join(userData, 'Local State'), 'utf8'));
|
|
377
|
+
const enc = Buffer.from(ls?.os_crypt?.encrypted_key || '', 'base64');
|
|
378
|
+
if (enc.subarray(0, 5).toString() !== 'DPAPI') throw new Error('Local State 中没有 DPAPI 密钥');
|
|
379
|
+
const key = await dpapiUnprotect(enc.subarray(5));
|
|
380
|
+
decrypt = (data) => {
|
|
381
|
+
if (data.subarray(0, 3).toString() !== 'v10') throw new Error('未知的加密格式');
|
|
382
|
+
const nonce = data.subarray(3, 15);
|
|
383
|
+
const tag = data.subarray(data.length - 16);
|
|
384
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
385
|
+
d.setAuthTag(tag);
|
|
386
|
+
return Buffer.concat([d.update(data.subarray(15, data.length - 16)), d.final()]).toString('utf8');
|
|
387
|
+
};
|
|
388
|
+
encrypt = (text) => {
|
|
389
|
+
const nonce = crypto.randomBytes(12);
|
|
390
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
391
|
+
const ct = Buffer.concat([c.update(text, 'utf8'), c.final()]);
|
|
392
|
+
return Buffer.concat([Buffer.from('v10'), nonce, ct, c.getAuthTag()]);
|
|
393
|
+
};
|
|
394
|
+
} else {
|
|
395
|
+
let password = 'peanuts', iterations = 1;
|
|
396
|
+
if (process.platform === 'darwin') {
|
|
397
|
+
for (const n of appNames) {
|
|
398
|
+
password = await macKeychainPassword(`${n} Safe Storage`);
|
|
399
|
+
if (password) break;
|
|
400
|
+
}
|
|
401
|
+
if (!password) throw new Error('无法从钥匙串读取 Qoder 的 Safe Storage 密钥');
|
|
402
|
+
iterations = 1003;
|
|
403
|
+
}
|
|
404
|
+
const key = crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
|
|
405
|
+
const iv = Buffer.alloc(16, ' ');
|
|
406
|
+
decrypt = (data) => {
|
|
407
|
+
const ver = data.subarray(0, 3).toString();
|
|
408
|
+
if (ver !== 'v10') throw new Error(ver === 'v11' ? '需要系统密钥环(v11),暂不支持' : '未知的加密格式');
|
|
409
|
+
const d = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
|
410
|
+
return Buffer.concat([d.update(data.subarray(3)), d.final()]).toString('utf8');
|
|
411
|
+
};
|
|
412
|
+
encrypt = (text) => {
|
|
413
|
+
const c = crypto.createCipheriv('aes-128-cbc', key, iv);
|
|
414
|
+
return Buffer.concat([Buffer.from('v10'), c.update(text, 'utf8'), c.final()]);
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const codec = { decrypt, encrypt };
|
|
418
|
+
keyCache.set(userData, codec);
|
|
419
|
+
return codec;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 读取本机 Qoder 客户端当前登录的账号(解密 auth.v1.dat)。
|
|
424
|
+
* @returns {Promise<{accounts: Array, errors: Array}>} 每个账号含 provider/token/refreshToken/expiresAt/user
|
|
425
|
+
*/
|
|
426
|
+
export async function readQoderAppAccounts() {
|
|
427
|
+
const accounts = [];
|
|
428
|
+
const errors = [];
|
|
429
|
+
for (const app of detectQoderApps()) {
|
|
430
|
+
const file = path.join(app.dataDir, 'auth.v1.dat');
|
|
431
|
+
if (!exists(file)) continue;
|
|
432
|
+
try {
|
|
433
|
+
const { decrypt } = await safeStorageCodec(app.dataDir, ['Qoder App', app.label.includes('国内') ? 'Qoder CN' : 'Qoder']);
|
|
434
|
+
const obj = JSON.parse(decrypt(fs.readFileSync(file)));
|
|
435
|
+
if (typeof obj?.token !== 'string' || !obj.token) throw new Error('auth.v1.dat 中没有 token');
|
|
436
|
+
accounts.push({
|
|
437
|
+
provider: app.provider,
|
|
438
|
+
source: `${app.label} 客户端`,
|
|
439
|
+
file,
|
|
440
|
+
authJson: obj,
|
|
441
|
+
token: obj.token,
|
|
442
|
+
refreshToken: typeof obj.refreshToken === 'string' ? obj.refreshToken : null,
|
|
443
|
+
expiresAt: typeof obj.expiresAt === 'string' ? obj.expiresAt : null,
|
|
444
|
+
user: {
|
|
445
|
+
id: obj.user?.id || null,
|
|
446
|
+
name: obj.user?.name || null,
|
|
447
|
+
email: obj.user?.email || null,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
} catch (e) {
|
|
451
|
+
errors.push({ provider: app.provider, file, error: e.message });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { accounts, errors };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ─── 切换本机 Qoder 登录账号 ───
|
|
458
|
+
|
|
459
|
+
const QODER_EXES = ['Qoder.exe', 'Qoder CN.exe'];
|
|
460
|
+
|
|
461
|
+
/** Qoder IDE(国际版 / 国内版)是否在运行 */
|
|
462
|
+
export function qoderRunning() {
|
|
463
|
+
if (process.platform !== 'win32') return false;
|
|
464
|
+
for (const exe of QODER_EXES) {
|
|
465
|
+
try {
|
|
466
|
+
const out = execFileSync('tasklist.exe', ['/FI', `IMAGENAME eq ${exe}`, '/NH'],
|
|
467
|
+
{ windowsHide: true, encoding: 'buffer', timeout: 8000 }).toString('latin1');
|
|
468
|
+
if (!/No Tasks|没有/i.test(out)) return true;
|
|
469
|
+
} catch { /* 查不到就换下一名 */ }
|
|
470
|
+
}
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
475
|
+
|
|
476
|
+
/** 结束 Qoder 进程:先普通终止(给客户端落盘的机会),超时后 /F */
|
|
477
|
+
export function terminateQoder({ timeoutMs = 8000 } = {}) {
|
|
478
|
+
if (process.platform !== 'win32') return { closed: false, supported: false };
|
|
479
|
+
if (!qoderRunning()) return { closed: false, running: false };
|
|
480
|
+
for (const exe of QODER_EXES) {
|
|
481
|
+
try { execFileSync('taskkill.exe', ['/IM', exe, '/T'], { windowsHide: true, stdio: 'ignore' }); } catch {}
|
|
482
|
+
}
|
|
483
|
+
let deadline = Date.now() + timeoutMs;
|
|
484
|
+
while (Date.now() < deadline && qoderRunning()) nap(250);
|
|
485
|
+
if (qoderRunning()) {
|
|
486
|
+
for (const exe of QODER_EXES) {
|
|
487
|
+
try { execFileSync('taskkill.exe', ['/IM', exe, '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); } catch {}
|
|
488
|
+
}
|
|
489
|
+
deadline = Date.now() + 3000;
|
|
490
|
+
while (Date.now() < deadline && qoderRunning()) nap(250);
|
|
491
|
+
}
|
|
492
|
+
return { closed: !qoderRunning(), running: qoderRunning() };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 把本机 Qoder 客户端的登录换成目标账号(写回 auth.v1.dat)。
|
|
497
|
+
* force:由调用方先 terminateQoder 再进来(运行中的 Qoder 退出时会把内存里的旧登录覆盖回文件)。
|
|
498
|
+
*/
|
|
499
|
+
export async function switchTo(account, { force = false } = {}) {
|
|
500
|
+
const auth = account.meta?.qoderAuth;
|
|
501
|
+
const file = account.meta?.qoderAuthFile;
|
|
502
|
+
if (!auth || !file) {
|
|
503
|
+
throw new Error(`账号「${account.name || account.id}」没有本机登录快照,无法切换:请在 Qoder 里登录该账号后到「添加账号 → 本机导入」导入一次`);
|
|
504
|
+
}
|
|
505
|
+
if (!fs.existsSync(file)) throw new Error(`Qoder 凭据文件不存在:${file}(客户端可能被卸载或换了数据目录)`);
|
|
506
|
+
if (!force && qoderRunning()) {
|
|
507
|
+
const e = new Error('Qoder 正在运行。切换会改写 Qoder 的登录文件,运行中的 Qoder 退出时会把旧登录写回去——退出后重试,或选择「关闭并强制切换」。');
|
|
508
|
+
e.qoderRunning = true;
|
|
509
|
+
throw e;
|
|
510
|
+
}
|
|
511
|
+
const { encrypt, decrypt } = await safeStorageCodec(path.dirname(file), ['Qoder App', account.provider === 'qoder-cn' ? 'Qoder CN' : 'Qoder']);
|
|
512
|
+
try {
|
|
513
|
+
if (JSON.parse(decrypt(fs.readFileSync(file))).token === auth.token) {
|
|
514
|
+
return { switched: false, alreadyActive: true };
|
|
515
|
+
}
|
|
516
|
+
} catch { /* 当前文件解不开就当作不同账号,继续写入 */ }
|
|
517
|
+
const buf = encrypt(JSON.stringify(auth));
|
|
518
|
+
const bak = file + '.creditdaddy.bak';
|
|
519
|
+
try { fs.copyFileSync(file, bak); } catch { /* 备份失败不阻断,下面原子写仍会成功 */ }
|
|
520
|
+
const tmp = file + '.tmp';
|
|
521
|
+
fs.writeFileSync(tmp, buf, { mode: 0o600 });
|
|
522
|
+
fs.renameSync(tmp, file);
|
|
523
|
+
return { switched: true, alreadyActive: false };
|
|
524
|
+
}
|