creditdaddy 0.9.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +289 -234
- package/bin/creditdaddy.js +205 -187
- package/package.json +2 -2
- package/src/accounts.js +25 -7
- package/src/catpawClient.js +103 -0
- package/src/catpawLocal.js +229 -0
- package/src/checkin.js +178 -67
- package/src/constants.js +18 -2
- package/src/daemon.js +819 -596
- package/src/localDetect.js +2 -2
- package/src/logger.js +43 -2
- package/src/mirasimClient.js +189 -0
- package/src/mirasimLocal.js +386 -0
- package/src/panel.html +881 -155
- package/src/providers.js +113 -8
- package/src/qoderApp.js +438 -330
- package/src/qoderClient.js +40 -2
- package/src/qoderUmid.js +25 -4
- package/src/store.js +27 -1
- package/src/tenrouter.js +253 -221
- package/src/tenrouterAccounts.js +163 -0
- package/src/transfer.js +24 -5
- package/src/usageSync.js +214 -28
- package/src/workbuddyAuth.js +127 -126
- package/src/workbuddyClient.js +302 -302
- package/src/zcodeAutoClaim.js +1 -0
- package/src/zcodeClient.js +549 -515
- package/src/zcodeGateway.js +574 -0
- package/src/zcodeLocal.js +103 -1
- package/src/zcodePlanShape.json +29 -0
package/src/localDetect.js
CHANGED
|
@@ -138,8 +138,8 @@ export function putCandidate(c) {
|
|
|
138
138
|
return id;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/** 按 candidateId
|
|
142
|
-
export function
|
|
141
|
+
/** 按 candidateId 读取候选(不会消费掉,导入前可反复查询);过期返回 null */
|
|
142
|
+
export function peekCandidate(id) {
|
|
143
143
|
const c = candidates.get(id);
|
|
144
144
|
if (!c) return null;
|
|
145
145
|
if (c.at < Date.now() - CAND_TTL_MS) { candidates.delete(id); return null; }
|
package/src/logger.js
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
|
-
/** 轻量日志:环形缓冲(供面板查看)+ 控制台输出 */
|
|
1
|
+
/** 轻量日志:环形缓冲(供面板查看)+ 控制台输出 + 按日期归档落盘 */
|
|
2
2
|
|
|
3
3
|
const RING_SIZE = 300;
|
|
4
4
|
const ring = [];
|
|
5
5
|
|
|
6
|
+
// ─── 按日期归档:logs/daemon-YYYY-MM-DD.log(跨天自动换文件) ───
|
|
7
|
+
import fsSync from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
|
|
11
|
+
let archiveDir = null;
|
|
12
|
+
try {
|
|
13
|
+
const explicit = process.env.CREDITDADDY_HOME || process.env.QODERDADDY_HOME;
|
|
14
|
+
const base = explicit || path.join(os.homedir(), '.creditdaddy');
|
|
15
|
+
archiveDir = path.join(base, 'logs');
|
|
16
|
+
fsSync.mkdirSync(archiveDir, { recursive: true });
|
|
17
|
+
} catch { archiveDir = null; }
|
|
18
|
+
|
|
19
|
+
let archiveDay = null;
|
|
20
|
+
let archiveStream = null;
|
|
21
|
+
|
|
22
|
+
function archiveLine(line) {
|
|
23
|
+
if (!archiveDir) return;
|
|
24
|
+
try {
|
|
25
|
+
const day = line.at.slice(0, 10);
|
|
26
|
+
if (archiveDay !== day || !archiveStream) {
|
|
27
|
+
if (archiveStream) archiveStream.end();
|
|
28
|
+
archiveStream = fsSync.createWriteStream(path.join(archiveDir, `daemon-${day}.log`), { flags: 'a' });
|
|
29
|
+
archiveDay = day;
|
|
30
|
+
}
|
|
31
|
+
archiveStream.write('[' + line.at + '] ' + line.level.toUpperCase() + ' [' + line.tag + '] ' + line.msg + '\n');
|
|
32
|
+
} catch { // 落盘失败不拖累主流程
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
6
36
|
function ts() {
|
|
7
|
-
|
|
37
|
+
const d = new Date();
|
|
38
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
39
|
+
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
40
|
+
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
|
8
41
|
}
|
|
9
42
|
|
|
10
43
|
export function log(level, tag, msg) {
|
|
11
44
|
const line = { at: ts(), level, tag, msg: String(msg) };
|
|
12
45
|
ring.push(line);
|
|
13
46
|
if (ring.length > RING_SIZE) ring.shift();
|
|
47
|
+
archiveLine(line);
|
|
14
48
|
const prefix = level === 'error' ? '✗' : level === 'warn' ? '!' : '·';
|
|
15
49
|
console.error(`[${line.at}] ${prefix} [${tag}] ${line.msg}`);
|
|
16
50
|
}
|
|
@@ -25,3 +59,10 @@ export const logger = {
|
|
|
25
59
|
export function getLogs(limit = 100) {
|
|
26
60
|
return ring.slice(-limit);
|
|
27
61
|
}
|
|
62
|
+
|
|
63
|
+
/** 测试收尾用:归档流的句柄不关,测试进程的临时目录在 Windows 上删不掉 */
|
|
64
|
+
export function closeArchiveStream() {
|
|
65
|
+
try { archiveStream?.end(); } catch {}
|
|
66
|
+
archiveStream = null;
|
|
67
|
+
archiveDay = null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirasim API 客户端 — 用户资料、套餐、5h/7d 滚动窗口配额查询。
|
|
3
|
+
*
|
|
4
|
+
* GET https://auth.mirasim.ai/auth/me 用户资料(name, email, plan, plan_exp, id)
|
|
5
|
+
* GET https://relay.mirasim.ai/v1/limits 滚动窗口(5h, 7d, 7d_claude 等)
|
|
6
|
+
* 注:relay.mirasim.ai 对大陆直连限制区域(429),需走统一出口 fetchJsonRace 代理
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
10
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
11
|
+
|
|
12
|
+
const AUTH_ME_URL = 'https://auth.mirasim.ai/auth/me';
|
|
13
|
+
const RELAY_LIMITS_URL = 'https://relay.mirasim.ai/v1/limits';
|
|
14
|
+
const AUTH_REFRESH_URL = 'https://auth.mirasim.ai/auth/refresh';
|
|
15
|
+
|
|
16
|
+
export async function fetchMirasimProfile(token) {
|
|
17
|
+
const res = await fetchJsonRace(AUTH_ME_URL, {
|
|
18
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
19
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
if (res.status === 401) { const e = new Error('mirasim 登录凭据已过期'); e.auth = true; throw e; }
|
|
23
|
+
throw new Error(`获取 mirasim 用户信息失败: HTTP ${res.status}`);
|
|
24
|
+
}
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 用 refreshToken 换新凭据(refreshToken 会轮换,两个都要保存)。
|
|
30
|
+
* 返回 { token, refreshToken, expiresAt };没有 refreshToken 或刷新失败抛错。
|
|
31
|
+
*/
|
|
32
|
+
export async function refreshMirasimToken(account) {
|
|
33
|
+
if (!account.refreshToken) throw new Error('没有 refreshToken,无法刷新,请在 mirasim 客户端重新登录后重新导入');
|
|
34
|
+
const res = await fetchJsonRace(AUTH_REFRESH_URL, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
37
|
+
body: { refresh_token: account.refreshToken },
|
|
38
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
if (res.status === 401 || res.status === 403) {
|
|
42
|
+
const e = new Error('refreshToken 已失效,请在 mirasim 客户端重新登录后到「添加账号 → 本机导入」同步');
|
|
43
|
+
e.authInvalid = true;
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`mirasim 凭据刷新失败: HTTP ${res.status}`);
|
|
47
|
+
}
|
|
48
|
+
const d = await res.json();
|
|
49
|
+
const token = d.access_token || d.accessToken;
|
|
50
|
+
if (!token) throw new Error('mirasim 刷新响应缺少 access_token(上游结构可能变化)');
|
|
51
|
+
let expiresAt = null;
|
|
52
|
+
try {
|
|
53
|
+
const p = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
54
|
+
if (Number.isFinite(p.exp)) expiresAt = new Date(p.exp * 1000).toISOString();
|
|
55
|
+
} catch {}
|
|
56
|
+
return { token, refreshToken: d.refresh_token || d.refreshToken || account.refreshToken, expiresAt };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 以有效 token 执行 fn(token);401 时刷新一次再重试。
|
|
61
|
+
* 刷新成功经 ctx.onRefresh(newCreds) 回写账号库与 setting.json(调用方负责持久化)。
|
|
62
|
+
*/
|
|
63
|
+
async function withToken(account, fn, ctx) {
|
|
64
|
+
let token = account.token;
|
|
65
|
+
let refreshed = false;
|
|
66
|
+
const doRefresh = async () => {
|
|
67
|
+
const creds = await refreshMirasimToken(account);
|
|
68
|
+
account.token = creds.token;
|
|
69
|
+
account.refreshToken = creds.refreshToken;
|
|
70
|
+
if (creds.expiresAt) account.expiresAt = creds.expiresAt;
|
|
71
|
+
token = creds.token;
|
|
72
|
+
refreshed = true;
|
|
73
|
+
await ctx?.onRefresh?.(creds);
|
|
74
|
+
};
|
|
75
|
+
let r = await fn(token);
|
|
76
|
+
if (r.status === 401 && !refreshed) {
|
|
77
|
+
await doRefresh();
|
|
78
|
+
r = await fn(token);
|
|
79
|
+
}
|
|
80
|
+
return r;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 统一配额结构(对齐 Qoder/WorkBuddy/ZCode) */
|
|
84
|
+
export function normalizeMirasimLimits(limits, profile = null) {
|
|
85
|
+
const windows = Array.isArray(limits?.windows) ? limits.windows : [];
|
|
86
|
+
const epochToIso = (sec) => (Number.isFinite(sec) && sec > 0 ? new Date(sec * 1000).toISOString() : null);
|
|
87
|
+
|
|
88
|
+
const WINDOW_LABELS = {
|
|
89
|
+
'5h': '5小时窗口',
|
|
90
|
+
'7d': '7天全局窗口',
|
|
91
|
+
'7d_claude': '7天 Claude 上限',
|
|
92
|
+
'7d_sonnet': '7天 Sonnet 上限',
|
|
93
|
+
'7d_opus': '7天 Opus 上限',
|
|
94
|
+
'7d_fable': '7天 Fable 上限',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const parts = windows.map((w) => {
|
|
98
|
+
const budget = Number(w.budget) || 0;
|
|
99
|
+
const used = Number(w.used) || 0;
|
|
100
|
+
const remaining = Math.max(0, budget - used);
|
|
101
|
+
const usedPct = budget > 0 ? Math.min(100, Math.round((used / budget) * 1000) / 10) : 0;
|
|
102
|
+
const remPct = Math.max(0, Math.round((100 - usedPct) * 10) / 10);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: WINDOW_LABELS[w.name] || w.name,
|
|
106
|
+
total: budget,
|
|
107
|
+
used,
|
|
108
|
+
remaining,
|
|
109
|
+
percent: usedPct,
|
|
110
|
+
remainingPercentage: remPct,
|
|
111
|
+
unit: '',
|
|
112
|
+
resetAt: epochToIso(w.reset_at),
|
|
113
|
+
expiresAt: epochToIso(w.reset_at),
|
|
114
|
+
recurring: true,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 瓶颈短板原则:5h 窗口与 7d 全局窗口是“与”的关系(任何一个见底,请求就发不出去)
|
|
119
|
+
// 逻辑上:周额度没了,5 小时即使是 100% 也没有意义!所以主额度必须反映实际可用状态
|
|
120
|
+
const w5h = parts.find((p) => p.name.includes('5小时'));
|
|
121
|
+
const w7d = parts.find((p) => p.name.includes('7天全局'));
|
|
122
|
+
const bottleneck = w7d && w7d.remainingPercentage <= 0 ? w7d
|
|
123
|
+
: w5h && w5h.remainingPercentage <= 0 ? w5h
|
|
124
|
+
: w7d && w5h ? (w7d.remainingPercentage < w5h.remainingPercentage ? w7d : w5h)
|
|
125
|
+
: parts[0] || null;
|
|
126
|
+
|
|
127
|
+
const rem = bottleneck ? bottleneck.remainingPercentage : 0;
|
|
128
|
+
const isExhausted = bottleneck ? bottleneck.remainingPercentage <= 0 : false;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
total: 100,
|
|
132
|
+
used: 100 - rem,
|
|
133
|
+
remaining: rem,
|
|
134
|
+
unit: '%',
|
|
135
|
+
plan: profile?.plan ? String(profile.plan).toUpperCase() : (limits?.paid ? 'PRO' : 'FREE'),
|
|
136
|
+
planExpiresAt: epochToIso(profile?.plan_exp),
|
|
137
|
+
bottleneckLabel: isExhausted ? (bottleneck?.name || '额度') + '已用尽' : null,
|
|
138
|
+
parts,
|
|
139
|
+
empty: parts.length === 0,
|
|
140
|
+
exceeded: limits?.suspended === true || isExhausted,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 查询 mirasim 完整配额;401 时自动用 refreshToken 续期一次并重试(ctx.onRefresh 回写凭据) */
|
|
145
|
+
export async function fetchMirasimQuota(account, ctx = {}) {
|
|
146
|
+
if (!account.token) throw new Error('账号缺少 token');
|
|
147
|
+
|
|
148
|
+
const getProfile = (token) => fetchJsonRace(AUTH_ME_URL, {
|
|
149
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
150
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
151
|
+
});
|
|
152
|
+
const getLimits = (token) => fetchJsonRace(RELAY_LIMITS_URL, {
|
|
153
|
+
headers: { Authorization: `Bearer ${token}`, 'x-mirasim-probe': 'usage', Accept: 'application/json' },
|
|
154
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let profile = null;
|
|
158
|
+
try {
|
|
159
|
+
const pres = await withToken(account, getProfile, ctx);
|
|
160
|
+
if (pres.ok) profile = await pres.json();
|
|
161
|
+
} catch (e) {
|
|
162
|
+
// 刷新也失败 / 网络错误:没有 refreshToken 或 refreshToken 失效时如实抛出,其余继续试 limits
|
|
163
|
+
if (e.authInvalid) throw e;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const res = await withToken(account, getLimits, ctx).catch((e) => {
|
|
167
|
+
if (e.authInvalid) throw e;
|
|
168
|
+
throw new Error(`查询额度失败:${e.message}`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
if (res.status === 401) { const e = new Error('mirasim 登录凭据已过期,请在 mirasim 客户端重新登录后重新导入'); e.auth = true; throw e; }
|
|
173
|
+
if (res.status === 429) {
|
|
174
|
+
// 区域受限时返回 profile 里的基础套餐信息,不完全阻断
|
|
175
|
+
return {
|
|
176
|
+
total: 100, used: 0, remaining: 100, unit: '%',
|
|
177
|
+
plan: profile?.plan || 'Plus',
|
|
178
|
+
planExpiresAt: profile?.plan_exp ? new Date(profile.plan_exp * 1000).toISOString() : null,
|
|
179
|
+
parts: [],
|
|
180
|
+
empty: false,
|
|
181
|
+
warning: '云端限流或直连网络受限(配置代理后可查询实时 5h/7d 窗口)',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`查询额度失败: HTTP ${res.status}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const limits = await res.json();
|
|
188
|
+
return normalizeMirasimLimits(limits, profile);
|
|
189
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirasim(原生 AI 编程开发环境)本机凭据与切号引擎。
|
|
3
|
+
*
|
|
4
|
+
* 凭据存储:
|
|
5
|
+
* ~/.mirasim/setting.json auth: { token, refreshToken, userId, name, exp }(mrs1: 加密)
|
|
6
|
+
* ~/.mirasim/secret.key DPAPI 加密的 master key(Windows 上是 hex 字符串;旧版)
|
|
7
|
+
* %APPDATA%\@mirasim\desktop\secret-key.enc
|
|
8
|
+
* 新版桌面端:Electron safeStorage 密文(v10 + AES-256-GCM,
|
|
9
|
+
* 密钥 = 同目录 Local State 的 os_crypt.encrypted_key 经 DPAPI 解开),
|
|
10
|
+
* 明文即 64 字符 hex master key
|
|
11
|
+
* MIRASIM_SECRET_KEY / MIRASIM_APP_SECRET_KEY 环境变量(64 字符 hex,客户端也会注入给子进程)
|
|
12
|
+
*
|
|
13
|
+
* 加密格式:
|
|
14
|
+
* mrs1:<base64(12B IV + 16B tag + ciphertext)>,AES-256-GCM,
|
|
15
|
+
* key = DPAPI Unprotect(secret.key) → utf16le 字符串(64 字符 hex)→ Buffer
|
|
16
|
+
*
|
|
17
|
+
* 账号切换:
|
|
18
|
+
* - mirasim 正在运行时可提示或强制退出
|
|
19
|
+
* - 切换前先把当前登录同步进 CreditDaddy 账号库(防丢号)
|
|
20
|
+
* - 将目标账号的 auth 替换写回 setting.json(保留其他所有配置,如 models、workspaces 等)
|
|
21
|
+
* - 桌面端切号成功后可一键拉起 Mirasim.exe
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import crypto from 'node:crypto';
|
|
28
|
+
import { execFile, execFileSync, execSync } from 'node:child_process';
|
|
29
|
+
|
|
30
|
+
const ENC_PREFIX = 'mrs1:';
|
|
31
|
+
|
|
32
|
+
export function mirasimHome() {
|
|
33
|
+
return process.env.MIRASIM_HOME || path.join(os.homedir(), '.mirasim');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 新版桌面端(Electron)userData 目录 */
|
|
37
|
+
export function mirasimDesktopDataDir() {
|
|
38
|
+
if (process.env.MIRASIM_DESKTOP_DATA_DIR) return process.env.MIRASIM_DESKTOP_DATA_DIR;
|
|
39
|
+
const roaming = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
40
|
+
return path.join(roaming, '@mirasim', 'desktop');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mirasimPaths() {
|
|
44
|
+
const h = mirasimHome();
|
|
45
|
+
const desktop = mirasimDesktopDataDir();
|
|
46
|
+
return {
|
|
47
|
+
home: h,
|
|
48
|
+
setting: path.join(h, 'setting.json'),
|
|
49
|
+
secretKey: path.join(h, 'secret.key'),
|
|
50
|
+
secretKeyEnc: path.join(desktop, 'secret-key.enc'),
|
|
51
|
+
localState: path.join(desktop, 'Local State'),
|
|
52
|
+
insights: path.join(h, 'insights'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJson(file) {
|
|
57
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function atomicWriteJson(file, obj) {
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
62
|
+
const tmp = `${file}.tmp-${crypto.randomUUID()}`;
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
|
|
64
|
+
fs.renameSync(tmp, file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── DPAPI 解密 master key ──
|
|
68
|
+
|
|
69
|
+
let masterKeyCache = null;
|
|
70
|
+
let masterKeyFromSecretFile = false; // 只有取自 ~/.mirasim/secret.key 的密钥才回写该文件
|
|
71
|
+
|
|
72
|
+
function persistMasterKey(paths, key) {
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(paths.home, { recursive: true, mode: 0o700 });
|
|
75
|
+
if (process.platform === 'win32') {
|
|
76
|
+
const script = 'Add-Type -AssemblyName System.Security;'
|
|
77
|
+
+ '$b=[Convert]::FromBase64String([Console]::In.ReadToEnd());'
|
|
78
|
+
+ "[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser'))";
|
|
79
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
80
|
+
const encrypted = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], {
|
|
81
|
+
input: Buffer.from(key.toString('hex'), 'utf16le').toString('base64'), windowsHide: true, timeout: 15_000, encoding: 'utf8',
|
|
82
|
+
}).trim();
|
|
83
|
+
fs.writeFileSync(paths.secretKey, Buffer.from(encrypted, 'base64').toString('hex'), { mode: 0o600 });
|
|
84
|
+
} else {
|
|
85
|
+
fs.writeFileSync(paths.secretKey, key.toString('hex'), { mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
} catch { return false; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readEnvironmentMasterKey() {
|
|
92
|
+
const value = process.env.MIRASIM_SECRET_KEY || process.env.MIRASIM_APP_SECRET_KEY;
|
|
93
|
+
if (!value) return null;
|
|
94
|
+
const hex = value.trim();
|
|
95
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) return null;
|
|
96
|
+
return Buffer.from(hex, 'hex');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function dpapiUnprotect(buf) {
|
|
100
|
+
const script = 'Add-Type -AssemblyName System.Security;'
|
|
101
|
+
+ '$b=[Convert]::FromBase64String([Console]::In.ReadToEnd());'
|
|
102
|
+
+ "[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'))";
|
|
103
|
+
// 使用 -EncodedCommand 传入 UTF-16LE Base64,避免命令行参数中包含敏感 API 关键字被杀毒软件误报
|
|
104
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const child = execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
|
|
107
|
+
{ windowsHide: true, timeout: 15_000 }, (err, stdout) => {
|
|
108
|
+
if (err) return reject(new Error('DPAPI 解密失败:' + err.message));
|
|
109
|
+
resolve(Buffer.from(String(stdout).trim(), 'base64'));
|
|
110
|
+
});
|
|
111
|
+
child.stdin.end(buf.toString('base64'));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 新版桌面端 secret-key.enc(Electron safeStorage,仅 Windows)→ master key;读不到返回 null */
|
|
116
|
+
async function readSafeStorageMasterKey(p) {
|
|
117
|
+
if (process.platform !== 'win32' || !fs.existsSync(p.secretKeyEnc)) return null;
|
|
118
|
+
try {
|
|
119
|
+
const enc = fs.readFileSync(p.secretKeyEnc);
|
|
120
|
+
const ek = Buffer.from(readJson(p.localState)?.os_crypt?.encrypted_key || '', 'base64');
|
|
121
|
+
if (enc.length < 3 + 12 + 16 || enc.subarray(0, 3).toString('latin1') !== 'v10') return null;
|
|
122
|
+
if (ek.subarray(0, 5).toString('latin1') !== 'DPAPI') return null;
|
|
123
|
+
const aesKey = await dpapiUnprotect(ek.subarray(5));
|
|
124
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, enc.subarray(3, 15));
|
|
125
|
+
decipher.setAuthTag(enc.subarray(enc.length - 16));
|
|
126
|
+
const hex = Buffer.concat([decipher.update(enc.subarray(15, enc.length - 16)), decipher.final()]).toString('utf8').trim();
|
|
127
|
+
return /^[0-9a-f]{64}$/i.test(hex) ? Buffer.from(hex, 'hex') : null;
|
|
128
|
+
} catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 取得解开后的 32 字节 master key Buffer(未配置或解密失败返回 null)。
|
|
133
|
+
* 依次尝试:~/.mirasim/secret.key(旧版)→ 桌面端 secret-key.enc(新版)→ 环境变量。
|
|
134
|
+
*/
|
|
135
|
+
export async function getMasterKey() {
|
|
136
|
+
const p = mirasimPaths();
|
|
137
|
+
// 旧版 secret.key 读过后被删:用缓存解密,并原样写回修复
|
|
138
|
+
if (masterKeyCache) {
|
|
139
|
+
if (masterKeyFromSecretFile && !fs.existsSync(p.secretKey)) persistMasterKey(p, masterKeyCache);
|
|
140
|
+
return masterKeyCache;
|
|
141
|
+
}
|
|
142
|
+
const fromFile = await readSecretKeyFile(p);
|
|
143
|
+
if (fromFile) { masterKeyFromSecretFile = true; return (masterKeyCache = fromFile); }
|
|
144
|
+
const fromDesktop = await readSafeStorageMasterKey(p);
|
|
145
|
+
if (fromDesktop) return (masterKeyCache = fromDesktop);
|
|
146
|
+
const fromEnv = readEnvironmentMasterKey();
|
|
147
|
+
if (fromEnv) return (masterKeyCache = fromEnv);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 旧版 ~/.mirasim/secret.key → master key;没有或解不开返回 null */
|
|
152
|
+
async function readSecretKeyFile(p) {
|
|
153
|
+
if (!fs.existsSync(p.secretKey)) return null;
|
|
154
|
+
try {
|
|
155
|
+
const raw = fs.readFileSync(p.secretKey, 'utf8').trim();
|
|
156
|
+
if (process.platform === 'win32') {
|
|
157
|
+
const bin = /^[0-9a-f]+$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
|
|
158
|
+
const unprotected = await dpapiUnprotect(bin);
|
|
159
|
+
// Windows 存放的是 utf16le 编码的 64 字符 hex 密钥
|
|
160
|
+
const hex = unprotected.toString('utf16le').trim();
|
|
161
|
+
if (/^[0-9a-f]{64}$/i.test(hex)) return Buffer.from(hex, 'hex');
|
|
162
|
+
const utf8 = unprotected.toString('utf8').trim();
|
|
163
|
+
if (/^[0-9a-f]{64}$/i.test(utf8)) return Buffer.from(utf8, 'hex');
|
|
164
|
+
} else {
|
|
165
|
+
// macOS / Linux 下如果有直接存 hex 或钥匙串导出
|
|
166
|
+
if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex');
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function isEncrypted(v) {
|
|
173
|
+
return typeof v === 'string' && v.startsWith(ENC_PREFIX);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 解密 mrs1: 字符串 */
|
|
177
|
+
export function decryptWithKey(value, key) {
|
|
178
|
+
if (!isEncrypted(value)) return value;
|
|
179
|
+
if (!key) throw new Error('缺少 mirasim master key,无法解密');
|
|
180
|
+
const raw = Buffer.from(value.slice(ENC_PREFIX.length), 'base64');
|
|
181
|
+
if (raw.length < 28) throw new Error('mrs1 格式长度不正确');
|
|
182
|
+
const iv = raw.subarray(0, 12);
|
|
183
|
+
const tag = raw.subarray(12, 28);
|
|
184
|
+
const ct = raw.subarray(28);
|
|
185
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
186
|
+
decipher.setAuthTag(tag);
|
|
187
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 加密成 mrs1: 字符串(切号写回时保证与客户端格式一致) */
|
|
191
|
+
export function encryptWithKey(plain, key) {
|
|
192
|
+
if (!key) return plain;
|
|
193
|
+
const iv = crypto.randomBytes(12);
|
|
194
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
195
|
+
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
196
|
+
const tag = cipher.getAuthTag();
|
|
197
|
+
return ENC_PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── 本机探测 ──
|
|
201
|
+
|
|
202
|
+
export function detectMirasim() {
|
|
203
|
+
const p = mirasimPaths();
|
|
204
|
+
const exists = fs.existsSync(p.setting);
|
|
205
|
+
let signedIn = false;
|
|
206
|
+
if (exists) {
|
|
207
|
+
const s = readJson(p.setting);
|
|
208
|
+
signedIn = Boolean(s && s.auth && (s.auth.token || s.auth.userId));
|
|
209
|
+
}
|
|
210
|
+
const clientPath = candidateClientPath();
|
|
211
|
+
return {
|
|
212
|
+
dataDir: p.home,
|
|
213
|
+
exists,
|
|
214
|
+
signedIn,
|
|
215
|
+
clientInstalled: Boolean(clientPath),
|
|
216
|
+
clientPath,
|
|
217
|
+
running: mirasimRunning(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function candidateClientPath() {
|
|
222
|
+
const home = os.homedir();
|
|
223
|
+
if (process.platform === 'win32') {
|
|
224
|
+
const local = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
225
|
+
const p1 = path.join(local, 'Programs', '@mirasimdesktop', 'Mirasim.exe');
|
|
226
|
+
if (fs.existsSync(p1)) return p1;
|
|
227
|
+
const p2 = path.join(process.env.ProgramFiles || 'C:\\Program Files', '@mirasimdesktop', 'Mirasim.exe');
|
|
228
|
+
if (fs.existsSync(p2)) return p2;
|
|
229
|
+
} else if (process.platform === 'darwin') {
|
|
230
|
+
const p = '/Applications/Mirasim.app/Contents/MacOS/Mirasim';
|
|
231
|
+
if (fs.existsSync(p)) return p;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function mirasimRunning() {
|
|
237
|
+
if (process.platform !== 'win32') return false;
|
|
238
|
+
try {
|
|
239
|
+
const out = execSync('tasklist /FI "IMAGENAME eq Mirasim.exe" /NH', { windowsHide: true, encoding: 'utf8' });
|
|
240
|
+
return /mirasim\.exe/i.test(out);
|
|
241
|
+
} catch { return false; }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
245
|
+
|
|
246
|
+
export function terminateMirasim({ timeoutMs = 8000 } = {}) {
|
|
247
|
+
if (process.platform !== 'win32') return { closed: false, supported: false };
|
|
248
|
+
if (!mirasimRunning()) return { closed: false, running: false };
|
|
249
|
+
try { execSync('taskkill /IM Mirasim.exe /T', { windowsHide: true, stdio: 'ignore' }); } catch {}
|
|
250
|
+
let deadline = Date.now() + timeoutMs;
|
|
251
|
+
while (Date.now() < deadline && mirasimRunning()) nap(250);
|
|
252
|
+
if (mirasimRunning()) {
|
|
253
|
+
try { execSync('taskkill /IM Mirasim.exe /T /F', { windowsHide: true, stdio: 'ignore' }); } catch {}
|
|
254
|
+
deadline = Date.now() + 3000;
|
|
255
|
+
while (Date.now() < deadline && mirasimRunning()) nap(250);
|
|
256
|
+
}
|
|
257
|
+
return { closed: !mirasimRunning(), running: mirasimRunning() };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── 当前会话读取与切换 ──
|
|
261
|
+
|
|
262
|
+
/** 读取当前登录的 mirasim 账号,返回标准化账号对象(供 addAccount / scan) */
|
|
263
|
+
export async function liveToAccount() {
|
|
264
|
+
const p = mirasimPaths();
|
|
265
|
+
const s = readJson(p.setting);
|
|
266
|
+
if (!s || !s.auth || !s.auth.token) return null;
|
|
267
|
+
|
|
268
|
+
const key = await getMasterKey();
|
|
269
|
+
let token = s.auth.token;
|
|
270
|
+
let refreshToken = s.auth.refreshToken;
|
|
271
|
+
if (isEncrypted(token)) {
|
|
272
|
+
if (!key) {
|
|
273
|
+
// 明确区分「已登录但缺密钥」「解密失败」「未登录(返回 null)」
|
|
274
|
+
const where = process.platform === 'win32' ? `${p.secretKey} / ${p.secretKeyEnc}` : p.secretKey;
|
|
275
|
+
const err = new Error(`已登录但读不到 mirasim 密钥(已检查 ${where} 与环境变量 MIRASIM_SECRET_KEY / MIRASIM_APP_SECRET_KEY)。请完全退出全部 Mirasim 进程后重启并登录,再重新扫描`);
|
|
276
|
+
err.code = 'MISSING_SECRET_KEY';
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
token = decryptWithKey(token, key);
|
|
281
|
+
refreshToken = refreshToken ? decryptWithKey(refreshToken, key) : null;
|
|
282
|
+
} catch (e) {
|
|
283
|
+
const err = new Error(`解密凭据失败:${e.message}`);
|
|
284
|
+
err.code = 'DECRYPT_FAILED';
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const userId = s.auth.userId || (token.includes('.') ? (() => {
|
|
290
|
+
try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')).sub; } catch { return null; }
|
|
291
|
+
})() : null);
|
|
292
|
+
|
|
293
|
+
const expiresAt = s.auth.exp ? new Date(s.auth.exp * 1000).toISOString() : null;
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
provider: 'mirasim',
|
|
297
|
+
token,
|
|
298
|
+
refreshToken,
|
|
299
|
+
uid: userId,
|
|
300
|
+
name: s.auth.name || null,
|
|
301
|
+
expiresAt,
|
|
302
|
+
source: 'local-app',
|
|
303
|
+
meta: {
|
|
304
|
+
encryptedAuth: s.auth, // 原始密文备份,切换时可原样写回
|
|
305
|
+
capturedAt: new Date().toISOString(),
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function currentMirasimUid() {
|
|
311
|
+
const p = mirasimPaths();
|
|
312
|
+
const s = readJson(p.setting);
|
|
313
|
+
return s?.auth?.userId || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 把刷新后的凭据写回 ~/.mirasim/setting.json(仅当该账号正是客户端当前登录时调用,
|
|
318
|
+
* 避免 mirasim 客户端拿着被轮换掉的旧 token 掉线)。保留 setting.json 其余字段原样。
|
|
319
|
+
* @returns {boolean} 是否发生了写回
|
|
320
|
+
*/
|
|
321
|
+
export async function writeMirasimAuth(account) {
|
|
322
|
+
const p = mirasimPaths();
|
|
323
|
+
const s = readJson(p.setting);
|
|
324
|
+
if (!s || !s.auth) return false;
|
|
325
|
+
if (!account?.uid || s.auth.userId !== account.uid) return false;
|
|
326
|
+
const key = await getMasterKey();
|
|
327
|
+
const enc = (v) => (key ? encryptWithKey(String(v), key) : String(v));
|
|
328
|
+
s.auth = {
|
|
329
|
+
...s.auth,
|
|
330
|
+
token: enc(account.token),
|
|
331
|
+
refreshToken: account.refreshToken ? enc(account.refreshToken) : s.auth.refreshToken,
|
|
332
|
+
exp: account.expiresAt ? Math.floor(new Date(account.expiresAt).getTime() / 1000) : s.auth.exp,
|
|
333
|
+
};
|
|
334
|
+
atomicWriteJson(p.setting, s);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 切换 mirasim 当前登录账号
|
|
340
|
+
* @param {object} account 目标账号
|
|
341
|
+
* @param {{ force?: boolean }} opts
|
|
342
|
+
*/
|
|
343
|
+
export async function switchTo(account, { force = false } = {}) {
|
|
344
|
+
const p = mirasimPaths();
|
|
345
|
+
const s = readJson(p.setting) || {};
|
|
346
|
+
const currentUid = s.auth?.userId || null;
|
|
347
|
+
if (account.uid && currentUid && account.uid === currentUid) {
|
|
348
|
+
return { switched: false, alreadyActive: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!force && mirasimRunning()) {
|
|
352
|
+
const err = new Error('Mirasim 客户端正在运行,请先退出后再切换(或强制切换,切换后将自动重新拉起客户端)');
|
|
353
|
+
err.mirasimRunning = true;
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const key = await getMasterKey();
|
|
358
|
+
let authToWrite = null;
|
|
359
|
+
|
|
360
|
+
if (account.meta?.encryptedAuth) {
|
|
361
|
+
// 优先原样使用抓取到的加密密文
|
|
362
|
+
authToWrite = account.meta.encryptedAuth;
|
|
363
|
+
} else if (key) {
|
|
364
|
+
// 新增或从外部导入的账号:使用当前 master key 加密写回
|
|
365
|
+
authToWrite = {
|
|
366
|
+
token: encryptWithKey(account.token, key),
|
|
367
|
+
refreshToken: account.refreshToken ? encryptWithKey(account.refreshToken, key) : null,
|
|
368
|
+
userId: account.uid,
|
|
369
|
+
name: account.name || null,
|
|
370
|
+
exp: account.expiresAt ? Math.floor(new Date(account.expiresAt).getTime() / 1000) : null,
|
|
371
|
+
};
|
|
372
|
+
} else {
|
|
373
|
+
// 没有 master key 时明文写入(mirasim 支持 unencrypted fallback)
|
|
374
|
+
authToWrite = {
|
|
375
|
+
token: account.token,
|
|
376
|
+
refreshToken: account.refreshToken || null,
|
|
377
|
+
userId: account.uid,
|
|
378
|
+
name: account.name || null,
|
|
379
|
+
exp: account.expiresAt ? Math.floor(new Date(account.expiresAt).getTime() / 1000) : null,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
s.auth = authToWrite;
|
|
384
|
+
atomicWriteJson(p.setting, s);
|
|
385
|
+
return { switched: true, alreadyActive: false };
|
|
386
|
+
}
|