dsh-mobile-plugin 0.5.19 → 0.5.21
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/canvas-app.html +441 -0
- package/lib/client.js +358 -28
- package/lib/mobile/attachment-store.js +111 -15
- package/lib/mobile/attachments.js +6 -2
- package/lib/mobile/host-bridge/endpoints.js +7 -1
- package/lib/mobile/local-images.js +1 -1
- package/lib/mobile/magpie-web.js +425 -0
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-send.js +13 -13
- package/lib/mobile/stats-ledger.cjs +21 -6
- package/lib/mobile/stats.js +7 -3
- package/lib/panel/index.html +12 -11
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +49 -0
- package/lib/routes/mobile-api.js +6 -0
- package/lib/session-cleanup.cjs +22 -7
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -38,8 +38,31 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
.dshm_footAction[aria-disabled="true"]{opacity:.45;cursor:default}
|
|
39
39
|
.dshm_footAction[aria-disabled="true"]:hover{background:transparent;color:var(--dsw-alias-label-secondary)}
|
|
40
40
|
.dshm_footActionRail{width:36px;height:36px;margin:2px 0;border-radius:var(--dsw-radius-md)}
|
|
41
|
+
/* 宿主那一半没装载时:整颗转红,右上角那颗红点显出来(data-dshm-alert 是它的记号)。
|
|
42
|
+
悬停不换回普通颜色——告警态跟着鼠标变浅等于这一颗在说谎。 */
|
|
43
|
+
.dshm_footAction[data-dshm-alert],.dshm_footAction[data-dshm-alert]:hover{color:var(--dsw-alias-state-error-primary)}
|
|
44
|
+
.dshm_alertDot{display:none}
|
|
45
|
+
.dshm_footAction[data-dshm-alert] .dshm_alertDot{display:inline}
|
|
41
46
|
.dshm_overlay{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.42)}
|
|
42
47
|
.dshm_dialog{box-sizing:border-box;width:100%;max-width:560px;max-height:82vh;overflow:auto;display:flex;flex-direction:column;gap:10px;padding:18px 20px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-settings-card-stroke);background:var(--dsw-alias-settings-card-fill);color:var(--dsw-alias-label-primary);box-shadow:0 12px 40px rgba(0,0,0,.28)}
|
|
48
|
+
/* 控制台那一窗比诊断窗宽:里面嵌的是一整页,窄了排版会挤成一列。 */
|
|
49
|
+
.dshm_dialogWide{max-width:1040px;height:82vh;max-height:82vh;overflow:hidden}
|
|
50
|
+
.dshm_dialogWide .dshm_frameWrap{flex:1;min-height:0;display:flex;position:relative}
|
|
51
|
+
.dshm_frame{flex:1;min-height:0;width:100%;border:0;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
|
|
52
|
+
/* iframe 一直不 load 时留的那句话:盖在空白之上,撤不撤由 onload 决定。 */
|
|
53
|
+
.dshm_frameTip{position:absolute;inset:auto 0 0;margin:0;padding:8px 10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
54
|
+
/* 入口那一层:只有两块大字按钮,左「进入控制台」右「报错调试」。 */
|
|
55
|
+
.dshm_choices{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:4px 0 2px}
|
|
56
|
+
.dshm_choice{display:flex;flex-direction:column;gap:6px;align-items:flex-start;text-align:left;min-height:96px;padding:14px 16px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit}
|
|
57
|
+
.dshm_choice:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
58
|
+
.dshm_choice:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
|
|
59
|
+
.dshm_choicePrimary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-bg-layer-1)}
|
|
60
|
+
.dshm_choicePrimary:hover{background:var(--dsw-alias-state-business-primary);opacity:.92}
|
|
61
|
+
.dshm_choicePrimary .dshm_choiceHint{color:inherit;opacity:.82}
|
|
62
|
+
.dshm_choiceTitle{font-size:15px;font-weight:600;line-height:22px}
|
|
63
|
+
.dshm_choiceHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
64
|
+
/* 版面竖向排列的那一列(外壳里没法内嵌时用它摆读数)。 */
|
|
65
|
+
.dshm_dialogColumn{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:10px}
|
|
43
66
|
.dshm_dialogTitle{margin:0;font-size:16px;font-weight:600;line-height:24px}
|
|
44
67
|
.dshm_dialogHeading{margin:2px 0 0;font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
45
68
|
.dshm_dialogLead{margin:0;font-size:13px;line-height:20px}
|
|
@@ -508,7 +531,10 @@ window.__ModuleLoader__.load({
|
|
|
508
531
|
nav: '移动端',
|
|
509
532
|
heading: '移动端',
|
|
510
533
|
cardPanel: '配置页面',
|
|
534
|
+
cardCanvas: '画布',
|
|
511
535
|
openPanel: '打开配置页面',
|
|
536
|
+
openCanvas: '打开画布',
|
|
537
|
+
canvasHint: '在浏览器里打开无限画布;它的库与手机共用同一份。',
|
|
512
538
|
openHint: '配对、网络配置与插件更新都在这个页面里。',
|
|
513
539
|
panelPending: '正在读取页面地址…',
|
|
514
540
|
panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
|
|
@@ -518,6 +544,18 @@ window.__ModuleLoader__.load({
|
|
|
518
544
|
footerPending: '网页地址还没就绪',
|
|
519
545
|
footerPublicConfirm: '这个地址位于公网。继续后会开放移动端插件的网页管理权限 2 小时;期间任何能访问该地址的人都可以操作面板。是否继续?',
|
|
520
546
|
footerLoadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
|
|
547
|
+
consoleTitle: '移动端插件控制台',
|
|
548
|
+
consoleEntryTitle: '移动端插件',
|
|
549
|
+
consoleEntryLead: '进控制台配置配对与网络;手机连不上、或者哪儿报错了,走报错调试。',
|
|
550
|
+
consoleEntryConsole: '进入控制台',
|
|
551
|
+
consoleEntryConsoleHint: '配对、网络、插件更新都在那一页',
|
|
552
|
+
consoleEntryDebug: '报错调试',
|
|
553
|
+
consoleEntryDebugHint: '装载、链路、设备、故障账本,出问题时看这里',
|
|
554
|
+
consoleBack: '返回',
|
|
555
|
+
consolePopupBlocked: '浏览器拦掉了新窗口,允许本站弹出窗口后再点一次。',
|
|
556
|
+
consoleLead: '这一屏是插件的排障面板:装载、链路、资源和故障账本都在里面,读的都是宿主此刻的原话。要配置就点「在浏览器里打开」。',
|
|
557
|
+
consoleOpenBrowser: '在浏览器里打开配置页面',
|
|
558
|
+
consoleFrameless: '这一屏没载入进来(桌面壳或代理挡住了内嵌页面)。点下面那颗按钮在浏览器里打开,读数一样看得到。',
|
|
521
559
|
diagTitleLoad: '打不开移动端配置页',
|
|
522
560
|
diagTitleOpen: '开放网页管理失败',
|
|
523
561
|
diagChecking: '正在读取宿主状态…',
|
|
@@ -566,7 +604,10 @@ window.__ModuleLoader__.load({
|
|
|
566
604
|
nav: 'Mobile',
|
|
567
605
|
heading: 'Mobile',
|
|
568
606
|
cardPanel: 'Configuration page',
|
|
607
|
+
cardCanvas: 'Canvas',
|
|
569
608
|
openPanel: 'Open the configuration page',
|
|
609
|
+
openCanvas: 'Open the canvas',
|
|
610
|
+
canvasHint: 'Open the infinite canvas in your browser; it shares one library with your phone.',
|
|
570
611
|
openHint: 'Pairing, network settings and plugin updates all live on this page.',
|
|
571
612
|
panelPending: 'Reading the page address…',
|
|
572
613
|
panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
|
|
@@ -576,6 +617,18 @@ window.__ModuleLoader__.load({
|
|
|
576
617
|
footerPending: 'The page address is not ready yet',
|
|
577
618
|
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?',
|
|
578
619
|
footerLoadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
|
|
620
|
+
consoleTitle: 'Mobile plugin console',
|
|
621
|
+
consoleEntryTitle: 'Mobile plugin',
|
|
622
|
+
consoleEntryLead: 'Open the console to configure pairing and networking; if the phone cannot connect or something reports an error, use troubleshooting.',
|
|
623
|
+
consoleEntryConsole: 'Open the console',
|
|
624
|
+
consoleEntryConsoleHint: 'pairing, networking and updates live there',
|
|
625
|
+
consoleEntryDebug: 'Troubleshoot',
|
|
626
|
+
consoleEntryDebugHint: 'loading, link, devices and the fault ledger',
|
|
627
|
+
consoleBack: 'Back',
|
|
628
|
+
consolePopupBlocked: 'The browser blocked the new window — allow pop-ups for this site and press again.',
|
|
629
|
+
consoleLead: 'This is the plugin troubleshooting panel: loading, link, resources and the fault ledger, all in the host’s own words. Press “Open the configuration page in a browser” to configure it.',
|
|
630
|
+
consoleOpenBrowser: 'Open the configuration page in a browser',
|
|
631
|
+
consoleFrameless: 'This panel did not load (the desktop shell or a proxy blocked the embedded page). Use the button below to open it in a browser — the readings are the same.',
|
|
579
632
|
diagTitleLoad: 'Cannot open the mobile configuration page',
|
|
580
633
|
diagTitleOpen: 'Could not open web management',
|
|
581
634
|
diagChecking: 'Reading host status…',
|
|
@@ -646,10 +699,13 @@ window.__ModuleLoader__.load({
|
|
|
646
699
|
*/
|
|
647
700
|
function createDiagnosisDialog(doc) {
|
|
648
701
|
let overlay = null;
|
|
702
|
+
/** 底下那一句说明(复制结果就地改它,不重画整窗)。 */
|
|
703
|
+
let noteEl = null;
|
|
649
704
|
let escapeListener = null;
|
|
650
705
|
const hide = () => {
|
|
651
706
|
if (overlay && typeof overlay.remove === 'function' && overlay.parentElement) overlay.remove();
|
|
652
707
|
overlay = null;
|
|
708
|
+
noteEl = null;
|
|
653
709
|
if (escapeListener && typeof doc.removeEventListener === 'function') {
|
|
654
710
|
doc.removeEventListener('keydown', escapeListener);
|
|
655
711
|
}
|
|
@@ -683,14 +739,43 @@ window.__ModuleLoader__.load({
|
|
|
683
739
|
}
|
|
684
740
|
if (spec.title) overlay.setAttribute('aria-label', spec.title);
|
|
685
741
|
// 每次重画:读数与按钮都是这一轮的现场,别留上一轮的字。
|
|
686
|
-
|
|
687
|
-
|
|
742
|
+
// **`children` 是 HTMLCollection,没有 `slice()`**(2026-11-05 真机现场:点图标一点反应都
|
|
743
|
+
// 没有,控制台一条 TypeError 就把它吞了 —— 假元素的 `children` 是数组,测试里从没红过)。
|
|
744
|
+
// 先摊成数组再边删边走。
|
|
745
|
+
for (const child of Array.from(overlay.children || [])) child.remove();
|
|
746
|
+
const card = spec.wide
|
|
747
|
+
? make('div', 'dshm_dialog dshm_dialogWide')
|
|
748
|
+
: make('div', 'dshm_dialog');
|
|
688
749
|
card.addEventListener('click', (event) => {
|
|
689
750
|
if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
|
|
690
751
|
});
|
|
691
752
|
overlay.appendChild(card);
|
|
692
753
|
if (spec.title) card.appendChild(make('h3', 'dshm_dialogTitle', spec.title));
|
|
693
754
|
if (spec.summary) card.appendChild(make('p', 'dshm_dialogLead', spec.summary));
|
|
755
|
+
// 自定义内容区(入口那两块就挂在这儿):节点由调用方造,这里只负责挂上。
|
|
756
|
+
if (spec.body) card.appendChild(spec.body);
|
|
757
|
+
// 内嵌一整页(调试那一窗):外壳里 `window.open` 不可靠,所以把页面嵌进来。
|
|
758
|
+
// `src` 已经由调用方按同源地址算好(壳里是 `dsh-app://app/api/relay/panel/debug`,
|
|
759
|
+
// 网页里是同源 http),远程浏览器的那两小时窗口也在拨号那一步开好了。
|
|
760
|
+
//
|
|
761
|
+
// **嵌不进来要有话说**:iframe 被挡(CSP、代理、协议处理器不认这条路径)时它只会
|
|
762
|
+
// 静默留白,用户看到一屏空。所以先摆一句「正在载入」,`onload` 一到就撤掉;一直不
|
|
763
|
+
// load 就一直是那句话 + 底下那颗「在浏览器里打开」——不至于「点了没反应」。
|
|
764
|
+
if (spec.frame && spec.frame.src) {
|
|
765
|
+
const wrap = make('div', 'dshm_frameWrap');
|
|
766
|
+
const frame = doc.createElement('iframe');
|
|
767
|
+
frame.className = 'dshm_frame';
|
|
768
|
+
frame.setAttribute('src', spec.frame.src);
|
|
769
|
+
frame.setAttribute('title', spec.frame.title || spec.title || '');
|
|
770
|
+
frame.setAttribute('loading', 'eager');
|
|
771
|
+
const tip = make('p', 'dshm_frameTip', spec.frame.hint || '');
|
|
772
|
+
frame.onload = () => {
|
|
773
|
+
if (tip && typeof tip.remove === 'function' && tip.parentElement) tip.remove();
|
|
774
|
+
};
|
|
775
|
+
wrap.appendChild(frame);
|
|
776
|
+
wrap.appendChild(tip);
|
|
777
|
+
card.appendChild(wrap);
|
|
778
|
+
}
|
|
694
779
|
if (spec.facts && spec.facts.length) {
|
|
695
780
|
if (spec.factsHeading) card.appendChild(make('h4', 'dshm_dialogHeading', spec.factsHeading));
|
|
696
781
|
const list = make('dl', 'dshm_facts');
|
|
@@ -708,7 +793,10 @@ window.__ModuleLoader__.load({
|
|
|
708
793
|
for (const step of spec.steps) steps.appendChild(make('li', null, step));
|
|
709
794
|
card.appendChild(steps);
|
|
710
795
|
}
|
|
711
|
-
|
|
796
|
+
// 这一行**总是挂上**(空着不占位):控制台那一窗的「复制」要就地改这一句,
|
|
797
|
+
// 重画整窗会把嵌进去的页面重新加载一遍。
|
|
798
|
+
const note = make('p', 'dshm_dialogNote', spec.note || '');
|
|
799
|
+
card.appendChild(note);
|
|
712
800
|
const actions = make('div', 'dshm_dialogActions');
|
|
713
801
|
for (const action of spec.actions || []) {
|
|
714
802
|
const button = make('button', action.primary ? 'dshm_btn dshm_btnPrimary' : 'dshm_btn', action.label);
|
|
@@ -718,9 +806,14 @@ window.__ModuleLoader__.load({
|
|
|
718
806
|
actions.appendChild(button);
|
|
719
807
|
}
|
|
720
808
|
card.appendChild(actions);
|
|
809
|
+
noteEl = note;
|
|
721
810
|
return overlay;
|
|
722
811
|
};
|
|
723
|
-
|
|
812
|
+
/** 就地更新底下那一句说明(不重画整窗)。 */
|
|
813
|
+
const note = (text) => {
|
|
814
|
+
if (noteEl) noteEl.textContent = text === undefined ? '' : String(text);
|
|
815
|
+
};
|
|
816
|
+
return { open, hide, note };
|
|
724
817
|
}
|
|
725
818
|
|
|
726
819
|
/** 复制一段文字:先走剪贴板 API,不行退回 textarea + execCommand。 */
|
|
@@ -794,6 +887,45 @@ window.__ModuleLoader__.load({
|
|
|
794
887
|
}
|
|
795
888
|
}
|
|
796
889
|
|
|
890
|
+
/**
|
|
891
|
+
* 画布页在**这个页面自己**的同源地址上(与面板同一台宿主、同一个前缀)。
|
|
892
|
+
*
|
|
893
|
+
* 桌面用户在 DSH 里点「打开画布」,就是跳到浏览器打开这一页;页面由插件托管,库与手机共用。
|
|
894
|
+
*/
|
|
895
|
+
function sameOriginCanvasUrl() {
|
|
896
|
+
try {
|
|
897
|
+
const base = typeof document !== 'undefined' && document.baseURI
|
|
898
|
+
? document.baseURI
|
|
899
|
+
: (typeof location !== 'undefined' ? location.href : '');
|
|
900
|
+
if (!base) return '';
|
|
901
|
+
return new URL('api/relay/canvas', base).href;
|
|
902
|
+
} catch {
|
|
903
|
+
return '';
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* 调试页在**这个页面自己**的同源地址上(不是宿主报的那个绝对地址)。
|
|
909
|
+
*
|
|
910
|
+
* 这里必须按 `document.baseURI` 推,**不能用面板那个绝对 http(s) 地址**:桌面版前端挂在
|
|
911
|
+
* `dsh-app://app/` 下(Electron 把 `dsh-app` 当安全协议),往里面塞一条 http 的 iframe
|
|
912
|
+
* 会被当成混合内容挡掉 —— 现场就是 `isWebPage()` 为假、退回「交给系统浏览器」,点了照旧跳走。
|
|
913
|
+
* 用相对地址推出来的是 `dsh-app://app/api/relay/panel/debug`:与页面同源,壳的协议处理器
|
|
914
|
+
* 会把 `/api/*` 转给本机宿主(客户端半边那些 `api/relay/status` 请求一直就是这么走的)。
|
|
915
|
+
* 反代挂子路径时 `baseURI` 自带前缀,这一条也顺带管住了。
|
|
916
|
+
*/
|
|
917
|
+
function consoleUrlOf() {
|
|
918
|
+
try {
|
|
919
|
+
const base = typeof document !== 'undefined' && document.baseURI
|
|
920
|
+
? document.baseURI
|
|
921
|
+
: (typeof location !== 'undefined' ? location.href : '');
|
|
922
|
+
if (!base) return '';
|
|
923
|
+
return new URL('api/relay/panel/debug', base).href;
|
|
924
|
+
} catch {
|
|
925
|
+
return '';
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
797
929
|
/**
|
|
798
930
|
* 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
|
|
799
931
|
*
|
|
@@ -812,7 +944,7 @@ window.__ModuleLoader__.load({
|
|
|
812
944
|
* @returns {Promise<{ok: true, cancelled?: boolean, url: string, webAccess: object|null}
|
|
813
945
|
* | {ok: false, missing: boolean, status: number, message: string}>}
|
|
814
946
|
*/
|
|
815
|
-
async function openConfigurationPage({ view, loadStatus = 0, t, target = null, confirmed = false }) {
|
|
947
|
+
async function openConfigurationPage({ view, loadStatus = 0, t, target = null, confirmed = false, navigate = true }) {
|
|
816
948
|
let state = readStatusView(view);
|
|
817
949
|
const failed = (error) => ({
|
|
818
950
|
ok: false,
|
|
@@ -868,6 +1000,9 @@ window.__ModuleLoader__.load({
|
|
|
868
1000
|
target.location.href = url;
|
|
869
1001
|
return { ok: true, url, webAccess: state.webAccess };
|
|
870
1002
|
}
|
|
1003
|
+
// `navigate: false` = 只要地址与「控制面已经认这个浏览器」,别开任何窗口:
|
|
1004
|
+
// 控制台那一窗是把这一页**嵌进来**,多开一个浏览器窗口等于点了就跳走。
|
|
1005
|
+
if (navigate === false) return { ok: true, url, webAccess: state.webAccess };
|
|
871
1006
|
if (!isWebPage(window)) {
|
|
872
1007
|
// 交回给页面导航:壳会把外部 http(s) 地址转给系统浏览器。
|
|
873
1008
|
window.location.href = url;
|
|
@@ -903,6 +1038,8 @@ window.__ModuleLoader__.load({
|
|
|
903
1038
|
const FOOTER_SEAT = '[data-slot="sidebar.settings"]';
|
|
904
1039
|
/** 自己那颗按钮的记号:同一页面里出现第二份实现时,靠它摘掉上一颗。 */
|
|
905
1040
|
const FOOTER_MARK = 'data-dshm-mobile-web';
|
|
1041
|
+
/** 告警态的记号:宿主那一半没装载(`/status` 404)时,整颗转红并显出右上角那颗红点。 */
|
|
1042
|
+
const FOOTER_ALERT = 'data-dshm-alert';
|
|
906
1043
|
/** 还没问到网页地址时的重试间隔。取到就停,不再问。 */
|
|
907
1044
|
const FOOTER_URL_RETRY_MS = 20000;
|
|
908
1045
|
/**
|
|
@@ -915,6 +1052,15 @@ window.__ModuleLoader__.load({
|
|
|
915
1052
|
'M10.4 4.4h3.2',
|
|
916
1053
|
'M11 19.4h2',
|
|
917
1054
|
];
|
|
1055
|
+
/**
|
|
1056
|
+
* 告警态那颗红点(用户 2026-11-05 从四个候选里拍的「整颗转红 + 右上角一颗点」那一款)。
|
|
1057
|
+
*
|
|
1058
|
+
* 圆心在机身右上角外侧:左缘压住右上那圈圆角(机身右边到 17.8),两块同色连成一整块,
|
|
1059
|
+
* 所以不用再抠一圈背景色的缝隙。24 画布内放得下——最右到 22,不越界。
|
|
1060
|
+
* 它常驻在 SVG 里,显示与否交给 CSS 的 `data-dshm-alert`:这一颗会被外壳整棵重建,
|
|
1061
|
+
* 换来换去不如让它挂着。
|
|
1062
|
+
*/
|
|
1063
|
+
const PHONE_ALERT_DOT = { cx: 18.9, cy: 5, r: 3.1 };
|
|
918
1064
|
/**
|
|
919
1065
|
* 图标边长:展开那一行 20,窄条里的圆钮 22(用户 2026-11-02 拍的,先 18 后嫌小又抬了一档)。
|
|
920
1066
|
*
|
|
@@ -926,7 +1072,7 @@ window.__ModuleLoader__.load({
|
|
|
926
1072
|
const PHONE_GLYPH_WIDE = 20;
|
|
927
1073
|
const PHONE_GLYPH_RAIL = 22;
|
|
928
1074
|
|
|
929
|
-
/**
|
|
1075
|
+
/** 造一颗手机图标(含那颗常驻的告警红点,显不显示看 `data-dshm-alert`)。文档没有 SVG 能力时返回 null,按钮照样成立(只剩无障碍名字)。 */
|
|
930
1076
|
function phoneIcon(doc, size) {
|
|
931
1077
|
if (!doc || typeof doc.createElementNS !== 'function') return null;
|
|
932
1078
|
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
@@ -944,6 +1090,13 @@ window.__ModuleLoader__.load({
|
|
|
944
1090
|
path.setAttribute('d', d);
|
|
945
1091
|
svg.appendChild(path);
|
|
946
1092
|
}
|
|
1093
|
+
const dot = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
1094
|
+
dot.setAttribute('class', 'dshm_alertDot');
|
|
1095
|
+
dot.setAttribute('cx', String(PHONE_ALERT_DOT.cx));
|
|
1096
|
+
dot.setAttribute('cy', String(PHONE_ALERT_DOT.cy));
|
|
1097
|
+
dot.setAttribute('r', String(PHONE_ALERT_DOT.r));
|
|
1098
|
+
dot.setAttribute('fill', 'currentColor');
|
|
1099
|
+
svg.appendChild(dot);
|
|
947
1100
|
return svg;
|
|
948
1101
|
}
|
|
949
1102
|
|
|
@@ -968,11 +1121,14 @@ window.__ModuleLoader__.load({
|
|
|
968
1121
|
*
|
|
969
1122
|
* 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
|
|
970
1123
|
*/
|
|
971
|
-
function syncFooterIcon(node, url, openLabel, pendingLabel, onClick) {
|
|
1124
|
+
function syncFooterIcon(node, url, openLabel, pendingLabel, onClick, alert) {
|
|
972
1125
|
const label = url ? openLabel : pendingLabel;
|
|
973
1126
|
node.setAttribute('aria-label', label);
|
|
974
1127
|
node.setAttribute('title', label);
|
|
975
1128
|
node.setAttribute('aria-disabled', 'false');
|
|
1129
|
+
// 告警态只换记号,图形和尺寸不动:CSS 按这个属性把整颗转红、把红点显出来。
|
|
1130
|
+
if (alert) node.setAttribute(FOOTER_ALERT, '');
|
|
1131
|
+
else node.removeAttribute(FOOTER_ALERT);
|
|
976
1132
|
// 有地址时仍然写 `href`:普通链接照旧能中键 / 新标签打开。只有「浏览器不是从本机来的」
|
|
977
1133
|
// 那两种情况要拦下来先开窗口,见 `openPanel`。
|
|
978
1134
|
if (url) node.setAttribute('href', url);
|
|
@@ -1047,6 +1203,12 @@ window.__ModuleLoader__.load({
|
|
|
1047
1203
|
const pendingLabel = t('footerPending');
|
|
1048
1204
|
const loadMissingLabel = t('footerLoadMissing');
|
|
1049
1205
|
const publicConfirm = t('footerPublicConfirm');
|
|
1206
|
+
/**
|
|
1207
|
+
* 宿主那一半没装载(`/status` 404):点了也打不开、重装前谁也修不好,所以这一种是
|
|
1208
|
+
* **真故障**,那颗图标转红告警;标题也换成更具体的那一句。地址只是还没读到(后台
|
|
1209
|
+
* 重试中)、或者窗口关着被回 423,都不是插件坏了,不红。
|
|
1210
|
+
*/
|
|
1211
|
+
const hostMissing = Boolean(problem && problem.status === 404);
|
|
1050
1212
|
|
|
1051
1213
|
/** 诊断窗单例:同一页面只留一个,反复打开就地重画。 */
|
|
1052
1214
|
function diagnosisDialog() {
|
|
@@ -1090,34 +1252,45 @@ window.__ModuleLoader__.load({
|
|
|
1090
1252
|
* 浏览器的弹窗拦截器吃掉;地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
|
|
1091
1253
|
* 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
|
|
1092
1254
|
*/
|
|
1255
|
+
/**
|
|
1256
|
+
* 点下去做什么(2026-11-05 用户要求改过一次)。
|
|
1257
|
+
*
|
|
1258
|
+
* **从「点了就跳浏览器」改成「点了先在这一页弹一屏」**:用户遇到问题时最缺的不是
|
|
1259
|
+
* 跳过去,而是「它现在到底哪一环断了」——启动、装载、隧道、旁挂进程、自检全都在那一屏
|
|
1260
|
+
* 里,比一个打不开的页面有用。跳转本身没取消,挪进了那一屏里的一颗按钮。
|
|
1261
|
+
*
|
|
1262
|
+
* 那一屏的内容是网页版的 `/api/relay/panel/debug`(React 写的,改完刷新就见效),
|
|
1263
|
+
* 这里只把同源地址算出来嵌进去。**必须先保证控制面认这个浏览器**,否则嵌进来的是一屏
|
|
1264
|
+
* 423 JSON:所以先按 `panelScope` 把两小时窗口开好,再画窗。
|
|
1265
|
+
*
|
|
1266
|
+
* 三种走不通的情况一律回到诊断窗(外壳里 `window.open` 不可靠、宿主那一半没装载、
|
|
1267
|
+
* 窗口开不出来)—— 不许「点了没反应」。
|
|
1268
|
+
*/
|
|
1093
1269
|
function openPanel(event) {
|
|
1094
|
-
const knowsUrl = typeof url === 'string' && url !== '';
|
|
1095
|
-
const needsWindow = !knowsUrl || (panelScope !== 'loopback' && !(webAccess && webAccess.enabled));
|
|
1096
|
-
if (!needsWindow) return; // 普通链接,交给浏览器自己开
|
|
1097
1270
|
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
1098
|
-
//
|
|
1099
|
-
|
|
1100
|
-
|
|
1271
|
+
// 公网地址先问一句——但**只在真要开那两小时窗口的时候问**:窗口已经开着,
|
|
1272
|
+
// 这一下没有扩大任何权限,再弹一次只是把上一轮点过的确认重问一遍。
|
|
1273
|
+
const willOpenWindow = panelScope !== 'loopback' && !(webAccess && webAccess.enabled);
|
|
1274
|
+
if (panelScope === 'public'
|
|
1275
|
+
&& url
|
|
1276
|
+
&& willOpenWindow
|
|
1101
1277
|
&& typeof window !== 'undefined'
|
|
1102
1278
|
&& typeof window.confirm === 'function'
|
|
1103
1279
|
&& !window.confirm(publicConfirm)) return;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1280
|
+
// 桌面壳(`dsh-app://app`)里不嵌:往自定义协议页里塞一条 http(s) iframe,多半被
|
|
1281
|
+
// 当成混合内容挡掉,用户看到的是一窗空白。那种环境仍走「交给系统浏览器」那条老路
|
|
1282
|
+
//(`navigate` 默认 true,壳的 `will-navigate` 会把它转给系统浏览器)。
|
|
1107
1283
|
void (async () => {
|
|
1108
|
-
const outcome = await
|
|
1284
|
+
const outcome = await openConsole({
|
|
1109
1285
|
view: { panelUrl: url || '', panelScope, webAccess },
|
|
1110
1286
|
loadStatus: problem ? problem.status : 0,
|
|
1111
1287
|
t,
|
|
1112
|
-
target,
|
|
1113
|
-
confirmed: knowsUrl,
|
|
1114
1288
|
});
|
|
1115
1289
|
if (outcome.ok) {
|
|
1116
|
-
if (outcome.cancelled && target && typeof target.close === 'function') target.close();
|
|
1117
1290
|
if (outcome.webAccess) setWebAccess(outcome.webAccess);
|
|
1118
1291
|
return;
|
|
1119
1292
|
}
|
|
1120
|
-
|
|
1293
|
+
// 宿主那一半压根没装载:那是一屏「装完没重启」,不是这一屏能修的。
|
|
1121
1294
|
if (outcome.missing) {
|
|
1122
1295
|
void openDiagnosis('load', { status: outcome.status || 404, message: (problem && problem.message) || '' });
|
|
1123
1296
|
return;
|
|
@@ -1126,6 +1299,138 @@ window.__ModuleLoader__.load({
|
|
|
1126
1299
|
})();
|
|
1127
1300
|
}
|
|
1128
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* 把「控制台」那一屏画出来:标题 + 一颗「在浏览器里打开」+ 内嵌的调试页。
|
|
1304
|
+
*
|
|
1305
|
+
* 嵌的是同一台宿主吐的页面(同源),所以它读 `/api/relay/*` 和面板页一模一样,
|
|
1306
|
+
* 不需要 token、也不新增接口。远程浏览器要先开那两小时窗口:`openConfigurationPage`
|
|
1307
|
+
* 在这一步之前已经把窗口开好了(它的 `target` 传 null,不导航任何地方)。
|
|
1308
|
+
*/
|
|
1309
|
+
async function openConsole({ view, loadStatus, t }) {
|
|
1310
|
+
if (typeof document === 'undefined') return { ok: false, missing: false, status: 0, message: t('panelMissing') };
|
|
1311
|
+
const outcome = await openConfigurationPage({ view, loadStatus, t, target: null, confirmed: true, navigate: false });
|
|
1312
|
+
if (!outcome.ok) return outcome;
|
|
1313
|
+
const pageUrl = consoleUrlOf();
|
|
1314
|
+
if (!pageUrl) return { ok: false, missing: false, status: 0, message: t('panelMissing') };
|
|
1315
|
+
const dialog = diagnosisDialog();
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* 第二层:那一屏调试页(嵌整页)。
|
|
1319
|
+
*
|
|
1320
|
+
* 从第一层的「报错调试」进来,用「返回」回第一层 —— **不是**关掉整窗:用户多半是
|
|
1321
|
+
* 想「看一眼再回去」,每次都重开一窗等于把位置也丢了。
|
|
1322
|
+
*/
|
|
1323
|
+
const showDebug = () => {
|
|
1324
|
+
dialog.open({
|
|
1325
|
+
title: t('consoleTitle'),
|
|
1326
|
+
wide: true,
|
|
1327
|
+
frame: { src: pageUrl, title: t('consoleTitle'), hint: t('consoleFrameless') },
|
|
1328
|
+
actions: [
|
|
1329
|
+
{
|
|
1330
|
+
label: t('consoleOpenBrowser'),
|
|
1331
|
+
primary: true,
|
|
1332
|
+
onClick: () => {
|
|
1333
|
+
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
|
1334
|
+
const opened = window.open(outcome.url, '_blank', 'noreferrer');
|
|
1335
|
+
if (opened) dialog.hide();
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1339
|
+
{ label: t('diagCopy'), onClick: () => void copyConsole() },
|
|
1340
|
+
{ label: t('consoleBack'), onClick: () => showEntry() },
|
|
1341
|
+
{ label: t('diagClose'), onClick: () => dialog.hide() },
|
|
1342
|
+
],
|
|
1343
|
+
});
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* 第一层:**只有一个简单入口**(2026-11-05 用户要求)。
|
|
1348
|
+
*
|
|
1349
|
+
* 原来点图标一上来就是整屏读数,太重了 —— 绝大多数时候人只是想去配置页,或者去看
|
|
1350
|
+
* 手机为什么连不上。所以这一层只摆两块:左边「进入控制台」(= 原来那个跳转),
|
|
1351
|
+
* 右边「报错调试」(才进第二层那一屏)。
|
|
1352
|
+
*/
|
|
1353
|
+
const showEntry = () => {
|
|
1354
|
+
const choices = makeChoiceGrid(dialog, t);
|
|
1355
|
+
dialog.open({
|
|
1356
|
+
title: t('consoleEntryTitle'),
|
|
1357
|
+
summary: t('consoleEntryLead'),
|
|
1358
|
+
body: choices.body,
|
|
1359
|
+
actions: [{ label: t('diagClose'), onClick: () => dialog.hide() }],
|
|
1360
|
+
});
|
|
1361
|
+
choices.console.onclick = () => {
|
|
1362
|
+
const opened = typeof window !== 'undefined' && typeof window.open === 'function'
|
|
1363
|
+
? window.open(outcome.url, '_blank', 'noreferrer')
|
|
1364
|
+
: null;
|
|
1365
|
+
if (opened) dialog.hide();
|
|
1366
|
+
else dialog.note(t('consolePopupBlocked'));
|
|
1367
|
+
};
|
|
1368
|
+
choices.debug.onclick = () => showDebug();
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
showEntry();
|
|
1372
|
+
return { ok: true, webAccess: outcome.webAccess };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* 入口那两块(左「进入控制台」、右「报错调试」)。
|
|
1377
|
+
*
|
|
1378
|
+
* 命令式 DOM,不用 react:客户端半边不经构建。两块都是大按钮,图标 + 标题 + 一句说明,
|
|
1379
|
+
* 点到哪里自己负责 —— 这里只造节点、把两张卡交给调用方填 onclick。
|
|
1380
|
+
*/
|
|
1381
|
+
function makeChoiceGrid(dialog, t) {
|
|
1382
|
+
const doc = typeof document !== 'undefined' ? document : null;
|
|
1383
|
+
const make = (tag, className, text) => {
|
|
1384
|
+
const element = doc.createElement(tag);
|
|
1385
|
+
if (className) element.className = className;
|
|
1386
|
+
if (text !== undefined) element.textContent = String(text);
|
|
1387
|
+
return element;
|
|
1388
|
+
};
|
|
1389
|
+
const body = make('div', 'dshm_choices');
|
|
1390
|
+
const card = (title, hint, tone) => {
|
|
1391
|
+
const button = make('button', 'dshm_choice' + (tone ? ` ${tone}` : ''));
|
|
1392
|
+
button.setAttribute('type', 'button');
|
|
1393
|
+
button.appendChild(make('span', 'dshm_choiceTitle', title));
|
|
1394
|
+
button.appendChild(make('span', 'dshm_choiceHint', hint));
|
|
1395
|
+
body.appendChild(button);
|
|
1396
|
+
return button;
|
|
1397
|
+
};
|
|
1398
|
+
const console = card(t('consoleEntryConsole'), t('consoleEntryConsoleHint'), 'dshm_choicePrimary');
|
|
1399
|
+
const debug = card(t('consoleEntryDebug'), t('consoleEntryDebugHint'), '');
|
|
1400
|
+
return { body, console, debug };
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** 把「复制诊断信息」那一套复用在控制台上:读的是嵌入页同样的几份,不另开一套。 */
|
|
1404
|
+
async function copyConsole() {
|
|
1405
|
+
const dialog = diagnosisDialog();
|
|
1406
|
+
const lines = ['dsh-mobile-plugin console'];
|
|
1407
|
+
lines.push(`panel_url=${url || ''}`);
|
|
1408
|
+
lines.push(`panel_scope=${panelScope || ''}`);
|
|
1409
|
+
try {
|
|
1410
|
+
const view = await call('/status');
|
|
1411
|
+
const next = view && typeof view === 'object' ? view : {};
|
|
1412
|
+
lines.push(`plugin_version=${next.appVersion || ''}`);
|
|
1413
|
+
lines.push(`tunnel=${next.tunnel && next.tunnel.state ? next.tunnel.state : ''}`);
|
|
1414
|
+
lines.push(`lan=${next.lan && next.lan.state ? next.lan.state : ''}`);
|
|
1415
|
+
lines.push(`sidecar=${next.sidecar && next.sidecar.state ? next.sidecar.state : ''}`);
|
|
1416
|
+
lines.push(`last_mobile_at=${next.lastMobileAt || 0}`);
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
lines.push(`status_error=${String((error && error.message) || error)}`);
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
const picture = await call(HOST_HALF_ENDPOINT);
|
|
1422
|
+
const half = picture && picture.half && typeof picture.half === 'object' ? picture.half : {};
|
|
1423
|
+
lines.push(`plugin_source=${half.source || ''}`);
|
|
1424
|
+
lines.push(`host_last_error=${half.lastError || ''}`);
|
|
1425
|
+
} catch {
|
|
1426
|
+
lines.push('host_half=unavailable');
|
|
1427
|
+
}
|
|
1428
|
+
if (typeof navigator !== 'undefined' && navigator.userAgent) lines.push(`user_agent=${navigator.userAgent}`);
|
|
1429
|
+
const ok = await copyDiagnosisText(document, lines.join('\n'));
|
|
1430
|
+
// 复制结果就地提示一下即可,不必重画整窗(重画会把嵌着的页面重新加载一遍)。
|
|
1431
|
+
dialog.note(ok ? t('diagCopied') : t('diagCopyFailed'));
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1129
1434
|
/**
|
|
1130
1435
|
* 把这一轮的现场摆成一屏**能自己动手**的自查。
|
|
1131
1436
|
*
|
|
@@ -1361,8 +1666,8 @@ window.__ModuleLoader__.load({
|
|
|
1361
1666
|
return undefined;
|
|
1362
1667
|
}
|
|
1363
1668
|
// 没地址时标题要说的是「为什么没有」:404 是宿主那一半没装载,其余是还没就绪。
|
|
1364
|
-
const notReadyLabel =
|
|
1365
|
-
const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel);
|
|
1669
|
+
const notReadyLabel = hostMissing ? loadMissingLabel : pendingLabel;
|
|
1670
|
+
const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel, hostMissing);
|
|
1366
1671
|
let attached = false;
|
|
1367
1672
|
try {
|
|
1368
1673
|
attached = ensureFooterIcon(doc, node);
|
|
@@ -1404,9 +1709,7 @@ window.__ModuleLoader__.load({
|
|
|
1404
1709
|
|
|
1405
1710
|
// 窄条,或者这一版外壳里认不出那一行:自己占一行,入口不能因为认不出位置就消失。
|
|
1406
1711
|
if (!wide || !anchored) {
|
|
1407
|
-
const label = url
|
|
1408
|
-
? openLabel
|
|
1409
|
-
: (problem && problem.status === 404 ? loadMissingLabel : pendingLabel);
|
|
1712
|
+
const label = url ? openLabel : (hostMissing ? loadMissingLabel : pendingLabel);
|
|
1410
1713
|
return h('a', {
|
|
1411
1714
|
className: 'dshm_footAction dshm_footActionRail',
|
|
1412
1715
|
href: url || undefined,
|
|
@@ -1415,6 +1718,7 @@ window.__ModuleLoader__.load({
|
|
|
1415
1718
|
'aria-label': label,
|
|
1416
1719
|
title: label,
|
|
1417
1720
|
'aria-disabled': 'false',
|
|
1721
|
+
[FOOTER_ALERT]: hostMissing ? '' : undefined,
|
|
1418
1722
|
onClick: openPanel,
|
|
1419
1723
|
}, h('svg', {
|
|
1420
1724
|
viewBox: '0 0 24 24',
|
|
@@ -1426,7 +1730,17 @@ window.__ModuleLoader__.load({
|
|
|
1426
1730
|
strokeLinecap: 'round',
|
|
1427
1731
|
'aria-hidden': 'true',
|
|
1428
1732
|
focusable: 'false',
|
|
1429
|
-
},
|
|
1733
|
+
}, [
|
|
1734
|
+
...PHONE_PATHS.map((d) => h('path', { key: d, d })),
|
|
1735
|
+
h('circle', {
|
|
1736
|
+
key: 'alert',
|
|
1737
|
+
className: 'dshm_alertDot',
|
|
1738
|
+
cx: PHONE_ALERT_DOT.cx,
|
|
1739
|
+
cy: PHONE_ALERT_DOT.cy,
|
|
1740
|
+
r: PHONE_ALERT_DOT.r,
|
|
1741
|
+
fill: 'currentColor',
|
|
1742
|
+
}),
|
|
1743
|
+
]));
|
|
1430
1744
|
}
|
|
1431
1745
|
return null;
|
|
1432
1746
|
}
|
|
@@ -1557,6 +1871,10 @@ window.__ModuleLoader__.load({
|
|
|
1557
1871
|
}
|
|
1558
1872
|
|
|
1559
1873
|
const hint = url === null ? t('panelPending') : url === '' ? t('panelMissing') : t('openHint');
|
|
1874
|
+
// 画布页与面板同源同宿主,地址从面板地址换尾巴;拿不到面板地址就按页面自身推。
|
|
1875
|
+
const canvasUrl = typeof url === 'string' && url
|
|
1876
|
+
? url.replace(/\/panel(?:\/(?:setup|settings))?$/, '/canvas')
|
|
1877
|
+
: sameOriginCanvasUrl();
|
|
1560
1878
|
return h('div', { className: 'dshm_root' },
|
|
1561
1879
|
h('h2', { className: 'dshm_heading' }, t('heading')),
|
|
1562
1880
|
h('div', { className: 'dshm_card' },
|
|
@@ -1574,7 +1892,19 @@ window.__ModuleLoader__.load({
|
|
|
1574
1892
|
failure
|
|
1575
1893
|
? h('p', { className: 'dshm_error' },
|
|
1576
1894
|
`${failure.kind === 'load' ? t('loadFailed') : t('openFailed')}:${failure.message}`)
|
|
1577
|
-
: null)
|
|
1895
|
+
: null),
|
|
1896
|
+
// 画布单独一张薄卡:点「打开画布」跳到浏览器打开插件托管的画布页(库与手机共用),
|
|
1897
|
+
// 不嵌在桌面端里。跟面板同源同宿主,地址从面板地址换尾巴拿。
|
|
1898
|
+
h('div', { className: 'dshm_card' },
|
|
1899
|
+
h('h3', { className: 'dshm_cardTitle' }, t('cardCanvas')),
|
|
1900
|
+
h('p', { className: 'dshm_note' }, t('canvasHint')),
|
|
1901
|
+
h('div', { className: 'dshm_actions' },
|
|
1902
|
+
h('a', {
|
|
1903
|
+
className: 'dshm_btn dshm_btnPrimary',
|
|
1904
|
+
href: canvasUrl || undefined,
|
|
1905
|
+
target: '_blank',
|
|
1906
|
+
rel: 'noreferrer',
|
|
1907
|
+
}, t('openCanvas')))));
|
|
1578
1908
|
}
|
|
1579
1909
|
|
|
1580
1910
|
return {
|