dsh-mobile-plugin 0.1.74 → 0.1.75

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.md CHANGED
@@ -60,7 +60,15 @@ cd <DSH_HOME>/profiles/<profile>
60
60
  pnpm add dsh-mobile-plugin@<版本>
61
61
  ```
62
62
 
63
- 宿主会优先挂载 profile 中安装的插件;未安装时才使用源码树版本。设备凭据库随 npm 包一同提供,不需要单独安装。不要使用 `npm install --legacy-peer-deps`;仓库锁文件应使用 `pnpm install --frozen-lockfile`。
63
+ 宿主会优先挂载 profile 中安装的插件;未安装时才使用源码树版本。设备凭据库随 npm 包一同提供,不需要单独安装。仓库锁文件应使用 `pnpm install --frozen-lockfile`。
64
+
65
+ > 不要使用 `npm install --legacy-peer-deps` 安装插件;正式安装请使用 DSH 插件市场。
66
+
67
+ ## 本机调试 OTA
68
+
69
+ 新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
70
+
71
+ 发布 OTA 完整包时,在插件仓库根目录执行 `npm pack`,再按中转服务器的部署说明上传到 `dsh-hub.wnluo.com`。
64
72
 
65
73
  ## 开发
66
74
 
package/lib/index.js CHANGED
@@ -15,16 +15,11 @@
15
15
  * 这一层同时是**故障边界**:代装载失败时外壳回滚到上一代并把错误暴露出去,而不是让整个
16
16
  * 插件从宿主里消失(那会让手机连不上,且日志里只剩一行 pending)。
17
17
  *
18
- * ## 更新从哪儿来(2026-10 改,2026-10-11 再分一刀)
18
+ * ## 更新来源
19
19
  *
20
- * **查与装都归实现自己**:查直接问 npm 注册表、装在 profile 里跑它自己的包管理器
21
- * (`lib/update-registry.cjs` / `lib/update-install.cjs`),市场不再是这条路上的任何一环。
22
- * 装完会把 `<profile>/node_modules/dsh-mobile-plugin` 指向一个新的 store 目录,外壳照着新
23
- * realpath 重新 import 一代即可,**不需要重启 DSH**。自动更新(默认关)按 `AUTO_INTERVAL_MS`
24
- * 轮询,见 `shell/updater.js`。
25
- *
26
- * 这里那个 `updateState` 对象记的是「最近一次查/装的结果」。名字里别再叫 market:里面那份
27
- * 市场信息(`marketResult`)只是外壳从市场那条老路继承下来的兜底,装的时候用不到它。
20
+ * 正式 npm 更新由 `shell/updater.js` 处理(自动安装默认关闭);紧急完整包由
21
+ * `shell/device-ota.js` 经本机控制面激活,正式版成功接管前保留 OTA 回退包。
22
+ * `updateState` 记录 npm 查装结果;`marketResult` 是旧外壳保留的兜底状态。
28
23
  */
29
24
 
30
25
  import path from 'node:path'
@@ -32,6 +27,7 @@ import { fileURLToPath } from 'node:url'
32
27
  import { createRuntime } from './shell/runtime.js'
33
28
  import * as updater from './shell/updater.js'
34
29
  import { installedPackageDir, installedVersion, profileNodeModules } from './shell/manager.js'
30
+ import { createDeviceOta } from './shell/device-ota.js'
35
31
 
36
32
  export const name = 'dsh-mobile-plugin'
37
33
  export const inject = ['webServer', 'sessions']
@@ -45,6 +41,7 @@ export const inject = ['webServer', 'sessions']
45
41
  */
46
42
  export function apply(ctx) {
47
43
  const runtime = createRuntime()
44
+ const deviceOta = createDeviceOta({ installed: installedVersion, runtime })
48
45
  // 跑着的外壳的指纹。它必须在**启动时**算:之后盘上那份可能已经被市场换掉了。
49
46
  const runningShellHash = updater.shellFingerprint(path.dirname(path.dirname(fileURLToPath(import.meta.url))))
50
47
  const updateState = {
@@ -70,6 +67,7 @@ export function apply(ctx) {
70
67
  if (updateState.checking) return updateState.result
71
68
  updateState.checking = true
72
69
  try {
70
+ await deviceOta.reconcileOfficial()
73
71
  updateState.result = await updater.checkUpdate(base())
74
72
  updateState.lastError = null
75
73
  } catch (error) {
@@ -153,6 +151,7 @@ export function apply(ctx) {
153
151
  check,
154
152
  apply: applyUpdate,
155
153
  reload: version => runtime.reload(version),
154
+ deviceOta,
156
155
  setAutoUpdate: enabled => {
157
156
  const value = updater.setAutoUpdate(enabled)
158
157
  log('info', 'dsh-mobile-plugin: 自动更新已%s', value ? '开启' : '关闭')
@@ -164,13 +163,14 @@ export function apply(ctx) {
164
163
  .start(ctx)
165
164
  .then(() => {
166
165
  log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
167
- // 自动更新:默认关。开了才轮询;每次都是「查一次 → 有新版就自己装 → 就地换代」。
166
+ // 每 10 分钟让已落盘的正式版接管 OTA;自动安装仍由开关控制。
168
167
  const timer = setInterval(() => {
169
- if (!updater.autoUpdateEnabled()) return
170
- applyUpdate().catch(error => {
171
- updateState.lastError = String(error?.message || error)
172
- log('warn', 'dsh-mobile-plugin: 自动更新失败:%s', updateState.lastError)
173
- })
168
+ deviceOta.reconcileOfficial()
169
+ .then(() => updater.autoUpdateEnabled() ? applyUpdate() : null)
170
+ .catch(error => {
171
+ updateState.lastError = String(error?.message || error)
172
+ log('warn', 'dsh-mobile-plugin: 更新检查失败:%s', updateState.lastError)
173
+ })
174
174
  }, updater.AUTO_INTERVAL_MS)
175
175
  if (typeof timer.unref === 'function') timer.unref()
176
176
  ctx.effect(() => () => clearInterval(timer), 'dsh-mobile-plugin: auto update timer')
@@ -12,14 +12,19 @@
12
12
 
13
13
  const { sendJson, readJsonBody } = require('../shared/http-util');
14
14
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
15
- const { canonicalDeviceKey } = require('./session-cache.js');
15
+ const {
16
+ canonicalDeviceKey,
17
+ rememberDeletedSessionIds,
18
+ pendingUnarchiveSessionIdsOf,
19
+ clearPendingUnarchiveSessionIds,
20
+ } = require('./session-cache.js');
16
21
  const { invalidateSessionList } = require('./sessions.js');
17
22
  const { unwrap } = require('./envelope.js');
18
23
  const { deleteSessionData, forgetArchivedSessions } = require('../session-cleanup.cjs');
19
24
 
20
25
  /**
21
26
  * POST /api/mobile/v1/workspace/clear-archived(token)
22
- * body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
27
+ * body: { confirm: true } → { ok: true, deleted: N, unarchiveFailedSessionIds }。confirm 不为 true 一律 400。
23
28
  */
24
29
  async function handleClearArchived(ctx, req, res) {
25
30
  const body = await readJsonBody(req, 64 * 1024);
@@ -46,12 +51,25 @@ async function handleClearArchived(ctx, req, res) {
46
51
  //
47
52
  // 擦集合是 best-effort:它失败**不**让整条清理报错(日志已经删成功,报错等于骗用户)。
48
53
  const removed = await deleteSessionData(selected);
49
- await forgetArchivedSessions(ctx, removed, 'mobile');
54
+ rememberDeletedSessionIds(ctx, removed);
55
+ const pendingUnarchive = pendingUnarchiveSessionIdsOf(ctx);
56
+ const retryable = [...new Set(selected)].filter((id) => pendingUnarchive.has(id));
57
+ const failedToUnarchive = await forgetArchivedSessions(ctx, retryable, 'mobile');
58
+ const failed = new Set(failedToUnarchive);
59
+ clearPendingUnarchiveSessionIds(ctx, retryable.filter((id) => !failed.has(id)));
50
60
  // 删的是列表里可见的行:手机端正开着列表,不立刻失效最长 10 秒还能点到死行。
51
61
  invalidateSessionList(ctx);
52
- // 只报**真删掉**的那些:没匹配到日志的 id 留着,否则集合里那一行会被清掉却没有任何东西消失。
53
- const cleared = new Set(removed);
54
- return { status: 200, body: { ok: true, deleted: removed.length, archivedSessionIds: archivedIds.filter((id) => !cleared.has(id)) } };
62
+ // 只从返回的归档集合中移除宿主确认已取消归档的 ID;失败项保留为重试候选。
63
+ const cleared = new Set(retryable.filter((id) => !failed.has(id)));
64
+ return {
65
+ status: 200,
66
+ body: {
67
+ ok: true,
68
+ deleted: removed.length,
69
+ archivedSessionIds: archivedIds.filter((id) => !cleared.has(id)),
70
+ unarchiveFailedSessionIds: failedToUnarchive,
71
+ },
72
+ };
55
73
  });
56
74
  sendJson(res, outcome.status, outcome.body);
57
75
  }
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /** Read-only DeepSeek account projection for the mobile app. */
4
+
5
+ const { sendJson } = require('../shared/http-util');
6
+ const { unwrap } = require('./envelope.js');
7
+
8
+ function readClientMetadata(req) {
9
+ let query;
10
+ try {
11
+ query = new URL(req.url, 'http://127.0.0.1').searchParams;
12
+ } catch {
13
+ return null;
14
+ }
15
+ const version = query.get('version');
16
+ const locale = query.get('locale');
17
+ const timezoneOffset = query.get('timezoneOffsetSeconds');
18
+ const timezoneOffsetSeconds = Number(timezoneOffset);
19
+ if (!version || !locale || timezoneOffset === null || !timezoneOffset.trim() || !Number.isInteger(timezoneOffsetSeconds)) return null;
20
+ return { version, locale, timezoneOffsetSeconds };
21
+ }
22
+
23
+ async function callAccount(ctx, method, payload = {}) {
24
+ try {
25
+ const call = ctx.apiProxy && ctx.apiProxy.account && ctx.apiProxy.account[method];
26
+ if (typeof call !== 'function') return { error: { code: 'host-unavailable' } };
27
+ return unwrap(await call({
28
+ rpcId: 'mobile',
29
+ method: `account.${method}`,
30
+ payload,
31
+ }));
32
+ } catch {
33
+ return { error: { code: 'host-unavailable' } };
34
+ }
35
+ }
36
+
37
+ function textOrNull(value) {
38
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
39
+ }
40
+
41
+ function normalizeProfile(result) {
42
+ const profile = result && result.status === 'ready' ? result.value : null;
43
+ if (!profile || typeof profile !== 'object') return { status: 'failed' };
44
+ return {
45
+ status: 'ready',
46
+ name: textOrNull(profile.name),
47
+ contact: textOrNull(profile.contact),
48
+ };
49
+ }
50
+
51
+ function normalizeWallets(result) {
52
+ if (!result || result.status !== 'ready') return { status: 'failed' };
53
+ const walletsOf = (value) => Array.isArray(value)
54
+ ? value.filter((wallet) => wallet && (wallet.currency === 'CNY' || wallet.currency === 'USD') && typeof wallet.balance === 'string')
55
+ .map((wallet) => ({ currency: wallet.currency, balance: wallet.balance }))
56
+ : [];
57
+ return {
58
+ status: 'ready',
59
+ wallets: walletsOf(result.value),
60
+ bonusWallets: walletsOf(result.bonusWallets),
61
+ };
62
+ }
63
+
64
+ function safeLinks(state) {
65
+ const links = state && state.links;
66
+ return {
67
+ usageUrl: textOrNull(links && links.usageUrl),
68
+ topUpUrl: textOrNull(links && links.topUpUrl),
69
+ };
70
+ }
71
+
72
+ /** GET /api/mobile/v1/deepseek-account — safe account details only; never login state or credentials. */
73
+ async function handleDeepSeekAccount(ctx, req, res) {
74
+ const client = readClientMetadata(req);
75
+ if (!client) {
76
+ return sendJson(res, 400, {
77
+ ok: false,
78
+ error: { code: 'bad-request', message: '需要 version、locale 和 timezoneOffsetSeconds' },
79
+ });
80
+ }
81
+
82
+ const stateResult = await callAccount(ctx, 'getState');
83
+ if (stateResult.error) {
84
+ return sendJson(res, 502, {
85
+ ok: false,
86
+ error: { code: stateResult.error.code || 'host-unavailable', message: '这台宿主暂时无法读取 DeepSeek 账号信息' },
87
+ });
88
+ }
89
+
90
+ const state = stateResult.value;
91
+ if (!state || (state.status !== 'signed-out' && state.status !== 'credential-stored')) {
92
+ return sendJson(res, 502, {
93
+ ok: false,
94
+ error: { code: 'host-response-invalid', message: '宿主返回的 DeepSeek 账号状态无效' },
95
+ });
96
+ }
97
+
98
+ const links = safeLinks(state);
99
+ if (state.status === 'signed-out') {
100
+ return sendJson(res, 200, {
101
+ ok: true,
102
+ status: 'signed-out',
103
+ profile: { status: 'unavailable' },
104
+ balance: { status: 'unavailable' },
105
+ links,
106
+ });
107
+ }
108
+
109
+ const [profileResult, balanceResult] = await Promise.all([
110
+ callAccount(ctx, 'getProfile', { client }),
111
+ callAccount(ctx, 'getBalance', { client }),
112
+ ]);
113
+ const profile = normalizeProfile(profileResult.value);
114
+ const balance = normalizeWallets(balanceResult.value);
115
+
116
+ return sendJson(res, 200, {
117
+ ok: true,
118
+ status: 'credential-stored',
119
+ profile: profileResult.error ? { status: 'failed' } : profile,
120
+ balance: balanceResult.error ? { status: 'failed' } : balance,
121
+ links,
122
+ });
123
+ }
124
+
125
+ module.exports = { handleDeepSeekAccount, readClientMetadata, normalizeProfile, normalizeWallets, safeLinks };
@@ -136,6 +136,11 @@ const ENDPOINTS = {
136
136
  'goals.complete': (ctx, payload, signal) => goalAction(ctx, 'complete', payload, signal),
137
137
  'goals.clear': (ctx, payload, signal) => goalAction(ctx, 'clear', payload, signal),
138
138
 
139
+ // account: mobile can inspect the stored DeepSeek account and its safe profile/balances only.
140
+ 'account.getState': inv('account', 'getState', () => ({})),
141
+ 'account.getProfile': inv('account', 'getProfile', (p) => ({ client: p.client })),
142
+ 'account.getBalance': inv('account', 'getBalance', (p) => ({ client: p.client })),
143
+
139
144
  // settings / llm / credentials
140
145
  'settings.describe': inv('settings', 'describe', () => ({})),
141
146
  'settings.mutate': inv('settings', 'mutate', (p) => pick(p, ['ns', 'ops', 'expectedRevision'])),
@@ -21,6 +21,7 @@ const sessionModelsCache = new WeakMap();
21
21
  const MAX_SESSION_MODEL_CACHE_ENTRIES = 256;
22
22
 
23
23
  const sessionListResponseCache = new WeakMap();
24
+ const deletedSessionState = new WeakMap();
24
25
  const mirrorHistoryCache = new WeakMap();
25
26
  // Cache only the derived last-message preview. The full history stays outside
26
27
  // the cache so repeated mobile list refreshes do not re-read/decompress logs.
@@ -81,6 +82,37 @@ function sessionListResponseState(ctx, deviceKey = 'legacy') {
81
82
  return state;
82
83
  }
83
84
 
85
+ function sessionCleanupStateOf(ctx) {
86
+ let state = deletedSessionState.get(ctx);
87
+ if (!state) {
88
+ state = { deleted: new Set(), pendingUnarchive: new Set() };
89
+ deletedSessionState.set(ctx, state);
90
+ }
91
+ return state;
92
+ }
93
+
94
+ function rememberDeletedSessionIds(ctx, sessionIds) {
95
+ const state = sessionCleanupStateOf(ctx);
96
+ for (const sessionId of sessionIds) {
97
+ if (typeof sessionId !== 'string' || !sessionId) continue;
98
+ state.deleted.add(sessionId);
99
+ state.pendingUnarchive.add(sessionId);
100
+ }
101
+ }
102
+
103
+ function deletedSessionIdsOf(ctx) {
104
+ return sessionCleanupStateOf(ctx).deleted;
105
+ }
106
+
107
+ function pendingUnarchiveSessionIdsOf(ctx) {
108
+ return sessionCleanupStateOf(ctx).pendingUnarchive;
109
+ }
110
+
111
+ function clearPendingUnarchiveSessionIds(ctx, sessionIds) {
112
+ const pending = sessionCleanupStateOf(ctx).pendingUnarchive;
113
+ for (const sessionId of sessionIds) pending.delete(sessionId);
114
+ }
115
+
84
116
  function sessionPreviewStateOf(ctx) {
85
117
  let state = sessionPreviewCache.get(ctx);
86
118
  if (!state) {
@@ -249,6 +281,10 @@ module.exports = {
249
281
  invalidateSessionModels,
250
282
  sessionListResponseState,
251
283
  invalidateSessionList,
284
+ rememberDeletedSessionIds,
285
+ deletedSessionIdsOf,
286
+ pendingUnarchiveSessionIdsOf,
287
+ clearPendingUnarchiveSessionIds,
252
288
  dropSessionListCache,
253
289
  sessionPreviewStateOf,
254
290
  sessionPreviewInFlightStateOf,
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * envelope.js 信封拆解(unwrap / envelopeValue),唯一的实现
12
12
  * rpc.js 对宿主 apiProxy 的窄接口封装(callSessions / callAgentPresets)
13
- * session-cache.js 按 ctx 隔离的八张状态表,全部 WeakMap 集中在这里
13
+ * session-cache.js 按 ctx 隔离的状态表,全部 WeakMap 集中在这里
14
14
  * attachments.js 图片附件还原
15
15
  * completion.js 完成提醒状态机
16
16
  * session-models.js 模型目录与切模型
@@ -34,7 +34,7 @@ const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVA
34
34
  const { unwrap } = require('./envelope.js');
35
35
  const { callSessions } = require('./rpc.js');
36
36
  const {
37
- canonicalDeviceKey, sessionListResponseState, invalidateSessionList,
37
+ canonicalDeviceKey, sessionListResponseState, invalidateSessionList, deletedSessionIdsOf,
38
38
  sessionPreviewStateOf, sessionPreviewInFlightStateOf, sessionPreviewClockOf,
39
39
  completionGenerationOf, bumpCompletionGeneration,
40
40
  completionStateOf, sessionModelsStateOf, invalidateSessionModels, sendIdempotencyStateOf,
@@ -223,12 +223,13 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
223
223
  }
224
224
  // 归档会话在这里剔除,与桌面端侧边栏行为一致:宿主 sessions.list 自己不过滤归档,
225
225
  // 「哪些会话已归档」只存在于 workspaceRegistry.archivedSessionIds(见 workspaceSessionFlags)。
226
+ // 清理时已删日志的 ID 也要压住:宿主 list 或 workspace.list 的短暂旧快照不能让它们复活。
226
227
  // archivedOnly(App 的「只看已归档」筛选视图)反过来只保留归档里的那些行:默认视图藏
227
228
  // 它们,筛选视图正是为了把它们找回来(查看 / 取消归档)。
229
+ const deleted = deletedSessionIdsOf(ctx);
228
230
  const { archived, pinned } = await workspaceSessionFlags(ctx);
229
- const allSummaries = archivedOnly
230
- ? summaries.filter(s => s && archived.has(s.sessionId))
231
- : summaries.filter(s => s && !archived.has(s.sessionId));
231
+ const allSummaries = summaries.filter((item) => item && !deleted.has(item.sessionId)
232
+ && (archivedOnly === archived.has(item.sessionId)));
232
233
  // 状态跟踪覆盖完整列表,不能受预览补全上限影响。
233
234
  const completionStatesForContext = updateCompletionStates(ctx, allSummaries, deviceKey);
234
235
  // 列表完整返回:侧边栏分组依赖全部会话(工作区 sessionIds 排序 + 未分组兜底),