dsh-mobile-plugin 0.1.86 → 0.1.88

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.
@@ -2,7 +2,10 @@
2
2
 
3
3
  /**
4
4
  * 局域网直连代理:手机在同一个 Wi-Fi 下直接访问
5
- * http://<电脑IP>:<lanPort>,经配对码校验后代理到本机 dsh web。
5
+ * http://<电脑IP>:<lanPort>,带上配对拿到的令牌后代理到本机 dsh web。
6
+ *
7
+ * 只服务 App:未认证的请求一律 JSON 401,不给任何网页。曾经的 HTML 配对页
8
+ * 与配对后能进入的网页本体已删除(见 docs/decisions.md 2026-10-19)。
6
9
  *
7
10
  * 与中转隧道的关系:同网用本代理(零延迟、不依赖公网),
8
11
  * 异地走 dsh-mobile-server 中转。两者互不影响,可同时开启。
@@ -154,69 +157,15 @@ function createLanProxy(opts) {
154
157
  + `; Path=/; HttpOnly; SameSite=Lax; Max-Age=${core.MOBILE_TOKEN_TTL_SEC}`,
155
158
  );
156
159
  }
157
- function clearCookie(res) {
158
- // 与 setCookie 保持同一组属性:属性不一致时部分客户端会当成另一个 cookie,删不掉。
159
- res.setHeader('set-cookie', 'dsh_lan=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
160
- }
161
-
162
- const PAGE = `<!DOCTYPE html>
163
- <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
164
- <title>连接 DeepSeek Harness</title>
165
- <style>
166
- *{box-sizing:border-box;margin:0;padding:0}
167
- body{font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif;background:#0f1115;color:#e6e8eb;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
168
- .card{width:min(380px,100%);background:#171a21;border:1px solid #262b36;border-radius:16px;padding:28px}
169
- h1{font-size:18px;margin-bottom:6px}
170
- .sub{font-size:13px;color:#8b93a1;margin-bottom:20px;line-height:1.6}
171
- label{display:block;font-size:12px;color:#8b93a1;margin:14px 0 6px}
172
- input{width:100%;padding:10px 12px;font-size:15px;background:#0f1115;color:#e6e8eb;border:1px solid #2c3340;border-radius:10px;outline:none}
173
- input:focus{border-color:#4d6bfe}
174
- button{width:100%;margin-top:20px;padding:11px;font-size:15px;background:#4d6bfe;color:#fff;border:none;border-radius:10px;cursor:pointer}
175
- .err{margin-top:14px;font-size:13px;color:#f85149;min-height:18px}
176
- </style></head><body>
177
- <div class="card">
178
- <h1>连接 DeepSeek Harness</h1>
179
- <div class="sub">局域网直连 · 实例 <code>${instanceId}</code><br>请输入桌面端「远程连接」面板显示的 6 位配对码</div>
180
- <form id="f"><label for="code">配对码</label><input id="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
181
- <button type="submit">连接</button></form>
182
- <div class="err" id="err"></div>
183
- </div>
184
- <script>
185
- document.getElementById('f').addEventListener('submit', async (e) => {
186
- e.preventDefault();
187
- const code = document.getElementById('code').value.trim();
188
- if (!/^\d{6}$/.test(code)) {
189
- document.getElementById('err').textContent = '请输入 6 位数字配对码';
190
- return;
191
- }
192
- const r = await fetch('/__lan_pair', { method:'POST', headers:{'content-type':'application/json'},
193
- body: JSON.stringify({ code }) });
194
- if (r.ok) { location.href = '/'; }
195
- else { document.getElementById('err').textContent = '配对码错误或尝试过于频繁'; }
196
- });
197
- </script></body></html>`;
198
-
199
- /**
200
- * 是否是「程序化 API 请求」。
201
- *
202
- * 未认证时给浏览器导航回配对页(200 HTML)是刻意的:用户直接在手机浏览器里
203
- * 打开局域网地址即可输入配对码。但**绝不能**这样答复 API 请求 ——
204
- * `/api/mobile/v1/*` 被答成 200 + HTML 时,App 的 `app/src/api.ts` 会把它当成
205
- * 成功(`response.ok` 为真 → `publishRoute` 把连接通道钉在局域网、并重置局域网
206
- * 熔断),随后 `res.json()` 解析失败,`unwrapResult` 抛出「请求失败
207
- * (HTTP 200)」。结果是桌面端换过 lanSecret、或设备凭据被吊销之后,App 会永久
208
- * 卡在局域网通道上:既不会回退到中转,也不会提示重新配对。
209
- *
210
- * 判定规则:路径以 `/api/` 开头,或客户端明确不接受 `text/html`。
211
- */
212
- function isApiRequest(req, url) {
213
- if (url.pathname.startsWith('/api/')) return true;
214
- return !/\btext\/html\b/i.test(String(req.headers.accept || ''));
215
- }
216
-
217
160
  /**
218
- * 未认证的 API 请求:回 JSON 401,与移动 API 的鉴权失败保持同一种形状,
161
+ * 未认证的请求一律回 JSON 401,与移动 API 的鉴权失败保持同一种形状 ——
219
162
  * App 据此显示可读的「缺少或无效的移动 token」,而不是「请求失败 (HTTP 200)」。
163
+ *
164
+ * 浏览器导航也走这条路:这里**不再**提供网页版的配对页与配对后进入的网页本体,
165
+ * 手机端只有 App 一条路。给网页回 200 HTML 曾是刻意行为,但 App 的
166
+ * `app/src/api.ts` 会把 `response.ok` 当成功、把连接通道钉在局域网并重置熔断,
167
+ * 随后 `res.json()` 解析失败,桌面端换过 lanSecret 或凭据被吊销之后就永久卡住 ——
168
+ * 一个「谁都能打开的页面」换来的这点便利不值这条风险。
220
169
  */
