dsh-model 0.26.0 → 0.27.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.en.md CHANGED
@@ -28,7 +28,7 @@ English · [中文](README.md)
28
28
  | **Trae** | Detected automatically if Trae is installed and signed in; nothing to do | Check-in credits (200 a day in testing) |
29
29
  | **Qoder** | Paste a personal access token once (China or international) | That day's check-in credits |
30
30
  | **WorkBuddy (China)** | Sign in once (several accounts are fine) | Check-in credits |
31
- | **Doubao** (experimental) | Copy the sign-in cookie from the browser once (personal accounts only) | Daily trial credits |
31
+ | **Doubao** (experimental) | On Mac / Windows click "Read from this computer" to use the account signed in to the Doubao app or a browser; or paste the cookie by hand (personal accounts only) | Daily trial credits |
32
32
 
33
33
  - **Automatic by default**: done at a random 5–55 minutes after midnight Beijing time; failures retry an hour later; if the computer was off, it catches up within a minute of starting.
34
34
  - **You can see it**: one line at the top of the settings page, "Checked in today: …", with a one-click "Check in all" for anything missed, plus a one-time notice in the corner of dsh.
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
  | **Trae** | 本机装了 Trae 并登录过,自动识别,什么都不用做 | 签到送积分(实测每天 200) |
29
29
  | **Qoder** | 粘贴一次个人访问令牌(国内版、国际版都行) | 领当天的签到积分 |
30
30
  | **WorkBuddy 国内版** | 登录一次(可以加几个账号) | 签到送积分 |
31
- | **豆包**(实验) | 从浏览器复制一次登录 Cookie(只支持个人账号) | 「天天领额度」送体验额度 |
31
+ | **豆包**(实验) | Mac / Windows 上点「从本机自动读取」,读豆包电脑版或浏览器里已登录的账号;也可以手动粘贴 Cookie(只支持个人账号) | 「天天领额度」送体验额度 |
32
32
 
33
33
  - **自动签到默认打开**:北京时间零点后随机延后 5–55 分钟签好;失败的一小时后重试;电脑关机错过了,开机一分钟内补签。
34
34
  - **结果看得见**:设置页顶部一行「今日已完成签到:…」,没签上的可以「一键签到」;签完在 dsh 右下角提示一次。
@@ -104,6 +104,20 @@ export async function removeQoder(ctx, id) {
104
104
  const target = id ?? list[0]?.id;
105
105
  await saveQoders(ctx, list.filter((q) => q.id !== target));
106
106
  }
107
+ /** Cookie 串里某一项的值 */
108
+ export function cookieValue(cookie, name) {
109
+ for (const part of cookie.split(/;\s*/)) {
110
+ const i = part.indexOf('=');
111
+ if (i > 0 && part.slice(0, i).trim() === name)
112
+ return part.slice(i + 1);
113
+ }
114
+ return undefined;
115
+ }
116
+ /** 同一个豆包账号:uid_tt 一样(Cookie 里别的项每次都会变) */
117
+ const sameDoubaoAccount = (a, b) => {
118
+ const x = cookieValue(a, 'uid_tt');
119
+ return a === b || (Boolean(x) && x === cookieValue(b, 'uid_tt'));
120
+ };
107
121
  export async function loadDoubaos(ctx) {
108
122
  return (await loadSecrets(ctx)).checkin?.doubaos ?? [];
109
123
  }
@@ -117,14 +131,17 @@ async function saveDoubaos(ctx, list) {
117
131
  secrets.checkin = Object.keys(checkin).length ? checkin : undefined;
118
132
  await saveSecrets(ctx, secrets);
119
133
  }
