deepseek-harness-wallet 0.3.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.3.1 - 2026-08-23
6
+
7
+ - 修复 Windows DPAPI 账户密钥在重启后无法解密的问题,并在加密文件损坏或密钥不可用时锁定写入,避免覆盖原数据。 / Fixed Windows DPAPI account keys failing to decrypt after restart and locked writes when encrypted data cannot be recovered, preventing accidental overwrite.
8
+ - 修复 Electron/Tauri 同步通知句柄的点击/关闭回调断链,并让所有充值入口统一经过桌面端外链适配与首次域名确认。 / Fixed callback wiring for synchronous Electron/Tauri notification handles and routed every recharge entry through the desktop external-link adapter and first-use domain confirmation.
9
+ - 修复官方价格页改用“周一至周五”表述后同步被误判为结构变化的问题,并把视觉模型的计费生效时间校正为 2026-08-21 上线日。 / Accepted the official pricing page's current weekday-only wording and corrected vision-model billing to its 2026-08-21 launch date.
10
+ - 将周五 18:00 至周一 09:00 作为连续低谷周期:周五提示“周末全天低谷”,周六/周日分别显示当天全天低谷,周一 09:00 前显示剩余多久进入高峰;跨午夜不重复提醒。 / Treated Friday 18:00 through Monday 09:00 as one continuous off-peak period: Friday previews the all-weekend rule, Saturday/Sunday name the current day, Monday shows time remaining to peak, and midnight boundaries do not repeat alerts.
11
+ - 旧客户端在周末收到空峰值窗口,避免未强刷的 Edge 标签页继续误报高峰;账户文件现在自动保留加密 `.bak`,主文件缺失时可自动恢复。当前 91/91 通过。 / Weekend snapshots give stale clients no peak windows so an old Edge tab cannot keep falsely reporting peak; encrypted account files now keep a `.bak` fallback that restores a missing primary file. 91/91 tests pass.
12
+
5
13
  ## 0.3.0 - 2026-08-23
6
14
 
