dsh-wisp 1.36.0 → 1.42.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/lib/index.js CHANGED
@@ -10,9 +10,15 @@
10
10
  // for a sandboxed half to reach a real capability, so the update check goes
11
11
  // through it — nothing is bypassed.
12
12
  //
13
- // 2. Nothing else. `web` is declared as an OPTIONAL inject on purpose: a
14
- // required inject that nothing provides would leave this fiber `waiting`
15
- // forever, and the companion would simply disappear from the UI.
13
+ // 2. Answer `host.call('checkBalance')` — the account balance, through the
14
+ // `deepseekAccount` service. NOT through our own HTTP call with an API key:
15
+ // that service is documented as "only Host consumers can obtain a request
16
+ // credential", which is exactly the right shape — the platform owns the
17
+ // login, the plugin never sees a token, and there is nothing to leak into
18
+ // the page (the browser half could not read a key even if we had one).
19
+ // `web` and `deepseekAccount` are both declared as OPTIONAL injects on
20
+ // purpose: a required inject that nothing provides would leave this fiber
21
+ // `waiting` forever, and the companion would simply disappear from the UI.
16
22
  //
17
23
  // WHY TWO SOURCES. npm's registry is the authoritative one, but a publish can sit
18
24
  // in npm's staging queue for a long time (observed: `PUT 202`, still 404 after 18
@@ -25,11 +31,15 @@
25
31
  /** Cordis plugin name used by loader diagnostics. */
26
32
  export const name = 'wisp'
27
33
 
34
+ /** Stamped into every balance request. Must match package.json — the preflight guards it. */
35
+ export const VERSION = '1.42.0'
36
+
28
37
  /**
29
- * `web` is optional on purpose. A required inject that nothing provides keeps
30
- * the fiber in `waiting` forever, and the user's companion would vanish.
38
+ * `web` and `deepseekAccount` are optional on purpose. A required inject that
39
+ * nothing provides keeps the fiber in `waiting` forever, and the user's
40
+ * companion would vanish.
31
41
  */
32
- export const inject = { required: [], optional: ['web'] }
42
+ export const inject = { required: [], optional: ['web', 'deepseekAccount'] }
33
43
 
34
44
  /** The two places a published version can be read from. Overridable per install. */