120
- /** 加一个豆包账号;同一个 Cookie 再加 = 改名字。返回 id */
134
+ /** 加一个豆包账号;同一个账号再加 = 换成新的 Cookie(和名字)。返回 id */
121
135
  export async function addDoubao(ctx, entry) {
122
136
  const list = await loadDoubaos(ctx);
123
137
  const name = entry.name?.trim().slice(0, 20);
124
- const same = list.find((d) => d.cookie === entry.cookie);
138
+ const same = list.find((d) => sameDoubaoAccount(d.cookie, entry.cookie));
125
139
  if (same) {
140
+ same.cookie = entry.cookie;
126
141
  if (name)
127
142
  same.name = name;
143
+ if (entry.from)
144
+ same.from = entry.from;
128
145
  await saveDoubaos(ctx, list);
129
146
  return same.id;
130
147
  }
@@ -133,7 +150,7 @@ export async function addDoubao(ctx, entry) {
133
150
  while (used.has(String(n)))
134
151
  n++;
135
152
  const id = String(n);
136
- list.push({ id, cookie: entry.cookie, seed: entry.seed ?? randomBytes(16).toString('hex'), ...(name ? { name } : {}), savedAt: new Date().toISOString() });
153
+ list.push({ id, cookie: entry.cookie, seed: entry.seed ?? randomBytes(16).toString('hex'), ...(name ? { name } : {}), ...(entry.from ? { from: entry.from } : {}), savedAt: new Date().toISOString() });
137
154
  await saveDoubaos(ctx, list);
138
155
  return id;
139
156
  }
@@ -0,0 +1,219 @@
1
+ // checkin/browser-cookies.ts — 从本机已登录的豆包电脑版 / 浏览器里读 doubao.com 的登录 Cookie(设计 §14.21)
2
+ //
3
+ // 用户嫌"F12 → 网络 → 复制 Cookie"太麻烦(2026-10-29)。豆包电脑版(DoubaoWork)和 Chrome、Edge、Brave 都是 Chromium,
4
+ // Cookie 存在 <用户数据>/<配置>/Cookies(新版在 Network/Cookies)这个 SQLite 里,值是加密的:
5
+ // macOS:v10 + AES-128-CBC,密钥 = PBKDF2(钥匙串里「<应用> Safe Storage」的密码, 'saltysalt', 1003, sha1)。
6
+ // 读钥匙串时 macOS 会弹窗问用户是否允许,用户点允许才读得到。
7
+ // Windows:v10 + AES-256-GCM,密钥在 Local State 的 os_crypt.encrypted_key 里,用当前用户的 DPAPI 解开。
8
+ // 新版 Chrome 的 v20(应用绑定加密)读不了,跳过。
9
+ // Linux:不读(VPS 上也没有这些应用)。
10
+ // Cookie 库版本 ≥ 24 时,解开的值前面多 32 字节(host 的 sha256),要去掉。
11
+ // 只读 doubao.com 的 Cookie,只在用户点「从本机找」时读;先只看有没有登录(不碰密钥),有才去解密。
12
+ import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
13
+ import { copyFile, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { run, which } from '../util/exec.js';
17
+ /** 要读的 Cookie 所在域 */
18
+ const HOSTS = ['doubao.com', '.doubao.com', 'www.doubao.com'];
19
+ /** 有这个 Cookie 才算登录了 */
20
+ const SESSION_COOKIE = 'sessionid';
21
+ /** 本机可能有 doubao.com 登录的 Chromium 应用 */
22
+ export function cookieStores(home = homedir(), platform = process.platform, env = process.env) {
23
+ if (platform === 'darwin') {
24
+ const s = join(home, 'Library', 'Application Support');
25
+ return [
26
+ { label: '豆包电脑版', root: join(s, 'DoubaoWork'), services: ['DoubaoWork Browser Safe Storage', 'DoubaoWork Safe Storage'] },
27
+ { label: '豆包', root: join(s, 'Doubao'), services: ['Doubao Browser Safe Storage', 'Doubao Safe Storage'] },
28
+ { label: 'Chrome', root: join(s, 'Google', 'Chrome'), services: ['Chrome Safe Storage'] },
29
+ { label: 'Edge', root: join(s, 'Microsoft Edge'), services: ['Microsoft Edge Safe Storage'] },
30
+ { label: 'Brave', root: join(s, 'BraveSoftware', 'Brave-Browser'), services: ['Brave Safe Storage'] },
31
+ ];
32
+ }
33
+ if (platform === 'win32') {
34
+ const local = env.LOCALAPPDATA || join(home, 'AppData', 'Local');
35
+ const roaming = env.APPDATA || join(home, 'AppData', 'Roaming');
36
+ return [
37
+ { label: '豆包电脑版', root: join(local, 'DoubaoWork', 'User Data'), services: [] },
38
+ { label: '豆包电脑版', root: join(roaming, 'DoubaoWork'), services: [] },
39
+ { label: '豆包', root: join(local, 'Doubao', 'User Data'), services: [] },
40
+ { label: 'Chrome', root: join(local, 'Google', 'Chrome', 'User Data'), services: [] },
41
+ { label: 'Edge', root: join(local, 'Microsoft', 'Edge', 'User Data'), services: [] },
42
+ { label: 'Brave', root: join(local, 'BraveSoftware', 'Brave-Browser', 'User Data'), services: [] },
43
+ ];
44
+ }
45
+ return [];
46
+ }
47
+ const exists = async (p) => stat(p).then(() => true, () => false);
48
+ /** 列出 store 里每个配置的 Cookie 库 */
49
+ async function cookieDbs(store) {
50
+ let names;
51
+ try {
52
+ names = await readdir(store.root);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ const out = [];
58
+ for (const profile of names.filter((n) => n === 'Default' || /^Profile \d+$/.test(n)).sort()) {
59
+ for (const rel of [['Network', 'Cookies'], ['Cookies']]) {
60
+ const file = join(store.root, profile, ...rel);
61
+ if (await exists(file)) {
62
+ out.push({ store, profile, file });
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /** Chromium 的时间:1601 年起的毫秒(SQL 里已从微秒除下来,微秒超出 JS 安全整数);0 = 会话 Cookie */
70
+ const chromeTimeToMs = (t) => (t ? t - 11_644_473_600_000 : 0);
71
+ /** 读 Cookie 库:先复制一份再读(应用开着时原文件被锁)。返回 doubao.com 的行和库版本 */
72
+ export async function readCookieDb(file) {
73
+ const dir = await mkdtemp(join(tmpdir(), 'dshm-ck-'));
74
+ try {
75
+ const copy = join(dir, 'Cookies');
76
+ await copyFile(file, copy);
77
+ for (const ext of ['-journal', '-wal'])
78
+ await copyFile(file + ext, copy + ext).catch(() => { });
79
+ return await querySqlite(copy);
80
+ }
81
+ finally {
82
+ await rm(dir, { recursive: true, force: true });
83
+ }
84
+ }
85
+ const SQL_ROWS = `select host_key, name, value, hex(encrypted_value) as ev, expires_utc / 1000 as expires_utc from cookies where host_key in (${HOSTS.map((h) => `'${h}'`).join(',')})`;
86
+ const SQL_VERSION = `select value from meta where key = 'version'`;
87
+ async function querySqlite(file) {
88
+ let raw;
89
+ let version;
90
+ try {
91
+ // Node 22.5 起自带 SQLite;更老的 Node 退回系统的 sqlite3 命令
92
+ const mod = 'node:sqlite'; // 写成变量:@types/node 还没有它的类型
93
+ const { DatabaseSync } = (await import(mod));
94
+ const db = new DatabaseSync(file, { readOnly: true });
95
+ try {
96
+ raw = db.prepare(SQL_ROWS).all();
97
+ version = Number(db.prepare(SQL_VERSION).get()?.value) || 0;
98
+ }
99
+ finally {
100
+ db.close();
101
+ }
102
+ }
103
+ catch (error) {
104
+ if (error.code !== 'ERR_UNKNOWN_BUILTIN_MODULE' && !/node:sqlite/.test(String(error)))
105
+ throw error;
106
+ const bin = await which('sqlite3');
107
+ if (!bin)
108
+ throw new Error('读不了浏览器的 Cookie:Node 太旧(要 22.5 以上)且没有 sqlite3 命令');
109
+ const a = await run(bin, ['-readonly', '-json', file, SQL_ROWS], { timeoutMs: 20_000 });
110
+ const b = await run(bin, ['-readonly', file, SQL_VERSION], { timeoutMs: 20_000 });
111
+ if (a.code !== 0)
112
+ throw new Error(`读 Cookie 库失败:${a.stderr.trim()}`);
113
+ raw = a.stdout.trim() ? JSON.parse(a.stdout) : [];
114
+ version = Number(b.stdout.trim()) || 0;
115
+ }
116
+ return {
117
+ rows: raw.map((r) => ({ host_key: r.host_key, name: r.name, value: r.value ?? '', encrypted_value: Buffer.from(r.ev ?? '', 'hex'), expires_utc: Number(r.expires_utc) || 0 })),
118
+ version,
119
+ };
120
+ }
121
+ /** 没过期的 Cookie(会话 Cookie 也算) */
122
+ const alive = (r, now) => {
123
+ const ms = chromeTimeToMs(r.expires_utc);
124
+ return !ms || ms > now;
125
+ };
126
+ /** 只看哪些地方登录着豆包(不解密、不碰钥匙串,不会弹窗) */
127
+ export async function findDoubaoLogins(home = homedir(), platform = process.platform, now = Date.now()) {
128
+ const out = [];
129
+ for (const store of cookieStores(home, platform)) {
130
+ const dbs = await cookieDbs(store);
131
+ for (const db of dbs) {
132
+ const { rows } = await readCookieDb(db.file).catch(() => ({ rows: [] }));
133
+ if (rows.some((r) => r.name === SESSION_COOKIE && alive(r, now))) {
134
+ out.push({ label: dbs.length > 1 && db.profile !== 'Default' ? `${store.label}(${db.profile})` : store.label, db });
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /** macOS:Cookie 密钥 = PBKDF2(钥匙串密码)。读钥匙串会弹窗问用户 */
141
+ async function macKey(services) {
142
+ let last = '';
143
+ for (const s of services) {
144
+ const r = await run('/usr/bin/security', ['find-generic-password', '-w', '-s', s], { timeoutMs: 120_000 });
145
+ if (r.code === 0 && r.stdout.trim())
146
+ return pbkdf2Sync(r.stdout.trim(), 'saltysalt', 1003, 16, 'sha1');
147
+ last = r.stderr.trim();
148
+ // 用户点了拒绝:不再试别的名字
149
+ if (/cancel|denied|拒绝|取消/i.test(last) || r.code === 128)
150
+ throw new CookieAccessDenied();
151
+ }
152
+ throw new Error(`钥匙串里没找到它的 Cookie 密钥${last ? `(${last})` : ''}`);
153
+ }
154
+ /** Windows:Local State 里的密钥,用 DPAPI 解开 */
155
+ async function winKey(root) {
156
+ const state = JSON.parse(await readFile(join(root, 'Local State'), 'utf8'));
157
+ const enc = Buffer.from(state.os_crypt?.encrypted_key ?? '', 'base64');
158
+ if (enc.subarray(0, 5).toString() !== 'DPAPI')
159
+ throw new Error('Local State 里没有能用的 Cookie 密钥');
160
+ const script = "Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String($env:DSHM_BLOB); [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'))";
161
+ const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { env: { ...process.env, DSHM_BLOB: enc.subarray(5).toString('base64') }, timeoutMs: 30_000 });
162
+ if (r.code !== 0 || !r.stdout.trim())
163
+ throw new Error(`解不开 Cookie 密钥:${r.stderr.trim() || `退出码 ${r.code}`}`);
164
+ return Buffer.from(r.stdout.trim(), 'base64');
165
+ }
166
+ export class CookieAccessDenied extends Error {
167
+ constructor() {
168
+ super('没有允许读取钥匙串');
169
+ }
170
+ }
171
+ /** 解开一个 Cookie 值。读不了(如 v20)返回 null。导出给测试 */
172
+ export function decryptCookie(enc, key, platform, dbVersion) {
173
+ const tag = enc.subarray(0, 3).toString();
174
+ if (tag !== 'v10' && tag !== 'v11')
175
+ return null;
176
+ let plain;
177
+ if (platform === 'win32') {
178
+ const nonce = enc.subarray(3, 15);
179
+ const body = enc.subarray(15, enc.length - 16);
180
+ const d = createDecipheriv('aes-256-gcm', key, nonce);
181
+ d.setAuthTag(enc.subarray(enc.length - 16));
182
+ plain = Buffer.concat([d.update(body), d.final()]);
183
+ }
184
+ else {
185
+ const d = createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
186
+ plain = Buffer.concat([d.update(enc.subarray(3)), d.final()]);
187
+ }
188
+ return (dbVersion >= 24 ? plain.subarray(32) : plain).toString('utf8');
189
+ }
190
+ /** 读出一处登录的完整 Cookie 串(a=1; b=2)。macOS 会弹钥匙串授权 */
191
+ export async function readDoubaoCookie(login, platform = process.platform, now = Date.now(), testKey) {
192
+ const { rows, version } = await readCookieDb(login.db.file);
193
+ const key = testKey ?? (platform === 'darwin' ? await macKey(login.db.store.services) : await winKey(login.db.store.root));
194
+ // 同名的取更具体的域(www.doubao.com 盖过 .doubao.com)
195
+ const byName = new Map();
196
+ let unreadable = 0;
197
+ for (const r of rows.filter((x) => alive(x, now))) {
198
+ let value = r.value;
199
+ if (!value && r.encrypted_value.length) {
200
+ try {
201
+ value = decryptCookie(r.encrypted_value, key, platform, version);
202
+ }
203
+ catch {
204
+ value = null;
205
+ }
206
+ }
207
+ if (value === null) {
208
+ unreadable++;
209
+ continue;
210
+ }
211
+ const prev = byName.get(r.name);
212
+ if (!prev || r.host_key.length > prev.host.length)
213
+ byName.set(r.name, { host: r.host_key, value });
214
+ }
215
+ if (!byName.get(SESSION_COOKIE)?.value) {
216
+ throw new Error(unreadable ? `${login.label} 用的是新的加密方式,读不了:请用「手动粘贴 Cookie」` : `${login.label} 里的豆包登录不完整`);
217
+ }
218
+ return [...byName].map(([n, v]) => `${n}=${v.value}`).join('; ');
219
+ }
@@ -95,7 +95,7 @@ export async function doubaoStatus(cookie, seed) {
95
95
  export async function doubaoCheckin(cookie, seed) {
96
96
  const st = await doubaoStatus(cookie, seed);
97
97
  if (st.checkedIn === null)
98
- throw new Error('这个豆包账号没有签到任务:只有个人账号能领,飞书企业账号不行(也可能活动暂时下线)');
98
+ throw new Error('这个豆包账号的任务面板里没有「每日签到」(飞书企业账号、或豆包没给这个账号开签到时都是这样)');
99
99
  if (st.checkedIn)
100
100
  return { state: 'already' };
101
101
  const body = await post(apiUrl(CHECKIN_URL, seed), cookie, { scene: CAMPAIGN_GROUP });
@@ -24,7 +24,7 @@ export function checkinAdvice(itemId, message) {
24
24
  const id = itemId.split(':')[0];
25
25
  if (/无效|过期|401|403|unauthor|expired|not able to authenticate/i.test(message)) {
26
26
  if (id === 'doubao')
27
- return L('豆包登录过期:在浏览器里重新登录 doubao.com,到签到卡片里删掉它,再复制 Cookie 添加一次', 'Doubao sign-in expired: sign in at doubao.com again, remove it in the check-in card and add the new cookie');
27
+ return L('豆包登录过期:在豆包电脑版或浏览器里重新登录,再点签到卡片里的「+ 添加豆包 → 从本机自动读取」(会换成新的登录)', 'Doubao sign-in expired: sign in again in the Doubao app or a browser, then use "+ Add Doubao → Read from this computer" in the check-in card (it replaces the old sign-in)');
28
28
  if (id === 'trae')
29
29
  return L('Trae 登录过期:打开 Trae 重新登录一次', 'Trae sign-in expired: open Trae and sign in again');
30
30
  if (id === 'qoder')
@@ -20,8 +20,9 @@ import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheck
20
20
  import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
21
21
  import { findTraeCredentials, traeCheckin } from '../checkin/trae.js';
22
22
  import { DoubaoAuthError, doubaoCheckin, doubaoStatus, normalizeCookie } from '../checkin/doubao.js';
23
+ import { CookieAccessDenied, findDoubaoLogins, readDoubaoCookie } from '../checkin/browser-cookies.js';
23
24
  import { exportCheckin, importCheckin } from '../checkin/transfer.js';
24
- import { accountTag, addDoubao, checkinKind, addQoder, listWorkbuddyAccounts, loadDoubaos, loadQoders, removeDoubao, removeQoder, removeWorkbuddyAccount, workbuddyAccountStore, workbuddyAccountsDir } from '../checkin/accounts.js';
25
+ import { accountTag, addDoubao, checkinKind, cookieValue, addQoder, listWorkbuddyAccounts, loadDoubaos, loadQoders, removeDoubao, removeQoder, removeWorkbuddyAccount, workbuddyAccountStore, workbuddyAccountsDir } from '../checkin/accounts.js';
25
26
  import { CN_VARIANT } from '../bridge/workbuddy/variants.js';
26
27
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
27
28
  import { randomBytes } from 'node:crypto';
@@ -793,11 +794,14 @@ export class Daemon {
793
794
  qoder: qoders.map((q, i) => ({ id: q.id, label: checkers.find((c) => c.id === (i === 0 ? 'qoder' : `qoder:${q.id}`))?.label ?? q.id, region: q.region })),
794
795
  doubao: (await loadDoubaos(this.ctx)).map((d, i) => ({ id: d.id, label: checkers.find((c) => c.id === (i === 0 ? 'doubao' : `doubao:${d.id}`))?.label ?? d.id })),
795
796
  },
797
+ // 能不能从本机的豆包电脑版 / 浏览器读登录(看守护进程所在的机器,不是看浏览器)
798
+ localRead: process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'win' : null,
796
799
  // 支持、但这台还没设置的(签到卡片里列出来,旁边就是「添加」)
797
800
  missing: [
798
801
  ...(checkers.some((c) => checkinKind(c.id) === 'workbuddy') ? [] : [{ id: 'workbuddy', label: L('WorkBuddy 国内版', 'WorkBuddy (China)'), action: 'workbuddy' }]),
799
802
  ...(checkers.some((c) => checkinKind(c.id) === 'qoder') ? [] : [{ id: 'qoder', label: 'Qoder', action: 'qoder' }]),
800
- ...(checkers.some((c) => checkinKind(c.id) === 'doubao') ? [] : [{ id: 'doubao', label: L('豆包', 'Doubao'), action: 'doubao' }]),
803
+ // 本机豆包电脑版 / 浏览器登录着豆包:说出来,点「添加」就能自动读(只看有没有登录,不解密)
804
+ ...(checkers.some((c) => checkinKind(c.id) === 'doubao') ? [] : [{ id: 'doubao', label: L('豆包', 'Doubao'), action: 'doubao', found: (await findDoubaoLogins().catch(() => [])).map((f) => f.label) }]),
801
805
  ...(checkers.some((c) => checkinKind(c.id) === 'trae') ? [] : [{ id: 'trae', label: 'Trae', reason: L('这台没装 Trae:要在装了 Trae 的电脑上签', 'Trae is not installed here: it is checked in on a computer with Trae') }]),
802
806
  ],
803
807
  };
@@ -850,6 +854,48 @@ export class Daemon {
850
854
  throw new DshModelError('doubao_no_task', L('这个豆包账号没有签到任务:只有个人账号能领每日额度,飞书企业账号不行。请换成个人账号登录后再复制 Cookie', 'This Doubao account has no check-in task: only personal accounts get the daily credits, not Feishu enterprise accounts. Sign in with a personal account and copy the cookie again'));
851
855
  await addDoubao(this.ctx, { cookie, seed, ...(name ? { name } : {}) });
852
856
  }
857
+ /**
858
+ * 从本机找豆包登录(豆包电脑版、Chrome、Edge、Brave),读出 Cookie 加成签到账号。
859
+ * macOS 会弹窗问用户是否允许读钥匙串;同一个账号在几处都登录着只加一次,已加过的换成新的 Cookie。
860
+ */
861
+ async scanDoubao() {
862
+ const logins = await findDoubaoLogins();
863
+ if (!logins.length)
864
+ throw new DshModelError('doubao_not_found', L('这台电脑上没找到登录着的豆包:先在豆包电脑版或浏览器里登录 doubao.com,或用下面的「手动粘贴 Cookie」', 'No signed-in Doubao found on this computer: sign in to doubao.com in the Doubao app or a browser first, or paste the cookie by hand below'));
865
+ const added = [];
866
+ const skipped = [];
867
+ const seen = new Set();
868
+ for (const login of logins) {
869
+ let cookie;
870
+ try {
871
+ cookie = await readDoubaoCookie(login);
872
+ }
873
+ catch (error) {
874
+ skipped.push({ label: login.label, reason: error instanceof CookieAccessDenied ? L('没有允许读取(钥匙串弹窗里点了拒绝)', 'access was not allowed in the keychain prompt') : String(error.message ?? error) });
875
+ continue;
876
+ }
877
+ const uid = cookieValue(cookie, 'uid_tt') ?? cookie;
878
+ if (seen.has(uid))
879
+ continue;
880
+ seen.add(uid);
881
+ const existing = (await loadDoubaos(this.ctx)).find((d) => cookieValue(d.cookie, 'uid_tt') === uid);
882
+ const seed = existing?.seed ?? randomBytes(16).toString('hex');
883
+ try {
884
+ const st = await doubaoStatus(cookie, seed);
885
+ if (st.checkedIn === null) {
886
+ skipped.push({ label: login.label, reason: L('读到了登录,但豆包没给这个账号开「每日签到」:它的任务面板里只有一次性的新手任务(选工作领域、生成文档 / PPT 等,要在豆包里自己做)。飞书企业账号也是这样。打开豆包电脑版看看有没有「每日签到」,有的话告诉开发者', 'Signed in, but Doubao has not given this account a daily check-in: its task panel only has one-off starter tasks (pick a work field, make a document / slides…, done inside Doubao). Feishu enterprise accounts look like this too. Check whether the Doubao app shows a daily check-in, and tell the developer if it does') });
887
+ continue;
888
+ }
889
+ }
890
+ catch (error) {
891
+ skipped.push({ label: login.label, reason: error instanceof DoubaoAuthError ? L('登录已过期:在那里重新登录豆包', 'the sign-in expired: sign in to Doubao there again') : String(error.message ?? error) });
892
+ continue;
893
+ }
894
+ await addDoubao(this.ctx, { cookie, seed, from: login.label, ...(existing?.name ? {} : { name: login.label }) });
895
+ added.push(login.label);
896
+ }
897
+ return { added, skipped };
898
+ }
853
899
  /** 加一个 WorkBuddy 签到账号:打开链接授权(同来源登录一样),存成单独一份,只用来签到 */
854
900
  async startWorkbuddyCheckinLogin() {
855
901
  const all = await loadAll(this.ctx);
@@ -1233,6 +1279,12 @@ export class Daemon {
1233
1279
  const n = await importCheckin(this.ctx, String(b.text ?? ''), String(b.code ?? ''));
1234
1280
  return ok({ imported: n, ...(await this.checkinNow(true)) });
1235
1281
  }
1282
+ if (method === 'POST' && path === '/checkin/doubao/scan') {
1283
+ const found = await this.scanDoubao();
1284
+ if (found.added.length)
1285
+ await this.checkinNow(false).catch(() => null);
1286
+ return ok({ ...found, status: await this.checkinStatus() });
1287
+ }
1236
1288
  if (method === 'POST' && path === '/checkin/doubao') {
1237
1289
  await this.addDoubaoCookie(String(b.cookie ?? ''), typeof b.name === 'string' ? b.name : undefined);
1238
1290
  return ok(await this.checkinNow(true));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Daily check-in credits claimed for you (Trae, Qoder, WorkBuddy China), one OpenAI-compatible address that relays every model, and free or subscription model sources (SiliconFlow, Gemini, Codex, Kimi, Ollama…). For DeepSeek Harness (dsh) and any OpenAI-compatible tool. 每天自动签到领积分 · 一个地址中转所有模型 · 免费和订阅模型随你接。",
5
5
  "keywords": [
6
6
  "siliconflow",
package/plugin/client.js CHANGED
@@ -1011,20 +1011,48 @@ window.__ModuleLoader__.load({
1011
1011
  msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1012
1012
  }
1013
1013
 
1014
- /** 添加豆包签到:从浏览器复制一次登录 Cookie(豆包没有令牌,也不读豆包客户端的文件) */
1015
- function DoubaoDialog({ onDone, onClose, another }) {
1014
+ /** 从本机读豆包登录的结果说成一句话 */
1015
+ const scanText = (r) => [
1016
+ r.added.length ? L(`已添加:${r.added.join('、')}`, `Added: ${r.added.join(', ')}`) : '',
1017
+ ...r.skipped.map((x) => `${x.label}:${x.reason}`),
1018
+ ].filter(Boolean).join(';')
1019
+
1020
+ /** 添加豆包签到:优先从本机已登录的豆包电脑版 / 浏览器自动读;读不了再手动粘贴 Cookie */
1021
+ function DoubaoDialog({ onDone, onClose, another, found, localRead }) {
1016
1022
  const [cookie, setCookie] = useState('')
1017
1023
  const [name, setName] = useState('')
1018
1024
  const [busy, setBusy] = useState(false)
1019
1025
  const [msg, setMsg] = useState('')
1026
+ const [manual, setManual] = useState(false)
1027
+ const scan = async () => {
1028
+ setBusy(true)
1029
+ setMsg(localRead === 'mac' ? L('正在读取…macOS 会弹窗问是否允许读取钥匙串,请点「允许」(点「始终允许」以后就不再问)', 'Reading… macOS will ask to allow keychain access: click Allow (Always Allow skips the question next time)') : L('正在读取…', 'Reading…'))
1030
+ try {
1031
+ const r = await api('POST', '/checkin/doubao/scan')
1032
+ if (r.added.length) { onDone(r.status, scanText(r)); return }
1033
+ setMsg(scanText(r) || L('没读到能用的豆包登录', 'No usable Doubao sign-in found'))
1034
+ } catch (e) { setMsg(e.message) }
1035
+ setBusy(false)
1036
+ }
1020
1037
  const save = async () => {
1021
1038
  setBusy(true)
1022
1039
  setMsg(L('正在用这份 Cookie 查签到任务并签到…', 'Checking this cookie and checking in…'))
1023
1040
  try { await api('POST', '/checkin/doubao', { cookie: cookie.trim(), ...(name.trim() ? { name: name.trim() } : {}) }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
1024
1041
  }
1025
1042
  const step = (n, text) => h('div', { style: { ...S.meta, lineHeight: 1.7 } }, `${n}. `, text)
1043
+ const canScan = Boolean(localRead)
1026
1044
  return h(Modal, { title: another ? L('再加一个豆包账号', 'Add another Doubao account') : L('添加豆包签到(实验)', 'Add Doubao check-in (experimental)'), onClose },
1027
- h('p', { style: S.note }, L('豆包「天天领额度」每天送体验额度,只有个人账号能领(飞书企业账号不行)。豆包没有令牌,要从浏览器复制一次登录 Cookie:', 'Doubao gives daily credits to personal accounts (not Feishu enterprise accounts). Doubao has no token, so copy its sign-in cookie from the browser once:')),
1045
+ h('p', { style: S.note }, L('豆包「天天领额度」每天送体验额度,只有个人账号能领(飞书企业账号不行)。', 'Doubao gives daily credits to personal accounts (not Feishu enterprise accounts).')),
1046
+ canScan ? h('div', { style: { ...S.card, padding: '10px 12px', marginBottom: 10 } },
1047
+ h('div', { style: { ...S.meta, marginBottom: 6 } }, found && found.length
1048
+ ? L(`这台电脑上 ${found.join('、')} 登录着豆包,直接读它的登录就行:`, `Doubao is signed in here (${found.join(', ')}); just read that sign-in:`)
1049
+ : L('在豆包电脑版、Chrome、Edge 或 Brave 里登录过 doubao.com 的话,直接读它的登录:', 'If you are signed in to doubao.com in the Doubao app, Chrome, Edge or Brave, read that sign-in:')),
1050
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: () => void scan() }, busy ? L('读取中…', 'Reading…') : L('从本机自动读取', 'Read from this computer')),
1051
+ msg && !manual ? h('div', { style: { fontSize: 12, color: busy ? C.sub : C.warn, marginTop: 8, lineHeight: 1.6 } }, msg) : null,
1052
+ h('div', { style: { ...S.meta, marginTop: 6, color: C.faint } }, L('只读 doubao.com 的登录,保存在本机;豆包里退出登录或过期后,再点一次就会换成新的。', 'Only the doubao.com sign-in is read, and it stays on this machine; after it expires, click again to replace it.'))) : null,
1053
+ canScan && !manual ? h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px', fontSize: 12 }, onClick: () => setManual(true) }, L('▸ 手动粘贴 Cookie', '▸ Paste the cookie by hand')) : null,
1054
+ !canScan || manual ? h('div', null,
1055
+ h('p', { style: S.note }, L('手动:从浏览器复制一次登录 Cookie:', 'By hand: copy the sign-in cookie from the browser once:')),
1028
1056
  step(1, h('span', null, L('用电脑浏览器打开 ', 'Open '), h('a', { href: 'https://www.doubao.com/', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent } }, 'doubao.com ↗'), L(' 并登录', ' in a desktop browser and sign in'))),
1029
1057
  step(2, L('按 F12 打开开发者工具,切到「网络 / Network」,刷新一下页面', 'Press F12, open the Network tab, and reload the page')),
1030
1058
  step(3, L('点列表里任意一条 doubao.com 的请求,在「请求标头 / Request Headers」里找到 Cookie 那一行,右键复制它的值', 'Click any doubao.com request, find Cookie under Request Headers, and copy its value')),
@@ -1034,7 +1062,7 @@ window.__ModuleLoader__.load({
1034
1062
  h('div', { style: S.line },
1035
1063
  h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('粘贴 Cookie', 'Paste the cookie'), value: cookie, onChange: (e) => setCookie(e.target.value), 'aria-label': 'Cookie' }),
1036
1064
  h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !cookie.trim(), onClick: () => void save() }, L('保存', 'Save'))),
1037
- msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1065
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null) : null)
1038
1066
  }
1039
1067
 
1040
1068
  /** 把签到账号复制到另一台机器:导出一段加密文本 + 提取码,到那台的页面里导入 */
@@ -1121,7 +1149,7 @@ window.__ModuleLoader__.load({
1121
1149
  h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setTransfer('import') }, L('从另一台机器导入', 'Import from another machine'))),
1122
1150
  transfer ? h(TransferDialog, { mode: transfer, onDone: (r) => { setTransfer(''); setSt(r); setOpen(true) }, onClose: () => setTransfer('') }) : null,
1123
1151
  qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1124
- doubao ? h(DoubaoDialog, { onDone: () => { setDoubao(false); void refresh() }, onClose: () => setDoubao(false) }) : null)
1152
+ doubao ? h(DoubaoDialog, { localRead: st.localRead, found: ((st.missing || []).find((m) => m.id === 'doubao') || {}).found, onDone: (next) => { setDoubao(false); if (next) setSt(next); else void refresh() }, onClose: () => setDoubao(false) }) : null)
1125
1153
  }
1126
1154
  return h('div', { style: S.section },
1127
1155
  h('div', { style: { ...S.card, padding: '10px 14px' } },
@@ -1139,7 +1167,7 @@ window.__ModuleLoader__.load({
1139
1167
  (st.missing || []).length ? h('div', { style: { ...S.line, flexWrap: 'wrap', gap: 6, marginTop: 4, fontSize: 12, color: C.sub } },
1140
1168
  h('span', null, L('这台还能签:', 'Also available here:')),
1141
1169
  (st.missing || []).map((m) => h('span', { key: m.id, style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
1142
- m.label,
1170
+ m.found && m.found.length ? L(`${m.label}(本机 ${m.found.join('、')} 已登录)`, `${m.label} (signed in: ${m.found.join(', ')})`) : m.label,
1143
1171
  m.action ? h('button', { type: 'button', style: { ...S.btn, padding: '1px 6px', fontSize: 11 }, onClick: () => { if (m.action === 'workbuddy') void addWorkbuddy(); else if (m.action === 'qoder') setQoder(true); else setDoubao(true) } }, L('+ 添加', '+ Add')) : h('span', { style: { color: C.faint } }, `(${m.reason})`)))) : null,
1144
1172
  open ? h('div', { style: { marginTop: 8 } },
1145
1173
  st.results.map((r) => h('div', { key: r.id, style: { fontSize: 12, lineHeight: 1.8, color: r.state === 'failed' ? C.err : C.text } },
@@ -1164,7 +1192,7 @@ window.__ModuleLoader__.load({
1164
1192
  err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null),
1165
1193
  qoder ? h(QoderDialog, { another: st.qoder.configured, onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1166
1194
  transfer ? h(TransferDialog, { mode: transfer, onDone: (r) => { setTransfer(''); setSt(r); setOpen(true) }, onClose: () => setTransfer('') }) : null,
1167
- doubao ? h(DoubaoDialog, { another: Boolean(st.accounts && st.accounts.doubao && st.accounts.doubao.length), onDone: () => { setDoubao(false); setOpen(true); void refresh() }, onClose: () => setDoubao(false) }) : null,
1195
+ doubao ? h(DoubaoDialog, { another: Boolean(st.accounts && st.accounts.doubao && st.accounts.doubao.length), localRead: st.localRead, found: ((st.missing || []).find((m) => m.id === 'doubao') || {}).found, onDone: (next, note) => { setDoubao(false); setOpen(true); if (next) setSt(next); else void refresh(); if (note) setErr(note) }, onClose: () => setDoubao(false) }) : null,
1168
1196
  wbLogin ? h(LoginDialog, { source: { id: 'workbuddy-checkin', label: L('WorkBuddy 签到账号', 'WorkBuddy check-in account'), login: 'link' }, session: wbLogin, onDone: () => { setWbLogin(null); setOpen(true); setTimeout(() => void refresh(), 3000) }, onClose: () => { setWbLogin(null); void refresh() } }) : null)
1169
1197
  }
1170
1198
 
package/plugin/index.js CHANGED
@@ -63,7 +63,7 @@ const ALLOWED = [
63
63
  ['GET', /^\/self$/],
64
64
  ['GET', /^\/failures$/],
65
65
  ['GET', /^\/checkin$/],
66
- ['POST', /^\/checkin(\/auto|\/qoder|\/workbuddy|\/doubao|\/export|\/import)?$/],
66
+ ['POST', /^\/checkin(\/auto|\/qoder|\/workbuddy|\/doubao|\/doubao\/scan|\/export|\/import)?$/],
67
67
  ['DELETE', /^\/checkin\/qoder(\/[A-Za-z0-9_-]{1,40})?$/],
68
68
  ['DELETE', /^\/checkin\/workbuddy\/[A-Za-z0-9_-]{1,80}$/],
69
69
  ['DELETE', /^\/checkin\/doubao\/[A-Za-z0-9_-]{1,40}$/],