diff --git a/lib/client.js b/lib/client.js index 1cf05a7..3327318 100644 --- a/lib/client.js +++ b/lib/client.js @@ -35,6 +35,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru var index_exports = {}; __export(index_exports, { IMSettingsTab: () => IMSettingsTab, + LoopbackRecoveryNotice: () => LoopbackRecoveryNotice, apply: () => apply, inject: () => inject, name: () => name @@ -245,6 +246,10 @@ var EN = Object.freeze({ "\u672C\u673A\u6682\u65F6\u65E0\u6CD5\u8BBF\u95EE AI Office\u3002": "AI Office cannot currently be reached from this machine.", "AI Office \u8FDE\u63A5\u5DF2\u4E2D\u65AD\u3002": "The AI Office connection was interrupted.", "\u5E2E\u52A9\u4E0E\u53CD\u9988 \xB7 \u524D\u5F80 GitHub": "Help & feedback \xB7 Open GitHub", + "\u8BF7\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00": "Reopen with localhost", + "\u9875\u9762\u4F1A\u5728\u5F53\u524D\u7AEF\u53E3\u91CD\u65B0\u6253\u5F00\uFF0C\u673A\u5668\u4EBA\u914D\u7F6E\u4E0D\u4F1A\u6539\u53D8\u3002": "The page will reopen on the current port. Your bot configuration will not change.", + "\u4F7F\u7528 localhost \u91CD\u65B0\u6253\u5F00": "Reopen with localhost", + "\u5F53\u524D\u5730\u5740\u4E0E\u6D4F\u89C8\u5668\u7684\u672C\u673A\u8BF7\u6C42\u6821\u9A8C\u4E0D\u517C\u5BB9\u3002\u8BF7\u4F7F\u7528\u4E0A\u65B9\u6309\u94AE\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00\u3002": "This address is incompatible with the browser\u2019s local-request checks. Use the button above to reopen with localhost.", "\u5FAE\u4FE1": "WeChat", "\u98DE\u4E66": "Feishu", "\u9489\u9489": "DingTalk", @@ -10014,6 +10019,61 @@ function WhatsappSettingsTab({ rpcCall }) { )); } +// plugin-src/client/loopback-recovery.js +var TRANSPORT_FORBIDDEN = /^transport failure for \/[A-Za-z0-9._~-]+\/[A-Za-z0-9_$./~-]+: HTTP 403$/; +var LOOPBACK_RECOVERY_ERROR_CODE = "loopback-recovery-required"; +var LOOPBACK_RECOVERY_ERROR_MESSAGE = "\u5F53\u524D\u5730\u5740\u4E0E\u6D4F\u89C8\u5668\u7684\u672C\u673A\u8BF7\u6C42\u6821\u9A8C\u4E0D\u517C\u5BB9\u3002\u8BF7\u4F7F\u7528\u4E0A\u65B9\u6309\u94AE\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00\u3002"; +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); +} +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; + } +} +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; + } + }; +} +function createLoopbackAwareRpcCalls(rpcCalls, options) { + return Object.freeze(Object.fromEntries( + Object.entries(rpcCalls).map(([name2, rpcCall]) => [ + name2, + typeof rpcCall === "function" ? createLoopbackAwareRpcCall(rpcCall, options) : rpcCall + ]) + )); +} +function replacePageLocation(url, location = globalThis.location) { + location?.replace?.(url); +} + // plugin-src/client/styles.js var IM_STYLE_ID = "xmanrui-dsh-im-settings"; var CSS11 = String.raw` @@ -10073,6 +10133,14 @@ var CSS11 = 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; } @@ -10282,6 +10350,8 @@ var CSS11 = 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; } @@ -10398,6 +10468,27 @@ function ChannelLogo({ channel: channel4 }) { if (channel4 === "whatsapp") return h2(WhatsappLogo); return h2(OfficeLogo); } +function LoopbackRecoveryNotice({ recovery, onNavigate = replacePageLocation }) { + return h2( + "div", + { + className: "dim-loopbackRecovery", + role: "alert" + }, + h2( + "div", + { className: "dim-loopbackRecoveryCopy" }, + h2("strong", null, "\u8BF7\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00"), + h2("p", null, "\u9875\u9762\u4F1A\u5728\u5F53\u524D\u7AEF\u53E3\u91CD\u65B0\u6253\u5F00\uFF0C\u673A\u5668\u4EBA\u914D\u7F6E\u4E0D\u4F1A\u6539\u53D8\u3002"), + h2("code", null, recovery.origin) + ), + h2("button", { + type: "button", + className: "dim-loopbackRecoveryAction", + onClick: () => onNavigate(recovery.url) + }, "\u4F7F\u7528 localhost \u91CD\u65B0\u6253\u5F00") + ); +} function IMSettingsTab({ dingtalkRpcCall, discordRpcCall, @@ -10409,11 +10500,45 @@ function IMSettingsTab({ weixinRpcCall, whatsappRpcCall, officeRpcCall, - workspaceDirectoryPicker + workspaceDirectoryPicker, + browserLocation = globalThis.location, + navigateToRecoveryUrl = replacePageLocation }) { const [selected, setSelected] = React20.useState("weixin"); + const [loopbackRecovery, setLoopbackRecovery] = React20.useState(null); const githubTooltipId = React20.useId(); const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0]; + const reportLoopbackRecovery = React20.useCallback((recovery) => { + setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery); + }, []); + const rpcCalls = React20.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 h2( WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker }, @@ -10480,12 +10605,20 @@ function IMSettingsTab({ )) ), h2("div", { className: "dim-divider", "aria-hidden": "true" }), - h2("main", { - className: "dim-panel", - role: "tabpanel", - id: `dim-panel-${active.id}`, - "aria-labelledby": `dim-tab-${active.id}` - }, active.id === "weixin" ? h2(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h2(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h2(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h2(WecomSettingsTab, { rpcCall: wecomRpcCall }) : active.id === "qq" ? h2(QqSettingsTab, { rpcCall: qqRpcCall }) : active.id === "slack" ? h2(SlackSettingsTab, { rpcCall: slackRpcCall }) : active.id === "telegram" ? h2(TelegramSettingsTab, { rpcCall: telegramRpcCall }) : active.id === "discord" ? h2(DiscordSettingsTab, { rpcCall: discordRpcCall }) : active.id === "whatsapp" ? h2(WhatsappSettingsTab, { rpcCall: whatsappRpcCall }) : h2(OfficeSettingsTab, { rpcCall: officeRpcCall })) + h2( + "main", + { + className: "dim-panel", + role: "tabpanel", + id: `dim-panel-${active.id}`, + "aria-labelledby": `dim-tab-${active.id}` + }, + loopbackRecovery ? h2(LoopbackRecoveryNotice, { + recovery: loopbackRecovery, + onNavigate: navigateToRecoveryUrl + }) : null, + active.id === "weixin" ? h2(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall }) : active.id === "feishu" ? h2(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall }) : active.id === "dingtalk" ? h2(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall }) : active.id === "wecom" ? h2(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall }) : active.id === "qq" ? h2(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall }) : active.id === "slack" ? h2(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall }) : active.id === "telegram" ? h2(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall }) : active.id === "discord" ? h2(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall }) : active.id === "whatsapp" ? h2(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall }) : h2(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall }) + ) ) ) ); diff --git a/plugin-src/client/i18n.js b/plugin-src/client/i18n.js index 3211666..797f4b9 100644 --- a/plugin-src/client/i18n.js +++ b/plugin-src/client/i18n.js @@ -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', diff --git a/plugin-src/client/index.js b/plugin-src/client/index.js index 57c8ca7..1a61a27 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -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 })), ), )); } diff --git a/plugin-src/client/loopback-recovery.js b/plugin-src/client/loopback-recovery.js new file mode 100644 index 0000000..5f0be20 --- /dev/null +++ b/plugin-src/client/loopback-recovery.js @@ -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); +} diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index d716498..e8cbae9 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -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; } diff --git a/test/loopback-recovery.test.mjs b/test/loopback-recovery.test.mjs new file mode 100644 index 0000000..2254483 --- /dev/null +++ b/test/loopback-recovery.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { LoopbackRecoveryNotice } from '../plugin-src/client/index.js'; +import { + createLoopbackAwareRpcCall, + createLoopbackAwareRpcCalls, + createLoopbackRecovery, + LOOPBACK_RECOVERY_ERROR_CODE, + LOOPBACK_RECOVERY_ERROR_MESSAGE, + replacePageLocation, +} from '../plugin-src/client/loopback-recovery.js'; + +const WEIXIN_FORBIDDEN = new Error( + 'transport failure for /weixin/connection.status: HTTP 403', +); + +test('creates a localhost recovery target for the known loopback transport 403', () => { + const recovery = createLoopbackRecovery(WEIXIN_FORBIDDEN, { + href: 'http://127.0.0.1:3080/settings?tab=plugins#im', + }); + + assert.deepEqual(recovery, { + url: 'http://localhost:3080/settings?tab=plugins#im', + origin: 'http://localhost:3080', + }); + assert.deepEqual(createLoopbackRecovery(WEIXIN_FORBIDDEN, { + href: 'http://127.8.9.10:4111/', + }), { + url: 'http://localhost:4111/', + origin: 'http://localhost:4111', + }); +}); + +test('does not reinterpret unrelated 403s, protocols, or hosts', () => { + const cases = [ + [new Error('transport failure for /weixin/connection.status: HTTP 404'), 'http://127.0.0.1:3080/'], + [new Error('request failed: HTTP 403'), 'http://127.0.0.1:3080/'], + [WEIXIN_FORBIDDEN, 'http://localhost:3080/'], + [WEIXIN_FORBIDDEN, 'http://192.168.1.20:3080/'], + [WEIXIN_FORBIDDEN, 'https://127.0.0.1:3080/'], + [WEIXIN_FORBIDDEN, 'not a URL'], + ]; + + for (const [error, href] of cases) { + assert.equal(createLoopbackRecovery(error, { href }), null); + } +}); + +test('RPC wrapper reports the recovery and presents a safe actionable error', async () => { + const original = new Error( + 'transport failure for /whatsapp/connection.status: HTTP 403', + ); + let observed; + const rpcCall = createLoopbackAwareRpcCall(async () => { + throw original; + }, { + location: { href: 'http://127.0.0.1:3080/settings' }, + onRecovery: (recovery) => { observed = recovery; }, + }); + + await assert.rejects(rpcCall('connection.status', {}), (error) => { + assert.equal(error.code, LOOPBACK_RECOVERY_ERROR_CODE); + assert.equal(error.message, LOOPBACK_RECOVERY_ERROR_MESSAGE); + assert.equal(error.cause, original); + assert.equal(error.recoveryUrl, 'http://localhost:3080/settings'); + return true; + }); + assert.deepEqual(observed, { + url: 'http://localhost:3080/settings', + origin: 'http://localhost:3080', + }); +}); + +test('RPC wrappers preserve successful results, unrelated errors, and missing optional calls', async () => { + const result = { ok: true, value: { connected: true } }; + const successful = createLoopbackAwareRpcCall(async (...args) => ({ result, args }), { + location: { href: 'http://127.0.0.1:3080/' }, + }); + assert.deepEqual(await successful('connection.status', { botId: 'bot-1' }), { + result, + args: ['connection.status', { botId: 'bot-1' }], + }); + + const original = new Error('transport failure for /weixin/connection.status: HTTP 500'); + const failing = createLoopbackAwareRpcCall(async () => { throw original; }, { + location: { href: 'http://127.0.0.1:3080/' }, + }); + await assert.rejects(failing(), (error) => error === original); + + const calls = createLoopbackAwareRpcCalls({ + weixinRpcCall: async () => result, + optionalRpcCall: undefined, + }, { + location: { href: 'http://127.0.0.1:3080/' }, + }); + assert.equal(calls.optionalRpcCall, undefined); + assert.equal(await calls.weixinRpcCall(), result); +}); + +test('recovery notice exposes one localized navigation action', () => { + const recovery = { + url: 'http://localhost:3080/settings?tab=plugins#im', + origin: 'http://localhost:3080', + }; + const element = LoopbackRecoveryNotice({ recovery, onNavigate() {} }); + const markup = renderToStaticMarkup(element); + + assert.match(markup, /role="alert"/); + assert.match(markup, /请改用 localhost 重新打开/); + assert.match(markup, /http:\/\/localhost:3080/); + assert.match(markup, /]*>使用 localhost 重新打开<\/button>/); +}); + +test('recovery navigation replaces the current history entry', () => { + let replacedWith; + replacePageLocation('http://localhost:3080/settings', { + replace(url) { replacedWith = url; }, + }); + assert.equal(replacedWith, 'http://localhost:3080/settings'); +});