221
170
  function sendUnauthorized(res) {
222
171
  try {
@@ -225,11 +174,6 @@ document.getElementById('f').addEventListener('submit', async (e) => {
225
174
  } catch { /* 客户端已断开 */ }
226
175
  }
227
176
 
228
- function sendPage(res, status) {
229
- res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
230
- res.end(PAGE);
231
- }
232
-
233
177
  function proxyToUpstream(req, res) {
234
178
  // 读取隔离:命中本地镜像快照时直接回,不再连主进程。
235
179
  // 例外:带 ?fresh=1 的写后刷新必须回源——镜像最多旧 500ms,撞上写操作会把刚做完的
@@ -440,12 +384,6 @@ document.getElementById('f').addEventListener('submit', async (e) => {
440
384
  server = http.createServer((req, res) => {
441
385
  const url = new URL(req.url, 'http://lan.local');
442
386
  try {
443
- if (url.pathname === '/__lan_logout') {
444
- clearCookie(res);
445
- res.writeHead(302, { location: '/' });
446
- res.end();
447
- return;
448
- }
449
387
  if (url.pathname === '/__lan_pair' && req.method === 'POST') {
450
388
  const ip = req.socket.remoteAddress || '';
451
389
  let b = '';
@@ -546,9 +484,7 @@ document.getElementById('f').addEventListener('submit', async (e) => {
546
484
  return;
547
485
  }
548
486
  if (!authorized(req)) {
549
- // API 请求必须回 JSON 401(见 isApiRequest 注释);只有浏览器导航才给配对页。
550
- if (isApiRequest(req, url)) sendUnauthorized(res);
551
- else sendPage(res, 200);
487
+ sendUnauthorized(res);
552
488
  return;
553
489
  }
554
490
  const c = credential(req);
@@ -186,6 +186,38 @@ async function archivedIdsWithoutLogs(archivedSessionIds) {
186
186
  return [...orphans]
187
187
  }
188
188
 
189
+ /**
190
+ * 盘上已经不剩任何会话数据的 id:目录没了,或者目录里只剩宿主锁着的 `session.lock`。
191
+ *
192
+ * 只给移动端的「清理所有已归档」补账用:更早一次删除如果删到一半抛错(2026-10-18 前
193
+ * Windows 的 ENOTEMPTY 就是这样),已经删掉的那批 id 没能记进本进程的删除集,而宿主的
194
+ * 进程内清单还挂着它们 —— 再点清理只会回 `deleted: 0`,手机上永远清不掉。
195
+ *
196
+ * 判据比 archivedIdsWithoutLogs 更宽(那个只认规范名日志),但**不许宽到把迁移中途的
197
+ * 临时文件当成没有数据**:目录里只要还有任何非锁文件(含子目录)就算「还在」,读不出来
198
+ * 也按「还在」算。
199
+ */
200
+ function sessionIdsWithoutAnyData(sessionIds) {
201
+ const candidates = new Set(sessionIds)
202
+ const withoutData = new Set()
203
+ if (candidates.size === 0) return []
204
+ const visit = (directory) => {
205
+ let entries
206
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { return }
207
+ for (const entry of entries) {
208
+ if (!entry.isDirectory()) continue
209
+ const child = path.join(directory, entry.name)
210
+ if (!candidates.has(entry.name)) { visit(child); continue }
211
+ candidates.delete(entry.name)
212
+ let files
213
+ try { files = fs.readdirSync(child, { withFileTypes: true }) } catch { continue }
214
+ if (!files.some((file) => !file.name.endsWith('.lock'))) withoutData.add(entry.name)
215
+ }
216
+ }
217
+ visit(path.join(dshHome(), 'sessions'))
218
+ return [...candidates, ...withoutData]
219
+ }
220
+
189
221
  async function pruneOrphanedArchivedSessions(ctx) {
190
222
  const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
191
223
  if (typeof workspaceList !== 'function') return 0
@@ -308,4 +340,4 @@ function installSessionCleanup(ctx) {
308
340
  registerSessionCleanupTool(ctx)
309
341
  }
310
342
 
311
- module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
343
+ module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, sessionIdsWithoutAnyData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",