diff --git a/next-env.d.ts b/next-env.d.ts index b87975d..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./dist/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 69c63f2..6174909 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -12,6 +12,8 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; +use crate::browser::ProxySettings; +use crate::proxy_manager::PROXY_MANAGER; use crate::settings_manager::SettingsManager; use crate::sync; @@ -71,6 +73,20 @@ struct SyncTokenResponse { sync_token: String, } +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct CloudProxyConfigResponse { + host: String, + port: u16, + username: Option, + password: Option, + protocol: String, + #[serde(rename = "bandwidthLimitMb")] + bandwidth_limit_mb: i64, + #[serde(rename = "bandwidthUsedMb")] + bandwidth_used_mb: i64, +} + pub struct CloudAuthManager { client: Client, state: Mutex>, @@ -400,6 +416,7 @@ impl CloudAuthManager { let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { // Refresh token expired — clear everything + PROXY_MANAGER.remove_cloud_proxy(); self.clear_auth().await; let _ = crate::events::emit_empty("cloud-auth-expired"); return Err("Session expired. Please log in again.".to_string()); @@ -519,6 +536,9 @@ impl CloudAuthManager { .await; } + // Remove cloud proxy on logout + PROXY_MANAGER.remove_cloud_proxy(); + self.clear_auth().await; Ok(()) } @@ -568,6 +588,81 @@ impl CloudAuthManager { } } + /// Fetch proxy configuration from the cloud backend + async fn fetch_proxy_config(&self) -> Result, String> { + // Check cached user state for proxy bandwidth + { + let state = self.state.lock().await; + match &*state { + Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {} + _ => return Ok(None), + } + } + + match self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/proxy/config"); + let client = self.client.clone(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch proxy config: {e}"))?; + + let status = response.status(); + if status == reqwest::StatusCode::FORBIDDEN { + return Err("__403__".to_string()); + } + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("Proxy config fetch failed ({status}): {body}")); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse proxy config: {e}")) + } + }) + .await + { + Ok(config) => Ok(Some(config)), + Err(e) if e.contains("__403__") => Ok(None), + Err(e) => { + log::warn!("Failed to fetch cloud proxy config: {e}"); + Ok(None) + } + } + } + + /// Sync the cloud-managed proxy: fetch config and upsert or remove + pub async fn sync_cloud_proxy(&self) { + match self.fetch_proxy_config().await { + Ok(Some(config)) => { + let settings = ProxySettings { + proxy_type: config.protocol, + host: config.host, + port: config.port, + username: config.username, + password: config.password, + }; + match PROXY_MANAGER.upsert_cloud_proxy(settings) { + Ok(_) => log::debug!("Cloud proxy synced successfully"), + Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"), + } + } + Ok(None) => { + PROXY_MANAGER.remove_cloud_proxy(); + } + Err(e) => { + log::warn!("Failed to sync cloud proxy: {e}"); + } + } + } + /// Background loop that refreshes the sync token periodically pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) { loop { @@ -601,6 +696,9 @@ impl CloudAuthManager { log::debug!("Failed to refresh cloud profile: {e}"); } + // Sync cloud proxy credentials + CLOUD_AUTH.sync_cloud_proxy().await; + let _ = &app_handle; // keep app_handle alive } } @@ -628,6 +726,9 @@ pub async fn cloud_verify_otp( } } + // Sync cloud proxy after login + CLOUD_AUTH.sync_cloud_proxy().await; + let _ = &app_handle; Ok(state) } @@ -654,6 +755,30 @@ pub async fn cloud_has_active_subscription() -> Result { Ok(CLOUD_AUTH.has_active_paid_subscription().await) } +#[derive(Debug, Serialize)] +pub struct CloudProxyUsage { + pub used_mb: i64, + pub limit_mb: i64, + pub remaining_mb: i64, +} + +#[tauri::command] +pub async fn cloud_get_proxy_usage() -> Result, String> { + let state = CLOUD_AUTH.state.lock().await; + match &*state { + Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => { + let used = auth.user.proxy_bandwidth_used_mb; + let limit = auth.user.proxy_bandwidth_limit_mb; + Ok(Some(CloudProxyUsage { + used_mb: used, + limit_mb: limit, + remaining_mb: (limit - used).max(0), + })) + } + _ => Ok(None), + } +} + #[tauri::command] pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> { // Stop existing scheduler diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af56c65..db7151c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1100,6 +1100,8 @@ pub fn run() { if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await { log::warn!("Failed to refresh cloud sync token on startup: {e}"); } + // Sync cloud proxy credentials on startup + cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await; } cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; }); @@ -1218,6 +1220,7 @@ pub fn run() { cloud_auth::cloud_get_user, cloud_auth::cloud_refresh_profile, cloud_auth::cloud_logout, + cloud_auth::cloud_get_proxy_usage, cloud_auth::restart_sync_service ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 8040422..3c532b7 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -91,6 +91,8 @@ pub struct ProxyCheckResult { pub is_valid: bool, } +pub const CLOUD_PROXY_ID: &str = "cloud-included-proxy"; + // Stored proxy configuration with name and ID for reuse #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredProxy { @@ -101,6 +103,8 @@ pub struct StoredProxy { pub sync_enabled: bool, #[serde(default)] pub last_sync: Option, + #[serde(default)] + pub is_cloud_managed: bool, } impl StoredProxy { @@ -111,6 +115,7 @@ impl StoredProxy { proxy_settings, sync_enabled: false, last_sync: None, + is_cloud_managed: false, } } @@ -395,6 +400,61 @@ impl ProxyManager { Ok(stored_proxy) } + // Upsert the cloud-managed proxy (create or update) + pub fn upsert_cloud_proxy(&self, proxy_settings: ProxySettings) -> Result { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + + if let Some(existing) = stored_proxies.get_mut(CLOUD_PROXY_ID) { + existing.proxy_settings = proxy_settings; + let updated = existing.clone(); + drop(stored_proxies); + + if let Err(e) = self.save_proxy(&updated) { + log::warn!("Failed to save cloud proxy: {e}"); + } + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + Ok(updated) + } else { + let cloud_proxy = StoredProxy { + id: CLOUD_PROXY_ID.to_string(), + name: "Included Proxy".to_string(), + proxy_settings, + sync_enabled: false, + last_sync: None, + is_cloud_managed: true, + }; + stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); + drop(stored_proxies); + + if let Err(e) = self.save_proxy(&cloud_proxy) { + log::warn!("Failed to save cloud proxy: {e}"); + } + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + Ok(cloud_proxy) + } + } + + // Remove the cloud-managed proxy + pub fn remove_cloud_proxy(&self) { + let removed = { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.remove(CLOUD_PROXY_ID).is_some() + }; + + if removed { + if let Err(e) = self.delete_proxy_file(CLOUD_PROXY_ID) { + log::warn!("Failed to delete cloud proxy file: {e}"); + } + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + } + } + // Get all stored proxies pub fn get_stored_proxies(&self) -> Vec { let stored_proxies = self.stored_proxies.lock().unwrap(); @@ -423,6 +483,14 @@ impl ProxyManager { return Err(format!("Proxy with ID '{proxy_id}' not found")); } + // Block editing cloud-managed proxies + if stored_proxies + .get(proxy_id) + .is_some_and(|p| p.is_cloud_managed) + { + return Err("Cannot edit a cloud-managed proxy".to_string()); + } + // Check if new name conflicts with existing proxies if let Some(ref new_name) = name { if stored_proxies @@ -471,6 +539,15 @@ impl ProxyManager { // Remember if sync was enabled before deleting let was_sync_enabled = { let stored_proxies = self.stored_proxies.lock().unwrap(); + + // Block deleting cloud-managed proxies + if stored_proxies + .get(proxy_id) + .is_some_and(|p| p.is_cloud_managed) + { + return Err("Cannot delete a cloud-managed proxy".to_string()); + } + stored_proxies .get(proxy_id) .map(|p| p.sync_enabled) @@ -601,6 +678,7 @@ impl ProxyManager { let stored_proxies = self.stored_proxies.lock().unwrap(); let proxies: Vec = stored_proxies .values() + .filter(|p| !p.is_cloud_managed) .map(|p| ExportedProxy { name: p.name.clone(), proxy_type: p.proxy_settings.proxy_type.clone(), @@ -626,6 +704,7 @@ impl ProxyManager { let stored_proxies = self.stored_proxies.lock().unwrap(); stored_proxies .values() + .filter(|p| !p.is_cloud_managed) .map(|p| Self::build_proxy_url(&p.proxy_settings)) .collect::>() .join("\n") diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 74fc505..3b77eb6 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1228,6 +1228,11 @@ pub async fn set_proxy_sync_enabled( .find(|p| p.id == proxy_id) .ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?; + // Block modifying sync for cloud-managed proxies + if proxy.is_cloud_managed { + return Err("Cannot modify sync for a cloud-managed proxy".to_string()); + } + // If disabling, check if proxy is used by any synced profile if !enabled && is_proxy_used_by_synced_profile(&proxy_id) { return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string()); diff --git a/src/components/proxy-assignment-dialog.tsx b/src/components/proxy-assignment-dialog.tsx index 66c2ffd..dfd616f 100644 --- a/src/components/proxy-assignment-dialog.tsx +++ b/src/components/proxy-assignment-dialog.tsx @@ -144,6 +144,7 @@ export function ProxyAssignmentDialog({ {storedProxies.map((proxy) => ( {proxy.name} + {proxy.is_cloud_managed ? " (Included)" : ""} ))} diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 8712616..f06707a 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -100,7 +100,37 @@ export function ProxyManagementDialog({ {}, ); - const { storedProxies, proxyUsage, isLoading } = useProxyEvents(); + const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents(); + const [cloudProxyUsage, setCloudProxyUsage] = useState<{ + used_mb: number; + limit_mb: number; + } | null>(null); + + // Sort cloud-managed proxies first + const storedProxies = [...rawProxies].sort((a, b) => { + if (a.is_cloud_managed && !b.is_cloud_managed) return -1; + if (!a.is_cloud_managed && b.is_cloud_managed) return 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + // Fetch cloud proxy usage + useEffect(() => { + const fetchUsage = async () => { + try { + const usage = await invoke<{ + used_mb: number; + limit_mb: number; + remaining_mb: number; + } | null>("cloud_get_proxy_usage"); + setCloudProxyUsage(usage); + } catch { + // ignore + } + }; + if (isOpen) { + void fetchUsage(); + } + }, [isOpen]); // Listen for proxy sync status events useEffect(() => { @@ -281,6 +311,7 @@ export function ProxyManagementDialog({ {storedProxies.map((proxy) => { + const isCloud = proxy.is_cloud_managed === true; const syncDot = getSyncStatusDot( proxy, proxySyncStatus[proxy.id], @@ -288,20 +319,32 @@ export function ProxyManagementDialog({ return ( -
- - -
- - -

{syncDot.tooltip}

-
- - {proxy.name} +
+
+ {!isCloud && ( + + +
+ + +

{syncDot.tooltip}

+
+ + )} + {proxy.name} +
+ {isCloud && cloudProxyUsage && ( + + {cloudProxyUsage.used_mb} /{" "} + {cloudProxyUsage.limit_mb} MB used + + )}
@@ -310,36 +353,40 @@ export function ProxyManagementDialog({ - - -
- - handleToggleSync(proxy) - } - disabled={ - isTogglingSync[proxy.id] || - proxyInUse[proxy.id] - } - /> -
-
- - {proxyInUse[proxy.id] ? ( -

- Sync cannot be disabled while this proxy - is used by synced profiles -

- ) : ( -

- {proxy.sync_enabled - ? "Disable sync" - : "Enable sync"} -

- )} -
-
+ {isCloud ? ( + Cloud + ) : ( + + +
+ + handleToggleSync(proxy) + } + disabled={ + isTogglingSync[proxy.id] || + proxyInUse[proxy.id] + } + /> +
+
+ + {proxyInUse[proxy.id] ? ( +

+ Sync cannot be disabled while this proxy + is used by synced profiles +

+ ) : ( +

+ {proxy.sync_enabled + ? "Disable sync" + : "Enable sync"} +

+ )} +
+
+ )}
@@ -362,47 +409,55 @@ export function ProxyManagementDialog({ })); }} /> - - - - - -

