fix(client): recover loopback RPC 403 via localhost

This commit is contained in:
xmanrui 2026-08-25 00:50:27 +08:00
parent 9e39333ff6
commit d999cb511f
6 changed files with 425 additions and 18 deletions

View file

@ -55,6 +55,10 @@ const EN = Object.freeze({
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
'请改用 localhost 重新打开': 'Reopen with localhost',
'页面会在当前端口重新打开,机器人配置不会改变。': 'The page will reopen on the current port. Your bot configuration will not change.',
'使用 localhost 重新打开': 'Reopen with localhost',
'当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。': 'This address is incompatible with the browser’s local-request checks. Use the button above to reopen with localhost.',
'微信': 'WeChat',
'飞书': 'Feishu',
'钉钉': 'DingTalk',

View file

@ -42,6 +42,10 @@ import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
import {
createLoopbackAwareRpcCalls,
replacePageLocation,
} from './loopback-recovery.js';
import { installImStyles } from './styles.js';
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
@ -122,6 +126,22 @@ function ChannelLogo({ channel }) {
return h(OfficeLogo);
}
export function LoopbackRecoveryNotice({ recovery, onNavigate = replacePageLocation }) {
return h('div', {
className: 'dim-loopbackRecovery',
role: 'alert',
},
h('div', { className: 'dim-loopbackRecoveryCopy' },
h('strong', null, '请改用 localhost 重新打开'),
h('p', null, '页面会在当前端口重新打开,机器人配置不会改变。'),
h('code', null, recovery.origin)),
h('button', {
type: 'button',
className: 'dim-loopbackRecoveryAction',
onClick: () => onNavigate(recovery.url),
}, '使用 localhost 重新打开'));
}
export function IMSettingsTab({
dingtalkRpcCall,
discordRpcCall,
@ -134,10 +154,44 @@ export function IMSettingsTab({
whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker,
browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation,
}) {
const [selected, setSelected] = React.useState('weixin');
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
const githubTooltipId = React.useId();
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []);
const rpcCalls = React.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall,
discordRpcCall,
feishuRpcCall,
qqRpcCall,
slackRpcCall,
telegramRpcCall,
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
}, {
location: browserLocation,
onRecovery: reportLoopbackRecovery,
}), [
browserLocation,
dingtalkRpcCall,
discordRpcCall,
feishuRpcCall,
officeRpcCall,
qqRpcCall,
reportLoopbackRecovery,
slackRpcCall,
telegramRpcCall,
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
]);
return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
h('header', { className: 'dim-title' },
@ -184,25 +238,32 @@ export function IMSettingsTab({
role: 'tabpanel',
id: `dim-panel-${active.id}`,
'aria-labelledby': `dim-tab-${active.id}`,
}, active.id === 'weixin'
? h(WeixinSettingsTab, { rpcCall: weixinRpcCall })
},
loopbackRecovery
? h(LoopbackRecoveryNotice, {
recovery: loopbackRecovery,
onNavigate: navigateToRecoveryUrl,
})
: null,
active.id === 'weixin'
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
: active.id === 'feishu'
? h(FeishuSettingsTab, { rpcCall: feishuRpcCall })
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
: active.id === 'dingtalk'
? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
: active.id === 'wecom'
? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
: active.id === 'qq'
? h(QqSettingsTab, { rpcCall: qqRpcCall })
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
: active.id === 'slack'
? h(SlackSettingsTab, { rpcCall: slackRpcCall })
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
: active.id === 'telegram'
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
: active.id === 'discord'
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
: active.id === 'whatsapp'
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })),
),
));
}

View file

@ -0,0 +1,75 @@
const TRANSPORT_FORBIDDEN = /^transport failure for \/[A-Za-z0-9._~-]+\/[A-Za-z0-9_$./~-]+: HTTP 403$/;
export const LOOPBACK_RECOVERY_ERROR_CODE = 'loopback-recovery-required';
export const LOOPBACK_RECOVERY_ERROR_MESSAGE =
'当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。';
function isIpv4Loopback(hostname) {
const parts = hostname.split('.');
return parts.length === 4
&& parts[0] === '127'
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
}
/**
* Return a safe localhost navigation target for the known loopback transport failure.
*/
export function createLoopbackRecovery(error, location) {
if (!TRANSPORT_FORBIDDEN.test(error?.message ?? '')) return null;
if (typeof location?.href !== 'string') return null;
try {
const current = new URL(location.href);
if (current.protocol !== 'http:' || !isIpv4Loopback(current.hostname)) return null;
current.hostname = 'localhost';
return Object.freeze({
url: current.href,
origin: current.origin,
});
} catch {
return null;
}
}
/**
* Decorate one IM RPC caller with a narrowly scoped localhost recovery signal.
*/
export function createLoopbackAwareRpcCall(rpcCall, {
location,
onRecovery,
} = {}) {
if (typeof rpcCall !== 'function') throw new TypeError('rpcCall must be a function');
return async (...args) => {
try {
return await rpcCall(...args);
} catch (error) {
const recovery = createLoopbackRecovery(error, location);
if (!recovery) throw error;
onRecovery?.(recovery);
const presented = new Error(LOOPBACK_RECOVERY_ERROR_MESSAGE);
presented.code = LOOPBACK_RECOVERY_ERROR_CODE;
presented.cause = error;
presented.recoveryUrl = recovery.url;
throw presented;
}
};
}
/**
* Apply the same recovery behavior to every RPC caller in the combined settings page.
*/
export function createLoopbackAwareRpcCalls(rpcCalls, options) {
return Object.freeze(Object.fromEntries(
Object.entries(rpcCalls).map(([name, rpcCall]) => [
name,
typeof rpcCall === 'function'
? createLoopbackAwareRpcCall(rpcCall, options)
: rpcCall,
]),
));
}
/** Navigate without leaving the known-broken loopback address in browser history. */
export function replacePageLocation(url, location = globalThis.location) {
location?.replace?.(url);
}

View file

@ -57,6 +57,14 @@ const CSS = String.raw`
.dim-channelNote { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
.dim-panel { min-width: 0; container-type: inline-size; }
.dim-loopbackRecovery { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
.dim-loopbackRecoveryCopy { min-width: 0; }
.dim-loopbackRecoveryCopy strong { display: block; font-size: 14px; line-height: 20px; font-weight: 650; }
.dim-loopbackRecoveryCopy p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
.dim-loopbackRecoveryCopy code { display: block; overflow: hidden; margin-top: 5px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/16px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
.dim-loopbackRecoveryAction { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; }
.dim-loopbackRecoveryAction:hover { border-color: #0958d9; background: #0958d9; }
.dim-loopbackRecoveryAction:focus-visible { outline: 2px solid color-mix(in srgb, #1677ff 62%, white); outline-offset: 2px; }
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: stretch; gap: 8px; }
@ -266,6 +274,8 @@ const CSS = String.raw`
.dim-title p { white-space: normal; }
.dim-githubTooltip { right: auto; left: 0; }
.dim-rail { grid-template-columns: minmax(0, 1fr); }
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
.dim-loopbackRecoveryAction { width: 100%; }
.dim-directoryPickerBackdrop { padding: 10px; }
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
.dim-directoryPickerHeader { padding: 18px 17px 14px; }