diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index a473c7f..aec5a0a 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -628,7 +628,7 @@ export class SyncService implements OnModuleInit { method: "POST", headers: { "Content-Type": "application/json", - "x-internal-key": this.backendInternalKey!, + "x-internal-key": this.backendInternalKey ?? "undefined", }, body: JSON.stringify({ userId, count }), }); diff --git a/src-tauri/src/api_client.rs b/src-tauri/src/api_client.rs index 88d015c..70eb5d7 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -334,7 +334,7 @@ pub struct BrowserRelease { pub is_prerelease: bool, } -/// Wayfern version info from https://download.wayfern.com/version.json +/// Wayfern version info from https://donutbrowser.com/wayfern.json #[derive(Debug, Serialize, Deserialize, Clone)] pub struct WayfernVersionInfo { pub version: String, @@ -1115,7 +1115,7 @@ impl ApiClient { Ok(()) } - /// Fetch Wayfern version info from https://download.wayfern.com/version.json + /// Fetch Wayfern version info from https://donutbrowser.com/wayfern.json pub async fn fetch_wayfern_version_with_caching( &self, no_caching: bool, @@ -1128,8 +1128,8 @@ impl ApiClient { } } - log::info!("Fetching Wayfern version from https://download.wayfern.com/version.json"); - let url = "https://download.wayfern.com/version.json"; + log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json"); + let url = "https://donutbrowser.com/wayfern.json"; let response = self .client diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 58f1bb7..f8c01b3 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -73,6 +73,12 @@ struct SyncTokenResponse { sync_token: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocationItem { + pub code: String, + pub name: String, +} + #[derive(Debug, Deserialize)] #[allow(dead_code)] struct CloudProxyConfigResponse { @@ -650,7 +656,11 @@ impl CloudAuthManager { password: config.password, }; match PROXY_MANAGER.upsert_cloud_proxy(settings) { - Ok(_) => log::debug!("Cloud proxy synced successfully"), + Ok(_) => { + log::debug!("Cloud proxy synced successfully"); + // Propagate credential changes to derived location proxies + PROXY_MANAGER.update_cloud_derived_proxies(); + } Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"), } } @@ -663,6 +673,106 @@ impl CloudAuthManager { } } + /// Fetch country list from the cloud backend + pub async fn fetch_countries(&self) -> Result, String> { + self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/proxy/locations/countries"); + 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 countries: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Countries fetch failed ({status}): {body}")); + } + + response + .json::>() + .await + .map_err(|e| format!("Failed to parse countries: {e}")) + } + }) + .await + } + + /// Fetch state list for a country from the cloud backend + pub async fn fetch_states(&self, country: &str) -> Result, String> { + let country = country.to_string(); + self + .api_call_with_retry(move |access_token| { + let url = format!( + "{CLOUD_API_URL}/api/proxy/locations/states?country={}", + country + ); + let client = reqwest::Client::new(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch states: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("States fetch failed ({status}): {body}")); + } + + response + .json::>() + .await + .map_err(|e| format!("Failed to parse states: {e}")) + } + }) + .await + } + + /// Fetch city list for a country+state from the cloud backend + pub async fn fetch_cities( + &self, + country: &str, + state: &str, + ) -> Result, String> { + let country = country.to_string(); + let state = state.to_string(); + self + .api_call_with_retry(move |access_token| { + let url = format!( + "{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}", + country, state + ); + let client = reqwest::Client::new(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch cities: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Cities fetch failed ({status}): {body}")); + } + + response + .json::>() + .await + .map_err(|e| format!("Failed to parse cities: {e}")) + } + }) + .await + } + /// Background loop that refreshes the sync token periodically pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) { loop { @@ -755,6 +865,35 @@ pub async fn cloud_has_active_subscription() -> Result { Ok(CLOUD_AUTH.has_active_paid_subscription().await) } +#[tauri::command] +pub async fn cloud_get_countries() -> Result, String> { + CLOUD_AUTH.fetch_countries().await +} + +#[tauri::command] +pub async fn cloud_get_states(country: String) -> Result, String> { + CLOUD_AUTH.fetch_states(&country).await +} + +#[tauri::command] +pub async fn cloud_get_cities(country: String, state: String) -> Result, String> { + CLOUD_AUTH.fetch_cities(&country, &state).await +} + +#[tauri::command] +pub async fn create_cloud_location_proxy( + name: String, + country: String, + state: Option, + city: Option, +) -> Result { + // If no cloud proxy exists yet, attempt to sync it first + if !PROXY_MANAGER.has_cloud_proxy() { + CLOUD_AUTH.sync_cloud_proxy().await; + } + PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city) +} + #[derive(Debug, Serialize)] pub struct CloudProxyUsage { pub used_mb: i64, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db7151c..a0322e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1221,6 +1221,10 @@ pub fn run() { cloud_auth::cloud_refresh_profile, cloud_auth::cloud_logout, cloud_auth::cloud_get_proxy_usage, + cloud_auth::cloud_get_countries, + cloud_auth::cloud_get_states, + cloud_auth::cloud_get_cities, + cloud_auth::create_cloud_location_proxy, 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 3c532b7..bb5847c 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -105,6 +105,14 @@ pub struct StoredProxy { pub last_sync: Option, #[serde(default)] pub is_cloud_managed: bool, + #[serde(default)] + pub is_cloud_derived: bool, + #[serde(default)] + pub geo_country: Option, + #[serde(default)] + pub geo_state: Option, + #[serde(default)] + pub geo_city: Option, } impl StoredProxy { @@ -116,6 +124,10 @@ impl StoredProxy { sync_enabled: false, last_sync: None, is_cloud_managed: false, + is_cloud_derived: false, + geo_country: None, + geo_state: None, + geo_city: None, } } @@ -400,6 +412,12 @@ impl ProxyManager { Ok(stored_proxy) } + // Check if a cloud-managed proxy exists + pub fn has_cloud_proxy(&self) -> bool { + let stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.contains_key(CLOUD_PROXY_ID) + } + // 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(); @@ -424,6 +442,10 @@ impl ProxyManager { sync_enabled: false, last_sync: None, is_cloud_managed: true, + is_cloud_derived: false, + geo_country: None, + geo_state: None, + geo_city: None, }; stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); drop(stored_proxies); @@ -455,6 +477,155 @@ impl ProxyManager { } } + // Build a geo-targeted username from base username and location parts + fn build_geo_username( + base_username: &str, + country: &str, + state: &Option, + city: &Option, + ) -> String { + let mut username = format!("{}-country-{}", base_username, country); + if let Some(state) = state { + username = format!("{}-state-{}", username, state); + } + if let Some(city) = city { + username = format!("{}-city-{}", username, city); + } + username + } + + // Create a cloud-derived location proxy from the base cloud proxy credentials + pub fn create_cloud_location_proxy( + &self, + name: String, + country: String, + state: Option, + city: Option, + ) -> Result { + // Get base cloud proxy credentials + let base_proxy = { + let stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies + .get(CLOUD_PROXY_ID) + .cloned() + .ok_or_else(|| "No cloud proxy available. Please log in first.".to_string())? + }; + + let base_username = base_proxy + .proxy_settings + .username + .as_ref() + .ok_or_else(|| "Cloud proxy has no username".to_string())?; + + let geo_username = Self::build_geo_username(base_username, &country, &state, &city); + + let proxy_settings = ProxySettings { + proxy_type: base_proxy.proxy_settings.proxy_type.clone(), + host: base_proxy.proxy_settings.host.clone(), + port: base_proxy.proxy_settings.port, + username: Some(geo_username), + password: base_proxy.proxy_settings.password.clone(), + }; + + // Check if name already exists + { + let stored_proxies = self.stored_proxies.lock().unwrap(); + if stored_proxies.values().any(|p| p.name == name) { + return Err(format!("Proxy with name '{}' already exists", name)); + } + } + + let stored_proxy = StoredProxy { + id: uuid::Uuid::new_v4().to_string(), + name, + proxy_settings, + sync_enabled: false, + last_sync: None, + is_cloud_managed: false, + is_cloud_derived: true, + geo_country: Some(country), + geo_state: state, + geo_city: city, + }; + + { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone()); + } + + if let Err(e) = self.save_proxy(&stored_proxy) { + log::warn!("Failed to save location proxy: {e}"); + } + + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + + Ok(stored_proxy) + } + + // Update all cloud-derived proxies when base cloud proxy credentials change + pub fn update_cloud_derived_proxies(&self) { + let base_proxy = { + let stored_proxies = self.stored_proxies.lock().unwrap(); + match stored_proxies.get(CLOUD_PROXY_ID) { + Some(p) => p.clone(), + None => return, // No cloud proxy, nothing to update + } + }; + + let base_username = match &base_proxy.proxy_settings.username { + Some(u) => u.clone(), + None => return, + }; + + let mut updated = false; + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + + for proxy in stored_proxies.values_mut() { + if !proxy.is_cloud_derived { + continue; + } + + let country = match &proxy.geo_country { + Some(c) => c.clone(), + None => continue, + }; + + let geo_username = + Self::build_geo_username(&base_username, &country, &proxy.geo_state, &proxy.geo_city); + + proxy.proxy_settings.username = Some(geo_username); + proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone(); + proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone(); + proxy.proxy_settings.port = base_proxy.proxy_settings.port; + + updated = true; + } + + if updated { + // Save all updated proxies + let proxies_to_save: Vec = stored_proxies + .values() + .filter(|p| p.is_cloud_derived) + .cloned() + .collect(); + drop(stored_proxies); + + for proxy in &proxies_to_save { + if let Err(e) = self.save_proxy(proxy) { + log::warn!("Failed to save updated derived proxy {}: {e}", proxy.id); + } + } + + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + + log::debug!("Updated {} cloud-derived proxies", proxies_to_save.len()); + } + } + // Get all stored proxies pub fn get_stored_proxies(&self) -> Vec { let stored_proxies = self.stored_proxies.lock().unwrap(); @@ -678,7 +849,7 @@ impl ProxyManager { let stored_proxies = self.stored_proxies.lock().unwrap(); let proxies: Vec = stored_proxies .values() - .filter(|p| !p.is_cloud_managed) + .filter(|p| !p.is_cloud_managed && !p.is_cloud_derived) .map(|p| ExportedProxy { name: p.name.clone(), proxy_type: p.proxy_settings.proxy_type.clone(), @@ -704,7 +875,7 @@ impl ProxyManager { let stored_proxies = self.stored_proxies.lock().unwrap(); stored_proxies .values() - .filter(|p| !p.is_cloud_managed) + .filter(|p| !p.is_cloud_managed && !p.is_cloud_derived) .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 3b77eb6..fcf1b9f 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1050,34 +1050,41 @@ pub async fn set_profile_sync_enabled( // If enabling, first check that sync settings are configured if enabled { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; + // Cloud auth provides sync settings dynamically — skip local checks + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - if settings.sync_server_url.is_none() { - let _ = events::emit( - "profile-sync-status", - serde_json::json!({ - "profile_id": profile_id, - "status": "error", - "error": "Sync server not configured. Please configure sync settings first." - }), - ); - return Err("Sync server not configured. Please configure sync settings first.".to_string()); - } + if !cloud_logged_in { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - let _ = events::emit( - "profile-sync-status", - serde_json::json!({ - "profile_id": profile_id, - "status": "error", - "error": "Sync token not configured. Please configure sync settings first." - }), - ); - return Err("Sync token not configured. Please configure sync settings first.".to_string()); + if settings.sync_server_url.is_none() { + let _ = events::emit( + "profile-sync-status", + serde_json::json!({ + "profile_id": profile_id, + "status": "error", + "error": "Sync server not configured. Please configure sync settings first." + }), + ); + return Err( + "Sync server not configured. Please configure sync settings first.".to_string(), + ); + } + + let token = manager.get_sync_token(&app_handle).await.ok().flatten(); + if token.is_none() { + let _ = events::emit( + "profile-sync-status", + serde_json::json!({ + "profile_id": profile_id, + "status": "error", + "error": "Sync token not configured. Please configure sync settings first." + }), + ); + return Err("Sync token not configured. Please configure sync settings first.".to_string()); + } } } @@ -1240,18 +1247,24 @@ pub async fn set_proxy_sync_enabled( // If enabling, check that sync settings are configured if enabled { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - if settings.sync_server_url.is_none() { - return Err("Sync server not configured. Please configure sync settings first.".to_string()); - } + if !cloud_logged_in { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); + if settings.sync_server_url.is_none() { + return Err( + "Sync server not configured. Please configure sync settings first.".to_string(), + ); + } + + let token = manager.get_sync_token(&app_handle).await.ok().flatten(); + if token.is_none() { + return Err("Sync token not configured. Please configure sync settings first.".to_string()); + } } } @@ -1318,18 +1331,24 @@ pub async fn set_group_sync_enabled( // If enabling, check that sync settings are configured if enabled { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - if settings.sync_server_url.is_none() { - return Err("Sync server not configured. Please configure sync settings first.".to_string()); - } + if !cloud_logged_in { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); + if settings.sync_server_url.is_none() { + return Err( + "Sync server not configured. Please configure sync settings first.".to_string(), + ); + } + + let token = manager.get_sync_token(&app_handle).await.ok().flatten(); + if token.is_none() { + return Err("Sync token not configured. Please configure sync settings first.".to_string()); + } } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 880e1a9..cc3390e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -919,6 +919,7 @@ export default function Home() { onBulkCopyCookies={handleBulkCopyCookies} onOpenProfileSyncDialog={handleOpenProfileSyncDialog} onToggleProfileSync={handleToggleProfileSync} + crossOsUnlocked={crossOsUnlocked} /> diff --git a/src/components/location-proxy-dialog.tsx b/src/components/location-proxy-dialog.tsx new file mode 100644 index 0000000..efb6a96 --- /dev/null +++ b/src/components/location-proxy-dialog.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { emit } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { LocationItem } from "@/types"; +import { RippleButton } from "./ui/ripple"; + +interface LocationProxyDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function LocationProxyDialog({ + isOpen, + onClose, +}: LocationProxyDialogProps) { + const [countries, setCountries] = useState([]); + const [states, setStates] = useState([]); + const [cities, setCities] = useState([]); + + const [selectedCountry, setSelectedCountry] = useState(""); + const [selectedState, setSelectedState] = useState(""); + const [selectedCity, setSelectedCity] = useState(""); + const [proxyName, setProxyName] = useState(""); + + const [isLoadingCountries, setIsLoadingCountries] = useState(false); + const [isLoadingStates, setIsLoadingStates] = useState(false); + const [isLoadingCities, setIsLoadingCities] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const handleClose = useCallback(() => { + setSelectedCountry(""); + setSelectedState(""); + setSelectedCity(""); + setProxyName(""); + setStates([]); + setCities([]); + onClose(); + }, [onClose]); + + // Fetch countries on mount + useEffect(() => { + if (!isOpen) return; + setIsLoadingCountries(true); + invoke("cloud_get_countries") + .then((data) => setCountries(data)) + .catch((err) => { + console.error("Failed to fetch countries:", err); + toast.error("Failed to load countries"); + }) + .finally(() => setIsLoadingCountries(false)); + }, [isOpen]); + + // Fetch states when country changes + useEffect(() => { + if (!selectedCountry) { + setStates([]); + return; + } + setIsLoadingStates(true); + setSelectedState(""); + setSelectedCity(""); + setCities([]); + invoke("cloud_get_states", { country: selectedCountry }) + .then((data) => setStates(data)) + .catch((err) => console.error("Failed to fetch states:", err)) + .finally(() => setIsLoadingStates(false)); + }, [selectedCountry]); + + // Fetch cities when state changes + useEffect(() => { + if (!selectedCountry || !selectedState) { + setCities([]); + return; + } + setIsLoadingCities(true); + setSelectedCity(""); + invoke("cloud_get_cities", { + country: selectedCountry, + state: selectedState, + }) + .then((data) => setCities(data)) + .catch((err) => console.error("Failed to fetch cities:", err)) + .finally(() => setIsLoadingCities(false)); + }, [selectedCountry, selectedState]); + + // Auto-generate name from selections + useEffect(() => { + const parts: string[] = []; + const countryItem = countries.find((c) => c.code === selectedCountry); + if (countryItem) parts.push(countryItem.name); + const stateItem = states.find((s) => s.code === selectedState); + if (stateItem) parts.push(stateItem.name); + const cityItem = cities.find((c) => c.code === selectedCity); + if (cityItem) parts.push(cityItem.name); + if (parts.length > 0) { + setProxyName(parts.join(" - ")); + } + }, [selectedCountry, selectedState, selectedCity, countries, states, cities]); + + const handleCreate = useCallback(async () => { + if (!selectedCountry || !proxyName.trim()) return; + setIsCreating(true); + try { + await invoke("create_cloud_location_proxy", { + name: proxyName.trim(), + country: selectedCountry, + state: selectedState || null, + city: selectedCity || null, + }); + toast.success("Location proxy created"); + await emit("stored-proxies-changed"); + handleClose(); + } catch (error) { + console.error("Failed to create location proxy:", error); + toast.error( + typeof error === "string" ? error : "Failed to create location proxy", + ); + } finally { + setIsCreating(false); + } + }, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]); + + const countryOptions = countries.map((c) => ({ + value: c.code, + label: c.name, + })); + const stateOptions = states.map((s) => ({ value: s.code, label: s.name })); + const cityOptions = cities.map((c) => ({ value: c.code, label: c.name })); + + return ( + + + + Create Location Proxy + + Create a geo-targeted proxy from your cloud credentials + + + +
+
+ + +
+ + {selectedCountry && stateOptions.length > 0 && ( +
+ + +
+ )} + + {selectedState && cityOptions.length > 0 && ( +
+ + +
+ )} + +
+ + setProxyName(e.target.value)} + placeholder="Proxy name" + /> +
+
+ + + + + {isCreating ? "Creating..." : "Create"} + + +
+
+ ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index b666b62..b6d43e2 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -20,6 +20,7 @@ import { LuChevronDown, LuChevronUp, LuCookie, + LuLock, LuTrash2, LuUsers, } from "react-icons/lu"; @@ -73,6 +74,7 @@ import { trimName } from "@/lib/name-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile, + LocationItem, ProxyCheckResult, StoredProxy, TrafficSnapshot, @@ -170,6 +172,16 @@ type TableMeta = { syncStatuses: Record; onOpenProfileSyncDialog?: (profile: BrowserProfile) => void; onToggleProfileSync?: (profile: BrowserProfile) => void; + crossOsUnlocked?: boolean; + + // Country proxy creation (inline in proxy dropdown) + countries: LocationItem[]; + canCreateLocationProxy: boolean; + loadCountries: () => Promise; + handleCreateCountryProxy: ( + profileId: string, + country: LocationItem, + ) => Promise; }; const TagsCell = React.memo<{ @@ -691,6 +703,7 @@ interface ProfilesDataTableProps { onBulkCopyCookies?: () => void; onOpenProfileSyncDialog?: (profile: BrowserProfile) => void; onToggleProfileSync?: (profile: BrowserProfile) => void; + crossOsUnlocked?: boolean; } export function ProfilesDataTable({ @@ -713,6 +726,7 @@ export function ProfilesDataTable({ onBulkCopyCookies, onOpenProfileSyncDialog, onToggleProfileSync, + crossOsUnlocked = false, }: ProfilesDataTableProps) { const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); const [sorting, setSorting] = React.useState([]); @@ -822,6 +836,23 @@ export function ProfilesDataTable({ Record >({}); + // Country proxy creation state (for inline proxy creation in dropdown) + const [countries, setCountries] = React.useState([]); + const [countriesLoaded, setCountriesLoaded] = React.useState(false); + const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed); + const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked; + + const loadCountries = React.useCallback(async () => { + if (countriesLoaded || !canCreateLocationProxy) return; + try { + const data = await invoke("cloud_get_countries"); + setCountries(data); + setCountriesLoaded(true); + } catch (e) { + console.error("Failed to load countries:", e); + } + }, [countriesLoaded, canCreateLocationProxy]); + // Load cached check results for proxies React.useEffect(() => { const loadCachedResults = async () => { @@ -880,6 +911,35 @@ export function ProfilesDataTable({ [], ); + const handleCreateCountryProxy = React.useCallback( + async (profileId: string, country: LocationItem) => { + try { + await invoke("create_cloud_location_proxy", { + name: country.name, + country: country.code, + state: null, + city: null, + }); + await emit("stored-proxies-changed"); + // Wait briefly for proxy list to update, then find and assign the new proxy + await new Promise((r) => setTimeout(r, 200)); + const updatedProxies = + await invoke("get_stored_proxies"); + const newProxy = updatedProxies.find( + (p: StoredProxy) => + p.is_cloud_derived && p.geo_country === country.code, + ); + if (newProxy) { + await handleProxySelection(profileId, newProxy.id); + } + setOpenProxySelectorFor(null); + } catch (error) { + console.error("Failed to create country proxy:", error); + } + }, + [handleProxySelection], + ); + // Use shared browser state hook const browserState = useBrowserState( profiles, @@ -1323,6 +1383,13 @@ export function ProfilesDataTable({ syncStatuses, onOpenProfileSyncDialog, onToggleProfileSync, + crossOsUnlocked, + + // Country proxy creation + countries, + canCreateLocationProxy, + loadCountries, + handleCreateCountryProxy, }), [ selectedProfiles, @@ -1364,6 +1431,11 @@ export function ProfilesDataTable({ syncStatuses, onOpenProfileSyncDialog, onToggleProfileSync, + crossOsUnlocked, + countries, + canCreateLocationProxy, + loadCountries, + handleCreateCountryProxy, ], ); @@ -1835,7 +1907,17 @@ export function ProfilesDataTable({ sideOffset={8} > - + { + if (meta.canCreateLocationProxy) + void meta.loadCountries(); + }} + /> No proxies found. @@ -1878,6 +1960,35 @@ export function ProfilesDataTable({ ))} + {meta.canCreateLocationProxy && + meta.countries.length > 0 && ( + + {meta.countries + .filter( + (c) => + !meta.storedProxies.some( + (p) => + p.is_cloud_derived && + p.geo_country === c.code, + ), + ) + .map((country) => ( + + void meta.handleCreateCountryProxy( + profile.id, + country, + ) + } + > + +{" "} + {country.name} + + ))} + + )} @@ -1969,6 +2080,21 @@ export function ProfilesDataTable({ > View Network + { + if (meta.crossOsUnlocked) { + meta.onToggleProfileSync?.(profile); + } + }} + disabled={!meta.crossOsUnlocked} + > + + {profile.sync_enabled ? "Disable Sync" : "Enable Sync"} + {!meta.crossOsUnlocked && ( + + )} + + { meta.onAssignProfilesToGroup?.([profile.id]); diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index f06707a..d1fd61c 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; -import { GoPlus } from "react-icons/go"; +import { GoGlobe, GoPlus } from "react-icons/go"; import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu"; import { toast } from "sonner"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; @@ -38,6 +38,8 @@ import { import { useProxyEvents } from "@/hooks/use-proxy-events"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { ProxyCheckResult, StoredProxy } from "@/types"; +import { FlagIcon } from "./flag-icon"; +import { LocationProxyDialog } from "./location-proxy-dialog"; import { ProxyCheckButton } from "./proxy-check-button"; import { RippleButton } from "./ui/ripple"; @@ -85,6 +87,7 @@ export function ProxyManagementDialog({ const [showProxyForm, setShowProxyForm] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); + const [showLocationDialog, setShowLocationDialog] = useState(false); const [editingProxy, setEditingProxy] = useState(null); const [proxyToDelete, setProxyToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); @@ -277,14 +280,27 @@ export function ProxyManagementDialog({ Export - - - Create - +
+ {storedProxies.some((p) => p.is_cloud_managed) && ( + setShowLocationDialog(true)} + className="flex gap-2 items-center" + > + + Location + + )} + + + Create + +
{/* Proxies list */} @@ -316,12 +332,19 @@ export function ProxyManagementDialog({ proxy, proxySyncStatus[proxy.id], ); + const isDerived = proxy.is_cloud_derived === true; return (
- {!isCloud && ( + {isDerived && proxy.geo_country && ( + + )} + {!isCloud && !isDerived && (
+ {!isCloud && !isDerived && ( + + + + + +

Edit 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

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

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

+ ) : ( +

Delete proxy

+ )} +
+
)}
@@ -500,6 +521,10 @@ export function ProxyManagementDialog({ isOpen={showExportDialog} onClose={() => setShowExportDialog(false)} /> + setShowLocationDialog(false)} + /> ); } diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index b8d973e..0fc32a0 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuEye, LuEyeOff } from "react-icons/lu"; +import { LuEye, LuEyeOff, LuLock } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; import { Button } from "@/components/ui/button"; import { @@ -57,9 +57,10 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { const [isSendingCode, setIsSendingCode] = useState(false); const [isVerifying, setIsVerifying] = useState(false); - // Default to self-hosted tab if self-hosted is configured and not cloud-logged-in const [activeTab, setActiveTab] = useState("cloud"); + const isConnected = Boolean(serverUrl && token); + const loadSettings = useCallback(async () => { setIsLoading(true); try { @@ -82,10 +83,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } }, [isOpen, loadSettings]); - // If self-hosted is configured and not cloud-logged-in, default to self-hosted tab + // Auto-select the appropriate tab based on connection state useEffect(() => { - if (!isCloudLoading && !isLoggedIn && serverUrl && token) { + if (isCloudLoading) return; + if (isLoggedIn) { + setActiveTab("cloud"); + } else if (serverUrl && token) { setActiveTab("self-hosted"); + } else { + setActiveTab("cloud"); } }, [isCloudLoading, isLoggedIn, serverUrl, token]); @@ -173,13 +179,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } catch (e) { console.error("Failed to restart sync service:", e); } + // Auto-close dialog after successful login + onClose(); } catch (error) { console.error("OTP verification failed:", error); showErrorToast(String(error)); } finally { setIsVerifying(false); } - }, [email, otpCode, verifyOtp, t]); + }, [email, otpCode, verifyOtp, t, onClose]); const handleCloudLogout = useCallback(async () => { try { @@ -197,7 +205,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } }, [logout, t]); - const isConnected = Boolean(serverUrl && token); + // Determine which tabs are available + const cloudBlocked = !isLoggedIn && isConnected; + const selfHostedBlocked = isLoggedIn; return ( @@ -207,233 +217,254 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { {t("sync.description")} - - - - {t("sync.cloud.tabLabel")} - - - {t("sync.cloud.selfHostedTabLabel")} - - + {/* If cloud is logged in, don't show tabs at all - just show cloud account */} + {isLoggedIn && user ? ( +
+
+
+ {t("sync.cloud.connected")} +
- - {isCloudLoading ? ( -
-
+
+
+ + {t("sync.cloud.email")} + + {user.email}
- ) : isLoggedIn && user ? ( -
-
-
- {t("sync.cloud.connected")} +
+ + {t("sync.cloud.plan")} + + + {user.plan} + {user.planPeriod ? ` (${user.planPeriod})` : ""} + +
+
+ + {t("sync.cloud.profiles")} + + + {t("sync.cloud.profileUsage", { + used: user.cloudProfilesUsed, + limit: user.profileLimit, + })} + +
+ {user.proxyBandwidthLimitMb > 0 && ( +
+ Proxy Bandwidth + + {user.proxyBandwidthUsedMb} / {user.proxyBandwidthLimitMb}{" "} + MB +
+ )} +
-
-
- - {t("sync.cloud.email")} - - {user.email} +
+ + +
+
+ ) : ( + + + + + {t("sync.cloud.tabLabel")} + {cloudBlocked && ( + + )} + + + + + {t("sync.cloud.selfHostedTabLabel")} + {selfHostedBlocked && ( + + )} + + + + + + {isCloudLoading ? ( +
+
+
+ ) : ( +
+
+ +
+ setEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !codeSent) { + void handleSendCode(); + } + }} + /> + + {t("sync.cloud.sendCode")} + +
-
- - {t("sync.cloud.plan")} - - - {user.plan} - {user.planPeriod ? ` (${user.planPeriod})` : ""} - -
-
- - {t("sync.cloud.profiles")} - - - {t("sync.cloud.profileUsage", { - used: user.cloudProfilesUsed, - limit: user.profileLimit, - })} - -
- {user.proxyBandwidthLimitMb > 0 && ( -
- - Proxy Bandwidth - - - {user.proxyBandwidthUsedMb} /{" "} - {user.proxyBandwidthLimitMb} MB - + + {codeSent && ( +
+ + setOtpCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleVerifyOtp(); + } + }} + /> + + {isVerifying + ? t("sync.cloud.loggingIn") + : t("sync.cloud.verifyAndLogin")} +
)}
+ )} + -
- - + + {isLoading ? ( +
+
-
- ) : ( -
-
- -
- setEmail(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !codeSent) { - void handleSendCode(); - } - }} - /> - - {t("sync.cloud.sendCode")} - -
-
- - {codeSent && ( + ) : ( +
-
- )} -
- )} - - - {isLoading ? ( -
-
-
- ) : ( -
-
- - setServerUrl(e.target.value)} - /> -
- -
- -
- setToken(e.target.value)} - className="pr-10" - /> - - - - - - {showToken ? "Hide token" : "Show token"} - - +
+ +
+ setToken(e.target.value)} + className="pr-10" + /> + + + + + + {showToken ? "Hide token" : "Show token"} + + +
-
+ {isConnected && ( +
+
+ {t("sync.status.connected")} +
+ )} +
+ )} + + {isConnected && ( -
-
- {t("sync.status.connected")} -
+ )} -
- )} - - - {isConnected && ( - )} - - - Save - - - - + + Save + +
+ + + )}
); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 381c07b..624bf6e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -128,7 +128,7 @@ "settings": "Settings", "proxies": "Proxies", "groups": "Groups", - "syncService": "Sync Service", + "syncService": "Account", "integrations": "Integrations", "importProfile": "Import Profile" } @@ -262,7 +262,7 @@ } }, "sync": { - "title": "Sync Service", + "title": "Account", "config": "Sync Configuration", "serverUrl": "Server URL", "serverUrlPlaceholder": "https://sync.example.com", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f831e8a..0fd8c64 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -128,7 +128,7 @@ "settings": "Configuración", "proxies": "Proxies", "groups": "Grupos", - "syncService": "Servicio de Sincronización", + "syncService": "Cuenta", "integrations": "Integraciones", "importProfile": "Importar Perfil" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8de222a..e76b0a8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -128,7 +128,7 @@ "settings": "Paramètres", "proxies": "Proxies", "groups": "Groupes", - "syncService": "Service de synchronisation", + "syncService": "Compte", "integrations": "Intégrations", "importProfile": "Importer un profil" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6d465f6..aaddf71 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -128,7 +128,7 @@ "settings": "設定", "proxies": "プロキシ", "groups": "グループ", - "syncService": "同期サービス", + "syncService": "アカウント", "integrations": "統合", "importProfile": "プロファイルをインポート" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index f62ffa1..39fbee4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -128,7 +128,7 @@ "settings": "Configurações", "proxies": "Proxies", "groups": "Grupos", - "syncService": "Serviço de Sincronização", + "syncService": "Conta", "integrations": "Integrações", "importProfile": "Importar Perfil" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d07f052..6216555 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -128,7 +128,7 @@ "settings": "Настройки", "proxies": "Прокси", "groups": "Группы", - "syncService": "Служба синхронизации", + "syncService": "Аккаунт", "integrations": "Интеграции", "importProfile": "Импорт профиля" } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 976e950..e662c44 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -128,7 +128,7 @@ "settings": "设置", "proxies": "代理", "groups": "分组", - "syncService": "同步服务", + "syncService": "账户", "integrations": "集成", "importProfile": "导入配置文件" } diff --git a/src/types.ts b/src/types.ts index 553d77d..3287c82 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,6 +76,15 @@ export interface StoredProxy { sync_enabled?: boolean; last_sync?: number; is_cloud_managed?: boolean; + is_cloud_derived?: boolean; + geo_country?: string; + geo_state?: string; + geo_city?: string; +} + +export interface LocationItem { + code: string; + name: string; } export interface ProfileGroup {