diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index c0daa601c..fbab52858 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -253,6 +253,19 @@ pub async fn switch_proxy_provider( app_type: String, provider_id: String, ) -> Result<(), String> { + // Block official providers during proxy takeover + let provider = state + .db + .get_provider_by_id(&provider_id, &app_type) + .map_err(|e| format!("读取供应商失败: {e}"))? + .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; + if provider.category.as_deref() == Some("official") { + return Err( + "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" + .to_string(), + ); + } + state .proxy_service .switch_proxy_target(&app_type, &provider_id) diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index fd0a4d03f..d16f18089 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1407,6 +1407,16 @@ impl ProviderService { // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running; + // Block switching to official providers when proxy takeover is active. + // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. + if should_hot_switch && _provider.category.as_deref() == Some("official") { + return Err(AppError::localized( + "switch.official_blocked_by_proxy", + "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。", + "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.", + )); + } + if should_hot_switch { // Proxy takeover mode: hot-switch only, don't write Live config log::info!( diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index b713670cc..267d71ca7 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -15,6 +15,7 @@ use crate::services::provider::{ use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; +use tauri::Emitter; use tokio::sync::RwLock; /// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token) @@ -375,6 +376,26 @@ impl ProxyService { // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; + + // 8) Warn if the current provider is official (risk of account ban via proxy) + if let Ok(Some(current_id)) = + crate::settings::get_effective_current_provider(&self.db, &app) + { + if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) { + if provider.category.as_deref() == Some("official") { + if let Some(handle) = self.app_handle.read().await.as_ref() { + let _ = handle.emit( + "proxy-official-warning", + serde_json::json!({ + "appType": app_type_str, + "providerName": provider.name, + }), + ); + } + } + } + } + return Ok(()); } @@ -1548,6 +1569,14 @@ impl ProxyService { .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; + // Defense-in-depth: block official providers during proxy takeover + if provider.category.as_deref() == Some("official") { + return Err( + "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" + .to_string(), + ); + } + let logical_target_changed = crate::settings::get_effective_current_provider(&self.db, &app_type_enum) .map_err(|e| format!("读取当前供应商失败: {e}"))? diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 31af81b42..4bd63f1f6 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -297,6 +297,9 @@ pub fn create_tray_menu( .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?; menu_builder = menu_builder.item(&show_main_item).separator(); + // Pre-compute proxy running state (used to disable official providers in tray menu) + let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running()); + // 每个应用类型折叠为子菜单,避免供应商过多时菜单过长 for section in TRAY_SECTIONS.iter() { if !visible_apps.is_visible(§ion.app_type) { @@ -327,15 +330,32 @@ pub fn create_tray_menu( }; let submenu_id = format!("submenu_{}", app_type_str); + // Check if this app is under proxy takeover (for disabling official providers) + let is_app_taken_over = is_proxy_running + && (futures::executor::block_on(app_state.db.get_live_backup(app_type_str)) + .ok() + .flatten() + .is_some() + || app_state + .proxy_service + .detect_takeover_in_live_config_for_app(§ion.app_type)); + let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label); for (id, provider) in sort_providers(&providers) { let is_current = current_id == *id; + let is_official_blocked = + is_app_taken_over && provider.category.as_deref() == Some("official"); + let label = if is_official_blocked { + format!("{} \u{26D4}", &provider.name) // ⛔ emoji + } else { + provider.name.clone() + }; let item = CheckMenuItem::with_id( app, format!("{}{}", section.prefix, id), - &provider.name, - true, + &label, + !is_official_blocked, // disabled when blocked is_current, None::<&str>, ) diff --git a/src/App.tsx b/src/App.tsx index f7470a18a..4406ac753 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -270,7 +270,11 @@ function App() { deleteProvider, saveUsageScript, setAsDefaultModel, - } = useProviderActions(activeApp, isProxyRunning); + } = useProviderActions( + activeApp, + isProxyRunning, + isProxyRunning && isCurrentAppTakeoverActive, + ); const disableOmoMutation = useDisableCurrentOmo(); const handleDisableOmo = () => { @@ -401,6 +405,32 @@ function App() { }; }, [queryClient, t]); + // Listen for proxy-official-warning: warn when takeover is enabled with an official provider + useEffect(() => { + let unsubscribe: (() => void) | undefined; + + const setup = async () => { + unsubscribe = await listen("proxy-official-warning", (event) => { + const { providerName } = event.payload as { + appType: string; + providerName: string; + }; + toast.warning( + t("notifications.proxyOfficialWarning", { + name: providerName, + defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`, + }), + { duration: 8000 }, + ); + }); + }; + + void setup(); + return () => { + unsubscribe?.(); + }; + }, [t]); + useEffect(() => { let active = true; let unlistenResize: (() => void) | undefined; diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 98db9307d..344a71da8 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -7,6 +7,7 @@ import { Minus, Play, Plus, + ShieldAlert, Terminal, TestTube2, Trash2, @@ -36,6 +37,7 @@ interface ProviderActionsProps { isAutoFailoverEnabled?: boolean; isInFailoverQueue?: boolean; onToggleFailover?: (enabled: boolean) => void; + isOfficialBlockedByProxy?: boolean; // OpenClaw: default model isDefaultModel?: boolean; onSetAsDefault?: () => void; @@ -60,6 +62,7 @@ export function ProviderActions({ isAutoFailoverEnabled = false, isInFailoverQueue = false, onToggleFailover, + isOfficialBlockedByProxy = false, // OpenClaw: default model isDefaultModel = false, onSetAsDefault, @@ -166,6 +169,16 @@ export function ProviderActions({ }; } + if (isOfficialBlockedByProxy) { + return { + disabled: true, + variant: "secondary" as const, + className: "opacity-40 cursor-not-allowed", + icon: , + text: t("provider.blockedByProxy", { defaultValue: "已拦截" }), + }; + } + if (isCurrent) { return { disabled: true, diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index e85973e5b..c0a36bfd4 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -175,6 +175,8 @@ export function ProviderCard({ const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const isOfficial = isOfficialProvider(provider, appId); + const isOfficialBlockedByProxy = + isProxyTakeover && (provider.category === "official" || isOfficial); const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; @@ -424,6 +426,7 @@ export function ProviderCard({ isInConfig={isInConfig} isTesting={isTesting} isProxyTakeover={isProxyTakeover} + isOfficialBlockedByProxy={isOfficialBlockedByProxy} isOmo={isAnyOmo} onSwitch={() => onSwitch(provider)} onEdit={() => onEdit(provider)} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 85d125c34..64b2edf1c 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -23,7 +23,11 @@ import { openclawKeys } from "@/hooks/useOpenClaw"; * Hook for managing provider actions (add, update, delete, switch) * Extracts business logic from App.tsx */ -export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { +export function useProviderActions( + activeApp: AppId, + isProxyRunning?: boolean, + isProxyTakeover?: boolean, +) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -185,6 +189,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { ); } + // Block official providers when proxy takeover is active + if (isProxyTakeover && provider.category === "official") { + toast.error( + t("notifications.officialBlockedByProxy", { + defaultValue: + "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁", + }), + { duration: 6000 }, + ); + return; + } + try { const result = await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); @@ -220,7 +236,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 错误提示由 mutation 处理 } }, - [switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t], + [ + switchProviderMutation, + syncClaudePlugin, + activeApp, + isProxyRunning, + isProxyTakeover, + t, + ], ); // 删除供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b28e876c1..8dbb44b59 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -108,6 +108,7 @@ "currentlyUsing": "Currently Using", "enable": "Enable", "inUse": "In Use", + "blockedByProxy": "Blocked", "editProvider": "Edit Provider", "editProviderHint": "Configuration will be applied to the current provider immediately after update.", "deleteProvider": "Delete Provider", @@ -206,7 +207,9 @@ "openclawDefaultModelSetFailed": "Failed to set default model", "openclawNoModels": "No models configured", "backfillWarning": "Switched successfully, but failed to save changes back to the previous provider", - "windowControlFailed": "Window control failed: {{error}}" + "windowControlFailed": "Window control failed: {{error}}", + "officialBlockedByProxy": "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.", + "proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using proxy takeover." }, "confirm": { "deleteProvider": "Delete Provider", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a8ba0cf51..43e87c067 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -108,6 +108,7 @@ "currentlyUsing": "現在使用中", "enable": "有効化", "inUse": "使用中", + "blockedByProxy": "ブロック", "editProvider": "プロバイダーを編集", "editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。", "deleteProvider": "プロバイダーを削除", @@ -206,7 +207,9 @@ "openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました", "openclawNoModels": "モデルが設定されていません", "backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました", - "windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}" + "windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}", + "officialBlockedByProxy": "プロキシ接管モード中は公式プロバイダーに切り替えできません。プロキシ経由で公式 API にアクセスするとアカウントが停止される可能性があります。", + "proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。プロキシ接管を使用する前にサードパーティプロバイダーに切り替えてください。" }, "confirm": { "deleteProvider": "プロバイダーを削除", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 421d7f859..bf4b35766 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -108,6 +108,7 @@ "currentlyUsing": "当前使用", "enable": "启用", "inUse": "使用中", + "blockedByProxy": "已拦截", "editProvider": "编辑供应商", "editProviderHint": "更新配置后将立即应用到当前供应商。", "deleteProvider": "删除供应商", @@ -206,7 +207,9 @@ "openclawDefaultModelSetFailed": "设置默认模型失败", "openclawNoModels": "该供应商没有配置模型", "backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存", - "windowControlFailed": "窗口控制失败:{{error}}" + "windowControlFailed": "窗口控制失败:{{error}}", + "officialBlockedByProxy": "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁", + "proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用代理接管" }, "confirm": { "deleteProvider": "删除供应商",