dsh-mobile-plugin 0.1.69 → 0.1.71
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 +15 -3
- package/host.js +44 -5
- package/lib/client.js +93 -35
- package/lib/http/routing.js +35 -0
- package/lib/impl.js +2 -2
- package/lib/mirror/pusher.cjs +10 -0
- package/lib/mobile/host-bridge/endpoints.js +1 -0
- package/lib/mobile/session-models.js +45 -24
- package/lib/mobile/session-snapshot.js +61 -10
- package/lib/mobile/sessions.js +41 -19
- package/lib/mobile/subagents.js +3 -2
- package/lib/mobile/workspaces.js +55 -0
- package/lib/routes/control-plane.js +94 -3
- package/lib/routes/mobile-api.js +5 -0
- package/lib/shared/session-signal.js +26 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@ Electron 运行时**,`node host.js` 即可独立运行。
|
|
|
14
14
|
|
|
15
15
|
本仓库是插件的**公开发布仓库**,仓库根就是包根:别人从这里读代码,也从这里核对这份代码的说法。
|
|
16
16
|
|
|
17
|
+
隐私政策(App Store / Google Play 上架用的那份,描述手机 App 会经手哪些数据):[PRIVACY.md](PRIVACY.md)。
|
|
18
|
+
技术支持 / 常见问题(上架时填的「技术支持网址」):[SUPPORT.md](SUPPORT.md)。
|
|
19
|
+
|
|
17
20
|
开发和 App 客户端、中转服务器放在一起进行(一个私有 monorepo),所以这里的提交历史从拆分那天开始,
|
|
18
21
|
不是项目历史 —— 每次发版,把同一次改动一起推到这里。
|
|
19
22
|
|
|
@@ -83,9 +86,18 @@ DSH_HOME=/opt/dsh-mobile/.dsh DSH_MOBILE_PROFILE=mobile node host.js
|
|
|
83
86
|
|
|
84
87
|
本包有两条完全不同的安装路径,别混用。
|
|
85
88
|
|
|
86
|
-
**服务器 /
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
**服务器 / 无头部署:启动器 rsync + 插件本体走 npm。**(2026-10-11 改)那一台是**两半**,别混着看:
|
|
90
|
+
|
|
91
|
+
- **启动器**:`host.js`、两层 patch、`config/`、内嵌的凭据包。把仓库目录 rsync 到 `/opt/dsh-mobile/`,
|
|
92
|
+
然后 `systemctl restart dsh-mobile-host` —— 它只负责把宿主那棵树拼起来。
|
|
93
|
+
- **插件本体**:装在 profile 里,用 pnpm 从 npm 装:
|
|
94
|
+
`cd <DSH_HOME>/profiles/<profile> && pnpm add dsh-mobile-plugin@<版本>`。宿主按**绝对路径**挂那份的
|
|
95
|
+
`lib/index.js`(**稳定外壳**),所以这台面板里也有「检查 / 立即更新」,换代**不用重启宿主**。
|
|
96
|
+
profile 的 `autoInstallPeers: false` 是刻意的:peer 依赖(`@deepseek-ai/*`)由宿主自己的依赖树供,
|
|
97
|
+
两边必须解析到**同一份 realpath**,否则会出现两份 cordis(见 `lib/shell/manager.js` 的文件头)。
|
|
98
|
+
- 挂哪一份由 `host.js` 现场判断(`profile` 里有没有这个包,不看环境变量):装了挂那份外壳;
|
|
99
|
+
没装就退回**源码树直挂**(没有外壳,面板里更新不了,只能同步源码 + 重启宿主)。两种现场都会在
|
|
100
|
+
启动日志里打一行「挂载方式 = …」,`npm run test:plugin:server` 会核对它。
|
|
89
101
|
|
|
90
102
|
**桌面端:从 npm 装,更新交给 DSH 的插件市场。**(2026-10 改的:此前是本包自带的 OTA +
|
|
91
103
|
「稳定外壳 + 可热替换的代」,那套已经删掉。)
|
package/host.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* package's `cordis.patch.yml`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { writeFileSync } from 'node:fs'
|
|
17
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import { fileURLToPath } from 'node:url'
|
|
20
20
|
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
|
@@ -59,6 +59,32 @@ try {
|
|
|
59
59
|
initProfile(profileDir, PROFILE_TEMPLATES.web)
|
|
60
60
|
const profile = loadProfile(BIN_NAME, PROFILE_NAME, INSTALL_ANCHOR, home, { userLayer: true })
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 本插件是**装在这个 profile 里**的,还是「源码树直接跑」的?
|
|
64
|
+
*
|
|
65
|
+
* 这两种现场的挂载方式不一样,而差别全在「挂哪一份入口」上(2026-10-11 起两条都留着):
|
|
66
|
+
*
|
|
67
|
+
* - **装在 profile 里**(桌面端;服务器那台改成 `pnpm 装进 profile` 之后也是)→ 挂
|
|
68
|
+
* `<profile>/node_modules/dsh-mobile-plugin/lib/index.js`,也就是**稳定外壳**:
|
|
69
|
+
* 查更新、装新版、就地换代那一套全在它手里,所以面板上才有「检查 / 立即更新」。
|
|
70
|
+
* - **源码树直接跑**(老的无头部署、开发时直接 `node host.js`)→ 插件**就是** Loader 自己
|
|
71
|
+
* 正跑的那个包,没有任何人把它放进 `node_modules`,于是这一支挂随包发布的
|
|
72
|
+
* `lib/impl.js`(真正的实现)。代价是**没有外壳** → 那台的面板里更新不了,只能同步
|
|
73
|
+
* 源码 + 重启宿主。
|
|
74
|
+
*
|
|
75
|
+
* **两处都走「绝对路径」而不是包名**:`cordis.patch.yml` 里那行按包名挂载是给桌面端用的,
|
|
76
|
+
* 无头宿主这边解析不到 —— loader 是从**它自己所在的位置**解析包名的(实测报错原文:
|
|
77
|
+
* `Cannot find package 'dsh-mobile-plugin' imported from <…>/@deepseek-ai/cordis-plugin-loader/lib/index.js`),
|
|
78
|
+
* 而它在宿主自己的依赖树里,profile 里装的那份它根本看不见。而 loader 对解析失败不是留一条
|
|
79
|
+
* 警告,是**整棵插件树加载失败**(服务器那台当年就是这么完全起不来的)。所以那行在无头宿主
|
|
80
|
+
* 里一律摘掉,由这里按绝对路径插一行 —— 装没装 profile 决定的是**挂哪一份**,不是挂不挂。
|
|
81
|
+
*
|
|
82
|
+
* 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实,改不了口径。
|
|
83
|
+
*/
|
|
84
|
+
const installedDir = join(profileDir, 'node_modules', BIN_NAME)
|
|
85
|
+
const selfInstalled = existsSync(join(installedDir, 'package.json'))
|
|
86
|
+
const selfPatches = loadOverlayPatches(BIN_NAME, join(PACKAGE_DIR, 'cordis.patch.yml'))
|
|
87
|
+
|
|
62
88
|
// An empty entry list is the composition root: upstream fills the tree from
|
|
63
89
|
// the profile's bundle layers; every plugin arrives via a patch layer.
|
|
64
90
|
const rootConfig = join(profileDir, 'cordis.root.yml')
|
|
@@ -67,15 +93,24 @@ try {
|
|
|
67
93
|
const patches = [
|
|
68
94
|
...profile.layers.flatMap(layer => layer.patches),
|
|
69
95
|
...profile.patches,
|
|
70
|
-
...dropBareNameSelfMounts(
|
|
96
|
+
...dropBareNameSelfMounts(selfPatches, BIN_NAME),
|
|
71
97
|
// 宿主专属的层(不开浏览器、目录选择器钉成 browse)。刻意不放进
|
|
72
98
|
// cordis.patch.yml:那一层桌面 GUI 也会加载,而桌面操作者就坐在机器前,
|
|
73
99
|
// 不需要把原生目录选择器换成远程用的 browse 交互。
|
|
74
100
|
...loadOverlayPatches(BIN_NAME, join(PACKAGE_DIR, 'cordis.mobile-host.yml')),
|
|
75
101
|
// The mobile API is this very package. The Loader imports an entry by bare
|
|
76
|
-
// name from its own location, and `dsh-mobile-plugin
|
|
77
|
-
//
|
|
78
|
-
|
|
102
|
+
// name from its own location, and that location has no `dsh-mobile-plugin`,
|
|
103
|
+
// so the row names the entry module by absolute path — profile 里装了就挂
|
|
104
|
+
// 那份的外壳,没装就挂源码树自己的实现(见上)。
|
|
105
|
+
{
|
|
106
|
+
insert: [{
|
|
107
|
+
id: 'mobile-api',
|
|
108
|
+
name: selfInstalled
|
|
109
|
+
? join(installedDir, 'lib', 'index.js')
|
|
110
|
+
: join(PACKAGE_DIR, 'lib/impl.js'),
|
|
111
|
+
config: { enabled: true },
|
|
112
|
+
}],
|
|
113
|
+
},
|
|
79
114
|
// The DSH Web bundle disables every agent-plane row (shell, files, search,
|
|
80
115
|
// skills, goals, planning, compaction, delegation) and lets each session
|
|
81
116
|
// mount a preset instead — but it leaves the roster's shipped root to
|
|
@@ -93,6 +128,10 @@ try {
|
|
|
93
128
|
},
|
|
94
129
|
]
|
|
95
130
|
|
|
131
|
+
process.stderr.write(
|
|
132
|
+
`${BIN_NAME}: 挂载方式 = ${selfInstalled ? `profile 里装的那份(${installedDir},带稳定外壳,面板里能自更新)` : '源码树直挂(无外壳,更新靠同步源码 + 重启宿主)'}\n`,
|
|
133
|
+
)
|
|
134
|
+
|
|
96
135
|
const ctx = await boot(BIN_NAME, rootConfig, patches, (hostCtx) => {
|
|
97
136
|
// A headless mobile host has no command line: nothing passes flags and
|
|
98
137
|
// nobody is watching a help screen. The web startup plugin still waits on
|
package/lib/client.js
CHANGED
|
@@ -12,15 +12,12 @@
|
|
|
12
12
|
* 数据方向是单向的:桌面 → 宿主 → 手机。手机上手动折叠/展开只存在 App 本地
|
|
13
13
|
* (AsyncStorage),不会写回这份 localStorage。
|
|
14
14
|
*
|
|
15
|
-
* 2. **设置
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* 2. **设置 →「移动端」**(设置菜单里的顶层一项,不再挂在内置插件页签下)。保留
|
|
16
|
+
* **网页版入口**,并只加一颗由可信本机 DSH 设置页操作的公网访问开关;网页管理默认关闭,
|
|
17
|
+
* 每次开启固定 2 小时,到期不因访问续时。管理界面本体仍在宿主吐出的 HTML 里。
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* 「更新报成功、界面一动不动」。2026-10 起插件从 npm 分发、更新交给 DSH 的插件市场,
|
|
22
|
-
* 插件自己不再更新任何一个字节,这条缝隙也就没了。所以这里刻意保持「薄」:改版式只需
|
|
23
|
-
* 重装一次插件(市场里点一下),不用再怀疑哪一半是旧的。
|
|
19
|
+
* 为什么这里只加开关:这半边不可热替换,管理界面继续留在网页版;本机开关只控制
|
|
20
|
+
* 网页管理路由,不改手机 App API 或隧道状态。插件更新仍交给 DSH 插件市场。
|
|
24
21
|
*
|
|
25
22
|
* 3. **桌面端已读 → 宿主**(`startDesktopReadSync`)。桌面侧栏的「已完成待读」绿点
|
|
26
23
|
* 只活在渲染进程内存里(`UiSession.completionUnread`),宿主和手机都读不到。
|
|
@@ -214,7 +211,11 @@ window.__ModuleLoader__.load({
|
|
|
214
211
|
throw new Error(String((error && error.message) || error));
|
|
215
212
|
}
|
|
216
213
|
const body = await response.json().catch(() => ({}));
|
|
217
|
-
if (!response.ok)
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const error = new Error(errorText(body, response.status));
|
|
216
|
+
error.status = response.status;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
218
219
|
return body;
|
|
219
220
|
}
|
|
220
221
|
|
|
@@ -505,76 +506,122 @@ window.__ModuleLoader__.load({
|
|
|
505
506
|
heading: '移动端',
|
|
506
507
|
cardPanel: '配置页面',
|
|
507
508
|
openPanel: '打开配置页面',
|
|
508
|
-
openHint: '
|
|
509
|
+
openHint: '设置、配对和相关运维都在这个页面里。',
|
|
509
510
|
panelPending: '正在读取页面地址…',
|
|
510
511
|
panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
|
|
511
512
|
loadFailed: '读取宿主状态失败',
|
|
513
|
+
webAccessOn: '公网网页管理已开放,到期时间',
|
|
514
|
+
webAccessOff: '公网网页管理已关闭',
|
|
515
|
+
webAccessOpen: '开放网页管理 2 小时',
|
|
516
|
+
webAccessClose: '立即关闭网页管理',
|
|
517
|
+
webAccessHint: '默认关闭;开启后 2 小时自动关闭,访问不会延长时间,也不影响手机 App。只可从本机 DSH 设置页开关。',
|
|
518
|
+
webAccessUnavailable: '正在读取本机访问状态…',
|
|
519
|
+
webAccessError: '更新网页访问状态失败',
|
|
512
520
|
},
|
|
513
521
|
en: {
|
|
514
522
|
nav: 'Mobile',
|
|
515
523
|
heading: 'Mobile',
|
|
516
524
|
cardPanel: 'Configuration page',
|
|
517
525
|
openPanel: 'Open the configuration page',
|
|
518
|
-
openHint: 'Settings, pairing and plugin
|
|
526
|
+
openHint: 'Settings, pairing and plugin maintenance live on this page.',
|
|
519
527
|
panelPending: 'Reading the page address…',
|
|
520
528
|
panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
|
|
521
529
|
loadFailed: 'Failed to read host status',
|
|
530
|
+
webAccessOn: 'Public web management is open until',
|
|
531
|
+
webAccessOff: 'Public web management is closed',
|
|
532
|
+
webAccessOpen: 'Open web management for 2 hours',
|
|
533
|
+
webAccessClose: 'Close web management now',
|
|
534
|
+
webAccessHint: 'Closed by default; opening lasts 2 hours and visits do not extend it. Mobile app access is unaffected. Only this local DSH settings page can toggle it.',
|
|
535
|
+
webAccessUnavailable: 'Reading local access status…',
|
|
536
|
+
webAccessError: 'Failed to update web access status',
|
|
522
537
|
},
|
|
523
538
|
};
|
|
524
539
|
|
|
525
540
|
/* ── 设置项组件 ───────────────────────────────────────────────────────
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* 界面本体(状态、配对、中转配置、维护、更新)全部在宿主吐出的那个网页里
|
|
529
|
-
* (源码在 `React/`,产物 `lib/panel/index.html`)。为什么只留一个链接:客户端半边
|
|
530
|
-
* 是渲染器直接读 `node_modules` 里的文件,**不可热替换**,改一次就要完整安装 + 重启
|
|
531
|
-
* DSH;而网页版跟着插件包一起更新,改版式重装一次插件就生效。
|
|
541
|
+
* 保持一个薄卡片:一个网页版入口,以及一个可信本机开关,控制网页管理是否短时向公网开放。
|
|
542
|
+
* 界面本体仍在宿主吐出的 HTML(源码 `React/`,产物 `lib/panel/index.html`)。
|
|
532
543
|
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* 「半边是旧的」。
|
|
544
|
+
* 本机设置项半边来自渲染器的 `node_modules`,不可热替换;插件更新仍走 DSH 市场。
|
|
545
|
+
* 开关只影响控制面网页路由,不改移动 API、配对凭据或隧道状态。
|
|
536
546
|
*/
|
|
537
547
|
function MobileSettingsSection({ t }) {
|
|
538
548
|
/** null = 还没问到;'' = 问到了但这个宿主没给地址(老版本或还没起来)。 */
|
|
539
549
|
const [panelUrl, setPanelUrl] = useState(null);
|
|
540
550
|
const [error, setError] = useState(null);
|
|
551
|
+
const [webAccess, setWebAccess] = useState(null);
|
|
552
|
+
const [webAccessError, setWebAccessError] = useState(null);
|
|
553
|
+
const [webAccessBusy, setWebAccessBusy] = useState(false);
|
|
541
554
|
|
|
542
555
|
useEffect(() => {
|
|
543
556
|
let alive = true;
|
|
544
|
-
let
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
timer = null;
|
|
549
|
-
}
|
|
557
|
+
let retryTimer = null;
|
|
558
|
+
let refreshTimer = null;
|
|
559
|
+
const clearTimer = (timer) => {
|
|
560
|
+
if (timer !== null) clearInterval(timer);
|
|
550
561
|
};
|
|
551
|
-
/** 只问一次:地址由请求的 host 头算出来,问到就定了。 */
|
|
552
562
|
const load = async () => {
|
|
553
563
|
try {
|
|
554
564
|
const next = await call('/status');
|
|
555
565
|
if (!alive) return;
|
|
556
566
|
setError(null);
|
|
557
567
|
setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
|
|
558
|
-
|
|
568
|
+
setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
|
|
569
|
+
if (retryTimer !== null) {
|
|
570
|
+
clearTimer(retryTimer);
|
|
571
|
+
retryTimer = null;
|
|
572
|
+
}
|
|
573
|
+
if (refreshTimer === null) {
|
|
574
|
+
refreshTimer = setInterval(() => {
|
|
575
|
+
if (typeof document !== 'undefined' && document.hidden) return;
|
|
576
|
+
void load();
|
|
577
|
+
}, 15000);
|
|
578
|
+
}
|
|
559
579
|
} catch (problem) {
|
|
560
|
-
// 宿主还没挂上控制面路由(刚启动)时问不到,过 3s 再问 —— 不然这一页会永久停在
|
|
561
|
-
// 「正在读取页面地址…」。真的拿到「这个宿主不给我地址」也算答到了,同样停。
|
|
562
580
|
if (!alive) return;
|
|
563
581
|
setError(String((problem && problem.message) || problem));
|
|
582
|
+
setWebAccess(null);
|
|
583
|
+
if (refreshTimer !== null) {
|
|
584
|
+
clearTimer(refreshTimer);
|
|
585
|
+
refreshTimer = null;
|
|
586
|
+
}
|
|
587
|
+
// 无权的远程设置页不轮询;宿主还没启动等临时故障每 3s 重试。
|
|
588
|
+
if (problem?.status !== 403 && problem?.status !== 423 && retryTimer === null) {
|
|
589
|
+
retryTimer = setInterval(() => {
|
|
590
|
+
if (typeof document !== 'undefined' && document.hidden) return;
|
|
591
|
+
void load();
|
|
592
|
+
}, 3000);
|
|
593
|
+
}
|
|
564
594
|
}
|
|
565
595
|
};
|
|
566
596
|
void load();
|
|
567
|
-
timer = setInterval(() => {
|
|
568
|
-
if (typeof document !== 'undefined' && document.hidden) return;
|
|
569
|
-
void load();
|
|
570
|
-
}, 3000);
|
|
571
597
|
return () => {
|
|
572
598
|
alive = false;
|
|
573
|
-
|
|
599
|
+
clearTimer(retryTimer);
|
|
600
|
+
clearTimer(refreshTimer);
|
|
574
601
|
};
|
|
575
602
|
}, []);
|
|
576
603
|
|
|
604
|
+
const toggleWebAccess = async () => {
|
|
605
|
+
if (!webAccess || webAccessBusy) return;
|
|
606
|
+
setWebAccessBusy(true);
|
|
607
|
+
setWebAccessError(null);
|
|
608
|
+
try {
|
|
609
|
+
const next = await postJson('/web-access', { enabled: !webAccess.enabled });
|
|
610
|
+
setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
|
|
611
|
+
} catch (problem) {
|
|
612
|
+
setWebAccessError(String((problem && problem.message) || problem));
|
|
613
|
+
} finally {
|
|
614
|
+
setWebAccessBusy(false);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
577
618
|
const url = typeof panelUrl === 'string' ? panelUrl : '';
|
|
619
|
+
const webAccessEnabled = Boolean(webAccess && webAccess.enabled);
|
|
620
|
+
const expiresAt = webAccess && Number.isFinite(webAccess.expiresAt)
|
|
621
|
+
? new Date(webAccess.expiresAt).toLocaleTimeString()
|
|
622
|
+
: '';
|
|
623
|
+
let webAccessStatus = t('webAccessUnavailable');
|
|
624
|
+
if (webAccess) webAccessStatus = webAccessEnabled ? `${t('webAccessOn')} ${expiresAt}` : t('webAccessOff');
|
|
578
625
|
|
|
579
626
|
return h('div', { className: 'dshm_root' }, [
|
|
580
627
|
h('h2', { className: 'dshm_heading' }, t('heading')),
|
|
@@ -601,6 +648,17 @@ window.__ModuleLoader__.load({
|
|
|
601
648
|
}, panelUrl === null ? t('panelPending') : t('panelMissing')),
|
|
602
649
|
]),
|
|
603
650
|
h('p', { className: 'dshm_note' }, t('openHint')),
|
|
651
|
+
h('p', { className: 'dshm_note' }, webAccessStatus),
|
|
652
|
+
h('div', { className: 'dshm_actions' }, [
|
|
653
|
+
h('button', {
|
|
654
|
+
type: 'button',
|
|
655
|
+
className: webAccessEnabled ? 'dshm_btn' : 'dshm_btn dshm_btnPrimary',
|
|
656
|
+
disabled: !webAccess || webAccessBusy,
|
|
657
|
+
onClick: toggleWebAccess,
|
|
658
|
+
}, webAccessEnabled ? t('webAccessClose') : t('webAccessOpen')),
|
|
659
|
+
]),
|
|
660
|
+
h('p', { className: 'dshm_note' }, t('webAccessHint')),
|
|
661
|
+
webAccessError ? h('p', { className: 'dshm_error' }, `${t('webAccessError')}: ${webAccessError}`) : null,
|
|
604
662
|
]),
|
|
605
663
|
]);
|
|
606
664
|
}
|
package/lib/http/routing.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* 挡 token」这个刻意的不对称。
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
const net = require('node:net');
|
|
16
17
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
17
18
|
|
|
18
19
|
// Relay pairing cookies and instance headers are routing credentials only;
|
|
@@ -39,6 +40,38 @@ const crossSiteRequest = (req) => {
|
|
|
39
40
|
try { return new URL(origin).host !== host } catch { return true }
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// `trustedLocalUi` 是唯一允许开启网页管理窗口的来源判定,不等同于 localOnly:
|
|
44
|
+
// 既要 socket 直连 loopback,也要 Host 是 loopback/localhost,并拒绝常见代理转发头。
|
|
45
|
+
// 代理若改写 Host 且剥掉所有来源标记,后端无法从 HTTP 请求辨认原始客户端;这种代理配置
|
|
46
|
+
// 不支持用它开启窗口(应从本机直连或 SSH 隧道访问)。
|
|
47
|
+
const isLoopbackAddress = (address) => {
|
|
48
|
+
if (typeof address !== 'string' || !address) return false
|
|
49
|
+
const value = address.toLowerCase().replace(/^\[|\]$/g, '')
|
|
50
|
+
if (value === '::1' || value === '0:0:0:0:0:0:0:1') return true
|
|
51
|
+
const mapped = value.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/)
|
|
52
|
+
const ipv4 = mapped ? mapped[1] : value
|
|
53
|
+
if (net.isIP(ipv4) !== 4) return false
|
|
54
|
+
return Number(ipv4.split('.')[0]) === 127
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const trustedLocalUi = (req) => {
|
|
58
|
+
const socket = req?.socket || req?.connection
|
|
59
|
+
if (!isLoopbackAddress(socket?.remoteAddress)) return false
|
|
60
|
+
const headers = req?.headers && typeof req.headers === 'object' ? req.headers : {}
|
|
61
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
62
|
+
if (/^(?:forwarded$|x-forwarded-|x-real-ip$|via$|true-client-ip$|cf-connecting-ip$)/i.test(name)
|
|
63
|
+
&& String(value || '').trim()) return false
|
|
64
|
+
}
|
|
65
|
+
const host = headers.host
|
|
66
|
+
if (typeof host !== 'string' || !host) return false
|
|
67
|
+
try {
|
|
68
|
+
const hostname = new URL(`http://${host}`).hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
69
|
+
return hostname === 'localhost' || isLoopbackAddress(hostname)
|
|
70
|
+
} catch {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
// `method` 可以是单个方法名,也可以是方法名数组(同一个路径上挂 GET+POST,
|
|
43
76
|
// 例如 /api/relay/desktop-view 既读快照又收回传)。单方法时的 405 文案保持不变。
|
|
44
77
|
const localOnly = (method, handler) => async (req, res) => {
|
|
@@ -82,6 +115,8 @@ const createGuarded = (ctx, authorized, note = null) => (handler) => async (req,
|
|
|
82
115
|
module.exports = {
|
|
83
116
|
remoteRelayRequest,
|
|
84
117
|
crossSiteRequest,
|
|
118
|
+
isLoopbackAddress,
|
|
119
|
+
trustedLocalUi,
|
|
85
120
|
localOnly,
|
|
86
121
|
readJson,
|
|
87
122
|
createGuarded,
|
package/lib/impl.js
CHANGED
|
@@ -16,7 +16,7 @@ const pairingApproval = require('./relay/pairing-policy.js')
|
|
|
16
16
|
const { sendJson, sendHtml } = require('./shared/http-util.js')
|
|
17
17
|
// 控制面守卫(localOnly)与移动端守卫(guarded)都住在这里;两套守卫并排放,
|
|
18
18
|
// 「控制面多挡跨站与 method、移动端只挡 token」这个不对称一眼可见。
|
|
19
|
-
const { localOnly, readJson, createGuarded } = require('./http/routing.js')
|
|
19
|
+
const { localOnly, trustedLocalUi, readJson, createGuarded } = require('./http/routing.js')
|
|
20
20
|
const { registerMobileRoutes } = require('./routes/mobile-api.js')
|
|
21
21
|
const { registerControlPlaneRoutes } = require('./routes/control-plane.js')
|
|
22
22
|
const { hostMetadata, createStatusView } = require('./relay/status.js')
|
|
@@ -322,7 +322,7 @@ function apply(ctx) {
|
|
|
322
322
|
// 更新的机制住在外壳(`lib/index.js`)里,代这边只把它开成本机接口。取不到就如实报
|
|
323
323
|
// 不可用(`lib/impl.js` 被单独挂载时就是这样)。
|
|
324
324
|
getPluginRuntime: () => (typeof ctx.get === 'function' ? ctx.get('dshMobilePluginRuntime') : null),
|
|
325
|
-
localOnly,
|
|
325
|
+
localOnly, trustedLocalUi,
|
|
326
326
|
readJson,
|
|
327
327
|
sendJson,
|
|
328
328
|
// 面板页(lib/panel/index.html)整页吐给浏览器用;与控制面其它响应一样按注入传,不在这里 require,路由表才保持「只依赖注入项」。
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -386,6 +386,13 @@ function createMirrorPusher(deps) {
|
|
|
386
386
|
.sort((a, b) => a[1].dueAt - b[1].dueAt)
|
|
387
387
|
.map(([sessionId]) => sessionId);
|
|
388
388
|
|
|
389
|
+
const retainSessionSignals = (sessionsBody) => {
|
|
390
|
+
if (typeof sessionSignal.retain !== 'function') return;
|
|
391
|
+
const items = sessionsBody?.body?.items;
|
|
392
|
+
if (!Array.isArray(items)) return;
|
|
393
|
+
sessionSignal.retain(items.map((item) => String(item?.sessionId || '')).filter(Boolean));
|
|
394
|
+
};
|
|
395
|
+
|
|
389
396
|
const pushSessionNow = (sessionId, priority = 'event') => withSessionBuildSlot(priority, async () => {
|
|
390
397
|
try {
|
|
391
398
|
// 清单与消息快照并行:summary 用 Promise 传进去(见 buildSessionData 的注释),
|
|
@@ -407,6 +414,7 @@ function createMirrorPusher(deps) {
|
|
|
407
414
|
noteSessionPushFailure(sessionId, new Error(reason));
|
|
408
415
|
return;
|
|
409
416
|
}
|
|
417
|
+
retainSessionSignals(sessionsBody);
|
|
410
418
|
await pushMirrorPayload({
|
|
411
419
|
kind: 'sync', at: Date.now(),
|
|
412
420
|
activeSessionId: sessionId,
|
|
@@ -462,6 +470,7 @@ function createMirrorPusher(deps) {
|
|
|
462
470
|
recordMirrorPush(false, wsBlocked || 'sessions 快照不可用,本次未推送')
|
|
463
471
|
return
|
|
464
472
|
}
|
|
473
|
+
retainSessionSignals(sessionsBody);
|
|
465
474
|
const list = sessionsBody.body.items || [];
|
|
466
475
|
const highPrio = sessionIds
|
|
467
476
|
? list.filter(s => sessionIds.includes(String(s.sessionId)))
|
|
@@ -521,6 +530,7 @@ function createMirrorPusher(deps) {
|
|
|
521
530
|
// 判定并记录理由,别再无声无息。
|
|
522
531
|
const sessionsBlocked = guardSessions(sessionsBody);
|
|
523
532
|
if (sessionsBlocked) { if (online) recordMirrorPush(false, sessionsBlocked); return }
|
|
533
|
+
retainSessionSignals(sessionsBody);
|
|
524
534
|
// 清单内容版本(lib/shared/list-revision.js 的哈希):既是广播判据,也是下面
|
|
525
535
|
// 「中转镜像里那一版是不是这份」的比对基准 —— 全函数只算这一次。
|
|
526
536
|
const revision = sessionsRevisionOf(sessionsBody && sessionsBody.body);
|
|
@@ -107,6 +107,7 @@ const ENDPOINTS = {
|
|
|
107
107
|
|
|
108
108
|
// skills / subagents
|
|
109
109
|
'skills.list': inv('skills', 'list'),
|
|
110
|
+
'subagents.history': inv('subagents', 'history'),
|
|
110
111
|
'subagents.prompt': inv('subagents', 'prompt', (p) => ({
|
|
111
112
|
request: {
|
|
112
113
|
...pick(p, ['parentSessionId', 'childSessionId', 'mode', 'content', 'clientTimeZone']),
|
|
@@ -84,16 +84,45 @@ function modelsBody(value, fetchedAt) {
|
|
|
84
84
|
return result;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function startModelsRequest(ctx, entry, sessionId, fresh = false) {
|
|
88
|
+
// 在途的强制读挂在 entry 自己身上,不另开模块级表:entry 已经按 ctx + 设备 + 会话
|
|
89
|
+
// 隔离过(session-cache.js 的 sessionModelsStateOf),再建一张 WeakMap 只会多一份
|
|
90
|
+
// 需要解释归属的状态(见 test/module-split.test.cjs:模块级 WeakMap 只许住在
|
|
91
|
+
// session-cache.js 与 rpc.js)。
|
|
92
|
+
if (fresh) {
|
|
93
|
+
if (entry.freshPromise) return entry.freshPromise;
|
|
94
|
+
// A forced read supersedes an older normal request. Its eventual result must not overwrite fresh data.
|
|
95
|
+
entry.generation = (entry.generation || 0) + 1;
|
|
96
|
+
} else if (entry.promise) {
|
|
97
|
+
return entry.promise;
|
|
98
|
+
}
|
|
99
|
+
const generation = entry.generation || 0;
|
|
100
|
+
const request = Promise.resolve(callSessions(ctx, 'models', { sessionId })).then((response) => {
|
|
101
|
+
const { value, error } = unwrap(response);
|
|
102
|
+
if (error) return { error };
|
|
103
|
+
const safeValue = safeModelsValue(value);
|
|
104
|
+
const fetchedAt = Date.now();
|
|
105
|
+
if (generation === (entry.generation || 0)) {
|
|
106
|
+
entry.value = safeValue;
|
|
107
|
+
entry.fetchedAt = fetchedAt;
|
|
108
|
+
}
|
|
109
|
+
return { value: safeValue, fetchedAt };
|
|
110
|
+
});
|
|
111
|
+
const tracked = request.finally(() => { if (entry.promise === tracked) entry.promise = null; });
|
|
112
|
+
entry.promise = tracked;
|
|
113
|
+
if (!fresh) return tracked;
|
|
114
|
+
const forced = tracked.finally(() => { if (entry.freshPromise === forced) entry.freshPromise = null; });
|
|
115
|
+
entry.freshPromise = forced;
|
|
116
|
+
return forced;
|
|
117
|
+
}
|
|
118
|
+
|
|
87
119
|
async function buildModelsSnapshot(ctx, sessionId) {
|
|
88
120
|
if (!sessionId) return { ok: false };
|
|
89
121
|
const entry = sessionModelsStateOf(ctx, 'legacy', sessionId); const now = Date.now();
|
|
90
122
|
if (entry.value && now - entry.fetchedAt < SESSION_MODELS_CACHE_MS) return modelsBody(entry.value, entry.fetchedAt);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
if (entry.value) { void entry.promise.catch(() => undefined); return modelsBody(entry.value, entry.fetchedAt); }
|
|
96
|
-
const result = await entry.promise; return result.error ? { ok: false, error: result.error } : modelsBody(result.value, result.fetchedAt);
|
|
123
|
+
const request = startModelsRequest(ctx, entry, sessionId);
|
|
124
|
+
if (entry.value) { void request.catch(() => undefined); return modelsBody(entry.value, entry.fetchedAt); }
|
|
125
|
+
const result = await request; return result.error ? { ok: false, error: result.error } : modelsBody(result.value, result.fetchedAt);
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
/** GET /api/mobile/v1/session/models?sessionId=…(token)
|
|
@@ -105,31 +134,23 @@ async function handleModels(ctx, req, res) {
|
|
|
105
134
|
const sessionId = url.searchParams.get('sessionId');
|
|
106
135
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
107
136
|
const entry = sessionModelsStateOf(ctx, canonicalDeviceKey(req), sessionId);
|
|
137
|
+
const fresh = url.searchParams.get('fresh') === '1';
|
|
138
|
+
if (fresh) {
|
|
139
|
+
const result = await startModelsRequest(ctx, entry, sessionId, true);
|
|
140
|
+
if (result.error) return sendJson(res, 400, { ok: false, error: result.error });
|
|
141
|
+
return sendJson(res, 200, modelsBody(result.value, result.fetchedAt));
|
|
142
|
+
}
|
|
108
143
|
const now = Date.now();
|
|
109
144
|
if (entry.value && now - entry.fetchedAt < SESSION_MODELS_CACHE_MS) {
|
|
110
145
|
return sendJson(res, 200, modelsBody(entry.value, entry.fetchedAt));
|
|
111
146
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const { value, error } = unwrap(response);
|
|
115
|
-
if (error) return { error };
|
|
116
|
-
entry.value = safeModelsValue(value);
|
|
117
|
-
entry.fetchedAt = Date.now();
|
|
118
|
-
return { value: entry.value, fetchedAt: entry.fetchedAt };
|
|
119
|
-
});
|
|
120
|
-
// 与上面 buildModelsSnapshot 同一种写法:tracked 必须先声明再赋给 entry.promise。
|
|
121
|
-
// 反过来写(entry.promise = request.finally(…tracked) 在前)能跑,只因为 finally 的
|
|
122
|
-
// 回调是在微任务里才执行的,届时 const 已出 TDZ——但换个 await 位置就炸。
|
|
123
|
-
const tracked = request.finally(() => { if (entry.promise === tracked) entry.promise = null; });
|
|
124
|
-
entry.promise = tracked;
|
|
125
|
-
}
|
|
126
|
-
// Serve stale data immediately while one refresh is in flight. Initial loads
|
|
127
|
-
// still await the shared request, so concurrent cold requests collapse to one RPC.
|
|
147
|
+
const request = startModelsRequest(ctx, entry, sessionId);
|
|
148
|
+
// Normal reads keep stale-while-revalidate. Explicit fresh reads above always await the host.
|
|
128
149
|
if (entry.value) {
|
|
129
|
-
void
|
|
150
|
+
void request.catch(() => undefined);
|
|
130
151
|
return sendJson(res, 200, modelsBody(entry.value, entry.fetchedAt));
|
|
131
152
|
}
|
|
132
|
-
const result = await
|
|
153
|
+
const result = await request;
|
|
133
154
|
if (result.error) return sendJson(res, 400, { ok: false, error: result.error });
|
|
134
155
|
sendJson(res, 200, modelsBody(result.value, result.fetchedAt));
|
|
135
156
|
}
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* 消息与统计快照:镜像推送与 /messages 分页共用的两条折叠路径。
|
|
5
5
|
*
|
|
6
6
|
* buildMessagesSnapshot(不解析 query)与 handleMessages(解析 beforeSeq/afterSeq/limit)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 共用消息折叠逻辑。普通会话走 sessions.history;子代理必须携带 durable parent address,
|
|
8
|
+
* 改走 subagents.history,不能把 child sessionId 当普通会话读。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 上游历史接口不认 afterSeq(只认 beforeSeq/maxMessages),所以增量轮询在插件内用较大的
|
|
11
|
+
* 窗口(Math.max(limit, 200))拉最近若干事件后再按 seq 过滤;缺口超过窗口时会漏,此时
|
|
12
|
+
* 用 resyncRequired 让客户端回落到权威快照,而不是安静地少消息。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const { sendJson } = require('../shared/http-util');
|
|
@@ -21,6 +21,26 @@ const { createTextImageBudget } = require('./local-images.js');
|
|
|
21
21
|
|
|
22
22
|
const MESSAGES_DEFAULT_LIMIT = 50; // /messages 默认条数
|
|
23
23
|
|
|
24
|
+
function historyHttpStatus(error) {
|
|
25
|
+
switch (error && error.code) {
|
|
26
|
+
case 'bad-request': return 400;
|
|
27
|
+
case 'session-not-found':
|
|
28
|
+
case 'subagent-not-found': return 404;
|
|
29
|
+
case 'subagent-unauthorized': return 403;
|
|
30
|
+
case 'host-unavailable': return 503;
|
|
31
|
+
default: return 502;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeHistoryError(error) {
|
|
36
|
+
return {
|
|
37
|
+
code: error && typeof error.code === 'string' ? error.code : 'host-call-failed',
|
|
38
|
+
message: error && typeof error.message === 'string' && error.message
|
|
39
|
+
? error.message
|
|
40
|
+
: '读取会话历史失败',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
/** 从 history 事件条目取事件序号(entry.event.seq,兼容裸事件 entry.seq)。 */
|
|
25
45
|
function eventSeqOf(entry) {
|
|
26
46
|
if (!entry) return undefined;
|
|
@@ -91,6 +111,15 @@ async function handleMessages(ctx, req, res) {
|
|
|
91
111
|
const url = new URL(req.url, 'http://x');
|
|
92
112
|
const sessionId = url.searchParams.get('sessionId');
|
|
93
113
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
114
|
+
const parentSessionId = url.searchParams.get('parentSessionId');
|
|
115
|
+
const mode = url.searchParams.get('mode');
|
|
116
|
+
const hasSubagentAddress = parentSessionId !== null || mode !== null;
|
|
117
|
+
if (hasSubagentAddress && (
|
|
118
|
+
typeof parentSessionId !== 'string' || !parentSessionId.trim()
|
|
119
|
+
|| (mode !== 'one-shot' && mode !== 'continuable')
|
|
120
|
+
)) {
|
|
121
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '子代理历史需要 parentSessionId 和有效 mode' } });
|
|
122
|
+
}
|
|
94
123
|
const beforeSeqRaw = url.searchParams.get('beforeSeq');
|
|
95
124
|
const beforeSeq = beforeSeqRaw !== null && /^\d+$/.test(beforeSeqRaw) && Number.isSafeInteger(Number(beforeSeqRaw))
|
|
96
125
|
? Number(beforeSeqRaw) : null;
|
|
@@ -106,12 +135,34 @@ async function handleMessages(ctx, req, res) {
|
|
|
106
135
|
}
|
|
107
136
|
const payload = { sessionId, maxMessages: limit };
|
|
108
137
|
if (beforeSeq !== null) payload.beforeSeq = beforeSeq;
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// 若缺口超过窗口才会漏(简单且安全的折中,不动上游分页协议)。
|
|
138
|
+
// 上游历史接口都不支持 afterSeq(只认 beforeSeq/maxMessages),故在插件内用较大的窗口
|
|
139
|
+
// 拉最近若干事件后再过滤 seq > afterSeq,覆盖常见轮询缺口;缺口超过窗口时要求客户端全量补齐。
|
|
112
140
|
if (afterSeq !== null) payload.maxMessages = Math.max(limit, 200);
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
|
|
142
|
+
let value;
|
|
143
|
+
let error;
|
|
144
|
+
try {
|
|
145
|
+
if (hasSubagentAddress) {
|
|
146
|
+
const historyPayload = {
|
|
147
|
+
parentSessionId,
|
|
148
|
+
childSessionId: sessionId,
|
|
149
|
+
mode,
|
|
150
|
+
maxMessages: payload.maxMessages,
|
|
151
|
+
};
|
|
152
|
+
if (payload.beforeSeq !== undefined) historyPayload.beforeSeq = payload.beforeSeq;
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
({ value, error } = unwrap(await ctx.apiProxy.subagents.history({
|
|
155
|
+
rpcId: 'mobile',
|
|
156
|
+
method: 'subagent.history',
|
|
157
|
+
payload: historyPayload,
|
|
158
|
+
}, controller.signal)));
|
|
159
|
+
} else {
|
|
160
|
+
({ value, error } = unwrap(await callSessions(ctx, 'history', payload)));
|
|
161
|
+
}
|
|
162
|
+
} catch (thrown) {
|
|
163
|
+
error = normalizeHistoryError(thrown);
|
|
164
|
+
}
|
|
165
|
+
if (error) return sendJson(res, historyHttpStatus(error), { ok: false, error });
|
|
115
166
|
const events = (value && value.events) || [];
|
|
116
167
|
// 增量:只保留水位之后的事件;非 afterSeq 调用时 events 原样保留(行为与之前完全一致)。
|
|
117
168
|
const filtered = afterSeq !== null ? events.filter((entry) => (eventSeqOf(entry) ?? -1) > afterSeq) : events;
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -67,24 +67,32 @@ const SESSION_LIST_CACHE_MS = 10000; // 会话列表增料缓存(降低手机
|
|
|
67
67
|
const RUNNING_PREVIEW_MIN_INTERVAL_MS = 3000;
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
70
|
+
* 宿主的两个**会话集合**(同一次 `workspace.list` 快照里拿到):
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
72
|
+
* - `archivedSessionIds`:已归档的会话。桌面端侧边栏据此把它们藏起来,而
|
|
73
|
+
* `sessions.list` 自身**不过滤**归档,所以移动端必须自己读这个集合:此前完全没读,
|
|
74
|
+
* App 的「归档会话」写是写成功了(宿主确实把 id 记进了 archivedSessionIds),列表却
|
|
75
|
+
* 照旧把那一行发回手机,用户看到的就是一次毫无反馈的空操作。
|
|
76
|
+
* - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
|
|
77
|
+
* 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
|
|
78
|
+
* 否则 App 既画不出置顶标记、也排不到工作区最前。
|
|
76
79
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
80
|
+
* 两个集合合并成一次 RPC:它们同源同一次快照,而会话列表每 10 秒就会重建一次,
|
|
81
|
+
* 分两次 `workspace.list` 等于平白给手机多塞一轮往返。
|
|
82
|
+
*
|
|
83
|
+
* 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
|
|
84
|
+
* RPC 抖动让整个会话列表凭空变空。
|
|
79
85
|
*/
|
|
80
|
-
async function
|
|
86
|
+
async function workspaceSessionFlags(ctx) {
|
|
81
87
|
try {
|
|
82
88
|
const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
83
89
|
const value = unwrap(resp).value;
|
|
84
|
-
const
|
|
85
|
-
|
|
90
|
+
const idSetOf = (field) => new Set(
|
|
91
|
+
value && Array.isArray(value[field]) ? value[field].filter(id => typeof id === 'string') : [],
|
|
92
|
+
);
|
|
93
|
+
return { archived: idSetOf('archivedSessionIds'), pinned: idSetOf('pinnedSessionIds') };
|
|
86
94
|
} catch {
|
|
87
|
-
return new Set();
|
|
95
|
+
return { archived: new Set(), pinned: new Set() };
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
|
|
@@ -139,17 +147,23 @@ async function handleSessions(ctx, req, res) {
|
|
|
139
147
|
const now = Date.now();
|
|
140
148
|
// 写操作之后的刷新带 ?fresh=1:跳过这层 10s 缓存、在途构建与宿主 list 的合流,直接按最新状态重建。
|
|
141
149
|
const wantFresh = isFreshRequest(req.url);
|
|
150
|
+
// 「只看已归档」筛选带 ?archived=1(见 buildSessionsResponse 的 archivedOnly):它返回的
|
|
151
|
+
// 是同一批会话的**另一个子集**,不能与默认视图共用这份 10 秒缓存——否则 App 点完
|
|
152
|
+
// 「筛选已归档会话」后,最长 10 秒内拿到的还是那份不含归档行的默认列表。
|
|
153
|
+
// 与 fresh 的区别:它只绕开这层缓存,不需要作废在途构建,也不 bump 朝代号(没有写入)。
|
|
154
|
+
const archivedOnly = new URL(req.url, 'http://x').searchParams.get('archived') === '1';
|
|
155
|
+
const bypassCache = wantFresh || archivedOnly;
|
|
142
156
|
if (wantFresh) {
|
|
143
157
|
// 顺手作废更早发起的构建:它们手里的数据是写入之前的,若在新结果之后才落地会把旧列表又写回缓存。
|
|
144
158
|
cache.value = null;
|
|
145
159
|
cache.updatedAt = 0;
|
|
146
160
|
generation = bumpCompletionGeneration(ctx, deviceKey);
|
|
147
161
|
}
|
|
148
|
-
if (!
|
|
162
|
+
if (!bypassCache && cache.value && now - cache.updatedAt < SESSION_LIST_CACHE_MS) {
|
|
149
163
|
return sendJson(res, 200, cache.value);
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
if (!
|
|
166
|
+
if (!bypassCache && cache.promise) {
|
|
153
167
|
const response = await cache.promise;
|
|
154
168
|
return sendJson(res, response.status, response.body);
|
|
155
169
|
}
|
|
@@ -158,7 +172,7 @@ async function handleSessions(ctx, req, res) {
|
|
|
158
172
|
// 但「丢掉」挡不住**已经在飞**的那次构建:它手里是变更之前的数据,落地后会在缓存里
|
|
159
173
|
// 再活 10 秒。用缓存自带的朝代号兜住(见 session-cache.js 的 dropSessionListCache)。
|
|
160
174
|
const epoch = cache.droppedAt || 0;
|
|
161
|
-
const promise = buildSessionsResponse(ctx, deviceKey, generation, wantFresh);
|
|
175
|
+
const promise = buildSessionsResponse(ctx, deviceKey, generation, wantFresh, archivedOnly);
|
|
162
176
|
cache.promise = promise;
|
|
163
177
|
try {
|
|
164
178
|
const response = await promise;
|
|
@@ -169,7 +183,7 @@ async function handleSessions(ctx, req, res) {
|
|
|
169
183
|
// 周期。15 秒轮询本来就跨过 10 秒窗口,这层缓存的收益只体现在突发刷新上。
|
|
170
184
|
const hasRunning = Array.isArray(response.body && response.body.items)
|
|
171
185
|
&& response.body.items.some((item) => item && item.running);
|
|
172
|
-
if (!hasRunning) {
|
|
186
|
+
if (!hasRunning && !archivedOnly) {
|
|
173
187
|
cache.value = response.body;
|
|
174
188
|
cache.updatedAt = Date.now();
|
|
175
189
|
}
|
|
@@ -180,7 +194,7 @@ async function handleSessions(ctx, req, res) {
|
|
|
180
194
|
}
|
|
181
195
|
}
|
|
182
196
|
|
|
183
|
-
async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = completionGenerationOf(ctx, deviceKey), fresh = false) {
|
|
197
|
+
async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = completionGenerationOf(ctx, deviceKey), fresh = false, archivedOnly = false) {
|
|
184
198
|
const { value, error } = unwrap(await callSessions(ctx, 'list', {}, 'mobile', undefined, { fresh }));
|
|
185
199
|
if (error) return { status: 400, body: { ok: false, error } };
|
|
186
200
|
// 形状不对 = 没取到,不是「你一个会话都没有」。旧实现 `(value.items || [])` 把
|
|
@@ -199,9 +213,13 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
199
213
|
};
|
|
200
214
|
}
|
|
201
215
|
// 归档会话在这里剔除,与桌面端侧边栏行为一致:宿主 sessions.list 自己不过滤归档,
|
|
202
|
-
// 「哪些会话已归档」只存在于 workspaceRegistry.archivedSessionIds(见
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
// 「哪些会话已归档」只存在于 workspaceRegistry.archivedSessionIds(见 workspaceSessionFlags)。
|
|
217
|
+
// archivedOnly(App 的「只看已归档」筛选视图)反过来只保留归档里的那些行:默认视图藏
|
|
218
|
+
// 它们,筛选视图正是为了把它们找回来(查看 / 取消归档)。
|
|
219
|
+
const { archived, pinned } = await workspaceSessionFlags(ctx);
|
|
220
|
+
const allSummaries = archivedOnly
|
|
221
|
+
? summaries.filter(s => s && archived.has(s.sessionId))
|
|
222
|
+
: summaries.filter(s => s && !archived.has(s.sessionId));
|
|
205
223
|
// 状态跟踪覆盖完整列表,不能受预览补全上限影响。
|
|
206
224
|
const completionStatesForContext = updateCompletionStates(ctx, allSummaries, deviceKey);
|
|
207
225
|
// 列表完整返回:侧边栏分组依赖全部会话(工作区 sessionIds 排序 + 未分组兜底),
|
|
@@ -226,6 +244,10 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
226
244
|
cwd: s.cwd || null,
|
|
227
245
|
// 会话定档的 Agent 预设(宿主 session.list 摘要里带,用来显示会话当前预设)。
|
|
228
246
|
...(typeof s.agentPreset === 'string' && s.agentPreset ? { agentPreset: s.agentPreset } : {}),
|
|
247
|
+
// 置顶来自 workspace.list 的 pinnedSessionIds(宿主 session.list 摘要里没有这个字段),
|
|
248
|
+
// 与归档同源同一份快照。App 侧栏据此把这条排到工作区最前、并在行尾画图钉标记。
|
|
249
|
+
// 和归档一样只在真置顶时出现:旧 App 忽略未知字段,行为不变。
|
|
250
|
+
...(pinned.has(s.sessionId) ? { pinned: true } : {}),
|
|
229
251
|
updatedAt: s.updatedAt || 0,
|
|
230
252
|
lastMessage: null,
|
|
231
253
|
...(s.origin === 'subagent' ? { origin: s.origin } : {}),
|
package/lib/mobile/subagents.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* 于是用户既不知道这个会话派过子代理,也没有任何入口能打开它 —— 子代理在手机上
|
|
11
11
|
* 等于不存在。
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* /
|
|
13
|
+
* 子会话在普通消息接口中只有携带 durable parent address 才能读取(parentSessionId + mode);
|
|
14
|
+
* /sessions 只给 origin/parentSessionId,mode 则来自这里的子代理目录。缺的只是
|
|
15
|
+
* 「这个会话有哪些子代理」——
|
|
15
16
|
* 那正是 subagent.list 的返回值(entries + parentAvailable),这里只做形状收敛、
|
|
16
17
|
* 限长与限条数,避免异常宿主把手机端列表撑爆。
|
|
17
18
|
*
|
package/lib/mobile/workspaces.js
CHANGED
|
@@ -201,6 +201,58 @@ async function handleArchiveSession(ctx, req, res) {
|
|
|
201
201
|
sendJson(res, outcome.status, outcome.body);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* 会话级开关写的公共骨架(取消归档 / 置顶 / 取消置顶三条共用)。
|
|
206
|
+
*
|
|
207
|
+
* 与 handleArchiveSession 完全同一套口径:请求体只要 sessionId;按 requestId 走幂等
|
|
208
|
+
* 重放(宿主的这三个动词本身幂等,但重投要拿到和第一次一样的响应体);成功后立刻作废
|
|
209
|
+
* 会话列表缓存,否则 App 按完最长 10 秒里拿到的还是旧那一行,看着像没生效。
|
|
210
|
+
*
|
|
211
|
+
* 宿主回执分别是 `{ archivedSessionIds }` / `{ pinnedSessionIds }`(后者按「最近置顶的
|
|
212
|
+
* 在前」排序),原样透传给 App——它据此立刻重排侧栏。
|
|
213
|
+
*/
|
|
214
|
+
async function runSessionFlagWrite(ctx, req, res, method) {
|
|
215
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
216
|
+
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
217
|
+
if (!sessionId) {
|
|
218
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 sessionId' } });
|
|
219
|
+
}
|
|
220
|
+
const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
|
|
221
|
+
const { value, error } = unwrap(await callWorkspace(ctx, method, { sessionId }));
|
|
222
|
+
if (error) return { status: 400, body: { ok: false, error } };
|
|
223
|
+
invalidateSessionList(ctx);
|
|
224
|
+
return { status: 200, body: { ok: true, ...(value || {}) } };
|
|
225
|
+
});
|
|
226
|
+
sendJson(res, outcome.status, outcome.body);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* POST /api/mobile/v1/workspace/unarchive-session(token){ sessionId }
|
|
231
|
+
* 透传宿主 workspace.unarchiveSession:把会话移出归档集合(未归档的 id 是 no-op)。
|
|
232
|
+
* 移动端此前没有任何取消归档入口,而归档又是移动端唯一的「删除」语义——归档错了
|
|
233
|
+
* 就只能去桌面端捞回来。
|
|
234
|
+
*/
|
|
235
|
+
async function handleUnarchiveSession(ctx, req, res) {
|
|
236
|
+
return runSessionFlagWrite(ctx, req, res, 'unarchiveSession');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* POST /api/mobile/v1/workspace/pin-session(token){ sessionId }
|
|
241
|
+
* 透传宿主 workspace.pinSession:把会话加进置顶集合(宿主侧对已归档会话报
|
|
242
|
+
* `gateway/bad-request`,所以 App 的置顶入口在归档行上不出现)。
|
|
243
|
+
*/
|
|
244
|
+
async function handlePinSession(ctx, req, res) {
|
|
245
|
+
return runSessionFlagWrite(ctx, req, res, 'pinSession');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* POST /api/mobile/v1/workspace/unpin-session(token){ sessionId }
|
|
250
|
+
* 透传宿主 workspace.unpinSession:把会话移出置顶集合(未置顶的 id 是 no-op)。
|
|
251
|
+
*/
|
|
252
|
+
async function handleUnpinSession(ctx, req, res) {
|
|
253
|
+
return runSessionFlagWrite(ctx, req, res, 'unpinSession');
|
|
254
|
+
}
|
|
255
|
+
|
|
204
256
|
module.exports = {
|
|
205
257
|
buildWorkspacesBody,
|
|
206
258
|
handleWorkspaces,
|
|
@@ -211,4 +263,7 @@ module.exports = {
|
|
|
211
263
|
handleDeleteWorkspace,
|
|
212
264
|
handleMoveSession,
|
|
213
265
|
handleArchiveSession,
|
|
266
|
+
handleUnarchiveSession,
|
|
267
|
+
handlePinSession,
|
|
268
|
+
handleUnpinSession,
|
|
214
269
|
};
|
|
@@ -45,6 +45,47 @@ const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobi
|
|
|
45
45
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
46
46
|
const MAX_DESKTOP_READ_IDS = 200;
|
|
47
47
|
|
|
48
|
+
function createWebAccessWindow({ now = Date.now, monotonicNow = () => performance.now() } = {}) {
|
|
49
|
+
let deadline = 0;
|
|
50
|
+
let expiresAt = 0;
|
|
51
|
+
const status = () => {
|
|
52
|
+
const current = monotonicNow();
|
|
53
|
+
if (deadline <= current) {
|
|
54
|
+
deadline = 0;
|
|
55
|
+
expiresAt = 0;
|
|
56
|
+
return { enabled: false, expiresAt: 0 };
|
|
57
|
+
}
|
|
58
|
+
return { enabled: true, expiresAt };
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
status,
|
|
62
|
+
isOpen: () => status().enabled,
|
|
63
|
+
enable() {
|
|
64
|
+
deadline = monotonicNow() + WEB_ACCESS_WINDOW_MS;
|
|
65
|
+
expiresAt = now() + WEB_ACCESS_WINDOW_MS;
|
|
66
|
+
return status();
|
|
67
|
+
},
|
|
68
|
+
disable() {
|
|
69
|
+
deadline = 0;
|
|
70
|
+
expiresAt = 0;
|
|
71
|
+
return status();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow, sendJson }) {
|
|
77
|
+
return (method, handler) => localOnly(method, async (req, res) => {
|
|
78
|
+
const route = String(req?.url || '').split('?', 1)[0];
|
|
79
|
+
if (WEB_ACCESS_ROUTES.has(route) && !trustedLocalUi(req) && !webAccessWindow.isOpen()) {
|
|
80
|
+
return sendJson(res, 423, {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: { code: 'web_access_closed', message: '网页管理暂未开放,请从 DSH 本机设置页开启 2 小时窗口' },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return handler(req, res);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
/**
|
|
49
90
|
* 面板页(`React/` 那份 React 源码构建成的单文件 HTML)在包里的位置。
|
|
50
91
|
*
|
|
@@ -165,7 +206,8 @@ function panelUrlOf(req) {
|
|
|
165
206
|
function registerControlPlaneRoutes(deps) {
|
|
166
207
|
const {
|
|
167
208
|
registerRoute,
|
|
168
|
-
localOnly,
|
|
209
|
+
localOnly: baseLocalOnly,
|
|
210
|
+
trustedLocalUi,
|
|
169
211
|
readJson,
|
|
170
212
|
sendJson,
|
|
171
213
|
sendHtml,
|
|
@@ -194,6 +236,13 @@ function registerControlPlaneRoutes(deps) {
|
|
|
194
236
|
// 缺省返回 null:`lib/impl.js` 单独挂载时(测试 / 老宿主)没有外壳,如实报不可用。
|
|
195
237
|
getPluginRuntime = () => null,
|
|
196
238
|
} = deps;
|
|
239
|
+
const webAccessWindow = createWebAccessWindow();
|
|
240
|
+
const localOnly = createWebAccessLocalOnly({
|
|
241
|
+
localOnly: baseLocalOnly,
|
|
242
|
+
trustedLocalUi: typeof trustedLocalUi === 'function' ? trustedLocalUi : () => false,
|
|
243
|
+
webAccessWindow,
|
|
244
|
+
sendJson,
|
|
245
|
+
});
|
|
197
246
|
|
|
198
247
|
// Pairing approval. The desktop shell used to answer this with an interactive
|
|
199
248
|
// prompt; a headless host has no surface for one, so `pairingPolicy` decides.
|
|
@@ -229,7 +278,17 @@ function registerControlPlaneRoutes(deps) {
|
|
|
229
278
|
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
230
279
|
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port) })
|
|
231
280
|
}) })
|
|
232
|
-
|
|
281
|
+
// 只有可信本机设置入口可以开 / 关窗口;窗口状态仅驻留进程内,DSH 重启后必定关闭。
|
|
282
|
+
registerRoute({ kind: 'exact', path: '/api/relay/web-access', handler: localOnly('POST', async (req, res) => {
|
|
283
|
+
if (typeof trustedLocalUi !== 'function' || !trustedLocalUi(req)) return sendJson(res, 403, { ok: false, error: { code: 'local_ui_only', message: '请从 DSH 本机设置页管理网页访问' } });
|
|
284
|
+
const body = await readJson(req);
|
|
285
|
+
if (typeof body?.enabled !== 'boolean') {
|
|
286
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
|
|
287
|
+
}
|
|
288
|
+
const webAccess = body.enabled ? webAccessWindow.enable() : webAccessWindow.disable();
|
|
289
|
+
return sendJson(res, 200, { ok: true, webAccess });
|
|
290
|
+
}) });
|
|
291
|
+
registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), webAccess: webAccessWindow.status(), ...mobileActivity() })) })
|
|
233
292
|
// 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
|
|
234
293
|
// 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
|
|
235
294
|
// /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
|
|
@@ -587,6 +646,38 @@ function registerControlPlaneRoutes(deps) {
|
|
|
587
646
|
}) })
|
|
588
647
|
}
|
|
589
648
|
|
|
649
|
+
// 路由字符串清单放在注册区之后,避免被按路由源码切片的契约测试误认为是真正的 handler。
|
|
650
|
+
/** 网页管理的固定开放窗口;按单调时钟计时,系统校时不会把窗口意外延长。 */
|
|
651
|
+
const WEB_ACCESS_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
652
|
+
const WEB_ACCESS_ROUTES = new Set([
|
|
653
|
+
'/api/relay/addresses',
|
|
654
|
+
'/api/relay/status',
|
|
655
|
+
'/api/relay/panel',
|
|
656
|
+
'/api/relay/panel/setup',
|
|
657
|
+
'/api/relay/panel/settings',
|
|
658
|
+
'/api/relay/faults',
|
|
659
|
+
'/api/relay/pairing',
|
|
660
|
+
'/api/relay/toggle',
|
|
661
|
+
'/api/relay/regenerate',
|
|
662
|
+
'/api/relay/onboarded',
|
|
663
|
+
'/api/relay/revoke-all',
|
|
664
|
+
'/api/relay/config',
|
|
665
|
+
'/api/relay/check-port',
|
|
666
|
+
'/api/relay/reconnect',
|
|
667
|
+
'/api/relay/update/status',
|
|
668
|
+
'/api/relay/update/check',
|
|
669
|
+
'/api/relay/update/apply',
|
|
670
|
+
'/api/relay/update/auto',
|
|
671
|
+
'/api/relay/restart-sidecar',
|
|
672
|
+
]);
|
|
673
|
+
|
|
590
674
|
// `panelDiagnosis` 一并导出,只为让 test/panel-artifact.test.cjs 能拿临时目录跑那三条分支:
|
|
591
675
|
// 三种「读不到」的文案分别对应「重启 / 升级 / 构建」,它们混起来就是这个 bug 的原样。
|
|
592
|
-
module.exports = {
|
|
676
|
+
module.exports = {
|
|
677
|
+
registerControlPlaneRoutes,
|
|
678
|
+
panelDiagnosis,
|
|
679
|
+
WEB_ACCESS_ROUTES,
|
|
680
|
+
WEB_ACCESS_WINDOW_MS,
|
|
681
|
+
createWebAccessWindow,
|
|
682
|
+
createWebAccessLocalOnly,
|
|
683
|
+
};
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -167,6 +167,11 @@ function registerMobileRoutes(deps) {
|
|
|
167
167
|
// 但插件从来没有注册 → 移动端「重命名工作区」和「归档会话」必然 404。
|
|
168
168
|
route('/api/mobile/v1/workspace/rename', workspaces.handleRenameWorkspace)
|
|
169
169
|
route('/api/mobile/v1/workspace/archive-session', workspaces.handleArchiveSession)
|
|
170
|
+
// 会话级开关的反向动作:取消归档 / 置顶 / 取消置顶。桌面端这三件事都有入口
|
|
171
|
+
// (行尾按钮 + 会话菜单),移动端此前一个都没有——归档错了只能回桌面端捞。
|
|
172
|
+
route('/api/mobile/v1/workspace/unarchive-session', workspaces.handleUnarchiveSession)
|
|
173
|
+
route('/api/mobile/v1/workspace/pin-session', workspaces.handlePinSession)
|
|
174
|
+
route('/api/mobile/v1/workspace/unpin-session', workspaces.handleUnpinSession)
|
|
170
175
|
// 桌面端 `/export` 其实是两步:命令本身只留一行 transcript 标记,真正的下载由
|
|
171
176
|
// @deepseek-ai/dsh-session-log-export 在浏览器侧监听 command/executed 后拉
|
|
172
177
|
// `GET /api/session.export` 完成。手机端没有浏览器下载管理器,而那个端点绑在宿主
|
|
@@ -26,10 +26,8 @@
|
|
|
26
26
|
const NOOP_RELEASE = () => {};
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* 键是 sessionId、只增不减:一个长期运行的插件会见过成千上万个会话(归档的、一次性的),
|
|
32
|
-
* 而水位只用来判「这次广播是不是新的」,旧会话的水位没有任何价值。超限时丢最早活动的那条。
|
|
29
|
+
* 最多额外保留多少个非当前会话水位。当前完整会话清单通过 retain() 保留,不受此上限影响;
|
|
30
|
+
* 这样长跑插件仍能回放所有未归档会话,同时把归档/一次性会话的尾巴限制在有限范围内。
|
|
33
31
|
*/
|
|
34
32
|
const MAX_TRACKED = 512;
|
|
35
33
|
|
|
@@ -37,14 +35,21 @@ function createSessionSignal() {
|
|
|
37
35
|
const listeners = new Set();
|
|
38
36
|
/** sessionId -> { seq, at }:最近一次广播出去的水位。 */
|
|
39
37
|
const watermarks = new Map();
|
|
38
|
+
const retainedSessionIds = new Set();
|
|
40
39
|
|
|
41
40
|
const pruneOldest = () => {
|
|
42
41
|
let oldestKey = null;
|
|
43
42
|
let oldestAt = Infinity;
|
|
44
43
|
for (const [key, value] of watermarks) {
|
|
44
|
+
if (retainedSessionIds.has(key)) continue;
|
|
45
45
|
if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key; }
|
|
46
46
|
}
|
|
47
47
|
if (oldestKey !== null) watermarks.delete(oldestKey);
|
|
48
|
+
return oldestKey !== null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const trimWatermarks = () => {
|
|
52
|
+
while (watermarks.size > MAX_TRACKED && pruneOldest()) { /* keep every current session */ }
|
|
48
53
|
};
|
|
49
54
|
|
|
50
55
|
return {
|
|
@@ -66,13 +71,28 @@ function createSessionSignal() {
|
|
|
66
71
|
if (previous && value <= previous.seq) return false;
|
|
67
72
|
const at = Date.now();
|
|
68
73
|
watermarks.set(id, { seq: value, at });
|
|
69
|
-
|
|
74
|
+
trimWatermarks();
|
|
70
75
|
for (const listener of [...listeners]) {
|
|
71
76
|
try { listener({ sessionId: id, seq: value, at }); } catch { /* 订阅者自己的异常不该影响推送方 */ }
|
|
72
77
|
}
|
|
73
78
|
return true;
|
|
74
79
|
},
|
|
75
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 声明当前完整、未归档会话集合;这些水位必须留到 SSE 重连回放,超出时只淘汰非当前会话。
|
|
83
|
+
* 同时清除刚从权威列表消失的会话(归档/删除),避免重放幽灵水位。
|
|
84
|
+
*/
|
|
85
|
+
retain(sessionIds) {
|
|
86
|
+
const next = new Set(Array.isArray(sessionIds) ? sessionIds.filter((id) => typeof id === 'string' && id.length > 0) : []);
|
|
87
|
+
for (const id of watermarks.keys()) {
|
|
88
|
+
if (!next.has(id)) watermarks.delete(id);
|
|
89
|
+
}
|
|
90
|
+
retainedSessionIds.clear();
|
|
91
|
+
for (const id of next) retainedSessionIds.add(id);
|
|
92
|
+
trimWatermarks();
|
|
93
|
+
return retainedSessionIds.size;
|
|
94
|
+
},
|
|
95
|
+
|
|
76
96
|
/** 订阅水位变化;返回退订函数(可重复调用)。 */
|
|
77
97
|
onBump(listener) {
|
|
78
98
|
if (typeof listener !== 'function') return NOOP_RELEASE;
|
|
@@ -101,6 +121,7 @@ function createSessionSignal() {
|
|
|
101
121
|
/** 测试用:清空状态。 */
|
|
102
122
|
reset() {
|
|
103
123
|
watermarks.clear();
|
|
124
|
+
retainedSessionIds.clear();
|
|
104
125
|
},
|
|
105
126
|
};
|
|
106
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
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",
|