From 7b5afb7afe763f2d74346d09c938103233222cd8 Mon Sep 17 00:00:00 2001 From: Dyna <108321411+Ahaohaohao@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:48:20 +0800 Subject: [PATCH] Add cors (#3909) * add external `cors` control panel * optimize format * fix-script.rs * fix-service.rs * fix-rs async_proxy_query.rs event_driven_proxy.rs service_ipc.rs service.rs sysopt.rs * lower the prettier version number to 3.5.3 * Revert "lower the prettier version number to 3.5.3" This reverts commit 0f1c3dfa8abad9f451f32d2da6211e86341bda84. * fix: prttier erros * add developer environment detection and controlled the display of development environment URL * submit required * fix-external-controller-cors * use the custom component ToggleButton to ensure a uniform button style * fix-tsx hotkey-viewer.tsx external-controller-cors.tsx * fix-bug_report.yml * remove the annoying title * fix-write overload problem * Individual button settings * fix-setting-clash.tsx --------- Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com> Co-authored-by: Tunglies --- UPDATELOG.md | 1 + src-tauri/src/config/clash.rs | 19 - src-tauri/src/core/async_proxy_query.rs | 6 +- src-tauri/src/core/event_driven_proxy.rs | 10 +- src-tauri/src/core/service.rs | 4 +- src-tauri/src/core/service_ipc.rs | 2 +- src-tauri/src/core/sysopt.rs | 12 +- src-tauri/src/enhance/script.rs | 4 +- .../setting/mods/external-controller-cors.tsx | 354 ++++++++++++++++++ src/components/setting/mods/hotkey-viewer.tsx | 66 +++- src/components/setting/setting-clash.tsx | 27 +- src/locales/en.json | 10 +- src/locales/zh.json | 10 +- src/services/types.d.ts | 4 + 14 files changed, 472 insertions(+), 57 deletions(-) create mode 100644 src/components/setting/mods/external-controller-cors.tsx diff --git a/UPDATELOG.md b/UPDATELOG.md index 28ac07e78..3f4465460 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -15,6 +15,7 @@ - `sidecar` 模式下清理多余的内核进程,防止运行出现异常 - 新 macOS 下 TUN 和系统代理模式托盘图标(暂测) - 快捷键事件通过系统通知 +- 添加外部 `cors` 控制面板 ### 🚀 优化改进 diff --git a/src-tauri/src/config/clash.rs b/src-tauri/src/config/clash.rs index 2379b298d..df3eec24f 100644 --- a/src-tauri/src/config/clash.rs +++ b/src-tauri/src/config/clash.rs @@ -97,25 +97,6 @@ impl IClashTemp { config.insert("port".into(), port.into()); config.insert("external-controller".into(), ctrl.into()); - // 强制覆盖 external-controller-cors 字段,允许本地和 tauri 前端 - let mut cors_map = Mapping::new(); - cors_map.insert("allow-private-network".into(), true.into()); - cors_map.insert( - "allow-origins".into(), - vec![ - "tauri://localhost", - "http://tauri.localhost", - // Only enable this in dev mode - #[cfg(feature = "verge-dev")] - "http://localhost:3000", - "https://yacd.metacubex.one", - "https://metacubex.github.io", - "https://board.zash.run.place", - ] - .into(), - ); - config.insert("external-controller-cors".into(), cors_map.into()); - config } diff --git a/src-tauri/src/core/async_proxy_query.rs b/src-tauri/src/core/async_proxy_query.rs index 788a6d268..a6a20cc1f 100644 --- a/src-tauri/src/core/async_proxy_query.rs +++ b/src-tauri/src/core/async_proxy_query.rs @@ -123,7 +123,7 @@ impl AsyncProxyQuery { .position(|&x| x == 0) .unwrap_or(url_buffer.len()); pac_url = String::from_utf16_lossy(&url_buffer[..end_pos]); - log::debug!(target: "app", "从注册表读取到PAC URL: {}", pac_url); + log::debug!(target: "app", "从注册表读取到PAC URL: {pac_url}"); } // 2. 检查自动检测设置是否启用 @@ -148,7 +148,7 @@ impl AsyncProxyQuery { || (detect_query_result == 0 && detect_value_type == REG_DWORD && auto_detect != 0); if pac_enabled { - log::debug!(target: "app", "PAC配置启用: URL={}, AutoDetect={}", pac_url, auto_detect); + log::debug!(target: "app", "PAC配置启用: URL={pac_url}, AutoDetect={auto_detect}"); if pac_url.is_empty() && auto_detect != 0 { pac_url = "auto-detect".to_string(); @@ -361,7 +361,7 @@ impl AsyncProxyQuery { (proxy_server, 8080) }; - log::debug!(target: "app", "从注册表读取到代理设置: {}:{}, bypass: {}", host, port, bypass_list); + log::debug!(target: "app", "从注册表读取到代理设置: {host}:{port}, bypass: {bypass_list}"); Ok(AsyncSysproxy { enable: true, diff --git a/src-tauri/src/core/event_driven_proxy.rs b/src-tauri/src/core/event_driven_proxy.rs index 6937e43a0..933d24108 100644 --- a/src-tauri/src/core/event_driven_proxy.rs +++ b/src-tauri/src/core/event_driven_proxy.rs @@ -534,7 +534,7 @@ impl EventDrivenProxyManager { let binary_path = match dirs::service_path() { Ok(path) => path, Err(e) => { - log::error!(target: "app", "获取服务路径失败: {}", e); + log::error!(target: "app", "获取服务路径失败: {e}"); return; } }; @@ -554,17 +554,17 @@ impl EventDrivenProxyManager { match output { Ok(output) => { if !output.status.success() { - log::error!(target: "app", "执行sysproxy命令失败: {:?}", args); + log::error!(target: "app", "执行sysproxy命令失败: {args:?}"); let stderr = String::from_utf8_lossy(&output.stderr); if !stderr.is_empty() { - log::error!(target: "app", "sysproxy错误输出: {}", stderr); + log::error!(target: "app", "sysproxy错误输出: {stderr}"); } } else { - log::debug!(target: "app", "成功执行sysproxy命令: {:?}", args); + log::debug!(target: "app", "成功执行sysproxy命令: {args:?}"); } } Err(e) => { - log::error!(target: "app", "执行sysproxy命令出错: {}", e); + log::error!(target: "app", "执行sysproxy命令出错: {e}"); } } } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 91b2ce28b..5c269db4e 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -218,7 +218,7 @@ pub async fn reinstall_service() -> Result<()> { Ok(()) } Err(err) => { - let error = format!("failed to install service: {}", err); + let error = format!("failed to install service: {err}"); service_state.last_error = Some(error.clone()); service_state.prefer_sidecar = true; service_state.save()?; @@ -346,7 +346,7 @@ pub async fn reinstall_service() -> Result<()> { Ok(()) } Err(err) => { - let error = format!("failed to install service: {}", err); + let error = format!("failed to install service: {err}"); service_state.last_error = Some(error.clone()); service_state.prefer_sidecar = true; service_state.save()?; diff --git a/src-tauri/src/core/service_ipc.rs b/src-tauri/src/core/service_ipc.rs index 4aad2e62f..d14d8484a 100644 --- a/src-tauri/src/core/service_ipc.rs +++ b/src-tauri/src/core/service_ipc.rs @@ -131,7 +131,7 @@ pub async fn send_ipc_request( logging!(info, Type::Service, true, "正在连接服务 (Windows)..."); - let command_type = format!("{:?}", command); + let command_type = format!("{command:?}"); let request = match create_signed_request(command, payload) { Ok(req) => req, diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index e7c4b4c52..8e72fa39b 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -153,7 +153,7 @@ impl Sysopt { let shell = app_handle.shell(); let output = if pac_enable { - let address = format!("http://{}:{}/commands/pac", proxy_host, pac_port); + let address = format!("http://{proxy_host}:{pac_port}/commands/pac"); let output = shell .command(sysproxy_exe.as_path().to_str().unwrap()) .args(["pac", address.as_str()]) @@ -162,7 +162,7 @@ impl Sysopt { .unwrap(); output } else { - let address = format!("{}:{}", proxy_host, port); + let address = format!("{proxy_host}:{port}"); let bypass = get_bypass(); let output = shell .command(sysproxy_exe.as_path().to_str().unwrap()) @@ -248,14 +248,14 @@ impl Sysopt { { if is_enable { if let Err(e) = startup_shortcut::create_shortcut() { - log::error!(target: "app", "创建启动快捷方式失败: {}", e); + log::error!(target: "app", "创建启动快捷方式失败: {e}"); // 如果快捷方式创建失败,回退到原来的方法 self.try_original_autostart_method(is_enable); } else { return Ok(()); } } else if let Err(e) = startup_shortcut::remove_shortcut() { - log::error!(target: "app", "删除启动快捷方式失败: {}", e); + log::error!(target: "app", "删除启动快捷方式失败: {e}"); self.try_original_autostart_method(is_enable); } else { return Ok(()); @@ -290,11 +290,11 @@ impl Sysopt { { match startup_shortcut::is_shortcut_enabled() { Ok(enabled) => { - log::info!(target: "app", "快捷方式自启动状态: {}", enabled); + log::info!(target: "app", "快捷方式自启动状态: {enabled}"); return Ok(enabled); } Err(e) => { - log::error!(target: "app", "检查快捷方式失败,尝试原来的方法: {}", e); + log::error!(target: "app", "检查快捷方式失败,尝试原来的方法: {e}"); } } } diff --git a/src-tauri/src/enhance/script.rs b/src-tauri/src/enhance/script.rs index d1ced5ed0..09ef378d3 100644 --- a/src-tauri/src/enhance/script.rs +++ b/src-tauri/src/enhance/script.rs @@ -141,8 +141,8 @@ fn test_script() { fn test_escape_unescape() { let test_string = r#"Hello "World"!\nThis is a test with \u00A9 copyright symbol."#; let escaped = escape_js_string_for_single_quote(test_string); - println!("Original: {}", test_string); - println!("Escaped: {}", escaped); + println!("Original: {test_string}"); + println!("Escaped: {escaped}"); let json_str = r#"{"key":"value","nested":{"key":"value"}}"#; let parsed = parse_json_safely(json_str).unwrap(); diff --git a/src/components/setting/mods/external-controller-cors.tsx b/src/components/setting/mods/external-controller-cors.tsx new file mode 100644 index 000000000..057180120 --- /dev/null +++ b/src/components/setting/mods/external-controller-cors.tsx @@ -0,0 +1,354 @@ +import { BaseDialog } from "@/components/base"; +import { useClash } from "@/hooks/use-clash"; +import { showNotice } from "@/services/noticeService"; +import { Delete as DeleteIcon } from "@mui/icons-material"; +import { + Box, + Button, + Divider, + List, + ListItem, + styled, + TextField, +} from "@mui/material"; +import { useLockFn, useRequest } from "ahooks"; +import { forwardRef, useImperativeHandle, useState } from "react"; +import { useTranslation } from "react-i18next"; + +// 自定义开关按钮样式 +const ToggleButton = styled("label")` + position: relative; + display: inline-block; + width: 48px; + height: 24px; + + input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #e0e0e0; + transition: 0.4s; + border-radius: 34px; + + &:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 4px; + bottom: 4px; + background-color: white; + transition: 0.4s; + border-radius: 50%; + } + } + + input:checked + .slider { + background-color: #2196f3; + } + + input:focus + .slider { + box-shadow: 0 0 1px #2196f3; + } + + input:checked + .slider:before { + transform: translateX(24px); + } +`; + +// 定义开发环境的URL列表 +// 这些URL在开发模式下会被自动包含在允许的来源中 +// 在生产环境中,这些URL会被过滤掉 +// 这样可以确保在生产环境中不会意外暴露开发环境的URL +const DEV_URLS = [ + "tauri://localhost", + "http://tauri.localhost", + "http://localhost:3000", +]; + +// 判断是否处于开发模式 +const isDevMode = import.meta.env.MODE === "development"; + +// 过滤开发环境URL +const filterDevOrigins = (origins: string[]) => { + if (isDevMode) { + return origins; + } + return origins.filter((origin: string) => !DEV_URLS.includes(origin.trim())); +}; + +// 获取完整的源列表,包括开发URL +const getFullOrigins = (origins: string[]) => { + if (!isDevMode) { + return origins; + } + + // 合并现有源和开发URL,并去重 + const allOrigins = [...origins, ...DEV_URLS]; + const uniqueOrigins = [...new Set(allOrigins)]; + return uniqueOrigins; +}; + +// 统一使用的按钮样式 +const buttonStyle = { + borderRadius: "8px", + textTransform: "none", + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", + transition: "all 0.3s ease", + "&:hover": { + boxShadow: "0 4px 8px rgba(0,0,0,0.15)", + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}; + +// 保存按钮样式 +const saveButtonStyle = { + ...buttonStyle, + backgroundColor: "#165DFF", + color: "white", + "&:hover": { + backgroundColor: "#0E42D2", + }, +}; + +// 添加按钮样式 +const addButtonStyle = { + ...buttonStyle, + backgroundColor: "#4CAF50", + color: "white", + "&:hover": { + backgroundColor: "#388E3C", + }, +}; + +// 删除按钮样式 +const deleteButtonStyle = { + ...buttonStyle, + backgroundColor: "#FF5252", + color: "white", + "&:hover": { + backgroundColor: "#D32F2F", + }, +}; + +interface ClashHeaderConfigingRef { + open: () => void; + close: () => void; +} + +export const HeaderConfiguration = forwardRef( + (props, ref) => { + const { t } = useTranslation(); + const { clash, mutateClash, patchClash } = useClash(); + const [open, setOpen] = useState(false); + + // CORS配置状态管理 + const [corsConfig, setCorsConfig] = useState<{ + allowPrivateNetwork: boolean; + allowOrigins: string[]; + }>(() => { + const cors = clash?.["external-controller-cors"]; + const origins = cors?.["allow-origins"] ?? ["*"]; + return { + allowPrivateNetwork: cors?.["allow-private-network"] ?? true, + allowOrigins: filterDevOrigins(origins), + }; + }); + + // 处理CORS配置变更 + const handleCorsConfigChange = ( + key: "allowPrivateNetwork" | "allowOrigins", + value: boolean | string[], + ) => { + setCorsConfig((prev) => ({ + ...prev, + [key]: value, + })); + }; + + // 添加新的允许来源 + const handleAddOrigin = () => { + handleCorsConfigChange("allowOrigins", [...corsConfig.allowOrigins, ""]); + }; + + // 更新允许来源列表中的某一项 + const handleUpdateOrigin = (index: number, value: string) => { + const newOrigins = [...corsConfig.allowOrigins]; + newOrigins[index] = value; + handleCorsConfigChange("allowOrigins", newOrigins); + }; + + // 删除允许来源列表中的某一项 + const handleDeleteOrigin = (index: number) => { + const newOrigins = [...corsConfig.allowOrigins]; + newOrigins.splice(index, 1); + handleCorsConfigChange("allowOrigins", newOrigins); + }; + + // 保存配置请求 + const { loading, run: saveConfig } = useRequest( + async () => { + // 保存时使用完整的源列表(包括开发URL) + const fullOrigins = getFullOrigins(corsConfig.allowOrigins); + + await patchClash({ + "external-controller-cors": { + "allow-private-network": corsConfig.allowPrivateNetwork, + "allow-origins": fullOrigins.filter( + (origin: string) => origin.trim() !== "", + ), + }, + }); + await mutateClash(); + }, + { + manual: true, + onSuccess: () => { + setOpen(false); + showNotice("success", t("Configuration saved successfully")); + }, + onError: () => { + showNotice("error", t("Failed to save configuration")); + }, + }, + ); + + useImperativeHandle(ref, () => ({ + open: () => { + const cors = clash?.["external-controller-cors"]; + const origins = cors?.["allow-origins"] ?? ["*"]; + setCorsConfig({ + allowPrivateNetwork: cors?.["allow-private-network"] ?? true, + allowOrigins: filterDevOrigins(origins), + }); + setOpen(true); + }, + close: () => setOpen(false), + })); + + const handleSave = useLockFn(async () => { + await saveConfig(); + }); + + return ( + setOpen(false)} + onCancel={() => setOpen(false)} + onOk={handleSave} + > + + + + + {t("Allow private network access")} + + + + handleCorsConfigChange( + "allowPrivateNetwork", + e.target.checked, + ) + } + id="private-network-toggle" + /> + + + + + + + + +
+
+ {t("Allowed Origins")} +
+ {corsConfig.allowOrigins.map((origin, index) => ( +
+ handleUpdateOrigin(index, e.target.value)} + placeholder={t("Please enter a valid url")} + inputProps={{ style: { fontSize: 14 } }} + /> + +
+ ))} + + + {isDevMode && ( +
+
+ {t( + "Development mode: Automatically includes Tauri and localhost origins", + )} +
+
+ )} +
+
+
+
+ ); + }, +); diff --git a/src/components/setting/mods/hotkey-viewer.tsx b/src/components/setting/mods/hotkey-viewer.tsx index 28aa6d551..29ae13e36 100644 --- a/src/components/setting/mods/hotkey-viewer.tsx +++ b/src/components/setting/mods/hotkey-viewer.tsx @@ -1,12 +1,62 @@ import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLockFn } from "ahooks"; -import { styled, Typography, Switch } from "@mui/material"; +import { styled, Typography } from "@mui/material"; import { useVerge } from "@/hooks/use-verge"; import { BaseDialog, DialogRef } from "@/components/base"; import { HotkeyInput } from "./hotkey-input"; import { showNotice } from "@/services/noticeService"; +// 修复后的自定义开关组件 +const ToggleButton = styled("label")` + position: relative; + display: inline-block; + width: 48px; + height: 24px; + + input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #e0e0e0; + transition: 0.4s; + border-radius: 34px; + + &:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 4px; + bottom: 4px; + background-color: white; + transition: 0.4s; + border-radius: 50%; + } + } + + input:checked + .slider { + background-color: #2196f3; + } + + input:focus + .slider { + box-shadow: 0 0 1px #2196f3; + } + + input:checked + .slider:before { + transform: translateX(24px); + } +`; + const ItemWrapper = styled("div")` display: flex; align-items: center; @@ -97,11 +147,15 @@ export const HotkeyViewer = forwardRef((props, ref) => { > {t("Enable Global Hotkey")} - setEnableHotkey(e.target.checked)} - /> + + setEnableHotkey(e.target.checked)} + id="global-hotkey-toggle" + /> + + {HOTKEY_FUNC.map((func) => ( diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index a56bf3ce8..01f648c1f 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -21,6 +21,7 @@ import { GuardState } from "./mods/guard-state"; import { NetworkInterfaceViewer } from "./mods/network-interface-viewer"; import { SettingItem, SettingList } from "./mods/setting-comp"; import { WebUIViewer } from "./mods/web-ui-viewer"; +import { HeaderConfiguration } from "./mods/external-controller-cors"; const isWIN = getSystem() === "windows"; @@ -57,6 +58,7 @@ const SettingClash = ({ onError }: Props) => { const coreRef = useRef(null); const networkRef = useRef(null); const dnsRef = useRef(null); + const corsRef = useRef(null); const onSwitchFormat = (_e: any, value: boolean) => value; const onChangeData = (patch: Partial) => { @@ -101,6 +103,7 @@ const SettingClash = ({ onError }: Props) => { + { ctrlRef.current?.open()} - label={ - <> - {t("External")} - - + label={<>{t("External")}} + extra={ + { + e.stopPropagation(); + corsRef.current?.open(); + }} + /> } + onClick={() => { + ctrlRef.current?.open(); + }} /> webRef.current?.open()} label={t("Web UI")} /> diff --git a/src/locales/en.json b/src/locales/en.json index 43cd38c69..544c14fed 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -639,5 +639,13 @@ "AppHiddenTitle": "APP Hidden", "AppHiddenBody": "APP window hidden by hotkey", "Invalid Profile URL": "Invalid profile URL. Please enter a URL starting with http:// or https://", - "Saved Successfully": "Saved successfully" + "Saved Successfully": "Saved successfully", + "External Cors": "External Cors", + "Enable one-click CORS for external API. Click to toggle CORS": "Enable one-click CORS for external API. Click to toggle CORS", + "External Cors Configuration": "External Cors Configuration", + "Allow private network access": "Allow private network access", + "Allowed Origins": "Allowed Origins", + "Please enter a valid url": "Please enter a valid url", + "Add": "Add", + "Development mode: Automatically includes Tauri and localhost origins": "Development mode: Automatically includes Tauri and localhost origins" } diff --git a/src/locales/zh.json b/src/locales/zh.json index a64efe022..00ce9b5bd 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -639,5 +639,13 @@ "AppHiddenTitle": "应用隐藏", "AppHiddenBody": "已通过快捷键隐藏应用窗口", "Invalid Profile URL": "无效的订阅链接,请输入以 http:// 或 https:// 开头的地址", - "Saved Successfully": "保存成功" + "Saved Successfully": "保存成功", + "External Cors": "外部控制跨域", + "Enable one-click CORS for external API. Click to toggle CORS": "设置内核跨域访问,点击切换 CORS是否启用", + "External Cors Configuration": "外部控制跨域配置", + "Allow private network access": "允许专用网络访问", + "Allowed Origins": "允许的来源", + "Please enter a valid url": "请输入有效的网址", + "Add": "添加", + "Development mode: Automatically includes Tauri and localhost origins": "开发模式:自动包含 Tauri 和 localhost 来源" } diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 4bd03235c..4ded6a0f5 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -31,6 +31,10 @@ interface IConfigData { "socks-port": number; "tproxy-port": number; "external-controller": string; + "external-controller-cors": { + "allow-private-network": boolean; + "allow-origins": string[]; + }; secret: string; "unified-delay": boolean; tun: {