creditdaddy 0.8.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 -0
- package/README.md +224 -0
- package/bin/creditdaddy.js +187 -0
- package/package.json +28 -0
- package/src/accounts.js +143 -0
- package/src/authDevice.js +205 -0
- package/src/checkin.js +200 -0
- package/src/constants.js +79 -0
- package/src/daemon.js +585 -0
- package/src/localDetect.js +147 -0
- package/src/logger.js +27 -0
- package/src/panel.html +1785 -0
- package/src/providers.js +75 -0
- package/src/qoderApp.js +330 -0
- package/src/qoderClient.js +199 -0
- package/src/qoderUmid.js +177 -0
- package/src/store.js +187 -0
- package/src/tenrouter.js +221 -0
- package/src/transfer.js +141 -0
- package/src/usageSync.js +319 -0
- package/src/workbuddyAuth.js +126 -0
- package/src/workbuddyClient.js +257 -0
- package/src/workbuddyLocal.js +133 -0
- package/src/zcodeAuth.js +131 -0
- package/src/zcodeClient.js +495 -0
- package/src/zcodeLocal.js +217 -0
- package/src/zcrypto.js +160 -0
package/src/providers.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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 } from './qoderClient.js';
|
|
13
|
+
import { checkinWorkbuddy, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
|
|
14
|
+
import { fetchZcodeQuota } from './zcodeClient.js';
|
|
15
|
+
|
|
16
|
+
/** 从 userinfo 响应中挑一个可读的显示名 */
|
|
17
|
+
export function displayNameFrom(ui) {
|
|
18
|
+
const pick = [ui?.nickname, ui?.name, ui?.username, ui?.email]
|
|
19
|
+
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|
20
|
+
.find(Boolean);
|
|
21
|
+
return pick || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Qoder /api/v2/quota/usage → 统一积分结构 */
|
|
25
|
+
export function normalizeQoderQuota(q) {
|
|
26
|
+
const names = [['userQuota', '套餐额度', true], ['addOnQuota', '附加额度', false], ['orgResourcePackage', '组织资源包', false]];
|
|
27
|
+
const expiresAt = Number(q?.expiresAt) > 0 && Number(q.expiresAt) < 253400000000000 ? new Date(Number(q.expiresAt)).toISOString() : null;
|
|
28
|
+
const parts = names
|
|
29
|
+
.map(([k, name, recurring]) => ({ x: q?.[k], name, recurring }))
|
|
30
|
+
.filter(({ x }) => x && Number(x.total) > 0)
|
|
31
|
+
.map(({ x, name, recurring }) => ({
|
|
32
|
+
name, recurring,
|
|
33
|
+
total: Number(x.total) || 0,
|
|
34
|
+
used: Number(x.used) || 0,
|
|
35
|
+
remaining: Number(x.remaining) || 0,
|
|
36
|
+
expiresAt,
|
|
37
|
+
}));
|
|
38
|
+
const sum = (k) => Math.round(parts.reduce((s, p) => s + p[k], 0) * 100) / 100;
|
|
39
|
+
return { total: sum('total'), used: sum('used'), remaining: sum('remaining'), parts, exceeded: Boolean(q?.isQuotaExceeded) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PRODUCTS = {
|
|
43
|
+
qoder: {
|
|
44
|
+
label: 'Qoder',
|
|
45
|
+
checkin: (account) => checkinQoder(account),
|
|
46
|
+
quota: async (account) => normalizeQoderQuota(await fetchQuotaUsage(account)),
|
|
47
|
+
verify: async (account) => {
|
|
48
|
+
const ui = await fetchUserinfo(account);
|
|
49
|
+
return { name: displayNameFrom(ui), uid: typeof ui?.id === 'string' ? ui.id : null, email: ui?.email || null };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
workbuddy: {
|
|
53
|
+
label: 'WorkBuddy',
|
|
54
|
+
checkin: (account, ctx) => checkinWorkbuddy(account, ctx),
|
|
55
|
+
quota: (account, ctx) => fetchWorkbuddyQuota(account, ctx),
|
|
56
|
+
verify: async (account) => {
|
|
57
|
+
const info = inspectToken(account.token);
|
|
58
|
+
if (!info.provider) throw new Error('不是 WorkBuddy / CodeBuddy 的登录 token');
|
|
59
|
+
if (info.provider !== account.provider) throw new Error(`token 属于${info.provider === 'workbuddy' ? '国内版' : '国际版'},与所选版本不符`);
|
|
60
|
+
return { uid: info.uid, name: info.name };
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
zcode: {
|
|
64
|
+
label: 'ZCode',
|
|
65
|
+
// 无 checkin:ZCode 积分来自活动领取(需阿里云验证码),不做后台自动签到
|
|
66
|
+
quota: (account) => fetchZcodeQuota(account),
|
|
67
|
+
verify: async () => {
|
|
68
|
+
throw new Error('ZCode 账号请通过「本机导入」添加(需要客户端的完整登录快照才能查询额度与切换)');
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function productImpl(provider) {
|
|
74
|
+
return PRODUCTS[productOf(provider)];
|
|
75
|
+
}
|
package/src/qoderApp.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
function candidateResourceDirs(v) {
|
|
36
|
+
const home = os.homedir();
|
|
37
|
+
if (process.platform === 'win32') {
|
|
38
|
+
const local = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
39
|
+
const pf = process.env.ProgramFiles || 'C:\\Program Files';
|
|
40
|
+
return [
|
|
41
|
+
path.join(local, 'Programs', v.installName, 'resources'),
|
|
42
|
+
path.join(pf, v.installName, 'resources'),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
if (process.platform === 'darwin') {
|
|
46
|
+
return [
|
|
47
|
+
`/Applications/${v.installName}.app/Contents/Resources`,
|
|
48
|
+
path.join(home, 'Applications', `${v.installName}.app`, 'Contents', 'Resources'),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
const slug = v.installName.toLowerCase().replace(/\s+/g, '-');
|
|
52
|
+
return [`/opt/${v.installName}/resources`, `/opt/${slug}/resources`, `/usr/lib/${slug}/resources`];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function userDataDir(v) {
|
|
56
|
+
const home = os.homedir();
|
|
57
|
+
const name = `${v.appId}.stable`;
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), name);
|
|
60
|
+
}
|
|
61
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', name);
|
|
62
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readVersion(resourcesDir) {
|
|
66
|
+
try {
|
|
67
|
+
const m = JSON.parse(fs.readFileSync(path.join(resourcesDir, 'build-manifest.json'), 'utf8'));
|
|
68
|
+
if (typeof m.productVersion === 'string') return m.productVersion;
|
|
69
|
+
} catch {}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 探测本机已安装的 Qoder 客户端(国际版 / 国内版) */
|
|
74
|
+
export function detectQoderApps() {
|
|
75
|
+
return VARIANTS.map((v) => {
|
|
76
|
+
const resources = candidateResourceDirs(v).find((d) => exists(d)) || null;
|
|
77
|
+
const exe = process.platform === 'win32' ? 'runtime-info.exe' : 'runtime-info';
|
|
78
|
+
const runtimeInfo = resources && exists(path.join(resources, 'umid', exe)) ? path.join(resources, 'umid', exe) : null;
|
|
79
|
+
const data = userDataDir(v);
|
|
80
|
+
return {
|
|
81
|
+
provider: v.provider,
|
|
82
|
+
label: v.label,
|
|
83
|
+
installed: Boolean(resources),
|
|
84
|
+
resourcesDir: resources,
|
|
85
|
+
version: resources ? readVersion(resources) : null,
|
|
86
|
+
runtimeInfo,
|
|
87
|
+
dataDir: data,
|
|
88
|
+
dataExists: exists(data),
|
|
89
|
+
signedIn: exists(path.join(data, 'auth.v1.dat')),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── 设备风控身份 ───
|
|
95
|
+
|
|
96
|
+
/** 与客户端一致的 Cosy-MachineOS 格式:x86_64_win32 / aarch64_darwin … */
|
|
97
|
+
export function machineOs() {
|
|
98
|
+
const a = process.arch === 'arm64' ? 'aarch64' : process.arch === 'x64' ? 'x86_64' : process.arch;
|
|
99
|
+
return `${a}_${process.platform}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 与客户端一致的主机名清洗:可打印 ASCII,超长截断并附短哈希 */
|
|
103
|
+
export function machineHostname(raw = os.hostname()) {
|
|
104
|
+
const e = String(raw || '').trim();
|
|
105
|
+
if (!e) return undefined;
|
|
106
|
+
const clip = (t) => {
|
|
107
|
+
if (t.length <= 96) return t;
|
|
108
|
+
const h = crypto.createHash('sha256').update(t, 'utf8').digest('hex').slice(0, 8);
|
|
109
|
+
const head = t.slice(0, 96 - 8 - 1).replace(/[-\s]+$/u, '');
|
|
110
|
+
return head ? `${head}-${h}` : `unknown-${h}`;
|
|
111
|
+
};
|
|
112
|
+
if (/^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/u.test(e)) return clip(e);
|
|
113
|
+
const h = crypto.createHash('sha256').update(e, 'utf8').digest('hex').slice(0, 8);
|
|
114
|
+
const n = e.replace(/[^\x21-\x7e]+/gu, '-').replace(/-{2,}/gu, '-').replace(/^-+|-+$/gu, '');
|
|
115
|
+
return clip(n ? `${n}-${h}` : `unknown-${h}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
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;
|
|
119
|
+
|
|
120
|
+
/** 机器 ID:优先复用客户端的 auth.machine-id,否则在 CreditDaddy 数据目录持久化一个 */
|
|
121
|
+
export function machineId(provider) {
|
|
122
|
+
const v = VARIANTS.find((x) => x.provider === provider) || VARIANTS[0];
|
|
123
|
+
for (const file of [path.join(userDataDir(v), 'auth.machine-id'), path.join(dataDir(), 'machine-id')]) {
|
|
124
|
+
try {
|
|
125
|
+
const id = fs.readFileSync(file, 'utf8').trim();
|
|
126
|
+
if (UUID_RE.test(id)) return id;
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
const id = crypto.randomUUID();
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
|
|
132
|
+
fs.writeFileSync(path.join(dataDir(), 'machine-id'), id, { mode: 0o600 });
|
|
133
|
+
} catch {}
|
|
134
|
+
return id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 客户端版本号(用于 Cosy-Version) */
|
|
138
|
+
export function clientVersion(provider) {
|
|
139
|
+
const app = detectQoderApps().find((a) => a.provider === provider && a.version)
|
|
140
|
+
|| detectQoderApps().find((a) => a.version);
|
|
141
|
+
return app?.version || DEFAULT_CLIENT_VERSION;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runRuntimeInfo(exe, env, account) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const withStdin = process.platform === 'darwin' || process.platform === 'win32';
|
|
147
|
+
const child = spawn(exe, withStdin ? [String(env), '--account-stdin'] : [String(env)], {
|
|
148
|
+
stdio: [withStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
});
|
|
151
|
+
let out = '';
|
|
152
|
+
let settled = false;
|
|
153
|
+
const done = (err, val) => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
if (child.exitCode === null) child.kill();
|
|
158
|
+
err ? reject(err) : resolve(val);
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(() => done(new Error('runtime-info 超时')), RISK_TIMEOUT_MS);
|
|
161
|
+
const tryParse = () => {
|
|
162
|
+
const nl = out.indexOf('\n');
|
|
163
|
+
if (nl < 0) return false;
|
|
164
|
+
try {
|
|
165
|
+
const j = JSON.parse(out.slice(0, nl));
|
|
166
|
+
const pick = (k) => {
|
|
167
|
+
const s = typeof j[k] === 'string' ? j[k].trim() : '';
|
|
168
|
+
if (!s || s.length > 4096) throw new Error(`runtime-info 输出缺少 ${k}`);
|
|
169
|
+
return s;
|
|
170
|
+
};
|
|
171
|
+
done(null, { machineToken: pick('machineToken'), machineCode: pick('machineCode'), machineType: pick('machineType') });
|
|
172
|
+
} catch (e) { done(e); }
|
|
173
|
+
return true;
|
|
174
|
+
};
|
|
175
|
+
child.stdout.on('data', (d) => {
|
|
176
|
+
out += d.toString('utf8');
|
|
177
|
+
if (out.length > 1024 * 1024) return done(new Error('runtime-info 输出过大'));
|
|
178
|
+
tryParse();
|
|
179
|
+
});
|
|
180
|
+
child.stderr.on('data', () => {});
|
|
181
|
+
child.once('error', (e) => done(new Error(`runtime-info 启动失败:${e.message}`)));
|
|
182
|
+
child.once('close', (code) => {
|
|
183
|
+
if (!tryParse()) done(new Error(`runtime-info 退出码 ${code}`));
|
|
184
|
+
});
|
|
185
|
+
if (withStdin) {
|
|
186
|
+
child.stdin.on('error', () => {});
|
|
187
|
+
child.stdin.end(JSON.stringify({ account }) + '\n');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const riskCache = new Map(); // `${provider}:${uid}` → { value, at } | { promise }
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 风控身份来源:优先本机 Qoder 客户端自带的 runtime-info(国际版 env=3 / 国内版 env=0,缺失时用另一版本的),
|
|
196
|
+
* 其次是从官方 qodercli 提取的设备身份组件(Linux / fnOS,见 qoderUmid.js,env 与 qodercli 一致)。
|
|
197
|
+
*/
|
|
198
|
+
function riskRunner(provider) {
|
|
199
|
+
const apps = detectQoderApps().filter((a) => a.runtimeInfo);
|
|
200
|
+
const app = apps.find((a) => a.provider === provider) || apps[0];
|
|
201
|
+
if (app) return { exe: app.runtimeInfo, env: (VARIANTS.find((v) => v.provider === provider) || VARIANTS[0]).riskEnv, source: 'app' };
|
|
202
|
+
const cli = installedUmid();
|
|
203
|
+
if (cli) return { exe: cli.path, env: CLI_RISK_ENV[provider] ?? CLI_RISK_ENV.qoder, source: 'cli' };
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 获取某账号的设备风控身份;本机既没有 Qoder 客户端也没装设备身份组件时返回 null。 */
|
|
208
|
+
export async function getRiskIdentity(provider, uid) {
|
|
209
|
+
if (!uid) return null;
|
|
210
|
+
const key = `${provider}:${uid}`;
|
|
211
|
+
const hit = riskCache.get(key);
|
|
212
|
+
if (hit?.value && Date.now() - hit.at < RISK_TTL_MS) return hit.value;
|
|
213
|
+
if (hit?.promise) return hit.promise;
|
|
214
|
+
|
|
215
|
+
const runner = riskRunner(provider);
|
|
216
|
+
if (!runner) return null;
|
|
217
|
+
|
|
218
|
+
const promise = runRuntimeInfo(runner.exe, runner.env, uid)
|
|
219
|
+
.then((value) => { riskCache.set(key, { value, at: Date.now() }); return value; })
|
|
220
|
+
.catch((e) => { riskCache.delete(key); throw e; });
|
|
221
|
+
riskCache.set(key, { promise });
|
|
222
|
+
return promise;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function riskIdentityAvailable() {
|
|
226
|
+
return Boolean(riskRunner('qoder'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 风控身份来源:'app'(Qoder 客户端)/ 'cli'(qodercli 设备身份组件)/ null */
|
|
230
|
+
export function riskIdentitySource() {
|
|
231
|
+
return riskRunner('qoder')?.source || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Electron safeStorage 解密 ───
|
|
235
|
+
|
|
236
|
+
function dpapiUnprotect(buf) {
|
|
237
|
+
const ps = 'Add-Type -AssemblyName System.Security;'
|
|
238
|
+
+ '$b=[Convert]::FromBase64String([Console]::In.ReadToEnd());'
|
|
239
|
+
+ "[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'))";
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const child = execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps],
|
|
242
|
+
{ windowsHide: true, timeout: 20_000 }, (err, stdout) => {
|
|
243
|
+
if (err) return reject(new Error('DPAPI 解密失败:' + err.message));
|
|
244
|
+
resolve(Buffer.from(String(stdout).trim(), 'base64'));
|
|
245
|
+
});
|
|
246
|
+
child.stdin.end(buf.toString('base64'));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function macKeychainPassword(service) {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
execFile('security', ['find-generic-password', '-w', '-s', service], { timeout: 20_000 },
|
|
253
|
+
(err, stdout) => resolve(err ? null : String(stdout).trim()));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const keyCache = new Map();
|
|
258
|
+
|
|
259
|
+
/** 取得某个 Electron userData 目录对应的 safeStorage 解密器 */
|
|
260
|
+
async function safeStorageDecryptor(userData, appNames) {
|
|
261
|
+
if (keyCache.has(userData)) return keyCache.get(userData);
|
|
262
|
+
let decrypt;
|
|
263
|
+
if (process.platform === 'win32') {
|
|
264
|
+
const ls = JSON.parse(fs.readFileSync(path.join(userData, 'Local State'), 'utf8'));
|
|
265
|
+
const enc = Buffer.from(ls?.os_crypt?.encrypted_key || '', 'base64');
|
|
266
|
+
if (enc.subarray(0, 5).toString() !== 'DPAPI') throw new Error('Local State 中没有 DPAPI 密钥');
|
|
267
|
+
const key = await dpapiUnprotect(enc.subarray(5));
|
|
268
|
+
decrypt = (data) => {
|
|
269
|
+
if (data.subarray(0, 3).toString() !== 'v10') throw new Error('未知的加密格式');
|
|
270
|
+
const nonce = data.subarray(3, 15);
|
|
271
|
+
const tag = data.subarray(data.length - 16);
|
|
272
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
273
|
+
d.setAuthTag(tag);
|
|
274
|
+
return Buffer.concat([d.update(data.subarray(15, data.length - 16)), d.final()]).toString('utf8');
|
|
275
|
+
};
|
|
276
|
+
} else {
|
|
277
|
+
let password = 'peanuts', iterations = 1;
|
|
278
|
+
if (process.platform === 'darwin') {
|
|
279
|
+
for (const n of appNames) {
|
|
280
|
+
password = await macKeychainPassword(`${n} Safe Storage`);
|
|
281
|
+
if (password) break;
|
|
282
|
+
}
|
|
283
|
+
if (!password) throw new Error('无法从钥匙串读取 Qoder 的 Safe Storage 密钥');
|
|
284
|
+
iterations = 1003;
|
|
285
|
+
}
|
|
286
|
+
const key = crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
|
|
287
|
+
decrypt = (data) => {
|
|
288
|
+
const ver = data.subarray(0, 3).toString();
|
|
289
|
+
if (ver !== 'v10') throw new Error(ver === 'v11' ? '需要系统密钥环(v11),暂不支持' : '未知的加密格式');
|
|
290
|
+
const d = crypto.createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, ' '));
|
|
291
|
+
return Buffer.concat([d.update(data.subarray(3)), d.final()]).toString('utf8');
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
keyCache.set(userData, decrypt);
|
|
295
|
+
return decrypt;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 读取本机 Qoder 客户端当前登录的账号(解密 auth.v1.dat)。
|
|
300
|
+
* @returns {Promise<{accounts: Array, errors: Array}>} 每个账号含 provider/token/refreshToken/expiresAt/user
|
|
301
|
+
*/
|
|
302
|
+
export async function readQoderAppAccounts() {
|
|
303
|
+
const accounts = [];
|
|
304
|
+
const errors = [];
|
|
305
|
+
for (const app of detectQoderApps()) {
|
|
306
|
+
const file = path.join(app.dataDir, 'auth.v1.dat');
|
|
307
|
+
if (!exists(file)) continue;
|
|
308
|
+
try {
|
|
309
|
+
const decrypt = await safeStorageDecryptor(app.dataDir, ['Qoder App', app.label.includes('国内') ? 'Qoder CN' : 'Qoder']);
|
|
310
|
+
const obj = JSON.parse(decrypt(fs.readFileSync(file)));
|
|
311
|
+
if (typeof obj?.token !== 'string' || !obj.token) throw new Error('auth.v1.dat 中没有 token');
|
|
312
|
+
accounts.push({
|
|
313
|
+
provider: app.provider,
|
|
314
|
+
source: `${app.label} 客户端`,
|
|
315
|
+
file,
|
|
316
|
+
token: obj.token,
|
|
317
|
+
refreshToken: typeof obj.refreshToken === 'string' ? obj.refreshToken : null,
|
|
318
|
+
expiresAt: typeof obj.expiresAt === 'string' ? obj.expiresAt : null,
|
|
319
|
+
user: {
|
|
320
|
+
id: obj.user?.id || null,
|
|
321
|
+
name: obj.user?.name || null,
|
|
322
|
+
email: obj.user?.email || null,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
} catch (e) {
|
|
326
|
+
errors.push({ provider: app.provider, file, error: e.message });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { accounts, errors };
|
|
330
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder OpenAPI 客户端 — 覆盖:userinfo、配额查询、活动列表、领取、PAT→jobToken 兑换。
|
|
3
|
+
*
|
|
4
|
+
* 签到协议对齐 Qoder App 0.2.x 客户端:
|
|
5
|
+
* GET /sash/api/v1/me/campaigns → { uid, showCampaign, claimable, campaigns:[…] }
|
|
6
|
+
* POST /sash/api/v1/me/campaigns/{id}/claim
|
|
7
|
+
* 国际版必须携带设备风控身份(Cosy-MachineToken/Code/Type)才会下发「每天领 100 Credits」,
|
|
8
|
+
* 风控身份由本机 Qoder 客户端的 runtime-info 生成(见 qoderApp.js);国内版无需。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
OPENAPI_BASE, CN_OPENAPI_BASE,
|
|
13
|
+
USERINFO_PATH, QUOTA_USAGE_PATH,
|
|
14
|
+
CAMPAIGNS_PATH, CAMPAIGN_CLAIM_PATH, JOB_TOKEN_EXCHANGE_PATH,
|
|
15
|
+
buildQoderHeaders, buildExchangeHeaders, FETCH_TIMEOUT_MS,
|
|
16
|
+
} from './constants.js';
|
|
17
|
+
import { getRiskIdentity, clientVersion, machineOs, machineHostname, machineId } from './qoderApp.js';
|
|
18
|
+
import { logger } from './logger.js';
|
|
19
|
+
|
|
20
|
+
export function apiBase(provider) {
|
|
21
|
+
return provider === 'qoder-cn' ? CN_OPENAPI_BASE : OPENAPI_BASE;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function req(url, options = {}) {
|
|
25
|
+
return fetch(url, { ...options, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** PAT (pt-...) 兑换短期 job token (jt-...)。普通 JSON POST,无需 COSY 签名。 */
|
|
29
|
+
export async function exchangePatToJobToken(pat, isCn = false) {
|
|
30
|
+
const url = `${isCn ? CN_OPENAPI_BASE : OPENAPI_BASE}${JOB_TOKEN_EXCHANGE_PATH}`;
|
|
31
|
+
const res = await req(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: buildExchangeHeaders(),
|
|
34
|
+
body: JSON.stringify({ personal_token: pat }),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const text = await res.text().catch(() => '');
|
|
38
|
+
throw new Error(`PAT 兑换失败: HTTP ${res.status} ${text.slice(0, 120)}`);
|
|
39
|
+
}
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
if (!data.token) throw new Error('PAT 兑换未返回 job token');
|
|
42
|
+
return { jobToken: data.token, expiresAt: data.expires_at || null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 解析账号的有效请求 token:PAT 先兑换成 job token */
|
|
46
|
+
export async function resolveToken(account) {
|
|
47
|
+
const token = String(account.token || '').trim();
|
|
48
|
+
if (!token) return null;
|
|
49
|
+
if (token.startsWith('pt-')) {
|
|
50
|
+
try {
|
|
51
|
+
const { jobToken } = await exchangePatToJobToken(token, account.provider === 'qoder-cn');
|
|
52
|
+
return jobToken;
|
|
53
|
+
} catch {
|
|
54
|
+
return null; // 兑换失败按无 token 处理,由上层报告
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return token;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 拉取账号信息(昵称/邮箱/uid),用于给账号起显示名 */
|
|
61
|
+
export async function fetchUserinfo(account) {
|
|
62
|
+
const token = await resolveToken(account);
|
|
63
|
+
if (!token) throw new Error('token 不可用');
|
|
64
|
+
const res = await req(`${apiBase(account.provider)}${USERINFO_PATH}`, {
|
|
65
|
+
headers: buildQoderHeaders(token),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) throw new Error(`userinfo HTTP ${res.status}`);
|
|
68
|
+
return res.json();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 查询配额/积分用量 */
|
|
72
|
+
export async function fetchQuotaUsage(account) {
|
|
73
|
+
const token = await resolveToken(account);
|
|
74
|
+
if (!token) throw new Error('token 不可用');
|
|
75
|
+
const res = await req(`${apiBase(account.provider)}${QUOTA_USAGE_PATH}`, {
|
|
76
|
+
headers: buildQoderHeaders(token),
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 401 || res.status === 403) throw new Error(`鉴权失败 (HTTP ${res.status}),token 可能已过期`);
|
|
79
|
+
if (!res.ok) throw new Error(`quota HTTP ${res.status}`);
|
|
80
|
+
return res.json();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 在基础请求头上叠加客户端身份与设备风控身份(与 Qoder App 的 createAuthorizedHeaders 一致)。
|
|
85
|
+
* 风控身份不可用时只加客户端身份,并在 riskError 中说明原因。
|
|
86
|
+
*/
|
|
87
|
+
export async function buildCampaignHeaders(account, token, uid) {
|
|
88
|
+
const headers = {
|
|
89
|
+
...buildQoderHeaders(token),
|
|
90
|
+
'Cosy-Version': clientVersion(account.provider),
|
|
91
|
+
'Cosy-MachineOS': machineOs(),
|
|
92
|
+
'Cosy-MachineId': machineId(account.provider),
|
|
93
|
+
};
|
|
94
|
+
const host = machineHostname();
|
|
95
|
+
if (host) headers['Cosy-MachineHostname'] = host;
|
|
96
|
+
let risk = null, riskError = null;
|
|
97
|
+
try {
|
|
98
|
+
risk = await getRiskIdentity(account.provider, uid);
|
|
99
|
+
if (!risk) riskError = process.platform === 'linux' ? '未安装 Qoder 设备身份组件(面板 Qoder 页可一键安装),无法生成设备风控身份' : '本机未安装 Qoder 客户端,无法生成设备风控身份';
|
|
100
|
+
} catch (e) {
|
|
101
|
+
riskError = e.message;
|
|
102
|
+
}
|
|
103
|
+
if (risk) {
|
|
104
|
+
headers['Cosy-MachineToken'] = risk.machineToken;
|
|
105
|
+
headers['Cosy-MachineCode'] = risk.machineCode;
|
|
106
|
+
headers['Cosy-MachineType'] = risk.machineType;
|
|
107
|
+
}
|
|
108
|
+
return { headers, risk: Boolean(risk), riskError };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function getCampaigns(base, headers) {
|
|
112
|
+
const res = await req(`${base}${CAMPAIGNS_PATH}`, { headers });
|
|
113
|
+
if (res.status === 401 || res.status === 403) {
|
|
114
|
+
const err = new Error(`鉴权失败 (HTTP ${res.status}),token 可能已过期`);
|
|
115
|
+
err.auth = true;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
if (!res.ok) throw new Error(`活动列表 HTTP ${res.status}`);
|
|
119
|
+
const payload = await res.json();
|
|
120
|
+
return {
|
|
121
|
+
uid: typeof payload?.uid === 'string' ? payload.uid : null,
|
|
122
|
+
campaigns: Array.isArray(payload?.campaigns) ? payload.campaigns : [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const creditCampaign = (c) => c.actionType === 'CLAIM_BENEFIT';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 对单个账号执行一次签到(领取所有可领活动)。
|
|
130
|
+
* 返回结果对象,status 取值:
|
|
131
|
+
* checked-in 领取成功(含金额)
|
|
132
|
+
* already 有积分活动但今日已领
|
|
133
|
+
* no-activity 当前无可领活动(不代表今天签过)
|
|
134
|
+
* failed 失败(错误见 error 字段)
|
|
135
|
+
* 附带 uid(Qoder 用户 ID),供调用方回写账号。
|
|
136
|
+
*/
|
|
137
|
+
export async function checkinOne(account) {
|
|
138
|
+
const label = account.name || account.id;
|
|
139
|
+
const base = { accountId: account.id, account: label, provider: account.provider };
|
|
140
|
+
const token = await resolveToken(account);
|
|
141
|
+
if (!token) {
|
|
142
|
+
return { ...base, status: 'failed', error: 'token 不可用(PAT 兑换失败或 token 为空)' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// 1) 基础请求拿 uid(风控身份按 uid 生成);uid 已知时可直接跳过
|
|
147
|
+
let uid = account.uid || null;
|
|
148
|
+
let first = null;
|
|
149
|
+
if (!uid) {
|
|
150
|
+
first = await getCampaigns(apiBase(account.provider), buildQoderHeaders(token));
|
|
151
|
+
uid = first.uid;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 2) 带客户端身份 + 设备风控身份重新拉取(国际版只有这样才会出现每日积分活动)
|
|
155
|
+
const { headers, risk, riskError } = await buildCampaignHeaders(account, token, uid);
|
|
156
|
+
if (riskError) logger.debug('CHECKIN', `${label}:${riskError}`);
|
|
157
|
+
const { campaigns, uid: uid2 } = (risk || !first) ? await getCampaigns(apiBase(account.provider), headers) : first;
|
|
158
|
+
uid = uid2 || uid;
|
|
159
|
+
|
|
160
|
+
const claimable = campaigns.filter((c) => creditCampaign(c) && c.claimStatus === 'CLAIMABLE');
|
|
161
|
+
if (claimable.length === 0) {
|
|
162
|
+
if (!campaigns.some(creditCampaign)) {
|
|
163
|
+
const hint = account.provider !== 'qoder' ? '当前无可领取的活动'
|
|
164
|
+
: risk ? '当前无可领取的活动(国际版每台设备每天限领一次,可能已被本机其他账号领取)'
|
|
165
|
+
: `当前无可领取的活动(${riskError || '缺少设备风控身份'};国际版需要本机安装 Qoder 客户端)`;
|
|
166
|
+
return { ...base, uid, status: 'no-activity', message: hint, claimedAmount: 0, risk };
|
|
167
|
+
}
|
|
168
|
+
return { ...base, uid, status: 'already', message: '今日已领', claimedAmount: 0, risk };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 3) 逐个领取
|
|
172
|
+
let totalClaimed = 0;
|
|
173
|
+
const claimedList = [];
|
|
174
|
+
const errors = [];
|
|
175
|
+
for (const c of claimable) {
|
|
176
|
+
try {
|
|
177
|
+
const claimRes = await req(`${apiBase(account.provider)}${CAMPAIGN_CLAIM_PATH(c.campaignId)}`, {
|
|
178
|
+
method: 'POST', headers,
|
|
179
|
+
});
|
|
180
|
+
if (claimRes.ok) {
|
|
181
|
+
const rj = await claimRes.json().catch(() => ({}));
|
|
182
|
+
const amount = rj.benefit?.amount || c.benefit?.amount || 0;
|
|
183
|
+
totalClaimed += amount;
|
|
184
|
+
claimedList.push({ campaignId: c.campaignId, campaignKey: c.campaignKey, amount, status: 'claimed' });
|
|
185
|
+
} else {
|
|
186
|
+
const text = await claimRes.text().catch(() => '');
|
|
187
|
+
errors.push(`HTTP ${claimRes.status} ${text.slice(0, 120)}`);
|
|
188
|
+
}
|
|
189
|
+
} catch (e) { errors.push(e.message); }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (claimedList.length > 0) {
|
|
193
|
+
return { ...base, uid, status: 'checked-in', claimedAmount: totalClaimed, campaigns: claimedList, risk };
|
|
194
|
+
}
|
|
195
|
+
return { ...base, uid, status: 'failed', error: `领取失败:${errors.join(';') || '未知原因'}`, risk };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return { ...base, status: 'failed', error: err?.message || '网络错误' };
|
|
198
|
+
}
|
|
199
|
+
}
|