From e40ba986b25543e4bc55321aaaacf66241fe2464 Mon Sep 17 00:00:00 2001 From: JSR Date: Sun, 5 Jul 2026 18:31:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SECTL=20Auth=20=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=9C=AC=E5=9C=B0=20HTTP=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=20(=E7=AB=AF=E5=8F=A3=2051267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 OAuth 回调从 secscore:// URI 协议改为本地 HTTP 回调服务,点击登录时 启动 127.0.0.1:51267 上的回调服务器,端口被占用时强杀占用进程后重试, 登录成功/失败/超时/关闭弹窗时关闭服务。 Co-Authored-By: Claude --- src-tauri/src/commands/oauth_server.rs | 94 ++++++++++++++++++++--- src/components/OAuth/OAuthLogin.tsx | 100 +++++++++++++++++++++---- src/preload/types.ts | 17 +++++ src/services/sectlAuth.ts | 4 +- 4 files changed, 189 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/oauth_server.rs b/src-tauri/src/commands/oauth_server.rs index 0731bb4..726a951 100644 --- a/src-tauri/src/commands/oauth_server.rs +++ b/src-tauri/src/commands/oauth_server.rs @@ -28,6 +28,9 @@ pub struct OAuthCallbackResult { static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); +/// OAuth 本地回调服务器监听端口 +const OAUTH_CALLBACK_PORT: u16 = 51267; + #[tauri::command] pub async fn oauth_start_callback_server( app_handle: tauri::AppHandle, @@ -37,16 +40,39 @@ pub async fn oauth_start_callback_server( // 如果服务器已经在运行,直接返回 URL if shutdown_tx.is_some() { - let port = 16888u16; + let port = OAUTH_CALLBACK_PORT; let url = format!("http://127.0.0.1:{}/oauth/callback", port); return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); } - let port = 16888u16; + let port = OAUTH_CALLBACK_PORT; let addr: SocketAddr = ([127, 0, 0, 1], port).into(); let url = format!("http://127.0.0.1:{}/oauth/callback", port); let url_for_spawn = url.clone(); + // 尝试绑定;若端口被占用,强杀占用进程后重试一次 + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + if e.kind() == std::io::ErrorKind::AddrInUse { + println!( + "[OAuth Callback] 端口 {} 被占用,尝试强杀占用进程", + port + ); + if let Err(kill_err) = kill_processes_on_port(port) { + eprintln!("[OAuth Callback] 强杀端口占用进程失败: {}", kill_err); + } + // 给系统一点时间回收端口 + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| format!("绑定回调服务器端口 {} 失败: {}", port, e))? + } else { + return Err(format!("绑定回调服务器端口 {} 失败: {}", port, e)); + } + } + }; + let (tx, rx) = oneshot::channel::<()>(); *shutdown_tx = Some(tx); drop(shutdown_tx); @@ -58,14 +84,6 @@ pub async fn oauth_start_callback_server( .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) .layer(axum::extract::Extension(app_handle_clone)); - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(l) => l, - Err(e) => { - eprintln!("Failed to bind OAuth callback server: {}", e); - return; - } - }; - println!("OAuth callback server started at {}", url_for_spawn); let server = axum::serve(listener, app); @@ -81,6 +99,62 @@ pub async fn oauth_start_callback_server( Ok(IpcResponse::success(OAuthServerStartResult { url, port })) } +/// 查找并强杀占用指定端口的进程。 +/// macOS/Linux 使用 lsof,Windows 使用 netstat + taskkill。 +fn kill_processes_on_port(port: u16) -> Result<(), String> { + #[cfg(unix)] + { + let output = std::process::Command::new("lsof") + .args(["-t", "-i", &format!(":{}", port)]) + .output() + .map_err(|e| format!("执行 lsof 失败: {}", e))?; + let pids = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect::>(); + for pid in pids { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status(); + println!("[OAuth Callback] 已强杀占用端口 {} 的进程 {}", port, pid); + } + Ok(()) + } + #[cfg(windows)] + { + let output = std::process::Command::new("netstat") + .args(["-ano"]) + .output() + .map_err(|e| format!("执行 netstat 失败: {}", e))?; + let text = String::from_utf8_lossy(&output.stdout); + let needle = format!(":{}", port); + let mut pids = std::collections::HashSet::::new(); + for line in text.lines() { + // 只匹配监听/连接行且包含目标端口 + if line.contains(&needle) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(pid) = parts.last() { + if pid.chars().all(|c| c.is_ascii_digit()) { + pids.push(pid.to_string()); + } + } + } + } + for pid in pids { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid, "/F"]) + .status(); + println!("[OAuth Callback] 已强杀占用端口 {} 的进程 {}", port, pid); + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = port; + Err("当前平台不支持强杀端口占用进程".into()) + } +} + #[tauri::command] pub async fn oauth_stop_callback_server( _state: State<'_, Arc>>, diff --git a/src/components/OAuth/OAuthLogin.tsx b/src/components/OAuth/OAuthLogin.tsx index 4ffdcc0..afd8af6 100644 --- a/src/components/OAuth/OAuthLogin.tsx +++ b/src/components/OAuth/OAuthLogin.tsx @@ -1,5 +1,5 @@ import { Button, message, Modal, Space, Spin } from "antd" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { open } from "@tauri-apps/plugin-shell" import { sectlAuth, SECTL_CONFIG } from "../../services/sectlAuth" @@ -20,50 +20,106 @@ interface OAuthLoginProps { export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { const { t } = useTranslation() const [loading, setLoading] = useState(false) + // 标记本次登录流程是否已完成,避免回调重复触发 + const completedRef = useRef(false) useEffect(() => { if (!visible) return const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID if (platformId) { - // 使用 deep link 回调地址 - sectlAuth.initialize(platformId, "secscore://oauth") + // 使用本地 HTTP 回调地址 (http://127.0.0.1:51267/oauth/callback) + sectlAuth.initialize(platformId, "http://127.0.0.1:51267/oauth/callback") SECTL_CONFIG.platformId = platformId - SECTL_CONFIG.callbackUrl = "secscore://oauth" + SECTL_CONFIG.callbackUrl = "http://127.0.0.1:51267/oauth/callback" } }, [visible]) - // 监听 Deep Link 回调 + // 监听本地 HTTP 回调服务器转发回来的 OAuth 回调 useEffect(() => { if (!visible || !loading) return + completedRef.current = false - const handleDeepLink = async (event: Event) => { - const customEvent = event as CustomEvent<{ code: string; state: string }> - const { code, state } = customEvent.detail + const api = (window as any).api + if (!api || typeof api.onOAuthCallback !== "function") return - console.log("[OAuthLogin] Received deep link callback:", { code, state }) + let disposed = false + let unlisten: (() => void) | null = null + + const handleCallback = async (payload: { + code?: string | null + state?: string | null + error?: string | null + error_description?: string | null + }) => { + if (disposed || completedRef.current) return + console.log("[OAuthLogin] Received http callback:", payload) + + if (payload.error) { + completedRef.current = true + message.error("登录失败: " + (payload.error_description || payload.error)) + await stopCallbackServer() + setLoading(false) + return + } + + if (!payload.code) { + completedRef.current = true + message.error("登录失败: 未收到授权码") + await stopCallbackServer() + setLoading(false) + return + } try { - // 使用 code 交换 token - const result = await sectlAuth.exchangeCode(code, state) + const result = await sectlAuth.exchangeCode( + payload.code, + payload.state || "" + ) if (result) { + completedRef.current = true const userInfo = await sectlAuth.getUserInfo() onSuccess(userInfo) onClose() } } catch (error: any) { + completedRef.current = true message.error(error.message || "登录失败") } finally { + await stopCallbackServer() setLoading(false) } } - window.addEventListener("ss:oauth-deep-link", handleDeepLink) + api + .onOAuthCallback(handleCallback) + .then((fn: () => void) => { + if (disposed) { + fn() + return + } + unlisten = fn + }) + .catch((err: any) => { + console.error("[OAuthLogin] Failed to listen oauth-callback:", err) + }) + return () => { - window.removeEventListener("ss:oauth-deep-link", handleDeepLink) + disposed = true + if (unlisten) unlisten() } }, [visible, loading, onClose, onSuccess]) + const stopCallbackServer = async () => { + const api = (window as any).api + if (!api || typeof api.oauthStopCallbackServer !== "function") return + try { + await api.oauthStopCallbackServer() + } catch (err) { + console.warn("[OAuthLogin] stop callback server failed:", err) + } + } + const handleOAuthLogin = async () => { if (!SECTL_CONFIG.platformId) { message.error("OAuth 配置未设置") @@ -73,25 +129,41 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { setLoading(true) try { + // 启动本地 HTTP 回调服务器 (端口 51267,被占用则强杀已有进程) + const api = (window as any).api + if (api && typeof api.oauthStartCallbackServer === "function") { + const res = await api.oauthStartCallbackServer() + if (!res?.success) { + throw new Error(res?.message || "启动回调服务器失败") + } + // 以服务器实际返回的 URL 作为 redirect_uri + if (res.data?.url) { + SECTL_CONFIG.callbackUrl = res.data.url + } + } + const authUrl = await sectlAuth.getAuthorizationUrl() // 使用 Tauri shell 打开系统浏览器 await open(authUrl) // 设置超时 setTimeout(() => { - if (loading) { + if (loading && !completedRef.current) { + stopCallbackServer() setLoading(false) message.warning("登录超时,请重试") } }, 300000) } catch (error: any) { message.error(error.message || "登录失败") + await stopCallbackServer() setLoading(false) } } const handleModalClose = () => { setLoading(false) + void stopCallbackServer() onClose() } diff --git a/src/preload/types.ts b/src/preload/types.ts index cdf24a0..5b31279 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -542,6 +542,23 @@ const api = { success: boolean message?: string }> => invoke("oauth_stop_callback_server"), + onOAuthCallback: ( + callback: (payload: { + code?: string | null + state?: string | null + error?: string | null + error_description?: string | null + }) => void + ): Promise => { + return listen<{ + code?: string | null + state?: string | null + error?: string | null + error_description?: string | null + }>("oauth-callback", (event) => { + callback(event.payload || {}) + }) + }, oauthReportOnline: ( platformId: string, deviceType: string, diff --git a/src/services/sectlAuth.ts b/src/services/sectlAuth.ts index 7022a1d..211556e 100644 --- a/src/services/sectlAuth.ts +++ b/src/services/sectlAuth.ts @@ -9,8 +9,8 @@ export const SECTL_CONFIG = { baseUrl: "https://appwrite.sectl.cn", authUrl: "https://sectl.cn", platformId: "", // 需要在设置中配置 - callbackUrl: "secscore://oauth", - callbackPort: 5173, + callbackUrl: "http://127.0.0.1:51267/oauth/callback", + callbackPort: 51267, } // Token 数据类型 (与 SDK TokenData 对齐)