Edit proxy

-
-
- - - - - - - - {(proxyUsage[proxy.id] ?? 0) > 0 ? ( -

- Cannot delete: in use by{" "} - {proxyUsage[proxy.id]} profile - {proxyUsage[proxy.id] > 1 ? "s" : ""} -

- ) : ( -

Delete proxy

- )} -
-
+ {!isCloud && ( + <> + + + + + +

Edit proxy

+
+
+ + + + + + + + {(proxyUsage[proxy.id] ?? 0) > 0 ? ( +

+ Cannot delete: in use by{" "} + {proxyUsage[proxy.id]} profile + {proxyUsage[proxy.id] > 1 + ? "s" + : ""} +

+ ) : ( +

Delete proxy

+ )} +
+
+ + )}
diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 32fc527..b8d973e 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -256,6 +256,17 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { })}
+ {user.proxyBandwidthLimitMb > 0 && ( +
+ + Proxy Bandwidth + + + {user.proxyBandwidthUsedMb} /{" "} + {user.proxyBandwidthLimitMb} MB + +
+ )}
diff --git a/src/types.ts b/src/types.ts index 52c2050..40a4e36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,12 +67,15 @@ export interface ProxyCheckResult { is_valid: boolean; } +export const CLOUD_PROXY_ID = "cloud-included-proxy"; + export interface StoredProxy { id: string; name: string; proxy_settings: ProxySettings; sync_enabled?: boolean; last_sync?: number; + is_cloud_managed?: boolean; } export interface ProfileGroup {