dsh-mobile-plugin 0.5.15 → 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 +300 -60
- package/lib/impl.js +11 -1
- package/lib/mobile/host-web.js +63 -0
- package/lib/relay/preview.js +47 -4
- package/lib/routes/mobile-api.js +10 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
|
|
3
3
|
*
|
|
4
4
|
* 五件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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',
|
|
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 =
|
|
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
|
-
*
|
|
933
|
-
*
|
|
1087
|
+
* 有地址且就在本机:普通链接,交给浏览器自己开。
|
|
1088
|
+
* 其余情况(从别的机器打开的页面、或者压根还没读到地址)都要先把两小时窗口开出来:
|
|
1089
|
+
* 新窗口必须在**点击这一帧**开出来(`window.open('', '_blank')`),等接口回来再开会被
|
|
1090
|
+
* 浏览器的弹窗拦截器吃掉;地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
|
|
934
1091
|
* 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
|
|
935
1092
|
*/
|
|
936
1093
|
function openPanel(event) {
|
|
937
|
-
|
|
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
|
-
|
|
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
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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 (
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
980
|
-
|
|
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':
|
|
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
|
|
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
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
|
|
1366
|
-
|
|
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,
|
|
@@ -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
|
+
};
|
package/lib/relay/preview.js
CHANGED
|
@@ -18,8 +18,14 @@
|
|
|
18
18
|
* WS 升级、SSE、分块传输原样穿过。
|
|
19
19
|
*
|
|
20
20
|
* 代价:宿主无法审查内容,边界只有「端口」与「目标主机」两道。前者由 `protectedPorts()` 按
|
|
21
|
-
* 身份拒绝——DSH 自身的 web
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -78,6 +78,8 @@ const API_VERSION = '1';
|
|
|
78
78
|
* 注册器;与 registerRoute 是两套名字空间,同一 path 可以两者都有,但升级路由重复注册会抛错。
|
|
79
79
|
* @param {object} deps.preview 预览字节管道服务。它自带鉴权与端口边界,不能包在 guarded 里——
|
|
80
80
|
* 升级请求需要的是一次拒绝或一次握手,不是 JSON。
|
|
81
|
+
* @param {Function} deps.hostWeb 网页版登录令牌那条(见 mobile/host-web.js)。令牌是凭据,
|
|
82
|
+
* 所以它走 route() 即包 guarded。
|
|
81
83
|
*/
|
|
82
84
|
function registerMobileRoutes(deps) {
|
|
83
85
|
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing } = deps;
|
|
@@ -499,6 +501,14 @@ function registerMobileRoutes(deps) {
|
|
|
499
501
|
// 自动清理开关:默认关,打开后宿主每次启动顺手清一次(面板那颗按钮不受它管)。
|
|
500
502
|
route('/api/mobile/v1/inbox/auto', inboxClean.handleInboxAuto)
|
|
501
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
|
+
|
|
502
512
|
// 内置浏览器预览(App 侧「点 localhost 链接在内置浏览器里打开」的宿主端一半)。
|
|
503
513
|
//
|
|
504
514
|
// 这是一条 **WebSocket 升级**路由,不是普通 HTTP 路由:手机原生模块按端口在环回监听、
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
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",
|