dsh-wisp 1.37.0 → 1.44.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.44.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,105 @@ 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
+ const UPDATE_PACKAGE_NAME = 'dsh-wisp'
55
+
56
+ /** 一眼看懂一个"服务"到底是什么(诊断用;永远不抛)。 */
57
+ function describeService(value) {
58
+ if (value === undefined) return 'undefined'
59
+ if (value === null) return 'null'
60
+ const type = typeof value
61
+ if (type !== 'object' && type !== 'function') return type
62
+ try {
63
+ const keys = Object.keys(value).slice(0, 6).join(',')
64
+ return type + '{' + keys + '}' + (typeof value.fetch === 'function' ? '+fetch' : '-fetch')
65
+ } catch (error) { return type + '(unreadable)' }
66
+ }
67
+
68
+ /**
69
+ * Resolve the `web` service LATE, on every call — and say HOW, and what was found.
70
+ *
71
+ * `inject.optional: ['web']` hands us `ctx.web`, but only when the service was
72
+ * already provided at the moment this fiber materialized — and this half is
73
+ * inserted by a bundle patch, so it can start BEFORE the web service registers.
74
+ * An optional dependency that was absent at start does not retroactively appear,
75
+ * which would disable the update check forever. `ctx.get('web')` looks the
76
+ * service up at call time instead, so a startup race cannot wedge it.
77
+ *
78
+ * 真实运行里两种路都拿不到时,**必须能说清是哪一种**:`ctx.get` 有没有?
79
+ * 拿到的东西是什么形状?—— 没有这一层,"没有联网的通道"是一句无法追问的话。
80
+ *
81
+ * @returns `{ service, how, why }` — never throws.
82
+ * how: 'ctx.get' | 'ctx.web' | 'none' 拿到了没有、从哪拿的
83
+ * why: 一句话说明"看到了什么"(进了 doctor 与她的失败台词)
84
+ */
85
+ export function resolveWebService(ctx) {
86
+ let late
87
+ let getThrew = null
88
+ try {
89
+ if (ctx && typeof ctx.get === 'function') late = ctx.get('web')
90
+ else getThrew = 'ctx.get 不是函数(' + describeService(ctx && ctx.get) + ')'
91
+ } catch (error) { getThrew = 'ctx.get 抛错:' + String(error && error.message ? error.message : error).slice(0, 60) }
92
+ if (late && typeof late.fetch === 'function') {
93
+ return { service: late, how: 'ctx.get', why: 'ctx.get("web")→' + describeService(late) }
94
+ }
95
+ let declared
96
+ let webThrew = null
97
+ try { declared = ctx && ctx.web } catch (error) { webThrew = String(error && error.message ? error.message : error).slice(0, 60) }
98
+ if (declared && typeof declared.fetch === 'function') {
99
+ return { service: declared, how: 'ctx.web', why: 'ctx.web→' + describeService(declared) }
100
+ }
101
+ const parts = []
102
+ parts.push('ctx.get("web")→' + describeService(late))
103
+ if (getThrew) parts.push(getThrew)
104
+ parts.push('ctx.web→' + describeService(declared))
105
+ if (webThrew) parts.push('ctx.web 读取抛错:' + webThrew)
106
+ /* 拿到了对象但没有 fetch 也要如实说 —— "有服务但没这个方法"和"根本没有服务"是两件事 */
107
+ const service = (late && typeof late === 'object') ? late : ((declared && typeof declared === 'object') ? declared : undefined)
108
+ if (service) {
109
+ return { service, how: 'partial', why: parts.join('; ') + '(无 fetch)' }
110
+ }
111
+ return { service: undefined, how: 'none', why: parts.join('; ') }
112
+ }
113
+
114
+ /**
115
+ * 兜底通道:向插件管理器问"这个包现在是什么版本"。
116
+ *
117
+ * 它本来就是**去 registry 问这个包**的那个人(安装前先 inspect),所以这条路
118
+ * 走的是应用自己那条网络出口、且尊重用户配的镜像源 —— 比我们自己去抓 packument
119
+ * 更接近"安装时会装到哪一版"。`web` 服务拿不到时,这是唯一还活着的更新通道。
120
+ *
121
+ * @returns `{ ok, version, registry }` 或 `{ ok: false, reason, detail }` — never throws.
122
+ */
123
+ export async function readManagedVersion(ctx, name, timeoutMs) {
124
+ let manager
125
+ try {
126
+ if (!ctx || typeof ctx.get !== 'function') return { ok: false, reason: 'no-ctx-get' }
127
+ manager = ctx.get('pluginManager')
128
+ } catch (error) {
129
+ return { ok: false, reason: 'manager-unreachable', detail: String(error && error.message ? error.message : error).slice(0, 80) }
130
+ }
131
+ if (!manager || typeof manager.inspect !== 'function') {
132
+ return { ok: false, reason: 'no-plugin-manager', detail: describeService(manager) }
133
+ }
134
+ try {
135
+ const info = await withTimeout(manager.inspect(name), timeoutMs)
136
+ if (info && info.status === 'accepted' && typeof info.version === 'string') {
137
+ return { ok: true, version: info.version, registry: info.registry ?? null }
138
+ }
139
+ return {
140
+ ok: false,
141
+ reason: 'inspect-refused',
142
+ detail: info && info.reason ? String(info.reason).slice(0, 100) : describeService(info),
143
+ }
144
+ } catch (error) {
145
+ return { ok: false, reason: 'inspect-failed', detail: String(error && error.message ? error.message : error).slice(0, 100) }
146
+ }
147
+ }
148
+
43
149
  /**
44
150
  * Fetch one manifest and pull `version` out of it.
45
151
  * @param web - the `web` service, or undefined when it is not loaded.
@@ -48,7 +154,10 @@ const CHECK_TIMEOUT_MS = 8000
48
154
  * @returns `{ ok, version }` or `{ ok: false, reason }` — never throws.
49
155
  */
