feat: block launching profiles for incompatible systems

This commit is contained in:
zhom
2026-02-16 22:18:11 +04:00
parent d52493b7e4
commit af2aa36ac6
16 changed files with 309 additions and 13 deletions
+1
View File
@@ -520,6 +520,7 @@ mod tests {
note: None,
sync_enabled: false,
last_sync: None,
host_os: None,
}
}
+9 -5
View File
@@ -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)?;
+21
View File
@@ -41,15 +41,36 @@ pub struct BrowserProfile {
pub sync_enabled: bool, // Whether sync is enabled for this profile
#[serde(default)]
pub last_sync: Option<u64>, // Timestamp of last successful sync (epoch seconds)
#[serde(default)]
pub host_os: Option<String>, // 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,
}
}
}
+1
View File
@@ -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
+107
View File
@@ -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::<BrowserProfile>(&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
+104 -7
View File
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.id)}
aria-label="Select profile"
>
<span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
</span>
</button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Created on {osName} - view only</p>
</TooltipContent>
</Tooltip>
);
}
// 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 (
<NonHoverableTooltip
content={<p>Created on {osName} - view only</p>}
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
/>
</span>
</NonHoverableTooltip>
);
}
if (isDisabled) {
const tooltipMessage = isRunning
? "Can't modify running profile"
@@ -1718,13 +1782,18 @@ export function ProfilesDataTable({
</Tooltip>
);
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 (
<button
@@ -1762,13 +1831,18 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
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 (
<TagsCell
@@ -1790,13 +1864,18 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
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 (
<NoteCell
@@ -1816,13 +1895,18 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
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;
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
const effectiveProxyId = hasOverride
@@ -2050,6 +2134,7 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isBrowserUpdating =
@@ -2057,6 +2142,12 @@ export function ProfilesDataTable({
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
const isDeleteDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
@@ -2077,6 +2168,7 @@ export function ProfilesDataTable({
onClick={() => {
meta.onOpenTrafficDialog?.(profile.id);
}}
disabled={isCrossOs}
>
View Network
</DropdownMenuItem>
@@ -2086,7 +2178,7 @@ export function ProfilesDataTable({
meta.onToggleProfileSync?.(profile);
}
}}
disabled={!meta.crossOsUnlocked}
disabled={!meta.crossOsUnlocked || isCrossOs}
>
<span className="flex items-center gap-2">
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
@@ -2110,6 +2202,7 @@ export function ProfilesDataTable({
onClick={() => {
meta.onConfigureCamoufox?.(profile);
}}
disabled={isDisabled}
>
Change Fingerprint
</DropdownMenuItem>
@@ -2121,6 +2214,7 @@ export function ProfilesDataTable({
onClick={() => {
meta.onCopyCookiesToProfile?.(profile);
}}
disabled={isDisabled}
>
Copy Cookies to Profile
</DropdownMenuItem>
@@ -2137,7 +2231,7 @@ export function ProfilesDataTable({
onClick={() => {
setProfileToDelete(profile);
}}
disabled={isDisabled}
disabled={isDeleteDisabled}
>
Delete
</DropdownMenuItem>
@@ -2210,7 +2304,10 @@ export function ProfilesDataTable({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="overflow-visible hover:bg-accent/50"
className={cn(
"overflow-visible hover:bg-accent/50",
isCrossOsProfile(row.original) && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
+12 -1
View File
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
getBrowserDisplayName,
getOSDisplayName,
isCrossOsProfile,
} from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
/**
@@ -48,6 +52,8 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
if (isCrossOsProfile(profile)) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
@@ -166,6 +172,11 @@ export function useBrowserState(
(profile: BrowserProfile): string => {
if (!isClient) return "Loading...";
if (isCrossOsProfile(profile) && profile.host_os) {
const osName = getOSDisplayName(profile.host_os);
return `Created on ${osName}. Can only be launched on ${osName}.`;
}
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "Spoofing a different operating system is harder — system-level APIs are more difficult to mask, making it easier for websites to detect inconsistencies. No anti-detect browser can perfectly spoof every detail across operating systems."
},
"crossOs": {
"viewOnly": "Created on {{os}} - view only",
"cannotLaunch": "Created on {{os}}. Can only be launched on {{os}}.",
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
}
}
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "Suplantar un sistema operativo diferente es más difícil: las API a nivel de sistema son más difíciles de enmascarar, lo que facilita que los sitios web detecten inconsistencias. Ningún navegador antidetección puede suplantar perfectamente cada detalle entre sistemas operativos."
},
"crossOs": {
"viewOnly": "Creado en {{os}} - solo lectura",
"cannotLaunch": "Creado en {{os}}. Solo se puede iniciar en {{os}}.",
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
}
}
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "Usurper un système d'exploitation différent est plus difficile : les API au niveau du système sont plus difficiles à masquer, ce qui permet aux sites web de détecter plus facilement les incohérences. Aucun navigateur anti-détection ne peut parfaitement usurper chaque détail d'un système d'exploitation à l'autre."
},
"crossOs": {
"viewOnly": "Créé sur {{os}} - lecture seule",
"cannotLaunch": "Créé sur {{os}}. Ne peut être lancé que sur {{os}}.",
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
}
}
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "異なるオペレーティングシステムの偽装はより困難です。システムレベルのAPIはマスクしにくく、ウェブサイトが矛盾を検出しやすくなります。どのアンチディテクトブラウザも、異なるOS間のすべての詳細を完璧に偽装することはできません。"
},
"crossOs": {
"viewOnly": "{{os}}で作成 - 閲覧のみ",
"cannotLaunch": "{{os}}で作成されました。{{os}}でのみ起動できます。",
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
}
}
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "Falsificar um sistema operacional diferente é mais difícil: as APIs de nível de sistema são mais difíceis de mascarar, facilitando a detecção de inconsistências pelos sites. Nenhum navegador antidetecção consegue falsificar perfeitamente todos os detalhes entre sistemas operacionais."
},
"crossOs": {
"viewOnly": "Criado em {{os}} - somente leitura",
"cannotLaunch": "Criado em {{os}}. Só pode ser iniciado em {{os}}.",
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
}
}
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "Подмена другой операционной системы сложнее — системные API труднее замаскировать, что упрощает обнаружение несоответствий веб-сайтами. Ни один антидетект-браузер не может идеально подменить все детали при смене операционной системы."
},
"crossOs": {
"viewOnly": "Создан на {{os}} - только просмотр",
"cannotLaunch": "Создан на {{os}}. Может быть запущен только на {{os}}.",
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
}
}
+5
View File
@@ -481,5 +481,10 @@
},
"fingerprint": {
"crossOsWarning": "伪装不同的操作系统更加困难——系统级API更难以掩盖,使网站更容易检测到不一致之处。没有任何反检测浏览器能够完美伪装跨操作系统的所有细节。"
},
"crossOs": {
"viewOnly": "在 {{os}} 上创建 - 仅查看",
"cannotLaunch": "在 {{os}} 上创建。只能在 {{os}} 上启动。",
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
}
}
+18
View File
@@ -48,3 +48,21 @@ export const getCurrentOS = () => {
}
return "unknown";
};
export function isCrossOsProfile(profile: { host_os?: string }): boolean {
if (!profile.host_os) return false;
return profile.host_os !== getCurrentOS();
}
export function getOSDisplayName(os: string): string {
switch (os) {
case "macos":
return "macOS";
case "windows":
return "Windows";
case "linux":
return "Linux";
default:
return os;
}
}
+1
View File
@@ -27,6 +27,7 @@ export interface BrowserProfile {
note?: string; // User note
sync_enabled?: boolean; // Whether sync is enabled for this profile
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
}
export type SyncStatus = "Disabled" | "Syncing" | "Synced" | "Error";