dsh-mobile-plugin 0.5.20 → 0.5.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/client.js +318 -27
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +12 -11
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +74 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/session-cleanup.cjs +22 -7
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -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}
|
|
@@ -521,6 +544,18 @@ window.__ModuleLoader__.load({
|
|
|
521
544
|
footerPending: '网页地址还没就绪',
|
|
522
545
|
footerPublicConfirm: '这个地址位于公网。继续后会开放移动端插件的网页管理权限 2 小时;期间任何能访问该地址的人都可以操作面板。是否继续?',
|
|
523
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: '这一屏没载入进来(桌面壳或代理挡住了内嵌页面)。点下面那颗按钮在浏览器里打开,读数一样看得到。',
|
|
524
559
|
diagTitleLoad: '打不开移动端配置页',
|
|
525
560
|
diagTitleOpen: '开放网页管理失败',
|
|
526
561
|
diagChecking: '正在读取宿主状态…',
|
|
@@ -582,6 +617,18 @@ window.__ModuleLoader__.load({
|
|
|
582
617
|
footerPending: 'The page address is not ready yet',
|
|
583
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?',
|
|
584
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.',
|
|
585
632
|
diagTitleLoad: 'Cannot open the mobile configuration page',
|
|
586
633
|
diagTitleOpen: 'Could not open web management',
|
|
587
634
|
diagChecking: 'Reading host status…',
|
|
@@ -652,10 +699,13 @@ window.__ModuleLoader__.load({
|
|
|
652
699
|
*/
|
|
653
700
|
function createDiagnosisDialog(doc) {
|
|
654
701
|
let overlay = null;
|
|
702
|
+
/** 底下那一句说明(复制结果就地改它,不重画整窗)。 */
|
|
703
|
+
let noteEl = null;
|
|
655
704
|
let escapeListener = null;
|
|
656
705
|
const hide = () => {
|
|
657
706
|
if (overlay && typeof overlay.remove === 'function' && overlay.parentElement) overlay.remove();
|
|
658
707
|
overlay = null;
|
|
708
|
+
noteEl = null;
|
|
659
709
|
if (escapeListener && typeof doc.removeEventListener === 'function') {
|
|
660
710
|
doc.removeEventListener('keydown', escapeListener);
|
|
661
711
|
}
|
|
@@ -689,14 +739,43 @@ window.__ModuleLoader__.load({
|
|
|
689
739
|
}
|
|
690
740
|
if (spec.title) overlay.setAttribute('aria-label', spec.title);
|
|
691
741
|
// 每次重画:读数与按钮都是这一轮的现场,别留上一轮的字。
|
|
692
|
-
|
|
693
|
-
|
|
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');
|
|
694
749
|
card.addEventListener('click', (event) => {
|
|
695
750
|
if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
|
|
696
751
|
});
|
|
697
752
|
overlay.appendChild(card);
|
|
698
753
|
if (spec.title) card.appendChild(make('h3', 'dshm_dialogTitle', spec.title));
|
|
699
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
|
+
}
|
|
700
779
|
if (spec.facts && spec.facts.length) {
|
|
701
780
|
if (spec.factsHeading) card.appendChild(make('h4', 'dshm_dialogHeading', spec.factsHeading));
|
|
702
781
|
const list = make('dl', 'dshm_facts');
|
|
@@ -714,7 +793,10 @@ window.__ModuleLoader__.load({
|
|
|
714
793
|
for (const step of spec.steps) steps.appendChild(make('li', null, step));
|
|
715
794
|
card.appendChild(steps);
|
|
716
795
|
}
|
|
717
|
-
|
|
796
|
+
// 这一行**总是挂上**(空着不占位):控制台那一窗的「复制」要就地改这一句,
|
|
797
|
+
// 重画整窗会把嵌进去的页面重新加载一遍。
|
|
798
|
+
const note = make('p', 'dshm_dialogNote', spec.note || '');
|
|
799
|
+
card.appendChild(note);
|
|
718
800
|
const actions = make('div', 'dshm_dialogActions');
|
|
719
801
|
for (const action of spec.actions || []) {
|
|
720
802
|
const button = make('button', action.primary ? 'dshm_btn dshm_btnPrimary' : 'dshm_btn', action.label);
|
|
@@ -724,9 +806,14 @@ window.__ModuleLoader__.load({
|
|
|
724
806
|
actions.appendChild(button);
|
|
725
807
|
}
|
|
726
808
|
card.appendChild(actions);
|
|
809
|
+
noteEl = note;
|
|
727
810
|
return overlay;
|
|
728
811
|
};
|
|
729
|
-
|
|
812
|
+
/** 就地更新底下那一句说明(不重画整窗)。 */
|
|
813
|
+
const note = (text) => {
|
|
814
|
+
if (noteEl) noteEl.textContent = text === undefined ? '' : String(text);
|
|
815
|
+
};
|
|
816
|
+
return { open, hide, note };
|
|
730
817
|
}
|
|
731
818
|
|
|
732
819
|
/** 复制一段文字:先走剪贴板 API,不行退回 textarea + execCommand。 */
|
|
@@ -817,6 +904,28 @@ window.__ModuleLoader__.load({
|
|
|
817
904
|
}
|
|
818
905
|
}
|
|
819
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
|
+
|
|
820
929
|
/**
|
|
821
930
|
* 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
|
|
822
931
|
*
|
|
@@ -835,7 +944,7 @@ window.__ModuleLoader__.load({
|
|
|
835
944
|
* @returns {Promise<{ok: true, cancelled?: boolean, url: string, webAccess: object|null}
|
|
836
945
|
* | {ok: false, missing: boolean, status: number, message: string}>}
|
|
837
946
|
*/
|
|
838
|
-
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 }) {
|
|
839
948
|
let state = readStatusView(view);
|
|
840
949
|
const failed = (error) => ({
|
|
841
950
|
ok: false,
|
|
@@ -891,6 +1000,9 @@ window.__ModuleLoader__.load({
|
|
|
891
1000
|
target.location.href = url;
|
|
892
1001
|
return { ok: true, url, webAccess: state.webAccess };
|
|
893
1002
|
}
|
|
1003
|
+
// `navigate: false` = 只要地址与「控制面已经认这个浏览器」,别开任何窗口:
|
|
1004
|
+
// 控制台那一窗是把这一页**嵌进来**,多开一个浏览器窗口等于点了就跳走。
|
|
1005
|
+
if (navigate === false) return { ok: true, url, webAccess: state.webAccess };
|
|
894
1006
|
if (!isWebPage(window)) {
|
|
895
1007
|
// 交回给页面导航:壳会把外部 http(s) 地址转给系统浏览器。
|
|
896
1008
|
window.location.href = url;
|
|
@@ -926,6 +1038,8 @@ window.__ModuleLoader__.load({
|
|
|
926
1038
|
const FOOTER_SEAT = '[data-slot="sidebar.settings"]';
|
|
927
1039
|
/** 自己那颗按钮的记号:同一页面里出现第二份实现时,靠它摘掉上一颗。 */
|
|
928
1040
|
const FOOTER_MARK = 'data-dshm-mobile-web';
|
|
1041
|
+
/** 告警态的记号:宿主那一半没装载(`/status` 404)时,整颗转红并显出右上角那颗红点。 */
|
|
1042
|
+
const FOOTER_ALERT = 'data-dshm-alert';
|
|
929
1043
|
/** 还没问到网页地址时的重试间隔。取到就停,不再问。 */
|
|
930
1044
|
const FOOTER_URL_RETRY_MS = 20000;
|
|
931
1045
|
/**
|
|
@@ -938,6 +1052,15 @@ window.__ModuleLoader__.load({
|
|
|
938
1052
|
'M10.4 4.4h3.2',
|
|
939
1053
|
'M11 19.4h2',
|
|
940
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 };
|
|
941
1064
|
/**
|
|
942
1065
|
* 图标边长:展开那一行 20,窄条里的圆钮 22(用户 2026-11-02 拍的,先 18 后嫌小又抬了一档)。
|
|
943
1066
|
*
|
|
@@ -949,7 +1072,7 @@ window.__ModuleLoader__.load({
|
|
|
949
1072
|
const PHONE_GLYPH_WIDE = 20;
|
|
950
1073
|
const PHONE_GLYPH_RAIL = 22;
|
|
951
1074
|
|
|
952
|
-
/**
|
|
1075
|
+
/** 造一颗手机图标(含那颗常驻的告警红点,显不显示看 `data-dshm-alert`)。文档没有 SVG 能力时返回 null,按钮照样成立(只剩无障碍名字)。 */
|
|
953
1076
|
function phoneIcon(doc, size) {
|
|
954
1077
|
if (!doc || typeof doc.createElementNS !== 'function') return null;
|
|
955
1078
|
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
@@ -967,6 +1090,13 @@ window.__ModuleLoader__.load({
|
|
|
967
1090
|
path.setAttribute('d', d);
|
|
968
1091
|
svg.appendChild(path);
|
|
969
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);
|
|
970
1100
|
return svg;
|
|
971
1101
|
}
|
|
972
1102
|
|
|
@@ -991,11 +1121,14 @@ window.__ModuleLoader__.load({
|
|
|
991
1121
|
*
|
|
992
1122
|
* 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
|
|
993
1123
|
*/
|
|
994
|
-
function syncFooterIcon(node, url, openLabel, pendingLabel, onClick) {
|
|
1124
|
+
function syncFooterIcon(node, url, openLabel, pendingLabel, onClick, alert) {
|
|
995
1125
|
const label = url ? openLabel : pendingLabel;
|
|
996
1126
|
node.setAttribute('aria-label', label);
|
|
997
1127
|
node.setAttribute('title', label);
|
|
998
1128
|
node.setAttribute('aria-disabled', 'false');
|
|
1129
|
+
// 告警态只换记号,图形和尺寸不动:CSS 按这个属性把整颗转红、把红点显出来。
|
|
1130
|
+
if (alert) node.setAttribute(FOOTER_ALERT, '');
|
|
1131
|
+
else node.removeAttribute(FOOTER_ALERT);
|
|
999
1132
|
// 有地址时仍然写 `href`:普通链接照旧能中键 / 新标签打开。只有「浏览器不是从本机来的」
|
|
1000
1133
|
// 那两种情况要拦下来先开窗口,见 `openPanel`。
|
|
1001
1134
|
if (url) node.setAttribute('href', url);
|
|
@@ -1070,6 +1203,12 @@ window.__ModuleLoader__.load({
|
|
|
1070
1203
|
const pendingLabel = t('footerPending');
|
|
1071
1204
|
const loadMissingLabel = t('footerLoadMissing');
|
|
1072
1205
|
const publicConfirm = t('footerPublicConfirm');
|
|
1206
|
+
/**
|
|
1207
|
+
* 宿主那一半没装载(`/status` 404):点了也打不开、重装前谁也修不好,所以这一种是
|
|
1208
|
+
* **真故障**,那颗图标转红告警;标题也换成更具体的那一句。地址只是还没读到(后台
|
|
1209
|
+
* 重试中)、或者窗口关着被回 423,都不是插件坏了,不红。
|
|
1210
|
+
*/
|
|
1211
|
+
const hostMissing = Boolean(problem && problem.status === 404);
|
|
1073
1212
|
|
|
1074
1213
|
/** 诊断窗单例:同一页面只留一个,反复打开就地重画。 */
|
|
1075
1214
|
function diagnosisDialog() {
|
|
@@ -1113,34 +1252,45 @@ window.__ModuleLoader__.load({
|
|
|
1113
1252
|
* 浏览器的弹窗拦截器吃掉;地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
|
|
1114
1253
|
* 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
|
|
1115
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
|
+
*/
|
|
1116
1269
|
function openPanel(event) {
|
|
1117
|
-
const knowsUrl = typeof url === 'string' && url !== '';
|
|
1118
|
-
const needsWindow = !knowsUrl || (panelScope !== 'loopback' && !(webAccess && webAccess.enabled));
|
|
1119
|
-
if (!needsWindow) return; // 普通链接,交给浏览器自己开
|
|
1120
1270
|
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1123
|
-
|
|
1271
|
+
// 公网地址先问一句——但**只在真要开那两小时窗口的时候问**:窗口已经开着,
|
|
1272
|
+
// 这一下没有扩大任何权限,再弹一次只是把上一轮点过的确认重问一遍。
|
|
1273
|
+
const willOpenWindow = panelScope !== 'loopback' && !(webAccess && webAccess.enabled);
|
|
1274
|
+
if (panelScope === 'public'
|
|
1275
|
+
&& url
|
|
1276
|
+
&& willOpenWindow
|
|
1124
1277
|
&& typeof window !== 'undefined'
|
|
1125
1278
|
&& typeof window.confirm === 'function'
|
|
1126
1279
|
&& !window.confirm(publicConfirm)) return;
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1280
|
+
// 桌面壳(`dsh-app://app`)里不嵌:往自定义协议页里塞一条 http(s) iframe,多半被
|
|
1281
|
+
// 当成混合内容挡掉,用户看到的是一窗空白。那种环境仍走「交给系统浏览器」那条老路
|
|
1282
|
+
//(`navigate` 默认 true,壳的 `will-navigate` 会把它转给系统浏览器)。
|
|
1130
1283
|
void (async () => {
|
|
1131
|
-
const outcome = await
|
|
1284
|
+
const outcome = await openConsole({
|
|
1132
1285
|
view: { panelUrl: url || '', panelScope, webAccess },
|
|
1133
1286
|
loadStatus: problem ? problem.status : 0,
|
|
1134
1287
|
t,
|
|
1135
|
-
target,
|
|
1136
|
-
confirmed: knowsUrl,
|
|
1137
1288
|
});
|
|
1138
1289
|
if (outcome.ok) {
|
|
1139
|
-
if (outcome.cancelled && target && typeof target.close === 'function') target.close();
|
|
1140
1290
|
if (outcome.webAccess) setWebAccess(outcome.webAccess);
|
|
1141
1291
|
return;
|
|
1142
1292
|
}
|
|
1143
|
-
|
|
1293
|
+
// 宿主那一半压根没装载:那是一屏「装完没重启」,不是这一屏能修的。
|
|
1144
1294
|
if (outcome.missing) {
|
|
1145
1295
|
void openDiagnosis('load', { status: outcome.status || 404, message: (problem && problem.message) || '' });
|
|
1146
1296
|
return;
|
|
@@ -1149,6 +1299,138 @@ window.__ModuleLoader__.load({
|
|
|
1149
1299
|
})();
|
|
1150
1300
|
}
|
|
1151
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
|
+
|
|
1152
1434
|
/**
|
|
1153
1435
|
* 把这一轮的现场摆成一屏**能自己动手**的自查。
|
|
1154
1436
|
*
|
|
@@ -1384,8 +1666,8 @@ window.__ModuleLoader__.load({
|
|
|
1384
1666
|
return undefined;
|
|
1385
1667
|
}
|
|
1386
1668
|
// 没地址时标题要说的是「为什么没有」:404 是宿主那一半没装载,其余是还没就绪。
|
|
1387
|
-
const notReadyLabel =
|
|
1388
|
-
const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel);
|
|
1669
|
+
const notReadyLabel = hostMissing ? loadMissingLabel : pendingLabel;
|
|
1670
|
+
const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel, hostMissing);
|
|
1389
1671
|
let attached = false;
|
|
1390
1672
|
try {
|
|
1391
1673
|
attached = ensureFooterIcon(doc, node);
|
|
@@ -1427,9 +1709,7 @@ window.__ModuleLoader__.load({
|
|
|
1427
1709
|
|
|
1428
1710
|
// 窄条,或者这一版外壳里认不出那一行:自己占一行,入口不能因为认不出位置就消失。
|
|
1429
1711
|
if (!wide || !anchored) {
|
|
1430
|
-
const label = url
|
|
1431
|
-
? openLabel
|
|
1432
|
-
: (problem && problem.status === 404 ? loadMissingLabel : pendingLabel);
|
|
1712
|
+
const label = url ? openLabel : (hostMissing ? loadMissingLabel : pendingLabel);
|
|
1433
1713
|
return h('a', {
|
|
1434
1714
|
className: 'dshm_footAction dshm_footActionRail',
|
|
1435
1715
|
href: url || undefined,
|
|
@@ -1438,6 +1718,7 @@ window.__ModuleLoader__.load({
|
|
|
1438
1718
|
'aria-label': label,
|
|
1439
1719
|
title: label,
|
|
1440
1720
|
'aria-disabled': 'false',
|
|
1721
|
+
[FOOTER_ALERT]: hostMissing ? '' : undefined,
|
|
1441
1722
|
onClick: openPanel,
|
|
1442
1723
|
}, h('svg', {
|
|
1443
1724
|
viewBox: '0 0 24 24',
|
|
@@ -1449,7 +1730,17 @@ window.__ModuleLoader__.load({
|
|
|
1449
1730
|
strokeLinecap: 'round',
|
|
1450
1731
|
'aria-hidden': 'true',
|
|
1451
1732
|
focusable: 'false',
|
|
1452
|
-
},
|
|
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
|
+
]));
|
|
1453
1744
|
}
|
|
1454
1745
|
return null;
|
|
1455
1746
|
}
|
package/lib/impl.js
CHANGED
|
@@ -30,8 +30,9 @@ const { createMirrorPusher } = require('./mirror/pusher.cjs')
|
|
|
30
30
|
const { randomBytes } = require('node:crypto')
|
|
31
31
|
const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManualIpv6Candidate } = require('./relay/lan-network.cjs')
|
|
32
32
|
// 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
|
|
33
|
-
const { pairingPayload } = require('./relay/pairing-payload.cjs')
|
|
33
|
+
const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
|
|
34
34
|
const { resolvePort } = require('./relay/port-allocator.cjs')
|
|
35
|
+
const { mountTailcat } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
|
|
35
36
|
const { createPreviewService } = require('./relay/preview.js')
|
|
36
37
|
const { mountTerminal, mountCli } = require('./mobile/services.js')
|
|
37
38
|
// 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
|
|
@@ -53,6 +54,9 @@ const statsScheduler = require('./mobile/stats-scheduler.cjs')
|
|
|
53
54
|
// 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
|
|
54
55
|
const { installExtensions } = require('./mobile/extensions.js')
|
|
55
56
|
const { mountPhoneControl } = require('./mobile/phone/index.js') // 「让 AI 操作手机」,见 mobile/phone/
|
|
57
|
+
// 公众号文章库:给 AI 的两个工具(写 / 读),见 mobile/wechat-tools.js。
|
|
58
|
+
const { installWechatTools } = require('./mobile/wechat-tools.js')
|
|
59
|
+
const { installPetTools } = require('./mobile/pet-tools.js') // 悬浮宠物的两个工具(看 / 写),见 mobile/pet-tools.js。
|
|
56
60
|
const { activeGuard } = require('./host-guard.cjs')
|
|
57
61
|
// 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
|
|
58
62
|
// 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
|
|
@@ -299,6 +303,8 @@ function apply(ctx) {
|
|
|
299
303
|
let lanPortProbe = null
|
|
300
304
|
const lanPortInUse = () => (state.lanEnabled && effectiveLanPort > 0 ? effectiveLanPort : state.lanPort)
|
|
301
305
|
const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
|
|
306
|
+
// 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
|
|
307
|
+
const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, lanPortInUse, hostName: () => state.name, ctx, name })
|
|
302
308
|
// 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
|
|
303
309
|
// lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
|
|
304
310
|
// 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
|
|
@@ -362,6 +368,14 @@ function apply(ctx) {
|
|
|
362
368
|
}
|
|
363
369
|
return undefined
|
|
364
370
|
}, `${name}: extension tool`)
|
|
371
|
+
ctx.effect(() => {
|
|
372
|
+
// 缺 tools 注入的 profile 只少这两个工具;文章库本身与它的路由照常可用。
|
|
373
|
+
try { installWechatTools(ctx) } catch (error) {
|
|
374
|
+
if (!String(error).includes('without inject')) throw error
|
|
375
|
+
}
|
|
376
|
+
return undefined
|
|
377
|
+
}, `${name}: wechat article tools`)
|
|
378
|
+
mount('宠物工具', () => installPetTools(ctx)) // 缺 tools 注入的 profile 只少这两个,包目录与路由照常可用。
|
|
365
379
|
mount('统计采集', () => ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`))
|
|
366
380
|
mount('过程播报', () => ctx.effect(() => installNarration(ctx), `${name}: process narration`))
|
|
367
381
|
|
|
@@ -376,6 +390,8 @@ function apply(ctx) {
|
|
|
376
390
|
getSidecarRestartedAt: sidecarLifecycle.getRestartedAt,
|
|
377
391
|
getSidecarRestartError: sidecarLifecycle.getRestartError,
|
|
378
392
|
getMirrorPush: mirror.getMirrorPush,
|
|
393
|
+
// 直连隧道那一格:手机轮询 `/status` 时据此自己把通道加进来。
|
|
394
|
+
getTailcat: () => (tailcat ? tailcat.status() : null),
|
|
379
395
|
})
|
|
380
396
|
function applyServices() {
|
|
381
397
|
void syncSidecar().catch(() => undefined)
|
|
@@ -420,6 +436,8 @@ function apply(ctx) {
|
|
|
420
436
|
lanPortInUse: advertisedLanPort, // 二维码里的端口也必须是实绑值
|
|
421
437
|
getLanPortAdjusted: () => lanPortAdjusted,
|
|
422
438
|
pairingPayload,
|
|
439
|
+
// 直连隧道:二维码要多带一段「去这个一次性节点换正式地址」,状态接口要多一行。
|
|
440
|
+
tailcat,
|
|
423
441
|
restartSidecar,
|
|
424
442
|
// 「手机上最近来过没有」——控制面状态里补给面板用(见 ./mobile/activity.cjs)。
|
|
425
443
|
mobileActivity: activity.snapshot,
|
|
@@ -487,6 +505,8 @@ function apply(ctx) {
|
|
|
487
505
|
// 核对的「路径 → handler」表,不用在 1000 行装配代码里翻。
|
|
488
506
|
mount('移动端路由', () => registerMobileRoutes({
|
|
489
507
|
registerRoute,
|
|
508
|
+
// 直连隧道:已配对的手机走这条拿到自己的入口地址,不用重新扫码。
|
|
509
|
+
tailcat,
|
|
490
510
|
// 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
|
|
491
511
|
// 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
|
|
492
512
|
registerUpgradeRoute: (route) => mountRoute(route, true),
|
package/lib/mobile/approvals.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const { randomUUID } = require('node:crypto');
|
|
20
20
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
21
|
+
const { cancelForwardedRequests } = require('./host-bridge/transport.js');
|
|
21
22
|
|
|
22
23
|
/** `/approval/respond` 只认这两种结论(与 App 的两个按钮一一对应)。 */
|
|
23
24
|
const ANSWER_OUTCOMES = new Set(['allowed-once', 'rejected']);
|
|
@@ -234,6 +235,8 @@ function noteDecided(sessionId, data) {
|
|
|
234
235
|
*/
|
|
235
236
|
function bindRequest(entry, request) {
|
|
236
237
|
entry.bound = true;
|
|
238
|
+
// 留一个身份引用:手机答完之后要按它找回宿主转发给桌面窗口的那一发(见 cancelForwardedCards)。
|
|
239
|
+
entry.requestRef = request;
|
|
237
240
|
entry.toolName = entry.toolName || (typeof request.toolName === 'string' ? request.toolName : undefined);
|
|
238
241
|
entry.callId = entry.callId || (typeof request.callId === 'string' ? request.callId : undefined);
|
|
239
242
|
const nicer = displayReasonOf(request) || (typeof request.reason === 'string' ? request.reason : undefined);
|
|
@@ -261,8 +264,10 @@ function bindRequest(entry, request) {
|
|
|
261
264
|
* 输的那张卡由结算帧收掉;两边都没人答就一直挂着。
|
|
262
265
|
*
|
|
263
266
|
* 不传 `next`(手机正在看这个会话)就是手机独家作答:不往下游转发,桌面从源头就不弹卡。
|
|
264
|
-
*
|
|
265
|
-
*
|
|
267
|
+
* 独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
|
|
268
|
+
*
|
|
269
|
+
* 唯一兜不住的边角是「审批发出时手机没在看、卡已转发到桌面,之后手机才连上来」:那一发
|
|
270
|
+
* 已经转发出去,手机后来答时靠 `cancelForwardedCards` 把它撤掉(见下)。
|
|
266
271
|
*/
|
|
267
272
|
function holdForPhone(entry, next) {
|
|
268
273
|
const downstream = next === undefined
|
|
@@ -275,6 +280,20 @@ function holdForPhone(entry, next) {
|
|
|
275
280
|
return Promise.race([entry.wait.then((result) => result.outcome), downstream]);
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
/**
|
|
284
|
+
* 收掉桌面那张审批卡:把宿主已经发给桌面窗口、还挂着的那一发转发撤掉。
|
|
285
|
+
*
|
|
286
|
+
* 手机在看这条会话时审批桥本就不转发,桌面没有卡可收;会走到这里的是那条边角 —— 审批
|
|
287
|
+
* 发出时手机没在看、卡已转发到桌面,之后手机才连上来答。那一发只有桌面自己答 / 本轮取消
|
|
288
|
+
* / 宿主撤回三条路会消失,本桥替它补上最后一条(与提问桥 2026-10-31 同一手法:把钩子收到
|
|
289
|
+
* 的请求对象交给传输层,按它认回网关 `pendingRemoteEvents` 里那一发并撤回)。认不到(老
|
|
290
|
+
* 宿主没这道网关)就什么都不做 —— 「桌面没卡可收」本来就是对的。
|
|
291
|
+
*/
|
|
292
|
+
function cancelForwardedCards(ctx, entry) {
|
|
293
|
+
if (!entry.requestRef) return;
|
|
294
|
+
cancelForwardedRequests(ctx, entry.requestRef, new Error('手机已经回答了这条审批'));
|
|
295
|
+
}
|
|
296
|
+
|
|
278
297
|
function onApprovalRequest(request, next) {
|
|
279
298
|
const sessionId = sessionIdOfRequest(request);
|
|
280
299
|
if (!sessionId) return next();
|
|
@@ -333,6 +352,7 @@ async function handleApprovalRespond(ctx, req, res) {
|
|
|
333
352
|
if (entry && !entry.settled && entry.bound
|
|
334
353
|
&& entry.sessionId === body.sessionId && entry.approvalId === body.approvalId) {
|
|
335
354
|
settle(entry, body.outcome);
|
|
355
|
+
cancelForwardedCards(ctx, entry);
|
|
336
356
|
return sendJson(res, 200, { ok: true, accepted: true });
|
|
337
357
|
}
|
|
338
358
|
const receipt = await respondViaLegacyProxy(ctx, {
|