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.
- package/lib/mobile/archive-clean.js +9 -4
- package/lib/mobile/history-derive.js +32 -4
- package/lib/mobile/host-bridge/endpoints.js +3 -2
- package/lib/mobile/host-bridge/shims.js +31 -5
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +12 -76
- package/lib/session-cleanup.cjs +33 -1
- package/package.json +1 -1
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 局域网直连代理:手机在同一个 Wi-Fi 下直接访问
|
|
5
|
-
* http://<电脑IP>:<lanPort
|
|
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
|
-
*
|
|
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
|
-
|
|
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);
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -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.
|
|
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",
|