From af2aa36ac6745dd775cd293923e87e79d202450b Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:18:11 +0400 Subject: [PATCH] feat: block launching profiles for incompatible systems --- src-tauri/src/auto_updater.rs | 1 + src-tauri/src/profile/manager.rs | 14 ++-- src-tauri/src/profile/types.rs | 21 +++++ src-tauri/src/profile_importer.rs | 1 + src-tauri/src/sync/engine.rs | 107 +++++++++++++++++++++++++ src/components/profile-data-table.tsx | 111 ++++++++++++++++++++++++-- src/hooks/use-browser-state.ts | 13 ++- src/i18n/locales/en.json | 5 ++ src/i18n/locales/es.json | 5 ++ src/i18n/locales/fr.json | 5 ++ src/i18n/locales/ja.json | 5 ++ src/i18n/locales/pt.json | 5 ++ src/i18n/locales/ru.json | 5 ++ src/i18n/locales/zh.json | 5 ++ src/lib/browser-utils.ts | 18 +++++ src/types.ts | 1 + 16 files changed, 309 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index fac2f33..37f10d1 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -520,6 +520,7 @@ mod tests { note: None, sync_enabled: false, last_sync: None, + host_os: None, } } diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index dedc415..cb2ba68 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -3,7 +3,7 @@ use crate::browser::{create_browser, BrowserType, ProxySettings}; use crate::camoufox_manager::CamoufoxConfig; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::events; -use crate::profile::types::BrowserProfile; +use crate::profile::types::{get_host_os, BrowserProfile}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::WayfernConfig; use directories::BaseDirs; @@ -173,6 +173,7 @@ impl ProfileManager { note: None, sync_enabled: false, last_sync: None, + host_os: None, }; match self @@ -286,6 +287,7 @@ impl ProfileManager { note: None, sync_enabled: false, last_sync: None, + host_os: None, }; match self @@ -331,6 +333,7 @@ impl ProfileManager { note: None, sync_enabled: false, last_sync: None, + host_os: Some(get_host_os()), }; // Save profile info @@ -466,8 +469,8 @@ impl ProfileManager { .find(|p| p.id == profile_uuid) .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; - // Check if browser is running - if profile.process_id.is_some() { + // Check if browser is running (cross-OS profiles can't be running locally) + if profile.process_id.is_some() && !profile.is_cross_os() { return Err( "Cannot delete profile while browser is running. Please stop the browser first.".into(), ); @@ -733,8 +736,8 @@ impl ProfileManager { .find(|p| p.id == profile_uuid) .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; - // Check if browser is running - if profile.process_id.is_some() { + // Check if browser is running (cross-OS profiles can't be running locally) + if profile.process_id.is_some() && !profile.is_cross_os() { return Err( format!( "Cannot delete profile '{}' while browser is running. Please stop the browser first.", @@ -847,6 +850,7 @@ impl ProfileManager { note: source.note, sync_enabled: false, last_sync: None, + host_os: Some(get_host_os()), }; self.save_profile(&new_profile)?; diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 0bde2b4..9f89f02 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -41,15 +41,36 @@ pub struct BrowserProfile { pub sync_enabled: bool, // Whether sync is enabled for this profile #[serde(default)] pub last_sync: Option, // Timestamp of last successful sync (epoch seconds) + #[serde(default)] + pub host_os: Option, // OS where profile was created ("macos", "windows", "linux") } pub fn default_release_type() -> String { "stable".to_string() } +pub fn get_host_os() -> String { + if cfg!(target_os = "macos") { + "macos".to_string() + } else if cfg!(target_os = "windows") { + "windows".to_string() + } else { + "linux".to_string() + } +} + impl BrowserProfile { /// Get the path to the profile data directory (profiles/{uuid}/profile) pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf { profiles_dir.join(self.id.to_string()).join("profile") } + + /// Returns true when the profile was created on a different OS than the current host. + /// Profiles without an `os` field (backward compat) are treated as native. + pub fn is_cross_os(&self) -> bool { + match &self.host_os { + Some(host_os) => host_os != &get_host_os(), + None => false, + } + } } diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index e0ae683..4c3d06b 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -555,6 +555,7 @@ impl ProfileImporter { note: None, sync_enabled: false, last_sync: None, + host_os: Some(crate::profile::types::get_host_os()), }; // Save the profile metadata diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 1098f0b..08fbebd 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -59,6 +59,15 @@ impl SyncEngine { app_handle: &tauri::AppHandle, profile: &BrowserProfile, ) -> SyncResult<()> { + if profile.is_cross_os() { + log::info!( + "Skipping file sync for cross-OS profile: {} ({})", + profile.name, + profile.id + ); + return Ok(()); + } + let profile_manager = ProfileManager::instance(); let profiles_dir = profile_manager.get_profiles_dir(); let profile_dir = profiles_dir.join(profile.id.to_string()); @@ -832,6 +841,49 @@ impl SyncEngine { let mut profile: BrowserProfile = serde_json::from_slice(&metadata_data) .map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?; + // Cross-OS profile: save metadata only, skip manifest + file downloads + if profile.is_cross_os() { + log::info!( + "Profile {} is cross-OS (host_os={:?}), downloading metadata only", + profile_id, + profile.host_os + ); + + fs::create_dir_all(&profile_dir).map_err(|e| { + SyncError::IoError(format!( + "Failed to create profile directory {}: {e}", + profile_dir.display() + )) + })?; + + profile.sync_enabled = true; + profile.last_sync = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + + profile_manager + .save_profile(&profile) + .map_err(|e| SyncError::IoError(format!("Failed to save cross-OS profile: {e}")))?; + + let _ = events::emit("profiles-changed", ()); + let _ = events::emit( + "profile-sync-status", + serde_json::json!({ + "profile_id": profile_id, + "status": "synced" + }), + ); + + log::info!( + "Cross-OS profile {} metadata downloaded successfully", + profile_id + ); + return Ok(true); + } + // Download manifest let manifest = self.download_manifest(&manifest_key).await?; let Some(manifest) = manifest else { @@ -940,6 +992,57 @@ impl SyncEngine { log::info!("No missing profiles found"); } + // Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device) + let profile_manager = ProfileManager::instance(); + // Collect cross-OS profiles before async operations to avoid holding non-Send Result across await + let cross_os_profiles: Vec<(String, bool)> = profile_manager + .list_profiles() + .unwrap_or_default() + .iter() + .filter(|p| p.is_cross_os() && p.sync_enabled) + .map(|p| (p.id.to_string(), p.sync_enabled)) + .collect(); + + if !cross_os_profiles.is_empty() { + for (pid, sync_enabled) in &cross_os_profiles { + let metadata_key = format!("profiles/{}/metadata.json", pid); + match self.client.stat(&metadata_key).await { + Ok(stat) if stat.exists => match self.client.presign_download(&metadata_key).await { + Ok(presign) => match self.client.download_bytes(&presign.url).await { + Ok(data) => { + if let Ok(mut remote_profile) = serde_json::from_slice::(&data) { + remote_profile.sync_enabled = *sync_enabled; + remote_profile.last_sync = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + if let Err(e) = profile_manager.save_profile(&remote_profile) { + log::warn!("Failed to refresh cross-OS profile {} metadata: {}", pid, e); + } else { + log::debug!("Refreshed cross-OS profile {} metadata", pid); + } + } + } + Err(e) => { + log::warn!( + "Failed to download cross-OS profile {} metadata: {}", + pid, + e + ); + } + }, + Err(e) => { + log::warn!("Failed to presign cross-OS profile {} metadata: {}", pid, e); + } + }, + _ => {} + } + } + let _ = events::emit("profiles-changed", ()); + } + Ok(downloaded) } } @@ -1048,6 +1151,10 @@ pub async fn set_profile_sync_enabled( .find(|p| p.id == profile_uuid) .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + if profile.is_cross_os() { + return Err("Cannot modify sync settings for a cross-OS profile".to_string()); + } + // If enabling, first check that sync settings are configured if enabled { // Cloud auth provides sync settings dynamically — skip local checks diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index b6d43e2..79b9604 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -13,6 +13,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; import type { Dispatch, SetStateAction } from "react"; import * as React from "react"; +import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; import { IoEllipsisHorizontal } from "react-icons/io5"; import { @@ -68,6 +69,8 @@ import { getBrowserDisplayName, getBrowserIcon, getCurrentOS, + getOSDisplayName, + isCrossOsProfile, } from "@/lib/browser-utils"; import { formatRelativeTime } from "@/lib/flag-utils"; import { trimName } from "@/lib/name-utils"; @@ -1464,6 +1467,7 @@ export function ProfilesDataTable({ const profile = row.original; const browser = profile.browser; const IconComponent = getBrowserIcon(browser); + const isCrossOs = isCrossOsProfile(profile); const isSelected = meta.isProfileSelected(profile.id); const isRunning = @@ -1474,6 +1478,66 @@ export function ProfilesDataTable({ const isDisabled = isRunning || isLaunching || isStopping || isBrowserUpdating; + // Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are + if (isCrossOs && !meta.showCheckboxes && !isSelected) { + const osName = profile.host_os + ? getOSDisplayName(profile.host_os) + : "another OS"; + const OsIcon = + profile.host_os === "macos" + ? FaApple + : profile.host_os === "windows" + ? FaWindows + : FaLinux; + return ( + + + + + + + +

Created on {osName} - view only

+
+
+ ); + } + + // Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete) + if (isCrossOs && (meta.showCheckboxes || isSelected)) { + const osName = profile.host_os + ? getOSDisplayName(profile.host_os) + : "another OS"; + return ( + Created on {osName} - view only

} + sideOffset={4} + horizontalOffset={8} + > + + + meta.handleCheckboxChange(profile.id, !!value) + } + aria-label="Select row" + className="w-4 h-4" + /> + +
+ ); + } + if (isDisabled) { const tooltipMessage = isRunning ? "Can't modify running profile" @@ -1718,13 +1782,18 @@ export function ProfilesDataTable({ ); + const isCrossOs = isCrossOsProfile(profile); const isRunning = meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); const isStopping = meta.stoppingProfiles.has(profile.id); const isBrowserUpdating = meta.isUpdating(profile.browser); const isDisabled = - isRunning || isLaunching || isStopping || isBrowserUpdating; + isRunning || + isLaunching || + isStopping || + isBrowserUpdating || + isCrossOs; return (