diff --git a/client/client.js b/client/client.js index d614db0..96551fc 100644 --- a/client/client.js +++ b/client/client.js @@ -1957,6 +1957,12 @@ var zh2 = { "tunnelMode": "\u516C\u7F51\u6A21\u5F0F\uFF1A", "modeQuick": "\u968F\u673A\u57DF\u540D\uFF08\u9ED8\u8BA4\uFF09", "modeNamed": "\u56FA\u5B9A\u57DF\u540D", + "namedChannelTitle": "\u{1F310} \u56FA\u5B9A\u57DF\u540D Named Tunnel", + "namedChannelHint": "\u957F\u671F\u7A33\u5B9A\u5165\u53E3\uFF1B\u4E0E\u968F\u673A\u57DF\u540D\u4E0D\u53EF\u540C\u65F6\u542F\u7528", + "namedPinTransition": "\u672C\u901A\u9053\u5F53\u524D\u4ECD\u4F7F\u7528\u516C\u7F51\u8BBF\u95EE\u5BC6\u7801\uFF1B\u540E\u7EED\u7248\u672C\u5C06\u6539\u4E3A\u6BCF\u8BBE\u5907\u72EC\u7ACB\u8BA4\u8BC1", + "quickChannelTitle": "\u26A1 \u968F\u673A\u57DF\u540D Quick Tunnel", + "quickChannelHint": "\u4E34\u65F6\u516C\u7F51\u5165\u53E3\uFF1B\u4E0E\u56FA\u5B9A\u57DF\u540D\u4E0D\u53EF\u540C\u65F6\u542F\u7528", + "namedConfig": "\u56FA\u5B9A\u57DF\u540D\u914D\u7F6E", "namedSummary": "\u56FA\u5B9A\u57DF\u540D\uFF1A{host} \xB7 Token {token}", "namedTokenSet": "\u5DF2\u914D\u7F6E", "namedTokenMissing": "\u672A\u914D\u7F6E", @@ -2055,6 +2061,12 @@ var en2 = { "tunnelMode": "Mode:", "modeQuick": "Random URL (default)", "modeNamed": "Fixed domain", + "namedChannelTitle": "\u{1F310} Fixed domain \xB7 Named Tunnel", + "namedChannelHint": "Stable long-term access; mutually exclusive with Quick Tunnel", + "namedPinTransition": "This channel currently uses the shared public PIN; per-device authentication arrives in a later version", + "quickChannelTitle": "\u26A1 Random domain \xB7 Quick Tunnel", + "quickChannelHint": "Temporary public access; mutually exclusive with Named Tunnel", + "namedConfig": "Fixed domain configuration", "namedSummary": "Fixed domain: {host} \xB7 Token {token}", "namedTokenSet": "configured", "namedTokenMissing": "not set", @@ -2218,24 +2230,34 @@ function PocketSettingsTab({ rpcCall, t }) { }; const [disclaimerOpen, setDisclaimerOpen] = (0, import_react2.useState)(false); const [disclaimerChecked, setDisclaimerChecked] = (0, import_react2.useState)(false); + const [requestedTunnelMode, setRequestedTunnelMode] = (0, import_react2.useState)(null); const doStartTunnel = async () => { - const cfg = status?.tunnelConfig; - if (cfg?.mode === "named" && (!cfg.hostname || !cfg.tokenSet)) { - setError(t("namedNeedCfg")); - return; - } + const mode = requestedTunnelMode ?? "quick"; setBusy(true); setError(null); - setTunnelState({ phase: "starting", detail: "\u6B63\u5728\u5F00\u542F\u2026", startedAt: Date.now() }); try { + let next = status; + const runningMode = next?.tunnelUrl ? /trycloudflare\.com/i.test(next.tunnelUrl) ? "quick" : "named" : null; + if (runningMode && runningMode !== mode) { + next = await call(POCKET_ENDPOINTS.tunnelStop, {}); + } + if (next?.tunnelConfig?.mode !== mode) { + next = await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode }); + } + const cfg = next?.tunnelConfig; + if (mode === "named" && (!cfg?.hostname || !cfg?.tokenSet)) throw new Error(t("namedNeedCfg")); + setStatus(next); + setTunnelState({ phase: "starting", detail: "\u6B63\u5728\u5F00\u542F\u2026", startedAt: Date.now() }); setStatus(await call(POCKET_ENDPOINTS.tunnelStart, { disclaimer: true })); } catch (err) { setError(err.message); } finally { + setRequestedTunnelMode(null); setBusy(false); } }; - const startTunnel = () => { + const startTunnel = (mode) => { + setRequestedTunnelMode(mode); setDisclaimerChecked(false); setDisclaimerOpen(true); }; @@ -2251,13 +2273,6 @@ function PocketSettingsTab({ rpcCall, t }) { } }; const [tunnelCfg, setTunnelCfg] = (0, import_react2.useState)(null); - const switchToQuick = async () => { - try { - setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: "quick" })); - } catch (err) { - setError(err.message); - } - }; const saveNamedTunnel = async () => { try { setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { @@ -2380,8 +2395,7 @@ function PocketSettingsTab({ rpcCall, t }) { const tunnelStateDetail = tunnelState?.detail ?? ""; const tunnelStateStarted = tunnelState?.startedAt ?? null; const tunnelModeView = status?.tunnelConfig ?? { mode: "quick", hostname: "", tokenSet: false }; - const namedMode = tunnelModeView.mode === "named"; - const namedActive = namedMode || tunnelCfg !== null; + const activeTunnelMode = !tunnelUrl ? null : /trycloudflare\.com/i.test(tunnelUrl) ? "quick" : "named"; const errText = (msg) => { const s = String(msg ?? ""); const i = s.indexOf(" | "); @@ -2396,15 +2410,6 @@ function PocketSettingsTab({ rpcCall, t }) { toastTimer.current = setTimeout(() => setToast(null), 2600); }; (0, import_react2.useEffect)(() => () => clearTimeout(toastTimer.current), []); - const modeBtnStyle = (active) => ({ - ...styles.btn, - height: 28, - padding: "0 12px", - fontSize: 12, - fontWeight: active ? 600 : 400, - background: active ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)", - color: active ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)" - }); const Switch = (on, onClick) => (0, import_react2.createElement)("button", { role: "switch", "aria-checked": !!on, @@ -2429,6 +2434,30 @@ function PocketSettingsTab({ rpcCall, t }) { ), extra ?? null ); + const tunnelStatusLine = () => tunnelStarting ? (0, import_react2.createElement)( + "div", + { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } }, + tunnelPhase === "downloading" ? fmt(t, "downloading", { s: elapsed(tunnelStateStarted) }) : fmt(t, "connecting", { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t("slowHint") : "" }) + ) : tunnelPhase === "error" ? (0, import_react2.createElement)( + "div", + { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, + fmt(t, "error", { detail: errText(tunnelStateDetail) || t("unknownError") }) + ) : null; + const publicPinRow = () => status?.accessToken ? row( + t("pinLabel"), + customPin?.which === "public" ? null : (0, import_react2.createElement)( + "span", + { style: { display: "inline-flex", alignItems: "center", gap: 8 } }, + (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1 } }, status?.accessToken), + customBtn("public") + ), + (0, import_react2.createElement)( + "div", + { style: { marginTop: 6 } }, + customPin?.which === "public" ? customPinRow("public") : null, + status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("pinCustomHint")) : null + ) + ) : null; const [advOpen, setAdvOpen] = (0, import_react2.useState)(false); return (0, import_react2.createElement)( "div", @@ -2545,119 +2574,110 @@ function PocketSettingsTab({ rpcCall, t }) { ) ) : (0, import_react2.createElement)("div", { style: styles.muted }, t("lanStarting")) ), - // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行 + // 固定域名 Named:独立配置与启停;二维码只在「实际运行中」的通道区块显示。 + // 过渡期(设备认证落地前)本通道仍使用公网访问密码。 (0, import_react2.createElement)( "div", { style: styles.block }, (0, import_react2.createElement)( "div", - { style: { display: "flex", alignItems: "center", justifyContent: "space-between" } }, - (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("wanAccess")), - tunnelUrl ? (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" }, onClick: stopTunnel }, t("stopTunnel")) : (0, import_react2.createElement)("button", { style: { ...styles.primary, height: 28, padding: "0 14px", fontSize: 12 }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy || tunnelStarting ? t("opening") : t("enable")) + { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } }, + (0, import_react2.createElement)( + "div", + null, + (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("namedChannelTitle")), + (0, import_react2.createElement)("div", { style: styles.muted }, t("namedChannelHint")) + ), + Switch(activeTunnelMode === "named", () => activeTunnelMode === "named" ? stopTunnel() : !tunnelStarting && startTunnel("named")) ), - tunnelStarting ? (0, import_react2.createElement)( + tunnelStatusLine(), + // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效 + tunnelModeView.mode === "named" && activeTunnelMode === "quick" ? (0, import_react2.createElement)("div", { style: { ...styles.warn, marginTop: 8 } }, t("namedTakeEffect")) : null, + // 固定域名配置:摘要 + 修改入口;编辑态整体替换为表单 + !tunnelCfg ? row( + t("namedConfig"), + (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("namedEdit")), + (0, import_react2.createElement)( + "div", + { style: { ...styles.muted, marginTop: 5 } }, + fmt(t, "namedSummary", { host: tunnelModeView.hostname || "\u2014", token: tunnelModeView.tokenSet ? t("namedTokenSet") : t("namedTokenMissing") }), + (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("namedHow")), + !tunnelModeView.tokenSet || !tunnelModeView.hostname ? (0, import_react2.createElement)("div", { style: { marginTop: 2, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("namedNeedCfg")) : null + ) + ) : null, + tunnelCfg ? (0, import_react2.createElement)( "div", - { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } }, - tunnelPhase === "downloading" ? fmt(t, "downloading", { s: elapsed(tunnelStateStarted) }) : fmt(t, "connecting", { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t("slowHint") : "" }) - ) : tunnelPhase === "error" ? (0, import_react2.createElement)( + { style: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", paddingTop: 9, marginTop: 9, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 } }, + (0, import_react2.createElement)( + "div", + null, + t("namedHostnameLabel"), + (0, import_react2.createElement)("input", { + style: { margin: "4px 0 0 6px", padding: "4px 8px", fontSize: 13, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none", width: 200 }, + placeholder: "pocket.example.com", + value: tunnelCfg.hostname ?? "", + autoFocus: true, + onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })), + onKeyDown: (e) => { + if (e.key === "Enter") saveNamedTunnel(); + if (e.key === "Escape") setTunnelCfg(null); + } + }) + ), + (0, import_react2.createElement)( + "div", + { style: { marginTop: 6 } }, + t("namedTokenLabel"), + (0, import_react2.createElement)("input", { + style: { margin: "4px 0 0 6px", padding: "4px 8px", fontSize: 13, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none", width: 240, fontFamily: "ui-monospace,Menlo,monospace" }, + type: "password", + value: tunnelCfg.token ?? "", + onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })), + onKeyDown: (e) => { + if (e.key === "Enter") saveNamedTunnel(); + if (e.key === "Escape") setTunnelCfg(null); + } + }) + ), + (0, import_react2.createElement)( + "div", + { style: { marginTop: 6, display: "flex", gap: 8 } }, + (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: saveNamedTunnel }, t("save")), + (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t("cancel")) + ), + (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("namedHow")), + (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("namedSecurity")), + tunnelCfg.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, errText(tunnelCfg.err)) : null + ) : null, + activeTunnelMode === "named" ? qrArea(status?.tunnelQr, tunnelUrl, t("namedRunningHint")) : null, + // 过渡提示:每设备独立认证在后续 PR 落地 + (0, import_react2.createElement)("div", { style: { ...styles.warn, marginTop: 8 } }, t("namedPinTransition")), + publicPinRow() + ), + // 随机域名 Quick:独立启停与共享密码(临时公网入口)。 + (0, import_react2.createElement)( + "div", + { style: styles.block }, + (0, import_react2.createElement)( "div", - { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, - fmt(t, "error", { detail: errText(tunnelStateDetail) || t("unknownError") }) - ) : !tunnelUrl && !isDesktop ? (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 8 } }, t("wanOffHint")) : null, - tunnelUrl ? (0, import_react2.createElement)( + { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } }, + (0, import_react2.createElement)( + "div", + null, + (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("quickChannelTitle")), + (0, import_react2.createElement)("div", { style: styles.muted }, t("quickChannelHint")) + ), + Switch(activeTunnelMode === "quick", () => activeTunnelMode === "quick" ? stopTunnel() : !tunnelStarting && startTunnel("quick")) + ), + tunnelStatusLine(), + activeTunnelMode === "quick" ? (0, import_react2.createElement)( "div", null, - qrArea(status.tunnelQr, tunnelUrl, namedMode ? t("namedRunningHint") : t("wanHint")), + qrArea(status?.tunnelQr, tunnelUrl, t("wanHint")), // 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示 - (0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: "4px solid var(--dsw-alias-state-warn-primary,#b45309)", background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 8, padding: "8px 10px" } }, t("wanEphemeralWarn")), - // 地址模式行(随机/固定;固定域名选中或编辑时高亮) - row( - t("modeLabel"), - (0, import_react2.createElement)( - "span", - { style: { display: "inline-flex", gap: 6 } }, - (0, import_react2.createElement)("button", { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : tunnelCfg ? () => setTunnelCfg(null) : void 0 }, t("modeQuick")), - (0, import_react2.createElement)("button", { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("modeNamed")) - ), - (0, import_react2.createElement)( - "div", - { style: { marginTop: 6 } }, - // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效 - namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? "") ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("namedTakeEffect")) : null, - // 固定域名:已保存摘要 + 修改入口(非编辑态) - namedMode && !tunnelCfg ? (0, import_react2.createElement)( - "div", - { style: { ...styles.muted } }, - fmt(t, "namedSummary", { host: tunnelModeView.hostname || "\u2014", token: tunnelModeView.tokenSet ? t("namedTokenSet") : t("namedTokenMissing") }), - (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("namedEdit")), - (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("namedHow")), - !tunnelModeView.tokenSet || !tunnelModeView.hostname ? (0, import_react2.createElement)("div", { style: { marginTop: 2, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("namedNeedCfg")) : null - ) : null, - // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变) - tunnelCfg ? (0, import_react2.createElement)( - "div", - { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 } }, - (0, import_react2.createElement)( - "div", - null, - t("namedHostnameLabel"), - (0, import_react2.createElement)("input", { - style: { margin: "4px 0 0 6px", padding: "4px 8px", fontSize: 13, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none", width: 200 }, - placeholder: "pocket.example.com", - value: tunnelCfg.hostname ?? "", - autoFocus: true, - onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })), - onKeyDown: (e) => { - if (e.key === "Enter") saveNamedTunnel(); - if (e.key === "Escape") setTunnelCfg(null); - } - }) - ), - (0, import_react2.createElement)( - "div", - { style: { marginTop: 6 } }, - t("namedTokenLabel"), - (0, import_react2.createElement)("input", { - style: { margin: "4px 0 0 6px", padding: "4px 8px", fontSize: 13, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none", width: 240, fontFamily: "ui-monospace,Menlo,monospace" }, - type: "password", - value: tunnelCfg.token ?? "", - onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })), - onKeyDown: (e) => { - if (e.key === "Enter") saveNamedTunnel(); - if (e.key === "Escape") setTunnelCfg(null); - } - }) - ), - (0, import_react2.createElement)( - "div", - { style: { marginTop: 6, display: "flex", gap: 8 } }, - (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: saveNamedTunnel }, t("save")), - (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t("cancel")) - ), - (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("namedHow")), - (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("namedSecurity")), - tunnelCfg.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, errText(tunnelCfg.err)) : null - ) : null - ) - ), - // 访问密码行:值 + 自定义(自定义输入态整体替换) - status.accessToken ? row( - t("pinLabel"), - customPin?.which === "public" ? null : (0, import_react2.createElement)( - "span", - { style: { display: "inline-flex", alignItems: "center", gap: 8 } }, - (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1 } }, status.accessToken), - customBtn("public") - ), - (0, import_react2.createElement)( - "div", - { style: { marginTop: 6 } }, - customPin?.which === "public" ? customPinRow("public") : null, - status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("pinCustomHint")) : null, - namedMode ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("namedSecurity")) : null - ) - ) : null - ) : null + (0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: "4px solid var(--dsw-alias-state-warn-primary,#b45309)", background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 8, padding: "8px 10px" } }, t("wanEphemeralWarn")) + ) : null, + publicPinRow() ), (0, import_react2.createElement)( "div", diff --git a/client/index.jsx b/client/index.jsx index 1227511..1456b5f 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -176,26 +176,42 @@ function PocketSettingsTab({ rpcCall, t }) { const [disclaimerOpen, setDisclaimerOpen] = useState(false); const [disclaimerChecked, setDisclaimerChecked] = useState(false); + // 三通道纯 UI 重组(issue #90 / 重做 PR #97):两个公网区块各自开关。 + // requestedTunnelMode:本次要开启的通道;开启前先用既有 RPC 停掉另一条、必要时切换配置——不碰后端。 + const [requestedTunnelMode, setRequestedTunnelMode] = useState(null); + const doStartTunnel = async () => { - // 命名隧道模式:Token/域名没配齐就不发起(服务端同样会拒绝) - const cfg = status?.tunnelConfig; - if (cfg?.mode === 'named' && (!cfg.hostname || !cfg.tokenSet)) { - setError(t('namedNeedCfg')); - return; - } + const mode = requestedTunnelMode ?? 'quick'; setBusy(true); setError(null); - setTunnelState({ phase: 'starting', detail: '正在开启…', startedAt: Date.now() }); try { + let next = status; + // 实际运行中的通道(纯 UI 推导):Quick 域名固定为 *.trycloudflare.com,其余视为 Named + const runningMode = next?.tunnelUrl + ? (/trycloudflare\.com/i.test(next.tunnelUrl) ? 'quick' : 'named') + : null; + if (runningMode && runningMode !== mode) { + next = await call(POCKET_ENDPOINTS.tunnelStop, {}); + } + if (next?.tunnelConfig?.mode !== mode) { + next = await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode }); + } + // 命名隧道模式:Token/域名没配齐就不发起(服务端同样会拒绝) + const cfg = next?.tunnelConfig; + if (mode === 'named' && (!cfg?.hostname || !cfg?.tokenSet)) throw new Error(t('namedNeedCfg')); + setStatus(next); + setTunnelState({ phase: 'starting', detail: '正在开启…', startedAt: Date.now() }); setStatus(await call(POCKET_ENDPOINTS.tunnelStart, { disclaimer: true })); } catch (err) { setError(err.message); } finally { + setRequestedTunnelMode(null); setBusy(false); } }; - const startTunnel = () => { + const startTunnel = (mode) => { // 每次开启都弹免责确认(勾选后才能继续) + setRequestedTunnelMode(mode); setDisclaimerChecked(false); setDisclaimerOpen(true); }; @@ -212,9 +228,6 @@ function PocketSettingsTab({ rpcCall, t }) { // 公网模式(issue #66):随机域名(默认零配置)/ 固定域名(Cloudflare 命名隧道 + Tunnel Token) // tunnelCfg:编辑态 { hostname, token, err } | null;token 输入留空 = 保持已存的 Token 不变 const [tunnelCfg, setTunnelCfg] = useState(null); - const switchToQuick = async () => { - try { setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: 'quick' })); } catch (err) { setError(err.message); } - }; const saveNamedTunnel = async () => { try { setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { @@ -348,9 +361,8 @@ function PocketSettingsTab({ rpcCall, t }) { const tunnelStateStarted = tunnelState?.startedAt ?? null; // 公网模式视图(issue #66):{ mode, hostname, tokenSet } const tunnelModeView = status?.tunnelConfig ?? { mode: 'quick', hostname: '', tokenSet: false }; - const namedMode = tunnelModeView.mode === 'named'; - // 模式按钮选中态高亮:固定域名模式本身,或正在编辑固定域名配置,都视为「选中」 - const namedActive = namedMode || tunnelCfg !== null; + // 实际运行中的通道(纯 UI 推导):tunnelUrl 为 trycloudflare.com 即 Quick,否则 Named;未运行 = null + const activeTunnelMode = !tunnelUrl ? null : (/trycloudflare\.com/i.test(tunnelUrl) ? 'quick' : 'named'); // 后端错误消息统一为「中文 | English」混排;按当前界面语言只显示对应一半 const errText = (msg) => { const s = String(msg ?? ''); @@ -367,12 +379,6 @@ function PocketSettingsTab({ rpcCall, t }) { toastTimer.current = setTimeout(() => setToast(null), 2600); }; useEffect(() => () => clearTimeout(toastTimer.current), []); - const modeBtnStyle = (active) => ({ - ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, - fontWeight: active ? 600 : 400, - background: active ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)', - color: active ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)', - }); // iOS 风格小开关(重排后统一用:局域网总开关 / 局域网密码开关) const Switch = (on, onClick) => h('button', { role: 'switch', 'aria-checked': !!on, @@ -388,6 +394,28 @@ function PocketSettingsTab({ rpcCall, t }) { const row = (label, control, extra) => h('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 9, marginTop: 9 } }, h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } }, h('span', { style: { fontSize: 13 } }, label), control), extra ?? null); + // 两个公网区块共享:启动/连接中或失败的状态行 + const tunnelStatusLine = () => tunnelStarting + ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, + tunnelPhase === 'downloading' + ? fmt(t, 'downloading', { s: elapsed(tunnelStateStarted) }) + : fmt(t, 'connecting', { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t('slowHint') : '' })) + : tunnelPhase === 'error' + ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, + fmt(t, 'error', { detail: errText(tunnelStateDetail) || t('unknownError') })) + : null; + // 两个公网区块共享:公网访问密码行(过渡期 Named/Quick 共用同一 PIN;设备认证落地后 Named 移除该行) + const publicPinRow = () => status?.accessToken + ? row(t('pinLabel'), + customPin?.which === 'public' + ? null + : h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } }, + h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status?.accessToken), + customBtn('public')), + h('div', { style: { marginTop: 6 } }, + customPin?.which === 'public' ? customPinRow('public') : null, + status?.publicPinCustom ? h('div', { style: { ...styles.warn } }, t('pinCustomHint')) : null)) + : null; // 高级(手动选地址)展开态 const [advOpen, setAdvOpen] = useState(false); @@ -488,92 +516,77 @@ function PocketSettingsTab({ rpcCall, t }) { : h('div', { style: styles.muted }, t('lanStarting'))), ), - // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行 + // 固定域名 Named:独立配置与启停;二维码只在「实际运行中」的通道区块显示。 + // 过渡期(设备认证落地前)本通道仍使用公网访问密码。 h('div', { style: styles.block }, - h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, - h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('wanAccess')), - tunnelUrl - ? h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: stopTunnel }, t('stopTunnel')) - : h('button', { style: { ...styles.primary, height: 28, padding: '0 14px', fontSize: 12 }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy || tunnelStarting ? t('opening') : t('enable')), + h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } }, + h('div', null, + h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('namedChannelTitle')), + h('div', { style: styles.muted }, t('namedChannelHint'))), + Switch(activeTunnelMode === 'named', () => activeTunnelMode === 'named' ? stopTunnel() : (!tunnelStarting && startTunnel('named'))), ), - tunnelStarting - ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, - tunnelPhase === 'downloading' - ? fmt(t, 'downloading', { s: elapsed(tunnelStateStarted) }) - : fmt(t, 'connecting', { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t('slowHint') : '' })) - : tunnelPhase === 'error' - ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, - fmt(t, 'error', { detail: errText(tunnelStateDetail) || t('unknownError') })) - : (!tunnelUrl && !isDesktop ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('wanOffHint')) : null), - tunnelUrl - ? h('div', null, - qrArea(status.tunnelQr, tunnelUrl, namedMode ? t('namedRunningHint') : t('wanHint')), - // 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示 - h('div', { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 8, padding: '8px 10px' } }, t('wanEphemeralWarn')), - // 地址模式行(随机/固定;固定域名选中或编辑时高亮) - row(t('modeLabel'), - h('span', { style: { display: 'inline-flex', gap: 6 } }, - h('button', { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : (tunnelCfg ? () => setTunnelCfg(null) : undefined) }, t('modeQuick')), - h('button', { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('modeNamed')), - ), - h('div', { style: { marginTop: 6 } }, - // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效 - namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? '') ? h('div', { style: { ...styles.warn } }, t('namedTakeEffect')) : null, - // 固定域名:已保存摘要 + 修改入口(非编辑态) - namedMode && !tunnelCfg ? h('div', { style: { ...styles.muted } }, - fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }), - h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')), - h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')), - !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null, - ) : null, - // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变) - tunnelCfg ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } }, - h('div', null, - t('namedHostnameLabel'), - h('input', { - style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 200 }, - placeholder: 'pocket.example.com', - value: tunnelCfg.hostname ?? '', - autoFocus: true, - onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })), - onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); }, - }), - ), - h('div', { style: { marginTop: 6 } }, - t('namedTokenLabel'), - h('input', { - style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 240, fontFamily: 'ui-monospace,Menlo,monospace' }, - type: 'password', - value: tunnelCfg.token ?? '', - onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })), - onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); }, - }), - ), - h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } }, - h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')), - h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')), - ), - h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')), - h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')), - tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(tunnelCfg.err)) : null, - ) : null, - ), - ), - // 访问密码行:值 + 自定义(自定义输入态整体替换) - status.accessToken - ? row(t('pinLabel'), - customPin?.which === 'public' - ? null - : h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } }, - h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.accessToken), - customBtn('public')), - h('div', { style: { marginTop: 6 } }, - customPin?.which === 'public' ? customPinRow('public') : null, - status?.publicPinCustom ? h('div', { style: { ...styles.warn } }, t('pinCustomHint')) : null, - namedMode ? h('div', { style: { ...styles.warn } }, t('namedSecurity')) : null)) - : null, - ) - : null, + tunnelStatusLine(), + // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效 + tunnelModeView.mode === 'named' && activeTunnelMode === 'quick' ? h('div', { style: { ...styles.warn, marginTop: 8 } }, t('namedTakeEffect')) : null, + // 固定域名配置:摘要 + 修改入口;编辑态整体替换为表单 + !tunnelCfg ? row(t('namedConfig'), + h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')), + h('div', { style: { ...styles.muted, marginTop: 5 } }, + fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }), + h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')), + !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null, + )) : null, + tunnelCfg ? h('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 9, marginTop: 9, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } }, + h('div', null, + t('namedHostnameLabel'), + h('input', { + style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 200 }, + placeholder: 'pocket.example.com', + value: tunnelCfg.hostname ?? '', + autoFocus: true, + onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })), + onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); }, + }), + ), + h('div', { style: { marginTop: 6 } }, + t('namedTokenLabel'), + h('input', { + style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 240, fontFamily: 'ui-monospace,Menlo,monospace' }, + type: 'password', + value: tunnelCfg.token ?? '', + onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })), + onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); }, + }), + ), + h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } }, + h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')), + h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')), + ), + h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')), + h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')), + tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(tunnelCfg.err)) : null, + ) : null, + activeTunnelMode === 'named' ? qrArea(status?.tunnelQr, tunnelUrl, t('namedRunningHint')) : null, + // 过渡提示:每设备独立认证在后续 PR 落地 + h('div', { style: { ...styles.warn, marginTop: 8 } }, t('namedPinTransition')), + publicPinRow(), + ), + + // 随机域名 Quick:独立启停与共享密码(临时公网入口)。 + h('div', { style: styles.block }, + h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } }, + h('div', null, + h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('quickChannelTitle')), + h('div', { style: styles.muted }, t('quickChannelHint'))), + Switch(activeTunnelMode === 'quick', () => activeTunnelMode === 'quick' ? stopTunnel() : (!tunnelStarting && startTunnel('quick'))), + ), + tunnelStatusLine(), + activeTunnelMode === 'quick' ? h('div', null, + qrArea(status?.tunnelQr, tunnelUrl, t('wanHint')), + // 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示 + h('div', { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 8, padding: '8px 10px' } }, t('wanEphemeralWarn')), + ) : null, + publicPinRow(), ), h('div', { style: styles.block }, diff --git a/client/pocket-locales.js b/client/pocket-locales.js index effee90..ae8b363 100644 --- a/client/pocket-locales.js +++ b/client/pocket-locales.js @@ -79,6 +79,12 @@ export const zh = { 'tunnelMode': '公网模式:', 'modeQuick': '随机域名(默认)', 'modeNamed': '固定域名', + 'namedChannelTitle': '🌐 固定域名 Named Tunnel', + 'namedChannelHint': '长期稳定入口;与随机域名不可同时启用', + 'namedPinTransition': '本通道当前仍使用公网访问密码;后续版本将改为每设备独立认证', + 'quickChannelTitle': '⚡ 随机域名 Quick Tunnel', + 'quickChannelHint': '临时公网入口;与固定域名不可同时启用', + 'namedConfig': '固定域名配置', 'namedSummary': '固定域名:{host} · Token {token}', 'namedTokenSet': '已配置', 'namedTokenMissing': '未配置', @@ -179,6 +185,12 @@ export const en = { 'tunnelMode': 'Mode:', 'modeQuick': 'Random URL (default)', 'modeNamed': 'Fixed domain', + 'namedChannelTitle': '🌐 Fixed domain · Named Tunnel', + 'namedChannelHint': 'Stable long-term access; mutually exclusive with Quick Tunnel', + 'namedPinTransition': 'This channel currently uses the shared public PIN; per-device authentication arrives in a later version', + 'quickChannelTitle': '⚡ Random domain · Quick Tunnel', + 'quickChannelHint': 'Temporary public access; mutually exclusive with Named Tunnel', + 'namedConfig': 'Fixed domain configuration', 'namedSummary': 'Fixed domain: {host} · Token {token}', 'namedTokenSet': 'configured', 'namedTokenMissing': 'not set', diff --git "a/docs/\344\270\211\351\200\232\351\201\223\347\213\254\347\253\213\347\256\241\347\220\206\346\224\271\351\200\240\346\226\271\346\241\210.md" "b/docs/\344\270\211\351\200\232\351\201\223\347\213\254\347\253\213\347\256\241\347\220\206\346\224\271\351\200\240\346\226\271\346\241\210.md" index 6cd92ac..b42bd45 100644 --- "a/docs/\344\270\211\351\200\232\351\201\223\347\213\254\347\253\213\347\256\241\347\220\206\346\224\271\351\200\240\346\226\271\346\241\210.md" +++ "b/docs/\344\270\211\351\200\232\351\201\223\347\213\254\347\253\213\347\256\241\347\220\206\346\224\271\351\200\240\346\226\271\346\241\210.md" @@ -195,7 +195,7 @@ 14. **Quick 不随 DSH 重启自动恢复,只有 Named 自动恢复**:Quick 域名重启后必然变新,自动恢复等于替用户重新做「把电脑暴露到公网」的决定(PIN 还可能轮换);Named 地址固定才适合常驻。`tunnel-auto.json` 记录开启时的通道,`restoreTunnelIfNeeded` 仅在 `mode === 'named'` 时拉起;手动关闭公网清除标记(两种通道都一样)。守护测试:`test/service.test.js`「Named 重启后自动拉起;Quick 只记录标记」。 15. **三通道安全声明分文案**(2026-09-03 拍板,同日二次修订):通用声明以「链接+密码」为模型,对 Named(无共享 PIN)不成立。LAN 讲同网段可尝试访问 + **HTTP 明文可被监听**;Quick 讲链接+PIN 模型 + Cloudflare 临时域名回收/复用风险;Named 讲长期入口可扫描 + 设备凭据管理/撤销 + Tunnel Token 保管 + 防爆破。二次修订要点:三份开启声明统一补后果等级句(执行任意命令 = 交出电脑)与「开关状态跨 DSH 重启保持」;Quick 风险方向修正为「你拿到的链接可能被别人用过并留下旧访客」(域名被回收分给你之后,旧访客顺着旧记录访问到的是**你的**隧道;你关闭后域名分给别人则与你的安全无关);Named 补浏览器账号云同步/公用设备凭据风险,删除丢失场景做不到的「清理 Cookie」指令;**局域网免密独立声明**:关闭「局域网访问密码」时单独弹窗勾选确认,不并入 LAN 开启声明。LAN 开启声明中「无弹窗时机」的缺口由决策 19(LAN 默认关闭)消除。守护:`test/locales.test.js`(zh/en key 对齐)。 -16. **设置页三区块 + `tunnelActiveMode`**:界面按**实际运行中**的通道显示二维码与启停态,而不是按配置模式——配置可以在隧道运行期间被改,显示配置会误导。守护测试:`test/smoke.test.js` 可选链守卫等。 +16. **设置页三区块 + 按「实际运行中的通道」显示**:界面按**实际运行中**的通道显示二维码与启停态,而不是按配置模式——配置可以在隧道运行期间被改,显示配置会误导。**实现修订(2026-09-24,随纯 UI 版 PR1 调整)**:上游反馈 PR1 不碰 `lib/service.mjs`,故不新增后端 `tunnelActiveMode` 字段,运行态改由客户端纯 UI 推导——`tunnelUrl` 为 `*.trycloudflare.com` 即 Quick,否则 Named,未运行则为 null;该推导只在打开设置页的浏览器里生效,语义与后端字段一致,误差仅限 URL 判据本身。`tunnelActiveMode` 后端字段留给设备认证 PR(PR2)一并落地。守护测试:`test/smoke.test.js` 可选链守卫等。 ### 提交与流程