35
45
  export const DEFAULT_SOURCES = {
@@ -37,9 +47,35 @@ export const DEFAULT_SOURCES = {
37
47
  github: 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json',
38
48
  }
39
49
 
40
- /** How long the whole check may take before it is reported as a timeout. */
50
+ /** How long ONE source may take before it is reported as a timeout. */
41
51
  const CHECK_TIMEOUT_MS = 8000
42
52
 
53
+ /**
54
+ * Resolve the `web` service LATE, on every call.
55
+ *
56
+ * `inject.optional: ['web']` hands us `ctx.web`, but only when the service was
57
+ * already provided at the moment this fiber materialized — and this half is
58
+ * inserted by a bundle patch, so it can start BEFORE the web service registers.
59
+ * An optional dependency that was absent at start does not retroactively appear,
60
+ * which would disable the update check forever. `ctx.get('web')` looks the
61
+ * service up at call time instead, so a startup race cannot wedge it.
62
+ *
63
+ * @returns the service, or undefined when this shell really has none.
64
+ */
65
+ export function resolveWebService(ctx) {
66
+ try {
67
+ if (ctx && typeof ctx.get === 'function') {
68
+ const late = ctx.get('web')
69
+ if (late && typeof late.fetch === 'function') return late
70
+ }
71
+ } catch (error) { /* restricted context: fall through to the declared property */ }
72
+ try {
73
+ const declared = ctx && ctx.web
74
+ if (declared && typeof declared.fetch === 'function') return declared
75
+ } catch (error) { /* no such property */ }
76
+ return undefined
77
+ }
78
+
43
79
  /**
44
80
  * Fetch one manifest and pull `version` out of it.
45
81
  * @param web - the `web` service, or undefined when it is not loaded.
@@ -71,8 +107,7 @@ export async function readVersion(web, url, signal) {
71
107
  }
72
108
  }
73
109
 
74
- /** Compare two dotted versions. Returns -1 / 0 / 1; non-numeric parts count as 0. */
75
- export function compareVersions(a, b) {
110
+ /** Compare two dotted versions. Returns -1 / 0 / 1; non-numeric parts count as 0. */export function compareVersions(a, b) {
76
111
  const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
77
112
  const x = Number.parseInt(n, 10)
78
113
  return Number.isFinite(x) ? x : 0
@@ -91,12 +126,25 @@ export function compareVersions(a, b) {
91
126
  * Read every source and report the highest version found.
92
127
  * @param web - the `web` service.
93
128
  * @param urls - `{ npm, github }` (or any other set of named sources).
94
- * @param signal - optional cancellation signal.
129
+ * @param signal - optional cancellation signal shared by every source.
130
+ * @param timeoutMs - when given, EACH source gets its own deadline instead.
95
131
  * @returns a plain, JSON-serializable verdict (it crosses the sandbox boundary).
96
132
  */
97
- export async function readPublishedVersion(web, urls, signal) {
133
+ export async function readPublishedVersion(web, urls, signal, timeoutMs) {
98
134
  const names = Object.keys(urls ?? {})
99
- const results = await Promise.all(names.map((key) => readVersion(web, urls[key], signal)))
135
+ const perSource = typeof timeoutMs === 'number' && timeoutMs > 0
136
+ && typeof AbortController === 'function' && typeof setTimeout === 'function'
137
+ const results = await Promise.all(names.map((key) => {
138
+ /* 每个源一个**自己的**截止时间。共用一个信号时,一个卡住的镜像会在到点时把
139
+ 另一个已经拿到结果的源一起作废 —— 那次检查就整个失败,而其实 npm 是通的。
140
+ (实测:raw.githubusercontent.com 在这台机器上根本连不上。) */
141
+ if (!perSource) return readVersion(web, urls[key], signal)
142
+ const controller = new AbortController()
143
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
144
+ return readVersion(web, urls[key], controller.signal)
145
+ .then((result) => { clearTimeout(timer); return result })
146
+ .catch((error) => { clearTimeout(timer); throw error })
147
+ }))
100
148
  const sources = {}
101
149
  let best = null
102
150
  let from = null
@@ -119,7 +167,132 @@ export async function readPublishedVersion(web, urls, signal) {
119
167
  return { ok: true, latest: best, from, sources }
120
168
  }
121
169
 
122
- /** Register the handler the browser half reaches through `host.call`. */
170
+ /* ---------------------------------------------------------------------------
171
+ ACCOUNT BALANCE.
172
+
173
+ The shape of the platform service, read from the live Host Service registry
174
+ rather than guessed:
175
+
176
+ ctx.deepseekAccount.getBalance(client: AccountClientMetadata)
177
+ -> { status: 'ready', value: AccountWallet[], bonusWallets: AccountWallet[] }
178
+ | { status: 'failed' }
179
+ | null // signed out, or the grant changed mid-query
180
+ ctx.deepseekAccount.getState()
181
+ -> { status: 'signed-out' | 'credential-stored', links, attempt }
182
+
183
+ AccountClientMetadata = { version, locale, timezoneOffsetSeconds }
184
+ AccountWallet = { currency: 'CNY' | 'USD', balance: string }
185
+
186
+ `null` deliberately means TWO different things, and telling the user the wrong
187
+ one is worse than saying nothing — so when it happens we ask getState() once
188
+ more to find out which. Signed out => "log in first"; still credential-stored
189
+ => the grant moved under us => "didn't read it, try again".
190
+ --------------------------------------------------------------------------- */
191
+
192
+ /** How long a balance query may take before it is reported as a timeout. */
193
+ export const BALANCE_TIMEOUT_MS = 8000
194
+
195
+ /** Keep only well-formed wallets, and never let an unbounded list cross the sandbox. */
196
+ export function normalizeWallets(list) {
197
+ if (!Array.isArray(list)) return []
198
+ const out = []
199
+ for (const wallet of list) {
200
+ if (!wallet || typeof wallet !== 'object') continue
201
+ if (typeof wallet.balance !== 'string') continue
202
+ out.push({ currency: typeof wallet.currency === 'string' ? wallet.currency : '', balance: wallet.balance })
203
+ if (out.length >= 8) break
204
+ }
205
+ return out
206
+ }
207
+
208
+ /**
209
+ * Settle with the platform call, or reject with `timeout` — whichever is first.
210
+ * The service takes no AbortSignal, so a race is the only way to bound it: an
211
+ * unbounded await here would mean she simply never says anything.
212
+ */
213
+ function withTimeout(promise, ms) {
214
+ if (!(ms > 0) || typeof setTimeout !== 'function') return Promise.resolve(promise)
215
+ return new Promise((resolve, reject) => {
216
+ let settled = false
217
+ const timer = setTimeout(() => {
218
+ if (settled) return
219
+ settled = true
220
+ reject(new Error('timeout'))
221
+ }, ms)
222
+ const finish = (fn, value) => {
223
+ if (settled) return
224
+ settled = true
225
+ if (typeof clearTimeout === 'function') clearTimeout(timer)
226
+ fn(value)
227
+ }
228
+ Promise.resolve(promise).then(
229
+ (value) => finish(resolve, value),
230
+ (error) => finish(reject, error),
231
+ )
232
+ })
233
+ }
234
+
235
+ /**
236
+ * Ask the platform for the account balance.
237
+ * @param account - the `deepseekAccount` service, or undefined when it is not loaded.
238
+ * @param client - `AccountClientMetadata` for the requesting UI.
239
+ * @param timeoutMs - override for tests; defaults to BALANCE_TIMEOUT_MS.
240
+ * @returns a plain, JSON-serializable verdict (it crosses the sandbox boundary) — never throws.
241
+ */
242
+ export async function readBalance(account, client, timeoutMs) {
243
+ const limit = typeof timeoutMs === 'number' ? timeoutMs : BALANCE_TIMEOUT_MS
244
+ if (!account || typeof account.getBalance !== 'function') {
245
+ return { ok: false, reason: 'no-account-service' }
246
+ }
247
+ let result = null
248
+ try {
249
+ result = await withTimeout(account.getBalance(client), limit)
250
+ } catch (error) {
251
+ const detail = error && error.message ? String(error.message) : String(error)
252
+ return { ok: false, reason: detail === 'timeout' ? 'timeout' : 'call-failed', detail }
253
+ }
254
+ if (result === null || result === undefined) {
255
+ /* null 同时意味着"没登录"和"授权在查询途中换了"。分不清时**不许猜**:
256
+ 只有 getState() 明确说 signed-out 才说"去登录",否则一律说"刚才没读到" ——
257
+ 叫人去登录一个已经登录的账户,比说"没读到"糟得多。 */
258
+ let state = null
259
+ try {
260
+ if (typeof account.getState === 'function') {
261
+ state = await withTimeout(account.getState(), limit)
262
+ }
263
+ } catch (error) { state = null }
264
+ const signedIn = state && typeof state.status === 'string' ? state.status === 'credential-stored' : null
265
+ return {
266
+ ok: true,
267
+ status: signedIn === false ? 'signed-out' : 'unavailable',
268
+ signedIn,
269
+ wallets: [],
270
+ bonusWallets: [],
271
+ }
272
+ }
273
+ if (typeof result !== 'object' || result.status !== 'ready') {
274
+ /* The platform answered, and the answer was "failed" — a result, not a throw. */
275
+ return { ok: true, status: 'failed', wallets: [], bonusWallets: [] }
276
+ }
277
+ return {
278
+ ok: true,
279
+ status: 'ready',
280
+ wallets: normalizeWallets(result.value),
281
+ bonusWallets: normalizeWallets(result.bonusWallets),
282
+ }
283
+ }
284
+
285
+ /** Build the metadata every balance query carries. */
286
+ export function accountClientMetadata(locale) {
287
+ return {
288
+ version: VERSION,
289
+ locale: typeof locale === 'string' && locale !== '' ? locale : 'zh-CN',
290
+ /* The service wants seconds EAST of UTC; getTimezoneOffset() is minutes WEST. */
291
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
292
+ }
293
+ }
294
+
295
+ /** Register the handlers the browser half reaches through `host.call`. */
123
296
  export function registerHandlers(ctx, options = {}) {
124
297
  const urls = options.urls && typeof options.urls === 'object' ? options.urls : DEFAULT_SOURCES
125
298
  /* `harness` is a sandbox global: the host half is evaluated inside a vm whose
@@ -131,30 +304,39 @@ export function registerHandlers(ctx, options = {}) {
131
304
  }
132
305
  const dispose = seat.handle('checkUpdate', async (args) => {
133
306
  const current = args && typeof args.current === 'string' ? args.current : null
134
- const canAbort = typeof AbortController === 'function'
135
- const controller = canAbort ? new AbortController() : null
136
- let timer = null
137
- if (controller && typeof setTimeout === 'function') {
138
- timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS)
139
- }
140
- try {
141
- const verdict = await readPublishedVersion(ctx && ctx.web, urls, controller ? controller.signal : undefined)
142
- return { ...verdict, current, checkedAt: new Date().toISOString() }
143
- } finally {
144
- if (timer !== null && typeof clearTimeout === 'function') clearTimeout(timer)
145
- }
307
+ /* 服务**在调用时**解析,不用闭包里那个可能早就定格的 ctx.web(见 resolveWebService)。 */
308
+ const web = resolveWebService(ctx)
309
+ const verdict = await readPublishedVersion(web, urls, undefined, CHECK_TIMEOUT_MS)
310
+ return { ...verdict, current, checkedAt: new Date().toISOString() }
146
311
  })
147
- return { registered: true, dispose, urls }
312
+ /* The balance handler. `ctx.deepseekAccount` is undefined unless the platform
313
+ provides it — readBalance() turns that into `no-account-service` rather than
314
+ a throw, so the browser half can say "not in this shell" honestly. */
315
+ const disposeBalance = seat.handle('checkBalance', async (args) => {
316
+ const verdict = await readBalance(
317
+ ctx && ctx.deepseekAccount,
318
+ accountClientMetadata(args && args.locale),
319
+ )
320
+ return { ...verdict, checkedAt: new Date().toISOString() }
321
+ })
322
+ return {
323
+ registered: true,
324
+ dispose: () => {
325
+ if (typeof dispose === 'function') dispose()
326
+ if (typeof disposeBalance === 'function') disposeBalance()
327
+ },
328
+ urls,
329
+ }
148
330
  }
149
331
 
150
332
  export function apply(ctx, config) {
151
- /* The body stays tiny: register the handler, and keep its disposer on the
152
- fiber so unloading the package takes the handler with it. */
333
+ /* The body stays tiny: register the handlers, and keep their disposer on the
334
+ fiber so unloading the package takes both of them with it. */
153
335
  const result = registerHandlers(ctx, config || {})
154
336
  if (result.registered && typeof ctx.effect === 'function') {
155
337
  ctx.effect(() => () => {
156
338
  if (typeof result.dispose === 'function') result.dispose()
157
- }, 'dsh-wisp: checkUpdate handler')
339
+ }, 'dsh-wisp: host handlers')
158
340
  }
159
341
  }
160
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.36.0",
3
+ "version": "1.42.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,6 +31,7 @@
31
31
  "build.mjs",
32
32
  "verify-wisp.mjs",
33
33
  "tools/smoke.mjs",
34
+ "tools/engine-probe.mjs",
34
35
  "tools/keyout.mjs",
35
36
  "tools/assets.mjs",
36
37
  "tools/tiers.mjs",