dsh-mobile-plugin 0.5.14 → 0.5.16

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/client.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
3
3
  *
4
4
  * 五件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
5
- * 提供「设置 → 移动端」那颗由本机打开的网页管理开关;在侧边栏最底下那一行挂一颗
6
- * 手机图标(点一下就是设置里那颗「打开配置页面」)。方向是单向的(桌面 → 宿主 → 手机),
7
- * 手机上的折叠只存在 App 本地,不写回。
5
+ * 设置里那一项「移动端」(一张薄卡,一颗「打开配置页面」);在侧边栏最底下那一行挂一颗
6
+ * 手机图标(点一下等于那张卡上的按钮)。方向是单向的(桌面 → 宿主 → 手机),手机上的
7
+ * 折叠只存在 App 本地,不写回。
8
8
  *
9
9
  * 本文件不经任何构建(package.json 直接收录 lib 下的 .js),所以不能写 JSX,统一用
10
10
  * `React.createElement` 的别名 `h`;客户端插件按约定写成惰性工厂,定时器与页签在物化时才起。
@@ -27,8 +27,8 @@ window.__ModuleLoader__.load({
27
27
  * 照抄 @deepseek-ai/dsh-client-ui-settings-plugins 的注入方式:同一
28
28
  * data-plugin-css 只注入一次,bundle 被重复物化也不会堆出第二份 <style>。
29
29
  *
30
- * 这一半只剩侧边栏那一颗图标与它打开的诊断弹窗(设置菜单里那一项已经按用户要求去掉):
31
- * 界面本体在网页版里,那边自带一套样式。
30
+ * 这一半有三样东西:设置里那一张薄卡、侧边栏那一颗图标(点它等于点那张卡上的按钮),
31
+ * 以及打不开时那张诊断弹窗。界面本体在网页版里,那边自带一套样式。
32
32
  */
33
33
  const STYLE_ID = 'dsh-mobile-plugin/footer-icon.css';
34
34
  const CSS = `
@@ -51,6 +51,13 @@ window.__ModuleLoader__.load({
51
51
  .dshm_factMono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
52
52
  .dshm_steps{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px}
53
53
  .dshm_dialogActions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;margin-top:2px}
54
+ .dshm_root{display:flex;flex-direction:column;gap:14px;width:100%;max-width:760px;color:var(--dsw-alias-label-primary)}
55
+ .dshm_card{border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill);display:flex;flex-direction:column;gap:10px;padding:14px 16px}
56
+ .dshm_heading{font-size:18px;font-weight:600;margin:0}
57
+ .dshm_cardTitle{font-size:14px;font-weight:600;line-height:20px;margin:0}
58
+ .dshm_actions{display:flex;flex-wrap:wrap;gap:8px}
59
+ .dshm_note{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
60
+ .dshm_error{margin:0;border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;padding:8px 10px}
54
61
  .dshm_btn{border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:13px;line-height:18px;padding:5px 12px;display:inline-flex;align-items:center;gap:6px;text-decoration:none}
55
62
  .dshm_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
56
63
  .dshm_btn:disabled{cursor:default;opacity:.45}
@@ -218,6 +225,22 @@ window.__ModuleLoader__.load({
218
225
  body: JSON.stringify(payload || {}),
219
226
  });
220
227
 
228
+ /**
229
+ * 打开那两小时网页管理窗口。
230
+ *
231
+ * 只有「浏览器不是从这台机器上打开的」才需要它:控制面不认它,面板会回 423。设置卡与
232
+ * 侧边栏图标都走这一份实现 —— 各写一遍迟早会在「窗口开着没有」上判得不一样,而这两种
233
+ * 判法对应的是「多开一次」与「跳过去吃一个 423」。
234
+ *
235
+ * 返回值是宿主回的新窗口状态,交给调用方存进自己的 state。
236
+ */
237
+ async function requestAccessWindow() {
238
+ const next = await postJson('/web-access', { enabled: true });
239
+ return next && next.webAccess && typeof next.webAccess.enabled === 'boolean'
240
+ ? next.webAccess
241
+ : { enabled: true, expiresAt: 0 };
242
+ }
243
+
221
244
  /* ── 三:侧边栏未读绿点的双向同步 ───────────────────────────────────────
222
245
  * 两端的绿点是两份互不相干的状态:
223
246
  * - 手机端 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
@@ -482,6 +505,15 @@ window.__ModuleLoader__.load({
482
505
  const NS = 'settings.mobileRelay';
483
506
  const DICT = {
484
507
  zh: {
508
+ nav: '移动端',
509
+ heading: '移动端',
510
+ cardPanel: '配置页面',
511
+ openPanel: '打开配置页面',
512
+ openHint: '配对、网络配置与插件更新都在这个页面里。',
513
+ panelPending: '正在读取页面地址…',
514
+ panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
515
+ loadFailed: '读取宿主状态失败',
516
+ openFailed: '打开配置页面失败',
485
517
  footerOpen: '打开移动端插件配置网页',
486
518
  footerPending: '网页地址还没就绪',
487
519
  footerPublicConfirm: '这个地址位于公网。继续后会开放移动端插件的网页管理权限 2 小时;期间任何能访问该地址的人都可以操作面板。是否继续?',
@@ -531,6 +563,15 @@ window.__ModuleLoader__.load({
531
563
  diagFactWindow: '两小时窗口',
532
564
  },
533
565
  en: {
566
+ nav: 'Mobile',
567
+ heading: 'Mobile',
568
+ cardPanel: 'Configuration page',
569
+ openPanel: 'Open the configuration page',
570
+ openHint: 'Pairing, network settings and plugin updates all live on this page.',
571
+ panelPending: 'Reading the page address…',
572
+ panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
573
+ loadFailed: 'Failed to read host status',
574
+ openFailed: 'Could not open the configuration page',
534
575
  footerOpen: 'Open the mobile plugin configuration page',
535
576
  footerPending: 'The page address is not ready yet',
536
577
  footerPublicConfirm: 'This address is public. Continuing opens mobile plugin web management for 2 hours; anyone who can reach this address can operate the panel during that time. Continue?',
@@ -724,6 +765,119 @@ window.__ModuleLoader__.load({
724
765
  return protocol === 'http:' || protocol === 'https:';
725
766
  }
726
767
 
768
+ /** 把一份 `/status` 返回收成拨号要用的三样;缺的当没有。 */
769
+ function readStatusView(view) {
770
+ const value = view && typeof view === 'object' ? view : {};
771
+ return {
772
+ panelUrl: typeof value.panelUrl === 'string' ? value.panelUrl : '',
773
+ panelScope: typeof value.panelScope === 'string' ? value.panelScope : '',
774
+ webAccess: value.webAccess && typeof value.webAccess.enabled === 'boolean' ? value.webAccess : null,
775
+ };
776
+ }
777
+
778
+ /**
779
+ * 配置页在**这个页面自己**的同源地址上。
780
+ *
781
+ * 宿主的 `panelUrl` 是按请求的 Host 头拼的,反代把 DSH 挂在子路径(`/dsh/`)下时那条会
782
+ * 丢掉前缀、Host 被改写时还会指到内网去;而页面自己的 `<base href="./">` 一定是带前缀、
783
+ * 也一定是用户浏览器真能到的那一层。所以拿不到宿主地址时按这个推,推不出来就算了。
784
+ */
785
+ function sameOriginPanelUrl() {
786
+ try {
787
+ const base = typeof document !== 'undefined' && document.baseURI
788
+ ? document.baseURI
789
+ : (typeof location !== 'undefined' ? location.href : '');
790
+ if (!base) return '';
791
+ return new URL('api/relay/panel', base).href;
792
+ } catch {
793
+ return '';
794
+ }
795
+ }
796
+
797
+ /**
798
+ * 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
799
+ *
800
+ * 为什么不能只读一次 `/status`:那条接口自己也在窗口门后面(`WEB_ACCESS_ROUTES`),
801
+ * 窗口关着时**不是本机**的浏览器只拿得到 423。而客户端原先规定「没地址就不去开窗口」,
802
+ * 两边互相锁死 —— 远程用户点了只会看到「网页地址还没就绪」,没有任何顺序能走通
803
+ * (2026-11-03 用户报的就是这个)。所以读不到地址时正确的动作不是放弃,而是**先开窗**:
804
+ * 那条开关接口本来就对同源页面豁免,开完再读一次,地址与类别就都有了。
805
+ *
806
+ * 地址优先用宿主给的 `panelUrl`,拿不到再用页面自己的同源地址(见 `sameOriginPanelUrl`)。
807
+ *
808
+ * 新窗口必须由调用方在**点击那一帧**开出来(`window.open('')`),这里只负责导航它;
809
+ * 等接口回来再开会被弹窗拦截器吃掉。`confirmed` = 调用方已经问过公网那句确认(问过的
810
+ * 不再问第二遍;地址是自救拿到的,类别此前不知道,所以在这里补问)。
811
+ *
812
+ * @returns {Promise<{ok: true, cancelled?: boolean, url: string, webAccess: object|null}
813
+ * | {ok: false, missing: boolean, status: number, message: string}>}
814
+ */
815
+ async function openConfigurationPage({ view, loadStatus = 0, t, target = null, confirmed = false }) {
816
+ let state = readStatusView(view);
817
+ const failed = (error) => ({
818
+ ok: false,
819
+ missing: false,
820
+ status: Number(error && error.status) || 0,
821
+ message: String((error && error.message) || error),
822
+ });
823
+
824
+ if (!state.panelUrl) {
825
+ // 404 = 宿主那一半没装载,开窗也救不了:交给调用方按「装完没重启」报。
826
+ if (loadStatus === 404) return { ok: false, missing: true, status: 404, message: '' };
827
+ // 先补读一次:可能只是页面上那一次读取还没回来,或者刚才网络抖了一下。
828
+ try {
829
+ state = readStatusView(await call('/status'));
830
+ } catch (error) {
831
+ if ((Number(error && error.status) || 0) === 404) {
832
+ return { ok: false, missing: true, status: 404, message: '' };
833
+ }
834
+ // 读不到多半是被窗口门挡住(423):先开窗再读一次。这是远程用户唯一能走通的顺序,
835
+ // 也正因为原先把这一步排在了「拿到地址之后」,两个条件互等,谁都过不去。
836
+ try {
837
+ const opened = await requestAccessWindow();
838
+ state = { ...readStatusView(await call('/status')), webAccess: opened };
839
+ } catch (inner) {
840
+ return failed(inner);
841
+ }
842
+ }
843
+ if (!state.panelUrl) {
844
+ return { ok: false, missing: false, status: 0, message: t('panelMissing') };
845
+ }
846
+ }
847
+
848
+ const url = state.panelUrl || sameOriginPanelUrl();
849
+ if (!url) return { ok: false, missing: false, status: 0, message: t('panelMissing') };
850
+
851
+ if (state.panelScope === 'public'
852
+ && !confirmed
853
+ && typeof window !== 'undefined'
854
+ && typeof window.confirm === 'function'
855
+ && !window.confirm(t('footerPublicConfirm'))) {
856
+ return { ok: true, cancelled: true, url, webAccess: state.webAccess };
857
+ }
858
+
859
+ if (state.panelScope !== 'loopback' && !(state.webAccess && state.webAccess.enabled)) {
860
+ try {
861
+ state.webAccess = await requestAccessWindow();
862
+ } catch (error) {
863
+ return failed(error);
864
+ }
865
+ }
866
+
867
+ if (target) {
868
+ target.location.href = url;
869
+ return { ok: true, url, webAccess: state.webAccess };
870
+ }
871
+ if (!isWebPage(window)) {
872
+ // 交回给页面导航:壳会把外部 http(s) 地址转给系统浏览器。
873
+ window.location.href = url;
874
+ return { ok: true, url, webAccess: state.webAccess };
875
+ }
876
+ const opened = typeof window.open === 'function' ? window.open(url, '_blank', 'noreferrer') : null;
877
+ if (!opened) return { ok: false, missing: false, status: 0, message: t('diagPopupBlocked') };
878
+ return { ok: true, url, webAccess: state.webAccess };
879
+ }
880
+
727
881
  /* ── 侧边栏底部那颗手机图标 ────────────────────────────────────────────
728
882
  * 用户要的是「点一下等于设置页那颗『打开配置页面』」,并且就在侧边栏最底下、头像
729
883
  * 那一行里。外壳那一行没有给插件留座位:那一行里从左到右是设置项自己渲染的一整颗
@@ -808,8 +962,9 @@ window.__ModuleLoader__.load({
808
962
  /**
809
963
  * 把「打开哪一页 / 现在能不能点」同步到元素上。
810
964
  *
811
- * 没有地址时**不写 href**:锚点没有 href 就点不动,比挂一个死链诚实;`aria-disabled`
812
- * 只负责把这一点说出来。
965
+ * 没有地址时**不写 href**(没有地址可写,别挂死链),但**点击处理照样挂上**:那颗图标的
966
+ * 点击会自己去把两小时窗口开出来、再读一次地址(见 `openConfigurationPage`)。原先这里
967
+ * 把 `onclick` 一起摘掉,现场就是「点了完全没反应」——2026-11-03 用户报的正是这个。
813
968
  *
814
969
  * 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
815
970
  */
@@ -817,12 +972,12 @@ window.__ModuleLoader__.load({
817
972
  const label = url ? openLabel : pendingLabel;
818
973
  node.setAttribute('aria-label', label);
819
974
  node.setAttribute('title', label);
820
- node.setAttribute('aria-disabled', url ? 'false' : 'true');
975
+ node.setAttribute('aria-disabled', 'false');
821
976
  // 有地址时仍然写 `href`:普通链接照旧能中键 / 新标签打开。只有「浏览器不是从本机来的」
822
977
  // 那两种情况要拦下来先开窗口,见 `openPanel`。
823
978
  if (url) node.setAttribute('href', url);
824
979
  else node.removeAttribute('href');
825
- node.onclick = url ? onClick : null;
980
+ node.onclick = onClick;
826
981
  }
827
982
 
828
983
  /** 把按钮放回设置那一行、紧跟头像出口。返回是否真在那一行里。 */
@@ -929,66 +1084,46 @@ window.__ModuleLoader__.load({
929
1084
  /**
930
1085
  * 点下去要做什么,取决于面板地址落在哪一类网络上(宿主在 `/status` 上算的 `panelScope`)。
931
1086
  *
932
- * 新窗口必须在**点击这一帧**就开出来(`window.open('', '_blank')`):等接口回来再开会被
933
- * 浏览器的弹窗拦截器吃掉。地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
1087
+ * 有地址且就在本机:普通链接,交给浏览器自己开。
1088
+ * 其余情况(从别的机器打开的页面、或者压根还没读到地址)都要先把两小时窗口开出来:
1089
+ * 新窗口必须在**点击这一帧**开出来(`window.open('', '_blank')`),等接口回来再开会被
1090
+ * 浏览器的弹窗拦截器吃掉;地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
934
1091
  * 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
935
1092
  */
936
1093
  function openPanel(event) {
937
- if (!url) {
938
- // 连地址都没问到:点了必须有反应 —— 弹诊断窗,而不是一颗点不动的灰图标。
939
- void openDiagnosis('load', problem);
940
- return;
941
- }
942
- const needsWindow = panelScope !== 'loopback' && !(webAccess && webAccess.enabled);
1094
+ const knowsUrl = typeof url === 'string' && url !== '';
1095
+ const needsWindow = !knowsUrl || (panelScope !== 'loopback' && !(webAccess && webAccess.enabled));
943
1096
  if (!needsWindow) return; // 普通链接,交给浏览器自己开
944
1097
  if (event && typeof event.preventDefault === 'function') event.preventDefault();
945
- if (panelScope === 'public'
1098
+ // 公网地址先问一句;地址都还没读到时问不了,交由 `openConfigurationPage` 拿到类别后补问。
1099
+ if (knowsUrl
1100
+ && panelScope === 'public'
946
1101
  && typeof window !== 'undefined'
947
1102
  && typeof window.confirm === 'function'
948
1103
  && !window.confirm(publicConfirm)) return;
949
1104
  const target = isWebPage(window) && typeof window.open === 'function'
950
1105
  ? window.open('', '_blank')
951
1106
  : null;
952
- void openWithWindow(target);
953
- }
954
-
955
- /**
956
- * 先把窗口开起来、再跳过去;失败就把那个空白页关掉并弹诊断窗。
957
- *
958
- * 还有一种「点了没反应」:浏览器把新窗口拦了(`window.open` 返回 null)。那时接口
959
- * 已经成功、地址也在手上,只是没地方给它 —— 直接告诉用户该允许弹窗,别让他以为坏了。
960
- *
961
- * 壳里的页面(`dsh-app://`)不适用这一套:那里本来就没有「新窗口」这回事,`null` 是
962
- * 正常答复、地址已经由壳交给系统浏览器了。当成被拦只会平白盖一屏诊断。
963
- */
964
- async function openWithWindow(target) {
965
- try {
966
- const next = await postJson('/web-access', { enabled: true });
967
- setWebAccess(next && next.webAccess && typeof next.webAccess.enabled === 'boolean'
968
- ? next.webAccess
969
- : { enabled: true, expiresAt: 0 });
970
- if (target) {
971
- target.location.href = url;
1107
+ void (async () => {
1108
+ const outcome = await openConfigurationPage({
1109
+ view: { panelUrl: url || '', panelScope, webAccess },
1110
+ loadStatus: problem ? problem.status : 0,
1111
+ t,
1112
+ target,
1113
+ confirmed: knowsUrl,
1114
+ });
1115
+ if (outcome.ok) {
1116
+ if (outcome.cancelled && target && typeof target.close === 'function') target.close();
1117
+ if (outcome.webAccess) setWebAccess(outcome.webAccess);
972
1118
  return;
973
1119
  }
974
- if (!isWebPage(window)) {
975
- // 交回给页面导航:壳会把外部 http(s) 地址转给系统浏览器。
976
- window.location.href = url;
1120
+ if (target && typeof target.close === 'function') target.close();
1121
+ if (outcome.missing) {
1122
+ void openDiagnosis('load', { status: outcome.status || 404, message: (problem && problem.message) || '' });
977
1123
  return;
978
1124
  }
979
- const retry = typeof window !== 'undefined' && typeof window.open === 'function'
980
- ? window.open(url, '_blank', 'noreferrer')
981
- : null;
982
- if (!retry) {
983
- void openDiagnosis('open', { status: 0, message: t('diagPopupBlocked') });
984
- }
985
- } catch (error) {
986
- if (target && typeof target.close === 'function') target.close();
987
- void openDiagnosis('open', {
988
- status: Number(error && error.status) || 0,
989
- message: String((error && error.message) || error),
990
- });
991
- }
1125
+ void openDiagnosis('open', { status: outcome.status || 0, message: outcome.message });
1126
+ })();
992
1127
  }
993
1128
 
994
1129
  /**
@@ -1279,7 +1414,7 @@ window.__ModuleLoader__.load({
1279
1414
  rel: 'noreferrer',
1280
1415
  'aria-label': label,
1281
1416
  title: label,
1282
- 'aria-disabled': url ? 'false' : 'true',
1417
+ 'aria-disabled': 'false',
1283
1418
  onClick: openPanel,
1284
1419
  }, h('svg', {
1285
1420
  viewBox: '0 0 24 24',
@@ -1346,8 +1481,104 @@ window.__ModuleLoader__.load({
1346
1481
  }, 'mobile-plugin: 客户端半边自检上报');
1347
1482
  }
1348
1483
 
1484
+ /* ── 设置里的那一项「移动端」───────────────────────────────────────────
1485
+ * 用户 2026-11-03 要求加回来:介绍产品时说的入口就是「设置 → 移动端」,2026-11-02 那一版
1486
+ * 把它整块下线、只留侧边栏那颗图标,于是从网页版进来的人不知道配置在哪。现在两项都在,
1487
+ * 它们进的是同一个页面、走同一条拨号链路(图标是快捷方式,这一张是设置里的正规入口)。
1488
+ *
1489
+ * 刻意只有一张薄卡:界面本体全在网页版里(A3-37),在这里再画一套等于长期维护第二套 UI,
1490
+ * 而两套迟早会打架。卡片只做两件事——说清配置在哪、把那一页开出来;开不出来就在卡上写原因。
1491
+ */
1492
+ function MobileSettingsSection({ t }) {
1493
+ /** null = 还在问宿主;'' = 问到了但宿主没给地址(老版本或还没起来)。 */
1494
+ const [url, setUrl] = useState(null);
1495
+ const [scope, setScope] = useState('');
1496
+ const [access, setAccess] = useState(null);
1497
+ /** `null` = 这一次没出错;否则 `{ kind: 'load' | 'open', message }`。 */
1498
+ const [failure, setFailure] = useState(null);
1499
+
1500
+ useEffect(() => {
1501
+ let alive = true;
1502
+ void (async () => {
1503
+ try {
1504
+ const next = await call('/status');
1505
+ if (!alive) return;
1506
+ const view = next && typeof next === 'object' ? next : {};
1507
+ setUrl(typeof view.panelUrl === 'string' ? view.panelUrl : '');
1508
+ setScope(typeof view.panelScope === 'string' ? view.panelScope : '');
1509
+ setAccess(view.webAccess && typeof view.webAccess.enabled === 'boolean' ? view.webAccess : null);
1510
+ setFailure(null);
1511
+ } catch (error) {
1512
+ if (!alive) return;
1513
+ setUrl('');
1514
+ setFailure({ kind: 'load', status: Number(error && error.status) || 0, message: String((error && error.message) || error) });
1515
+ }
1516
+ })();
1517
+ return () => { alive = false; };
1518
+ }, []);
1519
+
1520
+ /**
1521
+ * 拨到面板。判据与侧边栏那颗图标完全一致(A3-25):本机直接跳;从别的机器打开的浏览器
1522
+ * 控制面不认它,先把两小时窗口开出来再跳;公网地址多弹一句确认。地址还没读到时也照样
1523
+ * 走这条路——它会自己开窗再读一次(见 `openConfigurationPage`),不是干等。
1524
+ *
1525
+ * 新窗口必须在**点击这一帧**开出来,等到接口回来再开会被弹窗拦截器吃掉。
1526
+ */
1527
+ function openPanel(event) {
1528
+ const knowsUrl = typeof url === 'string' && url !== '';
1529
+ const needsWindow = !knowsUrl || (scope !== 'loopback' && !(access && access.enabled));
1530
+ if (!needsWindow) return; // 普通链接,交给浏览器自己开
1531
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
1532
+ if (knowsUrl
1533
+ && scope === 'public'
1534
+ && typeof window !== 'undefined'
1535
+ && typeof window.confirm === 'function'
1536
+ && !window.confirm(t('footerPublicConfirm'))) return;
1537
+ const target = isWebPage(window) && typeof window.open === 'function'
1538
+ ? window.open('', '_blank')
1539
+ : null;
1540
+ void (async () => {
1541
+ const outcome = await openConfigurationPage({
1542
+ view: { panelUrl: url || '', panelScope: scope, webAccess: access },
1543
+ loadStatus: failure && failure.kind === 'load' ? failure.status || 0 : 0,
1544
+ t,
1545
+ target,
1546
+ confirmed: knowsUrl,
1547
+ });
1548
+ if (outcome.ok) {
1549
+ if (outcome.cancelled && target && typeof target.close === 'function') target.close();
1550
+ if (outcome.webAccess) setAccess(outcome.webAccess);
1551
+ if (!outcome.cancelled) setFailure(null);
1552
+ return;
1553
+ }
1554
+ if (target && typeof target.close === 'function') target.close();
1555
+ setFailure({ kind: outcome.missing ? 'load' : 'open', status: outcome.status || 0, message: outcome.message });
1556
+ })();
1557
+ }
1558
+
1559
+ const hint = url === null ? t('panelPending') : url === '' ? t('panelMissing') : t('openHint');
1560
+ return h('div', { className: 'dshm_root' },
1561
+ h('h2', { className: 'dshm_heading' }, t('heading')),
1562
+ h('div', { className: 'dshm_card' },
1563
+ h('h3', { className: 'dshm_cardTitle' }, t('cardPanel')),
1564
+ h('p', { className: 'dshm_note' }, hint),
1565
+ h('div', { className: 'dshm_actions' },
1566
+ h('a', {
1567
+ className: 'dshm_btn dshm_btnPrimary',
1568
+ href: url || undefined,
1569
+ target: '_blank',
1570
+ rel: 'noreferrer',
1571
+ 'aria-disabled': 'false',
1572
+ onClick: openPanel,
1573
+ }, t('openPanel'))),
1574
+ failure
1575
+ ? h('p', { className: 'dshm_error' },
1576
+ `${failure.kind === 'load' ? t('loadFailed') : t('openFailed')}:${failure.message}`)
1577
+ : null));
1578
+ }
1579
+
1349
1580
  return {
1350
- // slots:注册侧边栏那颗图标,由 @deepseek-ai/dsh-client-runtime 提供(它 require
1581
+ // slots:注册设置项与侧边栏那颗图标,由 @deepseek-ai/dsh-client-runtime 提供(它 require
1351
1582
  // dsh-client-ui-slots 的 SlotCore 再以 `super(ctx, "slots")` 暴露服务)。
1352
1583
  // locale:字典,由 @deepseek-ai/dsh-client-locale 提供。
1353
1584
  // 这两个服务名对应 package.json 里 dsh.client.inject 注入的两个包。
@@ -1360,10 +1591,19 @@ window.__ModuleLoader__.load({
1360
1591
  startDesktopReadSync(ctx);
1361
1592
  startReadReceiptSync(ctx);
1362
1593
  startClientHalfReport(ctx);
1363
- // 侧边栏底部那颗手机图标:**它是进网页的唯一入口**(2026-11-02 用户要求把设置菜单
1364
- // 里那一项去掉)。走外壳给插件留的动作位注册(`name` 与 `inject()` 的路径必须是同一个
1365
- // 槽名,改一处不报错、只是静默挂不上);展开态再自己插进设置那一行,细节见
1366
- // `MobileWebEntry` 上面那段注释。
1594
+ // 设置里那一项:外壳声明 `settings.section`(设置菜单里的顶层一项),先声明后注册 ——
1595
+ // 外壳可能比我们晚一步声明它。跟内置插件页的页签是两回事:这一项是自己的设置页。
1596
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1597
+ name: 'settings.section',
1598
+ id: 'mobile',
1599
+ order: 20,
1600
+ label: () => t('nav'),
1601
+ locale: NS,
1602
+ inject: () => ({ t }),
1603
+ }, MobileSettingsSection));
1604
+ // 侧边栏底部那颗手机图标:它是设置那一项的快捷方式,进的是同一个页面。走外壳给插件
1605
+ // 留的动作位注册(`name` 与 `inject()` 的路径必须是同一个槽名,改一处不报错、只是
1606
+ // 静默挂不上);展开态再自己插进设置那一行,细节见 `MobileWebEntry` 上面那段注释。
1367
1607
  ctx.slots.inject(FOOTER_SLOT, () => ctx.slots.register({
1368
1608
  name: FOOTER_SLOT,
1369
1609
  id: 'mobile-web',
package/lib/impl.js CHANGED
@@ -33,6 +33,8 @@ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManu
33
33
  const { pairingPayload } = require('./relay/pairing-payload.cjs')
34
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
35
35
  const { createPreviewService } = require('./relay/preview.js')
36
+ // 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
37
+ const hostWeb = require('./mobile/host-web.js')
36
38
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
37
39
  // 否则「撤销全部授权」只清了中转侧,同网设备仍能用旧凭据直连宿主。
38
40
  // 入口按两条盘上布局分别找(嵌在插件包内 / 与插件包同级),见 shared/credentials-entry.js。
@@ -460,7 +462,11 @@ function apply(ctx) {
460
462
  // `protectedPorts` 是取值器而不是快照:局域网端口会在运行中被自适应改掉
461
463
  // (见上面的 resolveLanPort),快照会让这道边界跟着失效。DSH 自身的 web 端口
462
464
  // (`ctx.webServer.port`,默认 3080)也从这里按身份排掉——预览这条管道刻意不做
463
- // 「把桌面端 GUI 搬进手机」这件事,所以不允许它连回宿主自己。
465
+ // 「把桌面端 GUI 搬进手机」这件事。
466
+ //
467
+ // `ownWebPort` 是同一条边界上的唯一例外:用户显式要求手机能打开网页版(2026-11-03),
468
+ // 所以 `mode=webapp` 那一档只放行这一个端口(且目标必须是环回)。取值器而非快照,
469
+ // 理由同上:端口会被配置改,快照会让放行的那一个停在旧号上。
464
470
  const preview = createPreviewService({
465
471
  authorized,
466
472
  protectedPorts: () => {
@@ -468,6 +474,7 @@ function apply(ctx) {
468
474
  ports.delete(0)
469
475
  return ports
470
476
  },
477
+ ownWebPort: () => ctx.webServer.port,
471
478
  onLog: (message) => ctx.logger?.info?.(`${name}: %s`, message),
472
479
  })
473
480
  ctx.effect(() => () => preview.close(), `${name}: preview pipes`)
@@ -480,6 +487,9 @@ function apply(ctx) {
480
487
  // 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
481
488
  registerUpgradeRoute: (route) => mountRoute(route, true),
482
489
  preview,
490
+ // 网页版登录令牌那条:handler 在这里造好、按依赖交给路由表,理由与 preview 一样——
491
+ // 它要读的是本进程的 `ctx.webServer` 与 `ctx.get('connection')`。
492
+ hostWeb: hostWeb.handleHostWeb,
483
493
  guarded,
484
494
  state,
485
495
  hostMetadata,
@@ -156,6 +156,20 @@ function hasPending(sessionId) {
156
156
  return openEntries(sessionId).length > 0;
157
157
  }
158
158
 
159
+ /**
160
+ * 这条会话此刻挂着的审批卡(形状与推送帧一字不差),没有就 undefined。
161
+ *
162
+ * 与提问桥的 `pendingCardOf` 同一用途:手机回前台时先问原生要卡,不必等流重新接上
163
+ * (见 `questions.js` 那一处的说明)。只认已绑定的项 —— 与 `attachSession` 的补发口径
164
+ * 一致:没绑上钩子的卡答不动,送上去只会让人点出一个 409。
165
+ */
166
+ function pendingCardOf(sessionId) {
167
+ for (const entry of openEntries(sessionId)) {
168
+ if (entry.bound) return cardFrame(entry);
169
+ }
170
+ return undefined;
171
+ }
172
+
159
173
  /** 测试用:清空全部进程内状态(真实运行时由插件卸载时的 disposer 收尾)。 */
160
174
  function resetForTest() {
161
175
  watchers.clear();
@@ -367,5 +381,6 @@ module.exports = {
367
381
  handleApprovalRespond,
368
382
  attachSession,
369
383
  hasPending,
384
+ pendingCardOf,
370
385
  resetForTest,
371
386
  };
@@ -0,0 +1,365 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 无限画布的库(`/api/mobile/v1/canvas/*`)。
5
+ *
6
+ * 为什么要有它:画布整个活在 App 的 WebView 里,项目和素材全在页面的 IndexedDB 中 ——
7
+ * 清一次缓存、换一台手机就没了,桌面端也看不见。这个模块把「库」搬到宿主机上,App 负责
8
+ * 把本机那份推上来、把远端那份拉下去,桌面端以后读的是同一份。
9
+ *
10
+ * 存两样东西,各管一件事:
11
+ * · **清单**(每个域一份 JSON):项目图、资产行、工作台记录这些「结构」,加上一份
12
+ * `files: [{storageKey, hash, mimeType, bytes}]` —— 结构里引用了哪些字节、各是哪一份。
13
+ * · **字节**(`objects/<哈希前两位>/<其余>`):按内容哈希落盘,只增不改。同一个 storageKey
14
+ * 在手机上是不可变的(键是随机串),所以判「要不要传」只看远端清单里有没有这个键。
15
+ *
16
+ * 清单永远在字节之后写:先传缺的文件、再写清单。写清单这一步会逐个 stat 文件,缺一个就
17
+ * 整份拒绝(`missing-file`)—— 一份指向不存在字节的清单在手机上表现为「画布能打开、图全是
18
+ * 坏的」,而且它会把下一次同步判成「已同步」,坏得无声无息。
19
+ *
20
+ * 能力面:只有一个固定目录,**客户端给不了任何路径**,也只会拿到哈希。文件名是 64 位十六
21
+ * 进制(不是哈希形状的一律不认,顺带挡住路径穿越),与分片上传那条通道同一口径。
22
+ */
23
+
24
+ const crypto = require('node:crypto');
25
+ const fs = require('node:fs');
26
+ const fsp = require('node:fs/promises');
27
+ const path = require('node:path');
28
+ const { pipeline } = require('node:stream/promises');
29
+
30
+ const { sendJson, readJsonBody } = require('../shared/http-util');
31
+ const { dshHome } = require('../shared/paths.js');
32
+ const { writeEncryptedFile, encryptedLengthOf } = require('./session-file.js');
33
+
34
+ /**
35
+ * 四个域与画布自己的同步引擎一一对应(`app/vendor/canvas-src` 的 app-sync)。
36
+ *
37
+ * 域是清单的分片,不是权限:每个域一份文件,谁变了推谁,别把整库塞进一份 JSON。
38
+ */
39
+ const DOMAINS = new Set(['canvas', 'assets', 'image-workbench', 'video-workbench']);
40
+
41
+ /** 清单请求体上限(**明文**字节):与分片上传同一个 8MB 预算,够装几万个节点。 */
42
+ const MAX_MANIFEST_BYTES = Math.ceil(8 * 1024 * 1024 * 4 / 3);
43
+ /** 一份清单最多认多少个文件条目:再多也传不完,早报错比传到一半失败好。 */
44
+ const MAX_FILES = 20000;
45
+ /** 单份字节的上限:与「会话文件」同一条口径,超过的整份传不回来。 */
46
+ const MAX_OBJECT_BYTES = 512 * 1024 * 1024;
47
+ /** 内容哈希:64 位小写十六进制。别的形状一律不认(这一条同时挡住路径穿越)。 */
48
+ const HASH_RE = /^[0-9a-f]{64}$/;
49
+ /** storageKey 只当清单里的键用、不拼路径,但仍然要求是干净的字符串。 */
50
+ const STORAGE_KEY_MAX = 300;
51
+ /** 清单版本号取内容哈希的前 16 位:同一份内容算出同一个号,不需要另存一个自增计数。 */
52
+ const REVISION_CHARS = 16;
53
+
54
+ /** 库根:插件自己的数据目录,`DSH_MOBILE_CANVAS` 只给测试用。 */
55
+ function rootDir() {
56
+ return process.env.DSH_MOBILE_CANVAS || path.join(dshHome(), 'storages', 'mobile-plugin', 'canvas');
57
+ }
58
+
59
+ /** 某个域的清单文件。 */
60
+ function manifestPath(domain) {
61
+ return path.join(rootDir(), `manifest-${domain}.json`);
62
+ }
63
+
64
+ /** 字节区根。 */
65
+ function objectsRoot() {
66
+ return path.join(rootDir(), 'objects');
67
+ }
68
+
69
+ /**
70
+ * 哈希 → 盘上路径。形状不对回 null:这个值要拼进文件路径,校验放在这里,不靠调用方记得。
71
+ */
72
+ function objectPathOf(hash) {
73
+ if (typeof hash !== 'string' || !HASH_RE.test(hash)) return null;
74
+ return path.join(objectsRoot(), hash.slice(0, 2), hash.slice(2));
75
+ }
76
+
77
+ /** 域 → 规范值;不认识回 null。 */
78
+ function domainOf(value) {
79
+ const domain = typeof value === 'string' ? value.trim() : '';
80
+ return DOMAINS.has(domain) ? domain : null;
81
+ }
82
+
83
+ /** 内容 → 版本号(内容变了才对不上,所以不需要另记时间)。 */
84
+ function revisionOf(payload) {
85
+ return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, REVISION_CHARS);
86
+ }
87
+
88
+ /**
89
+ * 读某个域的清单。没有回 null;盘上有但读不懂**如实抛**,不当成「没有」——
90
+ * 当成没有会让手机把本机那份推上来,把远端那份直接盖掉(那是最不该发生的静默失败)。
91
+ */
92
+ async function readManifest(domain) {
93
+ let text;
94
+ try {
95
+ text = await fsp.readFile(manifestPath(domain), 'utf8');
96
+ } catch (error) {
97
+ if (error && error.code === 'ENOENT') return null;
98
+ throw Object.assign(new Error(`读画布清单失败:${error.message}`), { code: 'canvas-store-unavailable' });
99
+ }
100
+ try {
101
+ const parsed = JSON.parse(text);
102
+ if (!parsed || typeof parsed !== 'object' || parsed.domain !== domain) throw new Error('形状不对');
103
+ return parsed;
104
+ } catch (error) {
105
+ throw Object.assign(new Error(`画布清单已损坏(${domain}):${error.message}`), { code: 'manifest-corrupt' });
106
+ }
107
+ }
108
+
109
+ /** 哈希对应的字节在不在盘上(清单落盘之前要逐个确认)。 */
110
+ async function objectExists(hash) {
111
+ const target = objectPathOf(hash);
112
+ if (!target) return false;
113
+ try {
114
+ const info = await fsp.stat(target);
115
+ return info.isFile();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * 校验并规范化一份要写进来的清单。
123
+ *
124
+ * @returns {{ status: number, code: string, message: string, missing?: string[] } | { files: Array<object>, data: unknown }}
125
+ */
126
+ function normalizePayload(body) {
127
+ const domain = domainOf(body.domain);
128
+ if (!domain) return { status: 400, code: 'bad-domain', message: 'domain 只能是 canvas / assets / image-workbench / video-workbench' };
129
+ if (!body.data || typeof body.data !== 'object' || Array.isArray(body.data)) {
130
+ return { status: 400, code: 'bad-data', message: '需要 { data }(一个对象)' };
131
+ }
132
+ const rawFiles = body.files === undefined ? [] : body.files;
133
+ if (!Array.isArray(rawFiles)) return { status: 400, code: 'bad-files', message: 'files 必须是数组' };
134
+ if (rawFiles.length > MAX_FILES) {
135
+ return { status: 413, code: 'too-many-files', message: `一份清单最多 ${MAX_FILES} 个文件` };
136
+ }
137
+ // 同一个 storageKey 只留最后一条:清单是整份替换的,重复条目只会在下发时变成两行。
138
+ const byKey = new Map();
139
+ for (const item of rawFiles) {
140
+ if (!item || typeof item !== 'object') return { status: 400, code: 'bad-files', message: 'files 里每一项都得是对象' };
141
+ const storageKey = typeof item.storageKey === 'string' ? item.storageKey : '';
142
+ if (!storageKey || storageKey.length > STORAGE_KEY_MAX || /[\u0000-\u001f\u007f]/.test(storageKey)) {
143
+ return { status: 400, code: 'bad-files', message: 'storageKey 必须是 1..300 个可见字符' };
144
+ }
145
+ if (!HASH_RE.test(String(item.hash || ''))) {
146
+ return { status: 400, code: 'bad-files', message: `文件 ${storageKey} 的 hash 不是 64 位十六进制` };
147
+ }
148
+ const bytes = Number(item.bytes);
149
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_OBJECT_BYTES) {
150
+ return { status: 400, code: 'bad-files', message: `文件 ${storageKey} 的字节数不合法` };
151
+ }
152
+ const mimeType = typeof item.mimeType === 'string' ? item.mimeType.slice(0, 120) : 'application/octet-stream';
153
+ byKey.set(storageKey, { storageKey, hash: String(item.hash), mimeType, bytes });
154
+ }
155
+ return { files: [...byKey.values()], data: body.data };
156
+ }
157
+
158
+ /**
159
+ * 写清单:先确认每个哈希都在盘上,再整份原子替换。
160
+ *
161
+ * 为什么逐个 stat:清单是「库里有哪些字节」的账,账上有一笔盘上没有的单子,手机下次同步会
162
+ * 因为「清单里有」而不再上传,那张图标就一直坏着。
163
+ */
164
+ async function writeManifest(domain, data, files) {
165
+ const missing = [];
166
+ for (const file of files) {
167
+ if (!(await objectExists(file.hash))) missing.push(file.storageKey);
168
+ }
169
+ if (missing.length) {
170
+ const shown = missing.slice(0, 5).join('、');
171
+ return {
172
+ status: 409,
173
+ code: 'missing-file',
174
+ message: `这些文件还没传上来:${shown}${missing.length > 5 ? ` 等 ${missing.length} 个` : ''}`,
175
+ missing,
176
+ };
177
+ }
178
+ const revision = revisionOf({ data, files });
179
+ const manifest = {
180
+ app: 'infinite-canvas',
181
+ version: 1,
182
+ domain,
183
+ revision,
184
+ updatedAt: new Date().toISOString(),
185
+ data,
186
+ files,
187
+ };
188
+ const target = manifestPath(domain);
189
+ const tmp = `${target}.tmp-${crypto.randomBytes(6).toString('hex')}`;
190
+ await fsp.mkdir(path.dirname(target), { recursive: true });
191
+ try {
192
+ await fsp.writeFile(tmp, JSON.stringify(manifest), 'utf8');
193
+ // 原子替换:手机随时可能来读,读到的要么是上一份、要么是这一份。
194
+ await fsp.rename(tmp, target);
195
+ } catch (error) {
196
+ try { await fsp.unlink(tmp); } catch { /* 已经不在了 */ }
197
+ throw Object.assign(new Error(`写画布清单失败:${error.message}`), { code: 'canvas-store-unavailable' });
198
+ }
199
+ return { revision, updatedAt: manifest.updatedAt, files: files.length };
200
+ }
201
+
202
+ /** 一份文件的 sha256(流式,不把整份读进内存)。 */
203
+ function sha256OfFile(filePath) {
204
+ return new Promise((resolve, reject) => {
205
+ const hash = crypto.createHash('sha256');
206
+ const stream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 });
207
+ stream.on('error', reject);
208
+ stream.on('data', (chunk) => hash.update(chunk));
209
+ stream.on('end', () => resolve(hash.digest('hex')));
210
+ });
211
+ }
212
+
213
+ /**
214
+ * 把一份刚落进暂存区的字节收进库里,回它的哈希。
215
+ *
216
+ * 分片上传(`uploads.js`)传完一份画布素材就交给这里:算哈希 → 落进 objects。同内容的文件
217
+ * 已经在库里就直接丢暂存的那份 —— 画布里的图经常是同一张被引用了好几次,哈希天然去重。
218
+ *
219
+ * @returns {Promise<{ hash: string, bytes: number }>}
220
+ */
221
+ async function storeUploadedFile(stagingPath) {
222
+ const info = await fsp.stat(stagingPath);
223
+ if (info.size > MAX_OBJECT_BYTES) {
224
+ const error = Object.assign(new Error('这份文件太大,画布库装不下'), { code: 'too-large', status: 413 });
225
+ throw error;
226
+ }
227
+ const hash = await sha256OfFile(stagingPath);
228
+ const target = objectPathOf(hash);
229
+ await fsp.mkdir(path.dirname(target), { recursive: true });
230
+ if (await objectExists(hash)) {
231
+ try { await fsp.unlink(stagingPath); } catch { /* 已经不在了 */ }
232
+ return { hash, bytes: info.size };
233
+ }
234
+ try {
235
+ await fsp.rename(stagingPath, target);
236
+ } catch {
237
+ // 跨设备(暂存根被 `DSH_MOBILE_INBOX` 挪到别的盘)时 rename 会 EXDEV:复制一份再删。
238
+ await fsp.copyFile(stagingPath, target);
239
+ try { await fsp.unlink(stagingPath); } catch { /* 已经不在了 */ }
240
+ }
241
+ return { hash, bytes: info.size };
242
+ }
243
+
244
+ /**
245
+ * GET /api/mobile/v1/canvas/manifest?domain=canvas&rev=<版本号>
246
+ *
247
+ * 带 `rev` 且与盘上那份一致时只回一句 `unchanged` —— 画布每开一次就同步一次,稳态那一发
248
+ * 不该把整份项目图再传一遍(跟会话列表那条条件刷同一个口径)。
249
+ */
250
+ async function handleCanvasManifestGet(_ctx, req, res) {
251
+ const query = new URL(req.url || '/', 'http://x').searchParams;
252
+ const domain = domainOf(query.get('domain'));
253
+ if (!domain) return sendJson(res, 400, { ok: false, error: { code: 'bad-domain', message: 'domain 只能是 canvas / assets / image-workbench / video-workbench' } });
254
+ let manifest;
255
+ try {
256
+ manifest = await readManifest(domain);
257
+ } catch (error) {
258
+ return sendJson(res, 500, { ok: false, error: { code: (error && error.code) || 'canvas-store-unavailable', message: String(error && error.message) } });
259
+ }
260
+ if (!manifest) return sendJson(res, 200, { ok: true, domain, revision: null, empty: true, files: [] });
261
+ const rev = String(query.get('rev') || '');
262
+ if (rev && rev === manifest.revision) {
263
+ return sendJson(res, 200, { ok: true, domain, revision: manifest.revision, updatedAt: manifest.updatedAt, unchanged: true });
264
+ }
265
+ sendJson(res, 200, {
266
+ ok: true,
267
+ domain,
268
+ revision: manifest.revision,
269
+ updatedAt: manifest.updatedAt,
270
+ data: manifest.data,
271
+ files: Array.isArray(manifest.files) ? manifest.files : [],
272
+ });
273
+ }
274
+
275
+ /**
276
+ * POST /api/mobile/v1/canvas/manifest { domain, data, files }
277
+ *
278
+ * 整份替换、不做字段级合并:合并是客户端的事(它手上有本机那份,也有刚拉下去的远端那份)。
279
+ * 宿主只负责「存下来、版本号算清楚、缺字节就拒绝」。
280
+ */
281
+ async function handleCanvasManifestSave(_ctx, req, res) {
282
+ const body = await readJsonBody(req, MAX_MANIFEST_BYTES);
283
+ if (!body || typeof body !== 'object') {
284
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '请求体不是合法 JSON,或超过体积上限' } });
285
+ }
286
+ const normalized = normalizePayload(body);
287
+ if (normalized.status) {
288
+ return sendJson(res, normalized.status, { ok: false, error: { code: normalized.code, message: normalized.message, ...(normalized.missing ? { missing: normalized.missing } : {}) } });
289
+ }
290
+ let result;
291
+ try {
292
+ result = await writeManifest(domainOf(body.domain), normalized.data, normalized.files);
293
+ } catch (error) {
294
+ return sendJson(res, 500, { ok: false, error: { code: (error && error.code) || 'canvas-store-unavailable', message: String(error && error.message) } });
295
+ }
296
+ if (result.status) {
297
+ return sendJson(res, result.status, { ok: false, error: { code: result.code, message: result.message, ...(result.missing ? { missing: result.missing } : {}) } });
298
+ }
299
+ sendJson(res, 200, { ok: true, domain: domainOf(body.domain), revision: result.revision, updatedAt: result.updatedAt, files: result.files });
300
+ }
301
+
302
+ /**
303
+ * POST /api/mobile/v1/canvas/file { hash } → 那份字节。
304
+ *
305
+ * 按哈希取、不按路径:客户端构造不出一个能读到别的东西的请求。响应形状与 `/session/file`
306
+ * 逐字节同形(含协议 v2 的分块信封与 `content-length`)—— 手机那边用的是同一个原生下载器,
307
+ * 换一个形状就要在 App 里再写一条下载路。
308
+ */
309
+ async function handleCanvasFile(_ctx, req, res) {
310
+ const body = await readJsonBody(req);
311
+ const hash = body && typeof body.hash === 'string' ? body.hash.trim().toLowerCase() : '';
312
+ const target = objectPathOf(hash);
313
+ if (!target) {
314
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { hash }(64 位十六进制)' } });
315
+ }
316
+ let info;
317
+ try {
318
+ info = await fsp.stat(target);
319
+ } catch {
320
+ return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
321
+ }
322
+ if (!info.isFile()) {
323
+ return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
324
+ }
325
+
326
+ const header = { 'content-type': 'application/octet-stream', 'cache-control': 'no-store' };
327
+ const opened = req && req.__dshE2ee;
328
+ if (opened && opened.keys && opened.req) {
329
+ res.writeHead(200, {
330
+ ...header,
331
+ 'content-type': 'application/x-dsh-e2ee-chunks',
332
+ 'content-length': String(encryptedLengthOf(info.size)),
333
+ 'x-dsh-file-size': String(info.size),
334
+ 'x-dsh-proto': '2',
335
+ 'x-dsh-content-type': 'application/octet-stream',
336
+ });
337
+ await writeEncryptedFile(req, res, target, opened);
338
+ return;
339
+ }
340
+
341
+ res.writeHead(200, { ...header, 'content-length': String(info.size) });
342
+ if (info.size === 0) return res.end();
343
+ const stream = fs.createReadStream(target);
344
+ res.on('close', () => stream.destroy());
345
+ try {
346
+ await pipeline(stream, res);
347
+ } catch {
348
+ // 头已经发出去了,改不了状态码,只能断开让对端按截断处理(与会话文件那条一致)。
349
+ try { res.destroy(); } catch { /* 连接已关闭 */ }
350
+ }
351
+ }
352
+
353
+ module.exports = {
354
+ DOMAINS,
355
+ MAX_OBJECT_BYTES,
356
+ rootDir,
357
+ objectPathOf,
358
+ objectExists,
359
+ readManifest,
360
+ writeManifest,
361
+ storeUploadedFile,
362
+ handleCanvasManifestGet,
363
+ handleCanvasManifestSave,
364
+ handleCanvasFile,
365
+ };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端「打开这台电脑上的 DSH 网页版」:把网页版的一次性登录令牌交给 App。
5
+ *
6
+ * 网页版里装着全部插件的管理界面,手机上要直接用它,就得让 App 的 WebView 以已登录身份
7
+ * 打开宿主自己的 web 端口。而那个端口只认环回、网页版每次启动换一枚一次性令牌,所以
8
+ * 这件事只能由宿主来办:它已经把令牌签好了,没有理由让手机再走一遍登录。
9
+ *
10
+ * 令牌是凭据,所以这条端点走 `guarded`——只有已配对设备拿得到,令牌也绝不进日志。
11
+ *
12
+ * 两个值都现取,不缓存:端口会被配置改,连接服务会随宿主重启换一份实例。任何一环取不到
13
+ * 都如实回「不可用」,让 App 退回「请在电脑上打开网页版」;这里抛错只会把一次能力探测
14
+ * 变成一条 500,反而看不出是哪一格缺了。
15
+ */
16
+
17
+ const { sendJson } = require('../shared/http-util');
18
+
19
+ /**
20
+ * 宿主 web 服务真正在听的端口。
21
+ *
22
+ * 取 `ctx.webServer.port` 而不是配置里的首选值:真正在听的是它,网页版也只绑在这个号上。
23
+ */
24
+ function hostPortOf(ctx) {
25
+ try {
26
+ const port = ctx?.webServer?.port;
27
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 网页版的一次性登录令牌,挂在连接服务的 `browserAuth` 上。
35
+ *
36
+ * 用 `ctx.get('connection')` 而不是把它写进 `inject`:注入一个这个宿主版本里没有的服务,
37
+ * 会让整行永远 pending(见 `host-bridge/transport.js` 里 `peerOf` 的注释)。服务不在、
38
+ * 字段不在、值不是非空字符串,都算这台宿主现在给不出令牌。
39
+ */
40
+ function launchTokenOf(ctx) {
41
+ try {
42
+ const connection = typeof ctx?.get === 'function' ? ctx.get('connection') : null;
43
+ const token = connection && connection.browserAuth ? connection.browserAuth.launchToken : null;
44
+ return typeof token === 'string' && token.length > 0 ? token : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** `GET /api/mobile/v1/host-web` */
51
+ async function handleHostWeb(ctx, _req, res) {
52
+ const port = hostPortOf(ctx);
53
+ if (port === null) return sendJson(res, 200, { ok: true, available: false, reason: 'no-port' });
54
+ const token = launchTokenOf(ctx);
55
+ if (token === null) return sendJson(res, 200, { ok: true, available: false, reason: 'no-launch-token' });
56
+ sendJson(res, 200, { ok: true, available: true, port, token });
57
+ }
58
+
59
+ module.exports = {
60
+ handleHostWeb,
61
+ hostPortOf,
62
+ launchTokenOf,
63
+ };
@@ -26,7 +26,7 @@ const {
26
26
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
27
27
  const { lastTurnOf } = require('./session-last-turn.js');
28
28
  const { sessionPreviewFor } = require('./session-preview.js');
29
- const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
29
+ const { workspaceSessionFlags, pendingInteractionOf, pendingCardOf } = require('./session-flags.js');
30
30
 
31
31
  /**
32
32
  * 一条会话在监控视图里的那几格。
@@ -35,8 +35,10 @@ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags
35
35
  * lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
36
36
  * 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
37
37
  *
38
- * 另有一个可选字段 `lastMessage`:只给可能要提醒的那几条带上一句「这一轮跑出了什么」,
39
- * 供原生完成提醒的横幅正文用;拿不到预览就不出这个字段。
38
+ * 另有两个可选字段:`lastMessage` 只给可能要提醒的那几条带上一句「这一轮跑出了什么」,
39
+ * 供原生完成提醒的横幅正文用;`pendingCard` 是**等你处理的那张卡本身**(与实时流推的帧
40
+ * 一字不差),只在那几条待处理的条目上带 —— 手机回前台时先问原生要它,就不必等流重新
41
+ * 接上才有卡(见 `session-flags.js` 的 pendingCardOf)。拿不到都不出字段。
40
42
  */
41
43
  function monitorItem(summary, state, fact) {
42
44
  const running = summary.running === true;
@@ -60,7 +62,19 @@ function monitorItem(summary, state, fact) {
60
62
  completed,
61
63
  };
62
64
  const pending = pendingInteractionOf(summary.sessionId, summary);
63
- if (pending) item.pendingInteraction = pending;
65
+ if (pending) {
66
+ item.pendingInteraction = pending;
67
+ // 卡的内容跟着一起走:它在上面这个判据之外没有第二条路能到手机上(推送只在流接入时
68
+ // 补发一次)。进指纹是名正言顺的 —— 换一道新题就是真的变了,原生那一拍该被唤醒。
69
+ const card = pendingCardOf(summary.sessionId, summary);
70
+ if (card) item.pendingCard = card;
71
+ } else {
72
+ // **显式写 null**:原生那头靠「这一格在不在」判「这台宿主认不认识它」,并据此决定
73
+ // 要不要把「看过了、没有挂起」记成账。不在就永远是「不知道」,回前台也就永远撤不掉
74
+ // 那张已经在电脑上答掉的旧卡。待处理但没有帧可给(0.1.x 摘要自带口径)时仍然不写,
75
+ // 那格含义是「不知道」,不是「没有」。
76
+ item.pendingCard = null;
77
+ }
64
78
  const failure = fact
65
79
  ? (fact.status === 'failed' && settledForThisDevice
66
80
  ? { turn: fact.turn, code: fact.code ?? null, message: fact.message ?? null }
@@ -74,6 +88,11 @@ function monitorItem(summary, state, fact) {
74
88
  turn: fact.turn,
75
89
  endedAt: fact.endedAt,
76
90
  status: fact.status,
91
+ // 这一轮是不是**已经有人读过了**(桌面 / 另一台手机 / 这台都算)。与设备无关的
92
+ // 事实,跟着事实走;上面那个 `completed` 用的是按设备的 `clearedAt`,而桌面点开
93
+ // 只看 `/api/relay/desktop-read`,它不动那个水位 —— 后台把「已经看过的这一轮」
94
+ // 弹出来之后,只有这个水位能让它自己消失(见原生那条撤销)。
95
+ ...(typeof fact.readAt === 'number' && fact.readAt > 0 ? { readAt: fact.readAt } : {}),
77
96
  ...(fact.status === 'failed' ? { code: fact.code ?? null, message: fact.message ?? null } : {}),
78
97
  };
79
98
  }
@@ -157,6 +157,20 @@ function attachSession(sessionId, sink) {
157
157
  };
158
158
  }
159
159
 
160
+ /**
161
+ * 这条会话此刻挂着的提问卡(形状与推送帧一字不差),没有就 undefined。
162
+ *
163
+ * 给 `/monitor` 用:手机回前台时可以**先问原生要这张卡**,不必等流重新接上。挂起项本来
164
+ * 只在进程内、推送只有「接入补发」那一次,回前台那条重连一旦落空卡就缺到下次重进对话;
165
+ * 让后台轮询顺手把卡的内容带回去,这条缺口才补得上。
166
+ */
167
+ function pendingCardOf(sessionId) {
168
+ for (const entry of openEntries(sessionId)) {
169
+ if (entry.bound) return cardFrame(entry);
170
+ }
171
+ return undefined;
172
+ }
173
+
160
174
  /** 会话摘要里的待处理标记(0.2.x 的宿主不会再下发这个字段,见 sessions.js 的用法)。 */
161
175
  function pendingKind(sessionId) {
162
176
  const open = openEntries(sessionId);
@@ -371,5 +385,6 @@ module.exports = {
371
385
  handleQuestionRespond,
372
386
  attachSession,
373
387
  pendingKind,
388
+ pendingCardOf,
374
389
  resetForTest,
375
390
  };
@@ -54,4 +54,22 @@ function pendingInteractionOf(sessionId, summary) {
54
54
  return questions.pendingKind(sessionId);
55
55
  }
56
56
 
57
- module.exports = { workspaceSessionFlags, pendingInteractionOf };
57
+ /**
58
+ * 这条会话挂着的卡**本身**(推送帧原样),拿不到就 undefined。
59
+ *
60
+ * 给 `/monitor` 用:手机上那张「等你回答 / 等你审批」的卡只有一条来路 —— 实时流重新
61
+ * 接上时插件的补发。回前台那条重连一旦落空(退避中、宿主把流收了、网络还没被采纳),
62
+ * 卡就缺到用户切一次会话为止。后台轮询顺手把卡的内容一起带回去,回前台问原生就能取到,
63
+ * 不必等网络。审批优先,与 `pendingInteractionOf` 同序 —— 两处判据分家会让「黄点写着
64
+ * 审批、卡片却是提问」。
65
+ *
66
+ * 摘要自己带了 `pendingInteraction`(0.1.x / 桌面口径)时没帧可给:那时候的卡是宿主
67
+ * 直接推的,本桥手上没有。
68
+ */
69
+ function pendingCardOf(sessionId, summary) {
70
+ const reported = summary && summary.pendingInteraction;
71
+ if (reported === 'approval' || reported === 'plan-review' || reported === 'question') return undefined;
72
+ return approvals.pendingCardOf(sessionId) || questions.pendingCardOf(sessionId);
73
+ }
74
+
75
+ module.exports = { workspaceSessionFlags, pendingInteractionOf, pendingCardOf };
@@ -39,6 +39,7 @@ const { sendJson, readJsonBody, readRawBody } = require('../shared/http-util');
39
39
  // 头名的大小写不保证(中转会压成小写,Node 版本之间也有差异),复用信封那一份读法。
40
40
  const { headerOf } = require('../shared/e2ee-request');
41
41
  const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
42
+ const canvasStore = require('./canvas-store.js');
42
43
 
43
44
  // 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
44
45
  // (`app/src/api/core.ts` 的 REQUEST_TIMEOUT_MS),一上来就给大片的机器会在慢速上行
@@ -108,7 +109,10 @@ function rememberFinishedUpload(uploadId, record) {
108
109
  /**
109
110
  * 按 uploadId 取刚落盘的上传。过期 / 不存在返回 null。
110
111
  *
111
- * @returns {{ path: string, name: string, bytes: number } | null}
112
+ * 记录有两种:收件箱那份带 `path`(图片内联要用它),画布素材那份带 `hash`(没有路径 ——
113
+ * 画布库只按内容哈希认文件)。两种都要能原样回放给重收一次口的客户端。
114
+ *
115
+ * @returns {{ path?: string, hash?: string, purpose?: string, name: string, bytes: number } | null}
112
116
  */
113
117
  function resolveFinishedUpload(uploadId) {
114
118
  const id = String(uploadId || '');
@@ -118,7 +122,8 @@ function resolveFinishedUpload(uploadId) {
118
122
  finishedUploads.delete(id);
119
123
  return null;
120
124
  }
121
- return { path: record.path, name: record.name, bytes: record.bytes };
125
+ const { at, ...rest } = record;
126
+ return rest;
122
127
  }
123
128
 
124
129
  /**
@@ -345,8 +350,16 @@ async function handleUploadBegin(ctx, req, res) {
345
350
  }
346
351
  const caps = resolveCaps(body);
347
352
  if (caps.status) return fail(res, caps.status, caps.code, caps.message);
353
+ // 画布库的素材也走这条通道(`purpose: 'canvas'`):传完不落收件箱,而是按内容哈希收进
354
+ // 画布库(见 `canvas-store.js`)。它与 `destPath` 互斥:一个是「收件箱里的一份文件」,
355
+ // 一个是「库里的一份字节」,同时给说明调用方把两件事搞混了。
356
+ const purpose = body.purpose === undefined || body.purpose === null ? '' : String(body.purpose);
357
+ if (purpose && purpose !== 'canvas') return fail(res, 400, 'bad-purpose', 'purpose 只认 canvas');
358
+ if (purpose && body.destPath !== undefined && body.destPath !== null) {
359
+ return fail(res, 400, 'bad-request', 'purpose 与 destPath 不能同时给');
360
+ }
348
361
  // 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满 7 天才清。
349
- const destination = await resolveDestDir(ctx, body);
362
+ const destination = purpose ? { dir: '' } : await resolveDestDir(ctx, body);
350
363
  if (destination.status) return fail(res, destination.status, destination.code, destination.message);
351
364
  const stagingDir = path.join(inboxRoot(), '.staging');
352
365
  try {
@@ -366,6 +379,8 @@ async function handleUploadBegin(ctx, req, res) {
366
379
  }
367
380
  const job = {
368
381
  name: sanitizeName(body.name),
382
+ // 画布素材:finish 时按内容哈希收进画布库,不进收件箱(见 `canvas-store.js`)。
383
+ purpose,
369
384
  // 指定落点就记在这里,finish 才用得上(中途不再判一次范围:begin 已经认过)。
370
385
  destDir: destination.dir,
371
386
  stagingPath,
@@ -560,6 +575,9 @@ async function handleUploadFinish(_ctx, req, res) {
560
575
  const job = uploads.get(uploadId);
561
576
  if (!job) {
562
577
  const replay = resolveFinishedUpload(uploadId);
578
+ if (replay && replay.purpose === 'canvas') {
579
+ return sendJson(res, 200, { ok: true, hash: replay.hash, bytes: replay.bytes, name: replay.name, uploadId });
580
+ }
563
581
  if (replay) return sendJson(res, 200, { ok: true, ...replay, uploadId });
564
582
  return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
565
583
  }
@@ -574,6 +592,19 @@ async function handleUploadFinish(_ctx, req, res) {
574
592
  }
575
593
  removeUpload(uploadId, job);
576
594
 
595
+ // 画布素材:不进收件箱,按内容哈希收进画布库,回哈希而不是路径(客户端拿不到也拼不出路径)。
596
+ if (job.purpose === 'canvas') {
597
+ try {
598
+ const stored = await canvasStore.storeUploadedFile(job.stagingPath);
599
+ // 与收件箱那条同一个理由:回包丢了,手机只会拿同一个 uploadId 再收一次口。
600
+ rememberFinishedUpload(uploadId, { purpose: 'canvas', hash: stored.hash, bytes: stored.bytes, name: job.name });
601
+ return sendJson(res, 200, { ok: true, hash: stored.hash, bytes: stored.bytes, name: job.name, uploadId });
602
+ } catch (error) {
603
+ try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
604
+ return fail(res, (error && error.status) || 500, (error && error.code) || 'canvas-store-unavailable', `收进画布库失败:${error.message}`);
605
+ }
606
+ }
607
+
577
608
  const dir = job.destDir || path.join(inboxRoot(), today());
578
609
  try {
579
610
  // 指定落点时目录在 begin 已确认存在:中途被删了就该按落盘失败报,不能悄悄重建一个
@@ -621,7 +652,8 @@ async function inlineUploadedImageBlocks(content) {
621
652
  if (!block || block.type !== 'image') continue;
622
653
  if (typeof block.data === 'string' && block.data) continue;
623
654
  const record = resolveFinishedUpload(block.uploadId);
624
- if (!record) return UPLOAD_EXPIRED_MESSAGE;
655
+ // 画布素材那份没有 `path`(只按哈希躺在画布库里),不能拿来内联:照旧报「过期」。
656
+ if (!record || !record.path) return UPLOAD_EXPIRED_MESSAGE;
625
657
  try {
626
658
  const bytes = await fsp.readFile(record.path);
627
659
  if (!bytes.length) return UPLOAD_EXPIRED_MESSAGE;
@@ -18,8 +18,14 @@
18
18
  * WS 升级、SSE、分块传输原样穿过。
19
19
  *
20
20
  * 代价:宿主无法审查内容,边界只有「端口」与「目标主机」两道。前者由 `protectedPorts()` 按
21
- * 身份拒绝——DSH 自身的 web 端口永远不许从预览进来;后者由 `isAllowedPreviewHost` 收到本机、
22
- * 私网与保留域名——放开公网,这条管道就等于一台任意目标的连接中转,移动 API 的能力面就扩了。
21
+ * 身份拒绝——DSH 自身的 web 端口平时不许从预览进来(唯一的例外见下);后者由
22
+ * `isAllowedPreviewHost` 收到本机、私网与保留域名——放开公网,这条管道就等于一台任意目标的
23
+ * 连接中转,移动 API 的能力面就扩了。
24
+ *
25
+ * 网页版那一档(`?mode=webapp`):用户显式要求手机能打开这台电脑上的 DSH 网页版,而网页版
26
+ * 只绑在宿主自己的环回端口上。这是推翻「不把桌面 GUI 搬进手机」那条刻意设计(2026-11-03)
27
+ * 的唯一入口——边界随之从「靠端口身份一律拒」收窄成「只放行宿主自己那一个端口、且目标
28
+ * 必须是环回」:能走这条管道的仍然只有已配对设备,别的端口与别的主机照旧按原规则拒。
23
29
  */
24
30
 
25
31
  const net = require('node:net');
@@ -76,6 +82,25 @@ function parsePreviewPort(req) {
76
82
  return port;
77
83
  }
78
84
 
85
+ /**
86
+ * 从升级请求的查询串里取 `mode`。
87
+ *
88
+ * 它只用来分辨「这一次是不是要连回宿主自己的网页版」。只认恰好等于 `webapp` 的那种取值,
89
+ * 其余(缺参数、空串、任何别的档位)一律当普通预览:以后再加档位时,老客户端与不认识的
90
+ * 值不该被顺带改掉行为。
91
+ *
92
+ * @returns {'webapp'|null}
93
+ */
94
+ function parsePreviewMode(req) {
95
+ let params;
96
+ try {
97
+ params = new URL(req && req.url ? req.url : '/', 'http://preview.invalid').searchParams;
98
+ } catch {
99
+ return null;
100
+ }
101
+ return params.get('mode') === 'webapp' ? 'webapp' : null;
102
+ }
103
+
79
104
  /** 环回类主机:这些写法都指「电脑自己」,拨号要走双栈(见 `previewDialTargets`)。 */
80
105
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '::']);
81
106
 
@@ -196,9 +221,11 @@ function refuseUpgrade(socket, status, reason, text) {
196
221
  * @param {object} deps
197
222
  * @param {(req: object) => boolean} deps.authorized 移动端鉴权(lib/mobile/auth.js 的 authorized)
198
223
  * @param {() => Set<number>} deps.protectedPorts DSH 自身占用的端口,永远不许从预览进来
224
+ * @param {() => (number|null)} [deps.ownWebPort] 宿主自己 web 服务的端口,`mode=webapp` 时
225
+ * 唯一允许的目标;必须是取值器而不是快照(端口会被配置改),缺省表示这台宿主不认这一档
199
226
  * @param {(message: string) => void} [deps.onLog]
200
227
  */
201
- function createPreviewService({ authorized, protectedPorts, onLog }) {
228
+ function createPreviewService({ authorized, protectedPorts, ownWebPort = () => null, onLog }) {
202
229
  const wss = new WebSocketServer({
203
230
  noServer: true,
204
231
  perMessageDeflate: false,
@@ -436,7 +463,23 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
436
463
  // DSH 自身的端口按**身份**拒绝,不是按数字拉黑。3080 是宿主 web 服务的实际端口
437
464
  // (`ctx.webServer.port`,可能被配置改过),从预览进来等于把桌面端 GUI/API 搬到了
438
465
  // 手机内置浏览器里——那条路刻意不做,所以在这里用同一个真值掐掉。
439
- if (protectedPorts().has(port)) {
466
+ //
467
+ // 网页版那一档是这条设计的**唯一例外**(用户显式要求,2026-11-03):手机上要打开的
468
+ // 网页版里装着全部插件的管理界面,它只能由电脑自己端出来。所以这一档不放宽「端口」,
469
+ // 而是把判据换成两条更窄的:目标端口必须就是宿主自己的 web 端口,目标主机必须是环回。
470
+ // 放行别的端口等于把预览变回任意本机服务的中转,放行别的主机等于把网页版端给别的机器。
471
+ if (parsePreviewMode(req) === 'webapp') {
472
+ let ownPort = null;
473
+ try { ownPort = ownWebPort(); } catch { ownPort = null; }
474
+ const ownPortValid = Number.isInteger(ownPort) && ownPort > 0 && ownPort <= 65535;
475
+ if (!ownPortValid || port !== ownPort) {
476
+ return refuseUpgrade(socket, 403, 'Forbidden', '网页版只能连电脑自己的 web 端口');
477
+ }
478
+ if (!LOOPBACK_HOSTS.has(target.host)) {
479
+ return refuseUpgrade(socket, 403, 'Forbidden',
480
+ `网页版只能连电脑自己,${target.host} 不是本机`);
481
+ }
482
+ } else if (protectedPorts().has(port)) {
440
483
  return refuseUpgrade(socket, 403, 'Forbidden', `端口 ${port} 属于 DSH 自身,不允许预览`);
441
484
  }
442
485
 
@@ -46,6 +46,7 @@ const pluginSelf = require('../mobile/plugin-self.js');
46
46
  const pluginMarket = require('../mobile/plugin-market.js');
47
47
  const { createRelayControlHandlers } = require('../mobile/relay-control.js');
48
48
  const uploads = require('../mobile/uploads.js');
49
+ const canvasStore = require('../mobile/canvas-store.js');
49
50
  const inboxClean = require('../mobile/inbox-clean.js');
50
51
  const git = require('../mobile/git.js');
51
52
  const pairing = require('../mobile/pairing.js');
@@ -77,6 +78,8 @@ const API_VERSION = '1';
77
78
  * 注册器;与 registerRoute 是两套名字空间,同一 path 可以两者都有,但升级路由重复注册会抛错。
78
79
  * @param {object} deps.preview 预览字节管道服务。它自带鉴权与端口边界,不能包在 guarded 里——
79
80
  * 升级请求需要的是一次拒绝或一次握手,不是 JSON。
81
+ * @param {Function} deps.hostWeb 网页版登录令牌那条(见 mobile/host-web.js)。令牌是凭据,
82
+ * 所以它走 route() 即包 guarded。
80
83
  */
81
84
  function registerMobileRoutes(deps) {
82
85
  const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing } = deps;
@@ -482,6 +485,14 @@ function registerMobileRoutes(deps) {
482
485
  route('/api/mobile/v1/upload/chunk', uploads.handleUploadChunk)
483
486
  route('/api/mobile/v1/upload/finish', uploads.handleUploadFinish)
484
487
  route('/api/mobile/v1/upload/abort', uploads.handleUploadAbort)
488
+
489
+ // 无限画布的库(App 那页 WebView 里的项目与素材,见 mobile/canvas-store.js)。
490
+ // 每个域一份清单、字节按内容哈希落在插件自己的目录里;手机推上来、拉下去都走这三条。
491
+ // 素材字节不另开通道,走上面的分片上传(`purpose: 'canvas'`)—— 断点续传与片大小
492
+ // 自适应那一套已经在那条路上,再写一份只是多一条会漂的实现。
493
+ route('/api/mobile/v1/canvas/manifest', canvasStore.handleCanvasManifestGet)
494
+ route('/api/mobile/v1/canvas/manifest/save', canvasStore.handleCanvasManifestSave)
495
+ route('/api/mobile/v1/canvas/file', canvasStore.handleCanvasFile)
485
496
  // 同一个收件目录的出口:上面四条只往里放,这两条报占用、按龄清掉(见 mobile/inbox-clean.js)。
486
497
  // 落盘的文件不认识会话,所以没有「删掉这条对话就顺带清掉它传上来的文件」这条路 ——
487
498
  // 唯一的出口只能按龄,口径写在那个文件头。
@@ -490,6 +501,14 @@ function registerMobileRoutes(deps) {
490
501
  // 自动清理开关:默认关,打开后宿主每次启动顺手清一次(面板那颗按钮不受它管)。
491
502
  route('/api/mobile/v1/inbox/auto', inboxClean.handleInboxAuto)
492
503
 
504
+ // 电脑上的 DSH 网页版(见 mobile/host-web.js):App 的内置浏览器要直接用它,而那个页面
505
+ // 只绑在宿主自己的环回端口上、且要一枚一次性登录令牌。令牌是凭据,所以这条**必须**
506
+ // 包 guarded(不是 PUBLIC_ROUTES)——只有已配对设备才拿得到。
507
+ //
508
+ // 它与下面的预览升级是同一件事的两半:这条给手机「去哪儿、带什么」,那条让字节真的流过去
509
+ // (`mode=webapp` 那一档才允许拨回宿主自己的 web 端口)。
510
+ route('/api/mobile/v1/host-web', deps.hostWeb)
511
+
493
512
  // 内置浏览器预览(App 侧「点 localhost 链接在内置浏览器里打开」的宿主端一半)。
494
513
  //
495
514
  // 这是一条 **WebSocket 升级**路由,不是普通 HTTP 路由:手机原生模块按端口在环回监听、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",