50
156
  export async function readVersion(web, url, signal) {
51
- if (!web || typeof web.fetch !== 'function') return { ok: false, reason: 'no-web-service' }
157
+ if (!web) return { ok: false, reason: 'no-web-service' }
158
+ /* "有服务但没有 fetch"和"根本没有服务"是两件事 —— 台词的下一步动作不同
159
+ (一个是应用/平台的问题,一个是插件注入的问题),所以别合并成一句。 */
160
+ if (typeof web.fetch !== 'function') return { ok: false, reason: 'web-has-no-fetch', detail: describeService(web) }
52
161
  try {
53
162
  const res = await web.fetch({ url }, signal)
54
163
  if (!res || typeof res.statusCode !== 'number') return { ok: false, reason: 'bad-result' }
@@ -71,8 +180,7 @@ export async function readVersion(web, url, signal) {
71
180
  }
72
181
  }
73
182
 
74
- /** Compare two dotted versions. Returns -1 / 0 / 1; non-numeric parts count as 0. */
75
- export function compareVersions(a, b) {
183
+ /** Compare two dotted versions. Returns -1 / 0 / 1; non-numeric parts count as 0. */export function compareVersions(a, b) {
76
184
  const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
77
185
  const x = Number.parseInt(n, 10)
78
186
  return Number.isFinite(x) ? x : 0
@@ -91,12 +199,25 @@ export function compareVersions(a, b) {
91
199
  * Read every source and report the highest version found.
92
200
  * @param web - the `web` service.
93
201
  * @param urls - `{ npm, github }` (or any other set of named sources).
94
- * @param signal - optional cancellation signal.
202
+ * @param signal - optional cancellation signal shared by every source.
203
+ * @param timeoutMs - when given, EACH source gets its own deadline instead.
95
204
  * @returns a plain, JSON-serializable verdict (it crosses the sandbox boundary).
96
205
  */
97
- export async function readPublishedVersion(web, urls, signal) {
206
+ export async function readPublishedVersion(web, urls, signal, timeoutMs) {
98
207
  const names = Object.keys(urls ?? {})
99
- const results = await Promise.all(names.map((key) => readVersion(web, urls[key], signal)))
208
+ const perSource = typeof timeoutMs === 'number' && timeoutMs > 0
209
+ && typeof AbortController === 'function' && typeof setTimeout === 'function'
210
+ const results = await Promise.all(names.map((key) => {
211
+ /* 每个源一个**自己的**截止时间。共用一个信号时,一个卡住的镜像会在到点时把
212
+ 另一个已经拿到结果的源一起作废 —— 那次检查就整个失败,而其实 npm 是通的。
213
+ (实测:raw.githubusercontent.com 在这台机器上根本连不上。) */
214
+ if (!perSource) return readVersion(web, urls[key], signal)
215
+ const controller = new AbortController()
216
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
217
+ return readVersion(web, urls[key], controller.signal)
218
+ .then((result) => { clearTimeout(timer); return result })
219
+ .catch((error) => { clearTimeout(timer); throw error })
220
+ }))
100
221
  const sources = {}
101
222
  let best = null
102
223
  let from = null
@@ -119,7 +240,132 @@ export async function readPublishedVersion(web, urls, signal) {
119
240
  return { ok: true, latest: best, from, sources }
120
241
  }
121
242
 
122
- /** Register the handler the browser half reaches through `host.call`. */
243
+ /* ---------------------------------------------------------------------------
244
+ ACCOUNT BALANCE.
245
+
246
+ The shape of the platform service, read from the live Host Service registry
247
+ rather than guessed:
248
+
249
+ ctx.deepseekAccount.getBalance(client: AccountClientMetadata)
250
+ -> { status: 'ready', value: AccountWallet[], bonusWallets: AccountWallet[] }
251
+ | { status: 'failed' }
252
+ | null // signed out, or the grant changed mid-query
253
+ ctx.deepseekAccount.getState()
254
+ -> { status: 'signed-out' | 'credential-stored', links, attempt }
255
+
256
+ AccountClientMetadata = { version, locale, timezoneOffsetSeconds }
257
+ AccountWallet = { currency: 'CNY' | 'USD', balance: string }
258
+
259
+ `null` deliberately means TWO different things, and telling the user the wrong
260
+ one is worse than saying nothing — so when it happens we ask getState() once
261
+ more to find out which. Signed out => "log in first"; still credential-stored
262
+ => the grant moved under us => "didn't read it, try again".
263
+ --------------------------------------------------------------------------- */
264
+
265
+ /** How long a balance query may take before it is reported as a timeout. */
266
+ export const BALANCE_TIMEOUT_MS = 8000
267
+
268
+ /** Keep only well-formed wallets, and never let an unbounded list cross the sandbox. */
269
+ export function normalizeWallets(list) {
270
+ if (!Array.isArray(list)) return []
271
+ const out = []
272
+ for (const wallet of list) {
273
+ if (!wallet || typeof wallet !== 'object') continue
274
+ if (typeof wallet.balance !== 'string') continue
275
+ out.push({ currency: typeof wallet.currency === 'string' ? wallet.currency : '', balance: wallet.balance })
276
+ if (out.length >= 8) break
277
+ }
278
+ return out
279
+ }
280
+
281
+ /**
282
+ * Settle with the platform call, or reject with `timeout` — whichever is first.
283
+ * The service takes no AbortSignal, so a race is the only way to bound it: an
284
+ * unbounded await here would mean she simply never says anything.
285
+ */
286
+ function withTimeout(promise, ms) {
287
+ if (!(ms > 0) || typeof setTimeout !== 'function') return Promise.resolve(promise)
288
+ return new Promise((resolve, reject) => {
289
+ let settled = false
290
+ const timer = setTimeout(() => {
291
+ if (settled) return
292
+ settled = true
293
+ reject(new Error('timeout'))
294
+ }, ms)
295
+ const finish = (fn, value) => {
296
+ if (settled) return
297
+ settled = true
298
+ if (typeof clearTimeout === 'function') clearTimeout(timer)
299
+ fn(value)
300
+ }
301
+ Promise.resolve(promise).then(
302
+ (value) => finish(resolve, value),
303
+ (error) => finish(reject, error),
304
+ )
305
+ })
306
+ }
307
+
308
+ /**
309
+ * Ask the platform for the account balance.
310
+ * @param account - the `deepseekAccount` service, or undefined when it is not loaded.
311
+ * @param client - `AccountClientMetadata` for the requesting UI.
312
+ * @param timeoutMs - override for tests; defaults to BALANCE_TIMEOUT_MS.
313
+ * @returns a plain, JSON-serializable verdict (it crosses the sandbox boundary) — never throws.
314
+ */
315
+ export async function readBalance(account, client, timeoutMs) {
316
+ const limit = typeof timeoutMs === 'number' ? timeoutMs : BALANCE_TIMEOUT_MS
317
+ if (!account || typeof account.getBalance !== 'function') {
318
+ return { ok: false, reason: 'no-account-service' }
319
+ }
320
+ let result = null
321
+ try {
322
+ result = await withTimeout(account.getBalance(client), limit)
323
+ } catch (error) {
324
+ const detail = error && error.message ? String(error.message) : String(error)
325
+ return { ok: false, reason: detail === 'timeout' ? 'timeout' : 'call-failed', detail }
326
+ }
327
+ if (result === null || result === undefined) {
328
+ /* null 同时意味着"没登录"和"授权在查询途中换了"。分不清时**不许猜**:
329
+ 只有 getState() 明确说 signed-out 才说"去登录",否则一律说"刚才没读到" ——
330
+ 叫人去登录一个已经登录的账户,比说"没读到"糟得多。 */
331
+ let state = null
332
+ try {
333
+ if (typeof account.getState === 'function') {
334
+ state = await withTimeout(account.getState(), limit)
335
+ }
336
+ } catch (error) { state = null }
337
+ const signedIn = state && typeof state.status === 'string' ? state.status === 'credential-stored' : null
338
+ return {
339
+ ok: true,
340
+ status: signedIn === false ? 'signed-out' : 'unavailable',
341
+ signedIn,
342
+ wallets: [],
343
+ bonusWallets: [],
344
+ }
345
+ }
346
+ if (typeof result !== 'object' || result.status !== 'ready') {
347
+ /* The platform answered, and the answer was "failed" — a result, not a throw. */
348
+ return { ok: true, status: 'failed', wallets: [], bonusWallets: [] }
349
+ }
350
+ return {
351
+ ok: true,
352
+ status: 'ready',
353
+ wallets: normalizeWallets(result.value),
354
+ bonusWallets: normalizeWallets(result.bonusWallets),
355
+ }
356
+ }
357
+
358
+ /** Build the metadata every balance query carries. */
359
+ export function accountClientMetadata(locale) {
360
+ return {
361
+ version: VERSION,
362
+ locale: typeof locale === 'string' && locale !== '' ? locale : 'zh-CN',
363
+ /* The service wants seconds EAST of UTC; getTimezoneOffset() is minutes WEST. */
364
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
365
+ }
366
+ }
367
+
368
+ /** Register the handlers the browser half reaches through `host.call`. */
123
369
  export function registerHandlers(ctx, options = {}) {
124
370
  const urls = options.urls && typeof options.urls === 'object' ? options.urls : DEFAULT_SOURCES
125
371
  /* `harness` is a sandbox global: the host half is evaluated inside a vm whose
@@ -131,30 +377,68 @@ export function registerHandlers(ctx, options = {}) {
131
377
  }
132
378
  const dispose = seat.handle('checkUpdate', async (args) => {
133
379
  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)
380
+ /* 服务**在调用时**解析,不用闭包里那个可能早就定格的 ctx.web(见 resolveWebService)。 */
381
+ const resolved = resolveWebService(ctx)
382
+ if (resolved.service) {
383
+ const verdict = await readPublishedVersion(resolved.service, urls, undefined, CHECK_TIMEOUT_MS)
384
+ return { ...verdict, current, diag: { via: 'web', how: resolved.how, why: resolved.why }, checkedAt: new Date().toISOString() }
139
385
  }
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)
386
+ /* 没有可用的 web 服务时的兜底:问插件管理器"这个包现在是什么版本"。
387
+ 它走的是应用自己那条网络出口(安装前就要去 registry 问),所以这条路
388
+ 是"安装时会装到哪一版"的权威答案 —— 而且不依赖插件能否拿到 web。 */
389
+ const managed = await readManagedVersion(ctx, UPDATE_PACKAGE_NAME, CHECK_TIMEOUT_MS)
390
+ const sources = { npm: managed.ok
391
+ ? { ok: true, version: managed.version }
392
+ : { ok: false, reason: managed.reason, detail: managed.detail } }
393
+ if (managed.ok) {
394
+ return {
395
+ ok: true,
396
+ latest: managed.version,
397
+ from: 'npm',
398
+ sources,
399
+ current,
400
+ diag: { via: 'pluginManager', how: resolved.how, why: resolved.why, registry: managed.registry ?? null },
401
+ checkedAt: new Date().toISOString(),
402
+ }
145
403
  }
404
+ sources.github = { ok: false, reason: 'no-web-service', detail: resolved.why }
405
+ return {
406
+ ok: false,
407
+ reason: 'no-web-service',
408
+ sources,
409
+ current,
410
+ diag: { via: 'none', how: resolved.how, why: resolved.why, manager: managed.reason + (managed.detail ? ':' + managed.detail : '') },
411
+ checkedAt: new Date().toISOString(),
412
+ }
413
+ })
414
+ /* The balance handler. `ctx.deepseekAccount` is undefined unless the platform
415
+ provides it — readBalance() turns that into `no-account-service` rather than
416
+ a throw, so the browser half can say "not in this shell" honestly. */
417
+ const disposeBalance = seat.handle('checkBalance', async (args) => {
418
+ const verdict = await readBalance(
419
+ ctx && ctx.deepseekAccount,
420
+ accountClientMetadata(args && args.locale),
421
+ )
422
+ return { ...verdict, checkedAt: new Date().toISOString() }
146
423
  })
147
- return { registered: true, dispose, urls }
424
+ return {
425
+ registered: true,
426
+ dispose: () => {
427
+ if (typeof dispose === 'function') dispose()
428
+ if (typeof disposeBalance === 'function') disposeBalance()
429
+ },
430
+ urls,
431
+ }
148
432
  }
149
433
 
150
434
  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. */
435
+ /* The body stays tiny: register the handlers, and keep their disposer on the
436
+ fiber so unloading the package takes both of them with it. */
153
437
  const result = registerHandlers(ctx, config || {})
154
438
  if (result.registered && typeof ctx.effect === 'function') {
155
439
  ctx.effect(() => () => {
156
440
  if (typeof result.dispose === 'function') result.dispose()
157
- }, 'dsh-wisp: checkUpdate handler')
441
+ }, 'dsh-wisp: host handlers')
158
442
  }
159
443
  }
160
444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.37.0",
3
+ "version": "1.44.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",