7
15
  - 新增 `deepseek-v4-flash-vision-exp` 视觉模型计费,沿用 V4 Flash 的峰谷价格;图片 token 由 Harness 上报后与文本 token 一起计入。 / Added billing for `deepseek-v4-flash-vision-exp` using V4 Flash peak/off-peak rates; image tokens reported by Harness are counted with text tokens.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/deepseek-harness-wallet?label=npm&color=5965d8)](https://www.npmjs.com/package/deepseek-harness-wallet)
4
4
  [![GitHub release](https://img.shields.io/github/v/release/feibi-mochi/deepseek-harness-control-center?label=release&color=5965d8)](https://github.com/feibi-mochi/deepseek-harness-control-center/releases)
5
5
  [![CI](https://github.com/feibi-mochi/deepseek-harness-control-center/actions/workflows/validate.yml/badge.svg)](https://github.com/feibi-mochi/deepseek-harness-control-center/actions/workflows/validate.yml)
6
- [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-0.1.0--rc.8-4aa3ff)](https://github.com/deepseek-ai/DeepSeek-Harness)
6
+ [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-0.1.1--rc.2-4aa3ff)](https://github.com/deepseek-ai/DeepSeek-Harness)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-3b7a57)](./LICENSE)
8
8
 
9
9
  **DeepSeek Harness monitoring, alerts, recharge, and session control center.**
@@ -14,6 +14,8 @@
14
14
 
15
15
  > A local-first companion that keeps account status, per-conversation usage, completion reminders, official recharge, flexible layout, and host-gated session controls beside the DSH composer.
16
16
 
17
+ > **Versions:** GitHub `main` is v0.3.1 source; npm and the latest Release remain v0.3.0. Use the GitHub install only for the current source build.
18
+
17
19
  > If DeepSeek Harness Control Center helps you, please consider leaving a ⭐ Star. Thank you!
18
20
 
19
21
  ## What it does
@@ -24,8 +26,8 @@
24
26
 
25
27
  - **Official DeepSeek** — live balance (60s global refresh with fast boot retries), an estimated current-session cost (not an official bill) locked to the price active for each usage event, including the 2026-08-17 peak/off-peak rollout, and token breakdown.
26
28
  - **Vision model accounting** — `deepseek-v4-flash-vision-exp` is priced like V4 Flash; image tokens reported by the Harness are included with text tokens.
27
- - **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash` and `v4-pro`, indicating real-time pricing windows (peak vs. 50% discount off-peak), countdown to the next switch, and optional desktop switch notifications. Weekdays use 09:00–12:00 and 14:00–18:00 Beijing time; Saturday and Sunday are all-day off-peak from 2026-08-23. Other models keep their flat rates.
28
- - **Official pricing sync** — periodically checks the official DeepSeek pricing page, applies only a fully validated table, and visibly keeps the built-in rules when the page is unavailable or changes format.
29
+ - **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash`, `v4-pro`, and `v4-flash-vision-exp`. Weekday peak windows are 09:00–12:00 and 14:00–18:00 Beijing time. After Friday 18:00 the card previews “weekend all-day off-peak”; Saturday and Sunday name the current all-day off-peak rule; Monday before 09:00 shows the time remaining to enter peak. Optional notifications treat Friday 18:00 through Monday 09:00 as one continuous off-peak period.
30
+ - **Official pricing sync** — periodically checks the official DeepSeek pricing page and applies only a fully validated table. Network failures retain the last validated rule (or the built-in rule before the first successful sync); an unrecognized page structure is marked for review instead of silently changing billing.
29
31
  - **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
30
32
  - **Provider classification** — observed wrapper routes appear in the settings page; opted-in routes join the official token/cost bucket and are priced with the official table.
31
33
  - **Click the chip** to open the detail panel: correctly formatted per-currency balances, cost and token splits, a freely editable low-balance threshold for the active account and currency (two decimals, persisted per account; alerts never mix currencies), manual refresh, and a jump to the official recharge page (first click shows the domain for confirmation — anti-phishing).
@@ -34,7 +36,7 @@
34
36
  - **Completion reminders** — optionally notify when a conversation finishes, with persistent or timed modes, queueing and deduplication for simultaneous completions, cross-tab coordination, and an in-page fallback when system notifications are unavailable.
35
37
  - **Optional permanent deletion** — when the DSH host advertises a real deletion capability, an opt-in setting enables a confirmed permanent-delete action in the session menu; unsupported hosts keep the control disabled.
36
38
  - **Low-balance alert** — below the threshold the chip turns red with a breathing animation and fires one desktop notification; it resets automatically once the balance recovers.
37
- - **Theme-native UI** — built entirely on `--dsw-alias-*` theme variables, so light and dark themes both render correctly; the panel closes when you click outside and flips open-direction near screen edges.
39
+ - **Theme-native UI** — uses DSH `--dsw-alias-*` variables with safe fallback colors, so light and dark themes both render correctly; the panel closes when you click outside and flips open-direction near screen edges.
38
40
  - **Clear current-session wallet data** — one button clears only the open conversation's token/cost records; it does not delete the conversation, and every other conversation is untouched.
39
41
 
40
42
  ## Multi-account
@@ -67,13 +69,13 @@ Details: [compatibility](#browser-desktop-and-os-compatibility) · [data and tru
67
69
 
68
70
  ## Install
69
71
 
70
- From npm:
72
+ From npm (stable v0.3.0):
71
73
 
72
74
  ```sh
73
75
  dsh plugin --profile web add deepseek-harness-wallet
74
76
  ```
75
77
 
76
- or from GitHub directly:
78
+ or from GitHub `main` (current v0.3.1 source):
77
79
 
78
80
  ```sh
79
81
  dsh plugin --profile web add github:feibi-mochi/deepseek-harness-control-center
@@ -84,7 +86,7 @@ Restart `dsh web`, then hard-refresh the page.
84
86
  ## Quick use
85
87
 
86
88
  1. Click the wallet or peak/off-peak card to open its control panel; open the Harness settings card for health and compatibility checks.
87
- 2. Choose horizontal or vertical layout, then use the scale control between 100% and 120%.
89
+ 2. The peak card supports horizontal/vertical layout and 100%–120% scaling. The wallet chip uses a separate scale: 100%–105% in the composer and up to 125% when docked or floating.
88
90
  3. Turn the official recharge button off when you need a smaller card; official and third-party rows can also be shown independently.
89
91
  4. Drag the card to any open area. If it is hard to find after a layout change, use **Reset/Dock(归位/停靠)** in the panel to return it to the sidebar.
90
92
  5. The card follows the host light/dark theme. A hard refresh after upgrading makes sure the new client bundle is loaded.
@@ -141,7 +143,7 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
141
143
  | --- | --- |
142
144
  | Token accounting | Listens to the `llm/stream` event and buckets per session and provider: `deepseek-official` plus explicitly opted-in wrapper routes use the official bucket; other providers stay third-party; each usage event also locks its contemporaneous official price, so multiple sessions and pricing windows never mix. |
143
145
  | Balance | The wallet plugin itself sends the active key directly only to the official `/user/balance` endpoint. When multi-account switching is enabled, the selected key is also written into the DSH credentials seam; DSH may then use it for subsequent model requests. |
144
- | Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json`: Windows uses current-user DPAPI, while other platforms use an owner-only AES-GCM key file. The UI only shows masked keys, and switching writes the chosen key into the credentials seam for subsequent LLM billing. Protect the DSH data directory as you would any local credential store. |
146
+ | Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json`, with an encrypted `accounts.json.bak` fallback that restores a missing primary file. Windows uses current-user DPAPI, so copies normally remain tied to the same Windows user. Other platforms use an owner-only AES-GCM key file; move or back up `accounts.json`, `.bak`, and `.key` together. The UI only shows masked keys, and switching writes the chosen key into the credentials seam for subsequent LLM billing. |
145
147
  | Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
146
148
  | Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
147
149
  | Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
@@ -153,20 +155,20 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
153
155
  CNY per 1M tokens, curated from official announcements (cache writes are not billed):
154
156
 
155
157
  - Since 2025-02-09 — deepseek-chat 2/8 (cache read 0.5), deepseek-reasoner 4/16 (cache read 1)
156
- - Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025), v4-flash-vision-exp 1/2 (cache read 0.02)
158
+ - Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025)
157
159
  - Since 2026-08-17 00:00 Beijing — peak/off-peak pricing for the v4 models (peak windows Beijing 09:00–12:00 / 14:00–18:00; off-peak is half the peak rate):
158
160
  - v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
159
161
  - v4-pro (off-peak / peak): cache read 0.15 / 0.30, input 4.5 / 9, output 13.5 / 27
160
- - v4-flash-vision-exp (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
162
+ - Since 2026-08-21 — v4-flash-vision-exp launched with the V4 Flash peak/off-peak table: cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9.
161
163
  - Since 2026-08-23 00:00 Beijing — Saturday and Sunday are no longer split into peak/off-peak windows; weekend calls use the off-peak rates all day. Weekday peak windows remain 09:00–12:00 and 14:00–18:00.
162
164
 
163
- deepseek-chat and deepseek-reasoner keep their flat rates. Each usage event is priced when it arrives; upgrading from 0.1.2 migrates legacy counters once using the then-current rate. Costs are estimates; the API-returned balance is authoritative.
165
+ Historical deepseek-chat and deepseek-reasoner records retain their original flat-rate table; this is not a claim that those legacy model names remain currently available. Each usage event is priced when it arrives; upgrading from 0.1.2 migrates legacy counters once using the then-current rate. Costs are estimates; the API-returned balance is authoritative.
164
166
 
165
167
  ## Roadmap
166
168
 
167
- - [ ] Third-party price tables (cost per token)
168
- - [ ] Balance history chart
169
- - [ ] Balance-API adapters for other providers (e.g. Zhipu)
169
+ - [ ] 365-day Token heatmap and rebuildable local usage ledger
170
+ - [ ] Z.ai Coding Plan monitoring, built on a generic provider-adapter contract
171
+ - [ ] Additional provider price/balance adapters only after real-account validation
170
172
 
171
173
  ## License
172
174
 
package/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto'
20
20
  import { spawnSync } from 'node:child_process'
21
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
21
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
22
22
  import { homedir } from 'node:os'
23
23
  import { dirname, join } from 'node:path'
24
24
 
@@ -27,7 +27,7 @@ export const inject = ['webServer']
27
27
 
28
28
  const OFFICIAL_PROVIDER = 'deepseek-official'
29
29
  const RECHARGE_URL = 'https://platform.deepseek.com/top_up'
30
- const PLUGIN_VERSION = '0.3.0'
30
+ const PLUGIN_VERSION = '0.3.1'
31
31
  const PRICING_SOURCE_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/'
32
32
  const PRICING_SYNC_INTERVAL_MS = 6 * 60 * 60_000
33
33
  const PRICING_SYNC_TIMEOUT_MS = 8_000
@@ -36,6 +36,7 @@ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
36
36
  const STORE_PATH = join(DSH_HOME, 'storages', 'wallet.json')
37
37
  const ACCOUNTS_PATH = join(DSH_HOME, 'storages', 'accounts.json')
38
38
  const ACCOUNTS_KEY_PATH = ACCOUNTS_PATH + '.key'
39
+ const ACCOUNTS_BACKUP_PATH = ACCOUNTS_PATH + '.bak'
39
40
  const ACCOUNTS_VERSION = 2
40
41
  const ACCOUNTS_CRYPTO_VERSION = 1
41
42
  const CREDENTIAL_REF = 'DEEPSEEK_API_KEY'
@@ -67,6 +68,22 @@ export function isBeijingPeak(atMs) {
67
68
  return peakWindows.some((window) => hours >= window.startHour && hours < window.endHour)
68
69
  }
69
70
 
71
+ export function pricingWindowSnapshot(atMs = Date.now()) {
72
+ const weekendOffPeak = atMs >= weekendOffPeakSince && isBeijingWeekend(atMs)
73
+ return {
74
+ timezone: 'Asia/Shanghai',
75
+ offsetMinutes: 480,
76
+ // Current clients use weekendOffPeak for a full green ring. Empty windows
77
+ // also make stale pre-weekend clients fail neutral instead of falsely
78
+ // claiming Sunday is a peak period until the browser is hard-refreshed.
79
+ windows: weekendOffPeak ? [] : peakWindows.map((window) => ({ ...window })),
80
+ offPeakRate: OFF_PEAK_RATE,
81
+ isPeak: isBeijingPeak(atMs),
82
+ weekendOffPeak,
83
+ weekendOffPeakSince,
84
+ }
85
+ }
86
+
70
87
  // Pricing timeline (CNY per 1M tokens; cacheWrite is not billed by DeepSeek).
71
88
  // Curated from the official announcements; later policies win per model.
72
89
  export const PRICE_POLICIES = [
@@ -82,7 +99,6 @@ export const PRICE_POLICIES = [
82
99
  models: {
83
100
  'deepseek-v4-flash': { cacheHit: 0.02, input: 1, output: 2 },
84
101
  'deepseek-v4-pro': { cacheHit: 0.025, input: 3, output: 6 },
85
- 'deepseek-v4-flash-vision-exp': { cacheHit: 0.02, input: 1, output: 2 },
86
102
  },
87
103
  },
88
104
  {
@@ -92,6 +108,13 @@ export const PRICE_POLICIES = [
92
108
  models: {
93
109
  'deepseek-v4-flash': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
94
110
  'deepseek-v4-pro': { cacheHit: [0.15, 0.3], input: [4.5, 9], output: [13.5, 27] },
111
+ },
112
+ },
113
+ {
114
+ // Vision Exp was released on 2026-08-21 and uses the V4 Flash table.
115
+ since: Date.UTC(2026, 7, 20, 16),
116
+ peakOffPeak: true,
117
+ models: {
95
118
  'deepseek-v4-flash-vision-exp': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
96
119
  },
97
120
  },
@@ -105,6 +128,7 @@ let pricingSync = {
105
128
  etag: null,
106
129
  lastModified: null,
107
130
  ruleVersion: 'built-in-2026-08-23',
131
+ modelCount: 3,
108
132
  message: '尚未检查官方价格页',
109
133
  }
110
134
 
@@ -167,17 +191,20 @@ export function parseOfficialPricingHtml(html) {
167
191
  }
168
192
  }
169
193
  const text = decodeHtmlText(html)
170
- const windowMatch = text.match(/高峰时段为北京时间\s*(\d{1,2}):00\s*-\s*(\d{1,2}):00、\s*(\d{1,2}):00\s*-\s*(\d{1,2}):00/)
194
+ const windowMatch = text.match(/高峰时段为北京时间(?:周一至周五)?\s*(\d{1,2}):00\s*-\s*(\d{1,2}):00、\s*(\d{1,2}):00\s*-\s*(\d{1,2}):00/)
171
195
  if (windowMatch === null) throw new Error('official peak window changed')
172
196
  const parsedWindows = [
173
197
  { startHour: Number(windowMatch[1]), endHour: Number(windowMatch[2]) },
174
198
  { startHour: Number(windowMatch[3]), endHour: Number(windowMatch[4]) },
175
199
  ]
200
+ const weekdayOnly = /高峰时段为北京时间周一至周五/.test(text)
176
201
  const weekendMatch = text.match(/北京时间\s*(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日[^。]*?00:00起[^。]*?周末[^。]*?低谷/)
177
- if (weekendMatch === null) throw new Error('official weekend rule changed')
178
- const weekendSince = Date.UTC(
179
- Number(weekendMatch[1]), Number(weekendMatch[2]) - 1, Number(weekendMatch[3]), 0, 0, 0, 0,
180
- ) - BEIJING_OFFSET_MS
202
+ if (!weekdayOnly && weekendMatch === null) throw new Error('official weekend rule changed')
203
+ const weekendSince = weekdayOnly
204
+ ? WEEKEND_OFF_PEAK_SINCE
205
+ : Date.UTC(
206
+ Number(weekendMatch[1]), Number(weekendMatch[2]) - 1, Number(weekendMatch[3]), 0, 0, 0, 0,
207
+ ) - BEIJING_OFFSET_MS
181
208
  return {
182
209
  models: modelsWithRates,
183
210
  peakWindows: parsedWindows,
@@ -187,11 +214,13 @@ export function parseOfficialPricingHtml(html) {
187
214
  }
188
215
 
189
216
  function applyOfficialPricing(parsed, response) {
190
- const base = PRICE_POLICIES[PRICE_POLICIES.length - 1]
191
- activePricePolicies = [
192
- ...PRICE_POLICIES.slice(0, -1),
193
- { ...base, models: parsed.models },
194
- ]
217
+ activePricePolicies = PRICE_POLICIES.map((policy) => {
218
+ const models = {}
219
+ for (const model of Object.keys(policy.models)) {
220
+ models[model] = parsed.models[model] || policy.models[model]
221
+ }
222
+ return { ...policy, models }
223
+ })
195
224
  peakWindows = parsed.peakWindows.map((window) => ({ ...window }))
196
225
  weekendOffPeakSince = parsed.weekendOffPeakSince
197
226
  pricingSync = {
@@ -201,6 +230,7 @@ function applyOfficialPricing(parsed, response) {
201
230
  etag: response?.headers?.get?.('etag') ?? pricingSync.etag,
202
231
  lastModified: response?.headers?.get?.('last-modified') ?? pricingSync.lastModified,
203
232
  ruleVersion: parsed.ruleVersion,
233
+ modelCount: Object.keys(parsed.models).length,
204
234
  message: '已同步官方价格页',
205
235
  }
206
236
  }
@@ -209,6 +239,7 @@ async function syncOfficialPricing(logger) {
209
239
  if (typeof fetch !== 'function') return
210
240
  const controller = new AbortController()
211
241
  const timeout = setTimeout(() => controller.abort(), PRICING_SYNC_TIMEOUT_MS)
242
+ let stage = 'network'
212
243
  try {
213
244
  const headers = {}
214
245
  if (pricingSync.etag) headers['if-none-match'] = pricingSync.etag
@@ -219,14 +250,18 @@ async function syncOfficialPricing(logger) {
219
250
  return
220
251
  }
221
252
  if (!response.ok) throw new Error('official pricing page returned ' + response.status)
253
+ stage = 'parse'
222
254
  const parsed = parseOfficialPricingHtml(await response.text())
223
255
  applyOfficialPricing(parsed, response)
224
256
  } catch (error) {
257
+ const hasValidatedOfficialRule = typeof pricingSync.ruleVersion === 'string' && pricingSync.ruleVersion.startsWith('official-')
225
258
  pricingSync = {
226
259
  ...pricingSync,
227
- status: pricingSync.checkedAt > 0 ? 'review-required' : 'offline',
260
+ status: stage === 'parse' ? 'review-required' : 'offline',
228
261
  checkedAt: Date.now(),
229
- message: pricingSync.checkedAt > 0 ? '官方价格页结构有变化,需要复核' : '官方价格页暂时不可用,使用内置规则',
262
+ message: stage === 'parse'
263
+ ? '官方价格页结构有变化,需要复核'
264
+ : hasValidatedOfficialRule ? '官方价格页暂时不可用,沿用上次已验证规则' : '官方价格页暂时不可用,使用内置规则',
230
265
  }
231
266
  if (logger && typeof logger.warn === 'function') logger.warn('dsh-wallet: official pricing sync unavailable')
232
267
  } finally {
@@ -241,7 +276,7 @@ export function pricingSnapshot() {
241
276
  checkedAt: pricingSync.checkedAt,
242
277
  ruleVersion: pricingSync.ruleVersion,
243
278
  message: pricingSync.message,
244
- modelCount: Object.keys(activePricePolicies[activePricePolicies.length - 1].models).length,
279
+ modelCount: pricingSync.modelCount,
245
280
  peakWindows: peakWindows.map((window) => ({ ...window })),
246
281
  weekendOffPeakSince,
247
282
  }
@@ -552,14 +587,22 @@ const WINDOWS_DPAPI_UNPROTECT = [
552
587
  let fallbackEncryptionKey = null
553
588
  let accountStorageScheme = process.platform === 'win32' ? 'windows-dpapi' : 'aes-gcm-file-key'
554
589
  let accountStorageError = null
590
+ let accountStorageLocked = false
591
+ let accountStorageRecovered = false
555
592
 
556
593
  function runWindowsDpapi(operation, value) {
557
594
  if (process.platform !== 'win32') return null
595
+ // Protect expects base64-encoded UTF-8 plaintext. Unprotect expects the
596
+ // DPAPI ciphertext's own base64 representation, so encoding it a second
597
+ // time makes every persisted account undecryptable after restart.
598
+ const input = operation === 'protect'
599
+ ? Buffer.from(value, 'utf8').toString('base64')
600
+ : value
558
601
  const result = spawnSync(
559
602
  'powershell.exe',
560
603
  ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', operation === 'protect' ? WINDOWS_DPAPI_PROTECT : WINDOWS_DPAPI_UNPROTECT],
561
604
  {
562
- input: Buffer.from(value, 'utf8').toString('base64'),
605
+ input,
563
606
  encoding: 'utf8',
564
607
  windowsHide: true,
565
608
  maxBuffer: 1024 * 1024,
@@ -570,7 +613,7 @@ function runWindowsDpapi(operation, value) {
570
613
  return output === '' ? null : output
571
614
  }
572
615
 
573
- function getFallbackEncryptionKey() {
616
+ function getFallbackEncryptionKey(createIfMissing = true) {
574
617
  if (fallbackEncryptionKey !== null) return fallbackEncryptionKey
575
618
  try {
576
619
  mkdirSync(dirname(ACCOUNTS_KEY_PATH), { recursive: true, mode: 0o700 })
@@ -579,6 +622,11 @@ function getFallbackEncryptionKey() {
579
622
  if (existing.length !== 32) throw new Error('account encryption key has an invalid length')
580
623
  fallbackEncryptionKey = existing
581
624
  } else {
625
+ if (!createIfMissing) {
626
+ accountStorageError = 'account-encryption-key-missing'
627
+ accountStorageLocked = true
628
+ return null
629
+ }
582
630
  fallbackEncryptionKey = randomBytes(32)
583
631
  writeFileSync(ACCOUNTS_KEY_PATH, fallbackEncryptionKey, { mode: 0o600 })
584
632
  chmodSync(ACCOUNTS_KEY_PATH, 0o600)
@@ -590,6 +638,29 @@ function getFallbackEncryptionKey() {
590
638
  }
591
639
  }
592
640
 
641
+ function encryptApiKeyAes(apiKey, key, iv = randomBytes(12)) {
642
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('AES account key must contain 32 bytes')
643
+ if (!Buffer.isBuffer(iv) || iv.length !== 12) throw new Error('AES-GCM IV must contain 12 bytes')
644
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
645
+ const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
646
+ return {
647
+ version: ACCOUNTS_CRYPTO_VERSION,
648
+ scheme: 'aes-gcm-file-key',
649
+ iv: iv.toString('base64'),
650
+ tag: cipher.getAuthTag().toString('base64'),
651
+ payload: ciphertext.toString('base64'),
652
+ }
653
+ }
654
+
655
+ function decryptApiKeyAes(value, key) {
656
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('AES account key must contain 32 bytes')
657
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(value.iv, 'base64'))
658
+ decipher.setAuthTag(Buffer.from(value.tag, 'base64'))
659
+ return Buffer.concat([decipher.update(Buffer.from(value.payload, 'base64')), decipher.final()]).toString('utf8')
660
+ }
661
+
662
+ export const __testing = { encryptApiKeyAes, decryptApiKeyAes, compareVersions }
663
+
593
664
  function protectApiKey(apiKey) {
594
665
  const dpapi = runWindowsDpapi('protect', apiKey)
595
666
  if (dpapi !== null) {
@@ -600,17 +671,9 @@ function protectApiKey(apiKey) {
600
671
  const key = getFallbackEncryptionKey()
601
672
  if (key === null) return null
602
673
  try {
603
- const iv = randomBytes(12)
604
- const cipher = createCipheriv('aes-256-gcm', key, iv)
605
- const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
674
+ const encrypted = encryptApiKeyAes(apiKey, key)
606
675
  accountStorageScheme = 'aes-gcm-file-key'
607
- return {
608
- version: ACCOUNTS_CRYPTO_VERSION,
609
- scheme: 'aes-gcm-file-key',
610
- iv: iv.toString('base64'),
611
- tag: cipher.getAuthTag().toString('base64'),
612
- payload: ciphertext.toString('base64'),
613
- }
676
+ return encrypted
614
677
  } catch {
615
678
  accountStorageError = 'account-encryption-failed'
616
679
  return null
@@ -618,25 +681,41 @@ function protectApiKey(apiKey) {
618
681
  }
619
682
 
620
683
  function unprotectApiKey(value) {
621
- if (value === null || typeof value !== 'object' || Array.isArray(value)) return ''
622
- if (value.version !== ACCOUNTS_CRYPTO_VERSION || typeof value.scheme !== 'string' || typeof value.payload !== 'string') return ''
684
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
685
+ accountStorageError = 'account-encrypted-record-invalid'
686
+ accountStorageLocked = true
687
+ return ''
688
+ }
689
+ if (value.version !== ACCOUNTS_CRYPTO_VERSION || typeof value.scheme !== 'string' || typeof value.payload !== 'string') {
690
+ accountStorageError = 'account-encrypted-record-invalid'
691
+ accountStorageLocked = true
692
+ return ''
693
+ }
623
694
  if (value.scheme === 'windows-dpapi') {
624
695
  const decoded = runWindowsDpapi('unprotect', value.payload)
625
- if (decoded === null) accountStorageError = 'account-decryption-failed'
696
+ if (decoded === null) {
697
+ accountStorageError = 'account-decryption-failed'
698
+ accountStorageLocked = true
699
+ }
626
700
  else {
627
701
  try { return Buffer.from(decoded, 'base64').toString('utf8') } catch { /* fall through */ }
702
+ accountStorageError = 'account-decryption-failed'
703
+ accountStorageLocked = true
628
704
  }
629
705
  return ''
630
706
  }
631
- if (value.scheme !== 'aes-gcm-file-key' || typeof value.iv !== 'string' || typeof value.tag !== 'string') return ''
632
- const key = getFallbackEncryptionKey()
707
+ if (value.scheme !== 'aes-gcm-file-key' || typeof value.iv !== 'string' || typeof value.tag !== 'string') {
708
+ accountStorageError = 'account-encrypted-record-invalid'
709
+ accountStorageLocked = true
710
+ return ''
711
+ }
712
+ const key = getFallbackEncryptionKey(false)
633
713
  if (key === null) return ''
634
714
  try {
635
- const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(value.iv, 'base64'))
636
- decipher.setAuthTag(Buffer.from(value.tag, 'base64'))
637
- return Buffer.concat([decipher.update(Buffer.from(value.payload, 'base64')), decipher.final()]).toString('utf8')
715
+ return decryptApiKeyAes(value, key)
638
716
  } catch {
639
717
  accountStorageError = 'account-decryption-failed'
718
+ accountStorageLocked = true
640
719
  return ''
641
720
  }
642
721
  }
@@ -645,8 +724,9 @@ export function accountStorageSnapshot() {
645
724
  return {
646
725
  encryptedAtRest: true,
647
726
  scheme: accountStorageScheme,
648
- status: accountStorageError === null ? 'ready' : 'error',
727
+ status: accountStorageLocked ? 'locked' : accountStorageRecovered ? 'recovered' : accountStorageError === null ? 'ready' : 'error',
649
728
  error: accountStorageError,
729
+ locked: accountStorageLocked,
650
730
  }
651
731
  }
652
732
 
@@ -680,14 +760,33 @@ export function normalizeAccountsData(value) {
680
760
  return { version: ACCOUNTS_VERSION, accounts, activeId }
681
761
  }
682
762
 
763
+ function readAccountsFile(path) {
764
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
765
+ const normalized = normalizeAccountsData(raw)
766
+ const legacyPlaintext = Array.isArray(raw?.accounts)
767
+ && raw.accounts.some((account) => account && typeof account.apiKey === 'string')
768
+ return { accounts: normalized, migrated: legacyPlaintext || raw?.version !== ACCOUNTS_VERSION }
769
+ }
770
+
683
771
  function loadAccounts() {
772
+ if (!existsSync(ACCOUNTS_PATH)) {
773
+ if (!existsSync(ACCOUNTS_BACKUP_PATH)) return { accounts: emptyAccounts(), migrated: false }
774
+ try {
775
+ const recovered = readAccountsFile(ACCOUNTS_BACKUP_PATH)
776
+ accountStorageRecovered = true
777
+ accountStorageError = 'account-store-restored-from-backup'
778
+ return { accounts: recovered.accounts, migrated: true }
779
+ } catch {
780
+ accountStorageError = 'account-backup-invalid'
781
+ accountStorageLocked = true
782
+ return { accounts: emptyAccounts(), migrated: false }
783
+ }
784
+ }
684
785
  try {
685
- const raw = JSON.parse(readFileSync(ACCOUNTS_PATH, 'utf8'))
686
- const normalized = normalizeAccountsData(raw)
687
- const legacyPlaintext = Array.isArray(raw?.accounts)
688
- && raw.accounts.some((account) => account && typeof account.apiKey === 'string')
689
- return { accounts: normalized, migrated: legacyPlaintext || raw?.version !== ACCOUNTS_VERSION }
786
+ return readAccountsFile(ACCOUNTS_PATH)
690
787
  } catch {
788
+ accountStorageError = 'account-store-invalid'
789
+ accountStorageLocked = true
691
790
  return { accounts: emptyAccounts(), migrated: false }
692
791
  }
693
792
  }
@@ -703,6 +802,7 @@ function persistAccounts(logger) {
703
802
  accountsSaveTimer = null
704
803
  }
705
804
  try {
805
+ if (accountStorageLocked) throw new Error('encrypted account store is locked')
706
806
  mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true, mode: 0o700 })
707
807
  const tmp = ACCOUNTS_PATH + '.tmp'
708
808
  const encryptedAccounts = accounts.accounts.map((account) => {
@@ -717,8 +817,14 @@ function persistAccounts(logger) {
717
817
  })
718
818
  writeFileSync(tmp, JSON.stringify({ version: ACCOUNTS_VERSION, accounts: encryptedAccounts, activeId: accounts.activeId }), { mode: 0o600 })
719
819
  chmodSync(tmp, 0o600)
820
+ if (existsSync(ACCOUNTS_PATH)) {
821
+ copyFileSync(ACCOUNTS_PATH, ACCOUNTS_BACKUP_PATH)
822
+ chmodSync(ACCOUNTS_BACKUP_PATH, 0o600)
823
+ }
720
824
  renameSync(tmp, ACCOUNTS_PATH)
721
825
  accountsNeedsSave = false
826
+ accountStorageRecovered = false
827
+ accountStorageError = null
722
828
  } catch (error) {
723
829
  accountStorageError = accountStorageError || 'account-encryption-failed'
724
830
  if (logger && typeof logger.warn === 'function') {
@@ -801,6 +907,7 @@ export function accountListView() {
801
907
  active: account.id === accounts.activeId,
802
908
  })),
803
909
  activeId: accounts.activeId,
910
+ storage: accountStorageSnapshot(),
804
911
  }
805
912
  }
806
913
 
@@ -1075,15 +1182,7 @@ function snapshotView(sessionId) {
1075
1182
  })(),
1076
1183
  rechargeUrl: RECHARGE_URL,
1077
1184
  // Peak/off-peak policy for the ring clock; billed in Asia/Shanghai.
1078
- pricingWindows: {
1079
- timezone: 'Asia/Shanghai',
1080
- offsetMinutes: 480,
1081
- windows: peakWindows.map(w => ({ startHour: w.startHour, endHour: w.endHour })),
1082
- offPeakRate: OFF_PEAK_RATE,
1083
- isPeak: isBeijingPeak(now),
1084
- weekendOffPeak: now >= weekendOffPeakSince && isBeijingWeekend(now),
1085
- weekendOffPeakSince,
1086
- },
1185
+ pricingWindows: pricingWindowSnapshot(now),
1087
1186
  accounts: {
1088
1187
  activeId: accounts.activeId,
1089
1188
  activeName: active !== null ? active.name : null,
@@ -1195,16 +1294,18 @@ export function apply(ctx, config) {
1195
1294
  const balanceTimer = setInterval(() => void refreshBalance(ctx), BALANCE_REFRESH_MS)
1196
1295
  if (typeof balanceTimer.unref === 'function') balanceTimer.unref()
1197
1296
 
1198
- ctx.effect(() => {
1199
- const first = setTimeout(() => void syncOfficialPricing(ctx.logger), 1000)
1200
- const timer = setInterval(() => void syncOfficialPricing(ctx.logger), PRICING_SYNC_INTERVAL_MS)
1201
- if (typeof first.unref === 'function') first.unref()
1202
- if (typeof timer.unref === 'function') timer.unref()
1203
- return () => {
1204
- clearTimeout(first)
1205
- clearInterval(timer)
1206
- }
1207
- }, 'dsh-wallet: official pricing sync')
1297
+ if (config.pricingSync !== false) {
1298
+ ctx.effect(() => {
1299
+ const first = setTimeout(() => void syncOfficialPricing(ctx.logger), 1000)
1300
+ const timer = setInterval(() => void syncOfficialPricing(ctx.logger), PRICING_SYNC_INTERVAL_MS)
1301
+ if (typeof first.unref === 'function') first.unref()
1302
+ if (typeof timer.unref === 'function') timer.unref()
1303
+ return () => {
1304
+ clearTimeout(first)
1305
+ clearInterval(timer)
1306
+ }
1307
+ }, 'dsh-wallet: official pricing sync')
1308
+ }
1208
1309
 
1209
1310
  ctx.effect(() => {
1210
1311
  const disposeSnapshot = ctx.webServer.register({
@@ -1306,6 +1407,7 @@ export function apply(ctx, config) {
1306
1407
  handler: async (req, res) => {
1307
1408
  if (req.method === 'GET') return json(res, 200, { ok: true, ...accountListView() })
1308
1409
  if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1410
+ if (accountStorageLocked) return json(res, 423, { ok: false, error: 'account-storage-locked' })
1309
1411
  const body = await readBody(req)
1310
1412
  if (body === null || typeof body.name !== 'string' || typeof body.apiKey !== 'string') {
1311
1413
  return json(res, 400, { ok: false, error: 'name and apiKey are required' })
@@ -1345,6 +1447,7 @@ export function apply(ctx, config) {
1345
1447
  path: '/api/wallet/accounts/activate',
1346
1448
  handler: async (req, res) => {
1347
1449
  if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1450
+ if (accountStorageLocked) return json(res, 423, { ok: false, error: 'account-storage-locked' })
1348
1451
  const body = await readBody(req)
1349
1452
  if (body === null || typeof body.id !== 'string') return json(res, 400, { ok: false, error: 'id is required' })
1350
1453
  const result = await activateAccount(ctx, body.id)
@@ -1361,6 +1464,7 @@ export function apply(ctx, config) {
1361
1464
  path: '/api/wallet/accounts/remove',
1362
1465
  handler: async (req, res) => {
1363
1466
  if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1467
+ if (accountStorageLocked) return json(res, 423, { ok: false, error: 'account-storage-locked' })
1364
1468
  const body = await readBody(req)
1365
1469
  if (body === null || typeof body.id !== 'string') return json(res, 400, { ok: false, error: 'id is required' })
1366
1470
  const result = removeAccount(body.id)
package/lib/client.js CHANGED
@@ -23,7 +23,7 @@ window.__ModuleLoader__.load({
23
23
 
24
24
  var POLL_MS = 15000
25
25
  // Keep in lockstep with package.json; a test enforces the sync.
26
- var WALLET_VERSION = '0.3.0'
26
+ var WALLET_VERSION = '0.3.1'
27
27
  var CONFIRM_KEY = 'dsh-wallet-recharge-confirmed'
28
28
  var CHIP_LAYOUT_KEY = 'dshw-chip-layout-v4'
29
29
  var PANEL_POS_KEY = 'dshw-panel-pos-v1'
@@ -206,7 +206,6 @@ window.__ModuleLoader__.load({
206
206
  }
207
207
  }
208
208
  var bridged = extension.notify(payload)
209
- if (bridged && typeof bridged.close === 'function') return bridged
210
209
  if (bridged !== false) {
211
210
  if (bridged && typeof bridged.then === 'function') {
212
211
  function attachDelegate(delegate) {
@@ -219,6 +218,14 @@ window.__ModuleLoader__.load({
219
218
  if (bridgeHandle.closed && delegate && typeof delegate.close === 'function') delegate.close()
220
219
  }
221
220
  Promise.resolve(bridged).then(attachDelegate).catch(function () { attachDelegate(false) })
221
+ } else if (bridged && typeof bridged === 'object') {
222
+ // Always return the wrapper whose onclick/onclose fields the
223
+ // wallet sets. Desktop bridges are asked to call payload
224
+ // callbacks; returning the raw native handle here disconnected
225
+ // those callbacks for synchronous Electron/Tauri adapters.
226
+ bridgeHandle.delegate = bridged
227
+ try { bridged.onclick = payload.onClick } catch (e) { /* optional callback */ }
228
+ try { bridged.onclose = payload.onClose } catch (e) { /* optional callback */ }
222
229
  }
223
230
  return bridgeHandle
224
231
  }
@@ -285,6 +292,18 @@ window.__ModuleLoader__.load({
285
292
 
286
293
  var compatibility = createCompatibilityAdapter(window)
287
294
 
295
+ function openOfficialRecharge() {
296
+ var url = 'https://platform.deepseek.com/top_up'
297
+ var confirmed = false
298
+ try { confirmed = compatibility.storage.getItem(CONFIRM_KEY) === '1' } catch (e) { /* ignore */ }
299
+ if (!confirmed) {
300
+ if (typeof window.confirm !== 'function') return false
301
+ if (!window.confirm('将打开 DeepSeek 官方充值页:\nplatform.deepseek.com\n\n确认继续?')) return false
302
+ try { compatibility.storage.setItem(CONFIRM_KEY, '1') } catch (e) { /* ignore */ }
303
+ }
304
+ return compatibility.openExternal(url)
305
+ }
306
+
288
307
  var css = [
289
308
  '.dshw_anchor{display:inline-flex;align-items:center;min-height:22px;min-width:0;vertical-align:middle}',
290
309
  '.dshw_anchorHome{overflow:hidden;min-width:44px}',
@@ -1215,7 +1234,10 @@ window.__ModuleLoader__.load({
1215
1234
  }
1216
1235
  refresh()
1217
1236
  fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
1218
- if (!stopped && json && json.ok) setAccounts(json)
1237
+ if (!stopped && json && json.ok) {
1238
+ setAccounts(json)
1239
+ setAccountsError(json.storage && json.storage.locked ? '账户加密文件无法解密,已锁定写入以保护原数据' : null)
1240
+ }
1219
1241
  }).catch(function () { if (!stopped) setAccountsError('\u8d26\u6237\u63a5\u53e3\u4e0d\u53ef\u7528') })
1220
1242
  fetch('/api/wallet/health').then(function (resp) { return resp.json() }).then(function (json) {
1221
1243
  if (!stopped && json && json.ok) setHealth(json)
@@ -1364,7 +1386,10 @@ window.__ModuleLoader__.load({
1364
1386
 
1365
1387
  function reloadAccounts() {
1366
1388
  fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
1367
- if (json && json.ok) { setAccounts(json); setAccountsError(null) }
1389
+ if (json && json.ok) {
1390
+ setAccounts(json)
1391
+ setAccountsError(json.storage && json.storage.locked ? '账户加密文件无法解密,已锁定写入以保护原数据' : null)
1392
+ }
1368
1393
  }).catch(function () { /* ignore */ })
1369
1394
  fetch('/api/wallet/snapshot').then(function (resp) { return resp.json() }).then(function (json) {
1370
1395
  if (json && json.ok) {
@@ -1466,10 +1491,22 @@ window.__ModuleLoader__.load({
1466
1491
  ? (hostCompatibility.status === 'compatible' ? '兼容' : hostCompatibility.status === 'upgrade-recommended' ? '建议升级' : '待检测')
1467
1492
  : '检测中'
1468
1493
  var pricingText = pricing
1469
- ? (pricing.status === 'synced' ? '已同步' : pricing.status === 'review-required' ? '待复核' : pricing.status === 'offline' ? '离线,使用内置规则' : '内置规则')
1494
+ ? (pricing.status === 'synced'
1495
+ ? '已同步'
1496
+ : pricing.status === 'review-required'
1497
+ ? '待复核'
1498
+ : pricing.status === 'offline'
1499
+ ? (String(pricing.ruleVersion || '').indexOf('official-') === 0 ? '离线,沿用已验证规则' : '离线,使用内置规则')
1500
+ : '内置规则')
1470
1501
  : '检测中'
1471
1502
  var storageText = accountStorage
1472
- ? (accountStorage.scheme === 'windows-dpapi' ? 'Windows DPAPI 加密' : 'AES-GCM 加密')
1503
+ ? (accountStorage.status === 'locked'
1504
+ ? '无法解密(已锁定写入)'
1505
+ : accountStorage.status === 'recovered'
1506
+ ? '已从加密备份恢复'
1507
+ : accountStorage.status === 'error'
1508
+ ? '加密存储异常'
1509
+ : accountStorage.scheme === 'windows-dpapi' ? 'Windows DPAPI 加密' : 'AES-GCM 加密')
1473
1510
  : '检测中'
1474
1511
  rows.push(React.createElement('div', { key: 'health', className: 'dshw_setCard', style: { display: 'block' } },
1475
1512
  React.createElement('div', { className: 'dshw_settingsGroupHeader' },
@@ -1789,7 +1826,7 @@ window.__ModuleLoader__.load({
1789
1826
  }, '刷新余额'),
1790
1827
  React.createElement('button', {
1791
1828
  type: 'button', className: 'dshw_btn dshw_btnPrimary',
1792
- onClick: function () { if (close) close(); window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener') }
1829
+ onClick: function () { if (close) close(); openOfficialRecharge() }
1793
1830
  }, '↗ 去官方充值'))))
1794
1831
  return React.createElement('div', { className: 'dshw_settingsSection', style: { display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '12px', color: 'var(--dsw-alias-label-primary,inherit)' } }, rows)
1795
1832
  }
@@ -1823,38 +1860,27 @@ window.__ModuleLoader__.load({
1823
1860
  var offsetMinutes = policy && Number.isFinite(policy.offsetMinutes) ? policy.offsetMinutes : 480
1824
1861
  var beijingNow = Number.isFinite(nowMs) ? new Date(nowMs + offsetMinutes * 60000) : null
1825
1862
  var day = beijingNow && Number.isFinite(beijingNow.getTime()) ? beijingNow.getUTCDay() : 6
1826
- var daysUntilMonday = day === 6 ? 2 : 1
1827
- var nextSwitchMs = beijingNow && Number.isFinite(beijingNow.getTime())
1828
- ? Date.UTC(
1829
- beijingNow.getUTCFullYear(), beijingNow.getUTCMonth(), beijingNow.getUTCDate() + daysUntilMonday,
1830
- 9 - (offsetMinutes / 60), 0, 0, 0
1831
- ) - offsetMinutes * 60000
1832
- : NaN
1833
- var weekendMsLeft = Number.isFinite(nextSwitchMs) && Number.isFinite(nowMs)
1834
- ? Math.max(0, nextSwitchMs - nowMs)
1835
- : 0
1836
- var weekendHours = Math.floor(weekendMsLeft / 3600000)
1837
- var weekendMinutes = Math.floor((weekendMsLeft % 3600000) / 60000)
1838
- var weekendLeftText = weekendHours > 0
1839
- ? weekendHours + ' 小时 ' + weekendMinutes + ' 分'
1840
- : weekendMinutes + ' 分钟'
1841
- var weekendLeftShort = weekendHours > 0
1842
- ? weekendHours + 'h' + (weekendMinutes > 0 ? weekendMinutes + 'm' : '')
1843
- : weekendMinutes + 'm'
1844
- var weekendDateKey = beijingNow && Number.isFinite(beijingNow.getTime())
1845
- ? [beijingNow.getUTCFullYear(), String(beijingNow.getUTCMonth() + 1).padStart(2, '0'), String(beijingNow.getUTCDate()).padStart(2, '0')].join('-')
1863
+ // Friday after 18:00, Saturday, Sunday and Monday before 09:00 are
1864
+ // one continuous off-peak period. Anchor it to Friday so unchanged
1865
+ // pricing does not emit duplicate notifications at each midnight.
1866
+ var weekendAnchor = beijingNow && Number.isFinite(beijingNow.getTime())
1867
+ ? new Date(Date.UTC(beijingNow.getUTCFullYear(), beijingNow.getUTCMonth(), beijingNow.getUTCDate() - (day === 6 ? 1 : 2)))
1868
+ : null
1869
+ var weekendDateKey = weekendAnchor
1870
+ ? [weekendAnchor.getUTCFullYear(), String(weekendAnchor.getUTCMonth() + 1).padStart(2, '0'), String(weekendAnchor.getUTCDate()).padStart(2, '0')].join('-')
1846
1871
  : 'weekend'
1847
1872
  var weekendTzName = policy.timezone || 'Asia/Shanghai'
1873
+ var weekendDayLabel = day === 6 ? '周六' : '周日'
1848
1874
  return {
1849
1875
  configured: true, windows: [], weekendOffPeak: true, inPeak: false,
1850
1876
  nextHour: 9,
1851
- ariaText: '当前为周末低谷时段,全天按半价计费,周一 09:00 恢复标准价',
1852
- tip: '峰谷时钟 · 当前周末低谷 · 周一 09:00 恢复标准价 · 还有 ' + weekendLeftText + ' · ' + weekendTzName,
1877
+ ariaText: '当前为' + weekendDayLabel + '低谷时段,全天按半价计费,周一 09:00 恢复标准价',
1878
+ tip: '峰谷时钟 · 当前' + weekendDayLabel + '低谷 · ' + weekendDayLabel + '全天半价 · 周一 09:00 恢复工作日规则 · ' + weekendTzName,
1853
1879
  periodName: '周末低谷', rateBadge: '半价',
1854
- countdownSummary: '周一 09:00 切换 · 剩 ' + weekendLeftShort,
1855
- windowSummary: '周末全天低谷 · 工作日高峰 09:00–12:00 / 14:00–18:00',
1856
- periodId: 'weekend-off-' + weekendDateKey,
1857
- switchBody: '周末低谷结束,已进入工作日高峰时段,按标准价计费',
1880
+ countdownSummary: weekendDayLabel + '全天低谷',
1881
+ windowSummary: weekendDayLabel + '全天低谷 · 工作日高峰 09:00–12:00 / 14:00–18:00',
1882
+ periodId: 'weekend-chain-' + weekendDateKey,
1883
+ switchBody: '已进入周末低谷,全天按半价计费',
1858
1884
  }
1859
1885
  }
1860
1886
  if (windows.length === 0) {
@@ -1899,8 +1925,24 @@ window.__ModuleLoader__.load({
1899
1925
  var period = inPeak ? '高峰' : '低谷' + rateWord
1900
1926
  var periodName = inPeak ? '高峰时段' : '低谷时段'
1901
1927
  var rateBadge = inPeak ? '标准价' : (rate === 0.5 ? '半价' : '×' + rate)
1902
- var countdownSummary = hh(nextHour) + ' 切换 · 剩 ' + leftShort
1903
- var windowSummary = '高峰 ' + winText
1928
+ var offsetMinutesForDay = policy && Number.isFinite(policy.offsetMinutes) ? policy.offsetMinutes : 480
1929
+ var localWallDate = Number.isFinite(nowMs) ? new Date(nowMs + offsetMinutesForDay * 60000) : null
1930
+ var localDay = localWallDate ? localWallDate.getUTCDay() : -1
1931
+ var weekendRuleSince = policy && Number.isFinite(policy.weekendOffPeakSince) ? policy.weekendOffPeakSince : -Infinity
1932
+ var nextLocalMidnightMs = localWallDate
1933
+ ? Date.UTC(localWallDate.getUTCFullYear(), localWallDate.getUTCMonth(), localWallDate.getUTCDate() + 1) - offsetMinutesForDay * 60000
1934
+ : Infinity
1935
+ var currentLocalMidnightMs = localWallDate
1936
+ ? Date.UTC(localWallDate.getUTCFullYear(), localWallDate.getUTCMonth(), localWallDate.getUTCDate()) - offsetMinutesForDay * 60000
1937
+ : Infinity
1938
+ var fridayAfterLastPeak = !inPeak && localDay === 5 && nowHour >= windows[windows.length - 1].endHour && nextLocalMidnightMs >= weekendRuleSince
1939
+ var mondayBeforeFirstPeak = !inPeak && localDay === 1 && nowHour < windows[0].startHour && currentLocalMidnightMs >= weekendRuleSince
1940
+ var countdownSummary = fridayAfterLastPeak
1941
+ ? '周末全天低谷'
1942
+ : mondayBeforeFirstPeak ? ('剩 ' + leftShort + ' 进入高峰') : (hh(nextHour) + ' 切换 · 剩 ' + leftShort)
1943
+ var windowSummary = fridayAfterLastPeak
1944
+ ? '周五 18:00 起低谷 · 周六/周日全天低谷'
1945
+ : '高峰 ' + winText
1904
1946
  // Dual timezone: billing is judged in the policy's base zone; a device
1905
1947
  // elsewhere also sees the local-clock span of the CURRENT segment.
1906
1948
  var localNote = ''
@@ -1918,15 +1960,24 @@ window.__ModuleLoader__.load({
1918
1960
  var ariaText = inPeak
1919
1961
  ? '当前为高峰时段,' + tzName + ' ' + winText + ',按标准价计费'
1920
1962
  : '当前为低谷时段,按平价的 ' + rate + ' 倍计费,' + tzName + ' 高峰 ' + winText
1921
- var tip = '峰谷时钟 · 当前' + period + ' · ' + switchText + ' · 还有 ' + leftText
1922
- + ' · 高峰 ' + winText + '(' + tzName + ')' + localNote
1963
+ var tip = fridayAfterLastPeak
1964
+ ? '峰谷时钟 · 当前低谷半价 · 下次高峰:周一 09:00 · ' + tzName + localNote
1965
+ : '峰谷时钟 · 当前' + period + ' · ' + switchText + ' · 还有 ' + leftText
1966
+ + ' · 高峰 ' + winText + '(' + tzName + ')' + localNote
1967
+ var periodId = (inPeak ? 'p' : 'o') + Math.round(segStart * 100)
1968
+ if ((fridayAfterLastPeak || mondayBeforeFirstPeak) && localWallDate) {
1969
+ var chainAnchorDays = mondayBeforeFirstPeak ? 3 : 0
1970
+ var chainAnchor = new Date(Date.UTC(localWallDate.getUTCFullYear(), localWallDate.getUTCMonth(), localWallDate.getUTCDate() - chainAnchorDays))
1971
+ var chainKey = [chainAnchor.getUTCFullYear(), String(chainAnchor.getUTCMonth() + 1).padStart(2, '0'), String(chainAnchor.getUTCDate()).padStart(2, '0')].join('-')
1972
+ periodId = 'weekend-chain-' + chainKey
1973
+ }
1923
1974
  return {
1924
1975
  configured: true, windows: windows, weekendOffPeak: false, inPeak: inPeak,
1925
1976
  nextHour: nextHour, ariaText: ariaText, tip: tip,
1926
1977
  periodName: periodName, rateBadge: rateBadge,
1927
1978
  countdownSummary: countdownSummary, windowSummary: windowSummary,
1928
1979
  // Reminder dedup id: entering this period at this boundary fires once.
1929
- periodId: (inPeak ? 'p' : 'o') + Math.round(segStart * 100),
1980
+ periodId: periodId,
1930
1981
  switchBody: inPeak ? '已进入高峰时段,按标准价计费' : '已进入低谷时段,按平价的 ' + rate + ' 倍计费',
1931
1982
  }
1932
1983
  }
@@ -2353,7 +2404,7 @@ window.__ModuleLoader__.load({
2353
2404
  onPointerDown: isRail ? undefined : handlePointerDown,
2354
2405
  onClick: function (e) {
2355
2406
  if (isRail) {
2356
- window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener')
2407
+ openOfficialRecharge()
2357
2408
  return
2358
2409
  }
2359
2410
  if (didDragRef.current !== true) {
@@ -2409,7 +2460,7 @@ window.__ModuleLoader__.load({
2409
2460
  'aria-label': '前往官方充值',
2410
2461
  onClick: function (e) {
2411
2462
  if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
2412
- window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener')
2463
+ openOfficialRecharge()
2413
2464
  }
2414
2465
  }, '充值') : null
2415
2466
  )
@@ -2451,7 +2502,7 @@ window.__ModuleLoader__.load({
2451
2502
  type: 'button',
2452
2503
  className: 'dshw_btn dshw_btnPrimary',
2453
2504
  style: { height: '22px', fontSize: '11px', padding: '0 8px' },
2454
- onClick: function () { window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener') }
2505
+ onClick: function () { openOfficialRecharge() }
2455
2506
  }, '↗ 充值'))),
2456
2507
  React.createElement('div', { className: 'dshw_divider' }),
2457
2508
  React.createElement('div', { className: 'dshw_peakRow' },
@@ -3283,7 +3334,9 @@ window.__ModuleLoader__.load({
3283
3334
  }).then(function (json) {
3284
3335
  if (json && json.ok) {
3285
3336
  setAccounts(json)
3286
- setAccountsError(null)
3337
+ setAccountsError(json.storage && json.storage.locked
3338
+ ? '账户加密文件无法解密,已锁定写入以保护原数据'
3339
+ : null)
3287
3340
  }
3288
3341
  }).catch(function () {
3289
3342
  setAccountsError('\u8d26\u6237\u5217\u8868\u52a0\u8f7d\u5931\u8d25')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deepseek-harness-wallet",
3
- "description": "Local-first account monitoring, usage accounting, official pricing sync, health checks, encrypted multi-account management, recharge, completion reminders, flexible layout, and host-gated session controls for DeepSeek Harness.",
4
- "version": "0.3.0",
3
+ "description": "Local-first DeepSeek Harness control center for balance monitoring, usage accounting, official pricing sync, peak/off-peak status, encrypted multi-account switching, health checks, completion reminders, recharge, and host-gated session controls.",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {