mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 07:16:11 +02:00
feat: add licensing handling
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::settings_manager::SettingsManager;
|
||||
|
||||
const TRIAL_DURATION_SECONDS: u64 = 14 * 24 * 60 * 60; // 2 weeks
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum TrialStatus {
|
||||
Active {
|
||||
remaining_seconds: u64,
|
||||
days_remaining: u64,
|
||||
hours_remaining: u64,
|
||||
minutes_remaining: u64,
|
||||
},
|
||||
Expired,
|
||||
}
|
||||
|
||||
pub struct CommercialLicenseManager;
|
||||
|
||||
impl CommercialLicenseManager {
|
||||
pub fn instance() -> &'static CommercialLicenseManager {
|
||||
&COMMERCIAL_LICENSE_MANAGER
|
||||
}
|
||||
|
||||
fn get_current_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("System time before UNIX epoch")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
pub async fn get_trial_status(&self, app_handle: &AppHandle) -> Result<TrialStatus, String> {
|
||||
let first_launch = self.get_or_set_first_launch(app_handle).await?;
|
||||
let now = Self::get_current_timestamp();
|
||||
|
||||
if now < first_launch {
|
||||
// Clock was set back, treat as expired
|
||||
return Ok(TrialStatus::Expired);
|
||||
}
|
||||
|
||||
let elapsed = now - first_launch;
|
||||
|
||||
if elapsed >= TRIAL_DURATION_SECONDS {
|
||||
Ok(TrialStatus::Expired)
|
||||
} else {
|
||||
let remaining = TRIAL_DURATION_SECONDS - elapsed;
|
||||
let days = remaining / (24 * 60 * 60);
|
||||
let hours = (remaining % (24 * 60 * 60)) / (60 * 60);
|
||||
let minutes = (remaining % (60 * 60)) / 60;
|
||||
|
||||
Ok(TrialStatus::Active {
|
||||
remaining_seconds: remaining,
|
||||
days_remaining: days,
|
||||
hours_remaining: hours,
|
||||
minutes_remaining: minutes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_set_first_launch(&self, app_handle: &AppHandle) -> Result<u64, String> {
|
||||
let settings_manager = SettingsManager::instance();
|
||||
let mut settings = settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
if let Some(timestamp) = settings.first_launch_timestamp {
|
||||
return Ok(timestamp);
|
||||
}
|
||||
|
||||
// First launch - record the timestamp
|
||||
let now = Self::get_current_timestamp();
|
||||
settings.first_launch_timestamp = Some(now);
|
||||
settings_manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
|
||||
log::info!("First launch timestamp recorded: {now}");
|
||||
|
||||
// Emit event to notify frontend
|
||||
if let Err(e) = app_handle.emit("first-launch-recorded", now) {
|
||||
log::warn!("Failed to emit first-launch-recorded event: {e}");
|
||||
}
|
||||
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
pub async fn acknowledge_expiration(&self, _app_handle: &AppHandle) -> Result<(), String> {
|
||||
let settings_manager = SettingsManager::instance();
|
||||
let mut settings = settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
settings.commercial_trial_acknowledged = true;
|
||||
settings_manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
|
||||
log::info!("Commercial trial expiration acknowledged");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_acknowledged(&self, _app_handle: &AppHandle) -> Result<bool, String> {
|
||||
let settings_manager = SettingsManager::instance();
|
||||
let settings = settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
Ok(settings.commercial_trial_acknowledged)
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref COMMERCIAL_LICENSE_MANAGER: CommercialLicenseManager = CommercialLicenseManager;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trial_duration() {
|
||||
// 2 weeks = 14 * 24 * 60 * 60 = 1,209,600 seconds
|
||||
assert_eq!(TRIAL_DURATION_SECONDS, 1_209_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_timestamp() {
|
||||
let timestamp = CommercialLicenseManager::get_current_timestamp();
|
||||
// Timestamp should be after 2020-01-01 (1577836800)
|
||||
assert!(timestamp > 1577836800);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ pub struct AppSettings {
|
||||
pub api_token: Option<String>, // Displayed token for user to copy
|
||||
#[serde(default)]
|
||||
pub sync_server_url: Option<String>, // URL of the sync server
|
||||
#[serde(default)]
|
||||
pub first_launch_timestamp: Option<u64>, // Unix epoch seconds when app was first launched
|
||||
#[serde(default)]
|
||||
pub commercial_trial_acknowledged: bool, // Has user dismissed the trial expiration modal
|
||||
#[serde(default)]
|
||||
pub mcp_enabled: bool, // Enable MCP (Model Context Protocol) server
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -66,6 +72,9 @@ impl Default for AppSettings {
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
sync_server_url: None,
|
||||
first_launch_timestamp: None,
|
||||
commercial_trial_acknowledged: false,
|
||||
mcp_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -753,6 +762,9 @@ mod tests {
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
sync_server_url: None,
|
||||
first_launch_timestamp: None,
|
||||
commercial_trial_acknowledged: false,
|
||||
mcp_enabled: false,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
use directories::BaseDirs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
use crate::browser::{create_browser, BrowserType};
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::profile::ProfileManager;
|
||||
|
||||
const ACCEPT_TERMS_FLAG: &str = "--accept-terms-and-conditions";
|
||||
const MIN_VALID_TIMESTAMP: i64 = 1577836800; // 2020-01-01 00:00:00 UTC
|
||||
|
||||
pub struct WayfernTermsManager {
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl WayfernTermsManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static WayfernTermsManager {
|
||||
&WAYFERN_TERMS_MANAGER
|
||||
}
|
||||
|
||||
fn get_license_file_path(&self) -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\Wayfern\license-accepted
|
||||
if let Some(app_data) = std::env::var_os("APPDATA") {
|
||||
return PathBuf::from(app_data)
|
||||
.join("Wayfern")
|
||||
.join("license-accepted");
|
||||
}
|
||||
// Fallback to home directory
|
||||
self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("AppData")
|
||||
.join("Roaming")
|
||||
.join("Wayfern")
|
||||
.join("license-accepted")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS: ~/Library/Application Support/Wayfern/license-accepted
|
||||
self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Wayfern")
|
||||
.join("license-accepted")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Linux: ~/.config/Wayfern/license-accepted or $XDG_CONFIG_HOME/Wayfern/license-accepted
|
||||
if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
let xdg_path = PathBuf::from(xdg_config);
|
||||
if !xdg_path.as_os_str().is_empty() {
|
||||
return xdg_path.join("Wayfern").join("license-accepted");
|
||||
}
|
||||
}
|
||||
self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join(".config")
|
||||
.join("Wayfern")
|
||||
.join("license-accepted")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terms_accepted(&self) -> bool {
|
||||
let license_file = self.get_license_file_path();
|
||||
|
||||
if !license_file.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read the timestamp from the file
|
||||
let contents = match std::fs::read_to_string(&license_file) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// Parse timestamp (Wayfern stores Unix timestamp as text)
|
||||
let timestamp: i64 = match contents.trim().parse() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// Check that timestamp is positive and after 2020-01-01
|
||||
timestamp >= MIN_VALID_TIMESTAMP
|
||||
}
|
||||
|
||||
fn get_any_wayfern_executable(&self) -> Option<PathBuf> {
|
||||
// First try to get executable from any downloaded Wayfern version
|
||||
let registry = DownloadedBrowsersRegistry::instance();
|
||||
let versions = registry.get_downloaded_versions("wayfern");
|
||||
|
||||
if versions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get first available version
|
||||
let version = versions.first()?;
|
||||
|
||||
// Get binaries directory
|
||||
let binaries_dir = ProfileManager::instance().get_binaries_dir();
|
||||
let mut browser_dir = binaries_dir;
|
||||
browser_dir.push("wayfern");
|
||||
browser_dir.push(version);
|
||||
|
||||
let browser = create_browser(BrowserType::Wayfern);
|
||||
browser.get_executable_path(&browser_dir).ok()
|
||||
}
|
||||
|
||||
pub async fn accept_terms(&self) -> Result<(), String> {
|
||||
let executable_path = self.get_any_wayfern_executable().ok_or_else(|| {
|
||||
"No Wayfern browser downloaded. Please download a Wayfern browser version first.".to_string()
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"Running Wayfern with {} flag: {:?}",
|
||||
ACCEPT_TERMS_FLAG,
|
||||
executable_path
|
||||
);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// On macOS, if it's an app bundle, we need to find the actual executable
|
||||
let executable_str = executable_path.to_string_lossy();
|
||||
if executable_str.ends_with(".app") {
|
||||
// Navigate to Contents/MacOS and find the executable
|
||||
let macos_dir = executable_path.join("Contents").join("MacOS");
|
||||
if let Ok(entries) = std::fs::read_dir(&macos_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
return self.run_accept_command(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err("Could not find executable in Wayfern app bundle".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
self.run_accept_command(&executable_path).await
|
||||
}
|
||||
|
||||
async fn run_accept_command(&self, executable_path: &PathBuf) -> Result<(), String> {
|
||||
let output = TokioCommand::new(executable_path)
|
||||
.arg(ACCEPT_TERMS_FLAG)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run Wayfern: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::error!("Wayfern terms acceptance failed: {stderr}");
|
||||
return Err(format!(
|
||||
"Wayfern terms acceptance failed with exit code: {:?}",
|
||||
output.status.code()
|
||||
));
|
||||
}
|
||||
|
||||
// Verify the license file was created
|
||||
if !self.is_terms_accepted() {
|
||||
return Err(
|
||||
"Terms acceptance command succeeded but license file was not created".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Wayfern terms and conditions accepted successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref WAYFERN_TERMS_MANAGER: WayfernTermsManager = WayfernTermsManager::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_license_file_path() {
|
||||
let manager = WayfernTermsManager::new();
|
||||
let path = manager.get_license_file_path();
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
assert!(
|
||||
path_str.contains("Wayfern"),
|
||||
"License file path should contain Wayfern"
|
||||
);
|
||||
assert!(
|
||||
path_str.ends_with("license-accepted"),
|
||||
"License file should be named license-accepted"
|
||||
);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
assert!(
|
||||
path_str.contains("Application Support"),
|
||||
"macOS path should contain Application Support"
|
||||
);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(
|
||||
path_str.contains(".config") || std::env::var_os("XDG_CONFIG_HOME").is_some(),
|
||||
"Linux path should be in .config or XDG_CONFIG_HOME"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_terms_accepted_no_file() {
|
||||
let manager = WayfernTermsManager::new();
|
||||
// This test will pass if no license file exists (which is typically the case in test env)
|
||||
// The actual behavior depends on whether the file exists
|
||||
let _ = manager.is_terms_accepted();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -21,7 +22,9 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
@@ -29,6 +32,7 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types";
|
||||
|
||||
@@ -70,6 +74,18 @@ export default function Home() {
|
||||
error: proxiesError,
|
||||
} = useProxyEvents();
|
||||
|
||||
// Wayfern terms and commercial trial hooks
|
||||
const {
|
||||
termsAccepted,
|
||||
isLoading: termsLoading,
|
||||
checkTerms,
|
||||
} = useWayfernTerms();
|
||||
const {
|
||||
trialStatus,
|
||||
hasAcknowledged: trialAcknowledged,
|
||||
checkTrialStatus,
|
||||
} = useCommercialTrial();
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
||||
@@ -961,6 +977,23 @@ export default function Home() {
|
||||
profile={currentProfileForSync}
|
||||
onSyncConfigOpen={() => setSyncConfigDialogOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */}
|
||||
<WayfernTermsDialog
|
||||
isOpen={!termsLoading && termsAccepted === false}
|
||||
onAccepted={checkTerms}
|
||||
/>
|
||||
|
||||
{/* Commercial Trial Modal - shown once when trial expires */}
|
||||
<CommercialTrialModal
|
||||
isOpen={
|
||||
!termsLoading &&
|
||||
termsAccepted === true &&
|
||||
trialStatus?.type === "Expired" &&
|
||||
!trialAcknowledged
|
||||
}
|
||||
onClose={checkTrialStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
|
||||
interface CommercialTrialModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CommercialTrialModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: CommercialTrialModalProps) {
|
||||
const [isAcknowledging, setIsAcknowledging] = useState(false);
|
||||
|
||||
const handleAcknowledge = useCallback(async () => {
|
||||
setIsAcknowledging(true);
|
||||
try {
|
||||
await invoke("acknowledge_trial_expiration");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to acknowledge trial expiration:", error);
|
||||
showErrorToast("Failed to save acknowledgment", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
} finally {
|
||||
setIsAcknowledging(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commercial Trial Expired</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your 2-week commercial trial period has ended.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If you are using Donut Browser for business purposes, you need to
|
||||
purchase a commercial license to continue.
|
||||
</p>
|
||||
<p className="text-sm font-medium">
|
||||
Personal use remains free and unrestricted.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Visit our website to learn more about commercial licensing options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<LoadingButton
|
||||
onClick={handleAcknowledge}
|
||||
isLoading={isAcknowledging}
|
||||
>
|
||||
I Understand
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -37,8 +37,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import {
|
||||
getThemeByColors,
|
||||
getThemeById,
|
||||
@@ -115,6 +117,10 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
const { termsAccepted } = useWayfernTerms();
|
||||
const { trialStatus } = useCommercialTrial();
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||
|
||||
const getPermissionIcon = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
@@ -417,6 +423,16 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMcpServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("get_mcp_server_status");
|
||||
setMcpEnabled(isRunning);
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP server status:", error);
|
||||
setMcpEnabled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Restore original theme when closing without saving
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
@@ -455,6 +471,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
loadSettings().catch(console.error);
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
loadApiServerStatus().catch(console.error);
|
||||
loadMcpServerStatus().catch(console.error);
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
@@ -481,6 +498,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
checkDefaultBrowserStatus,
|
||||
loadSettings,
|
||||
loadApiServerStatus,
|
||||
loadMcpServerStatus,
|
||||
]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
@@ -1047,6 +1065,100 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commercial License Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Commercial License</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
{trialStatus?.type === "Active" ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Trial: {trialStatus.days_remaining} days,{" "}
|
||||
{trialStatus.hours_remaining} hours remaining
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commercial use is free during the trial period
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
Trial expired
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Personal use remains free. Commercial use requires a
|
||||
license.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Server Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">MCP Server</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="mcp-enabled"
|
||||
checked={mcpEnabled}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={async (checked: boolean) => {
|
||||
setIsMcpStarting(true);
|
||||
try {
|
||||
if (checked) {
|
||||
await invoke("start_mcp_server");
|
||||
setMcpEnabled(true);
|
||||
showSuccessToast("MCP server started");
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
setMcpEnabled(false);
|
||||
showSuccessToast("MCP server stopped");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast("Failed to toggle MCP server", {
|
||||
description:
|
||||
e instanceof Error ? e.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="mcp-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable MCP Server (Model Context Protocol)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI assistants to control Wayfern and Camoufox browsers
|
||||
via MCP.
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-orange-600">
|
||||
(Accept terms first)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpEnabled && (
|
||||
<div className="p-3 space-y-2 text-xs rounded-md border bg-muted/40">
|
||||
<div className="font-medium">Available MCP Tools</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5 text-muted-foreground">
|
||||
<li>list_profiles - List Wayfern/Camoufox profiles</li>
|
||||
<li>run_profile - Launch a browser profile</li>
|
||||
<li>kill_profile - Stop a running browser</li>
|
||||
<li>get_profile - Get profile details</li>
|
||||
<li>list_proxies - List configured proxies</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface WayfernTermsDialogProps {
|
||||
isOpen: boolean;
|
||||
onAccepted: () => void;
|
||||
}
|
||||
|
||||
export function WayfernTermsDialog({
|
||||
isOpen,
|
||||
onAccepted,
|
||||
}: WayfernTermsDialogProps) {
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
setIsAccepting(true);
|
||||
try {
|
||||
await invoke("accept_wayfern_terms");
|
||||
showSuccessToast("Terms accepted successfully");
|
||||
onAccepted();
|
||||
} catch (error) {
|
||||
console.error("Failed to accept terms:", error);
|
||||
showErrorToast("Failed to accept terms", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
} finally {
|
||||
setIsAccepting(false);
|
||||
}
|
||||
}, [onAccepted]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg"
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wayfern Terms and Conditions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Before using Donut Browser, you must read and agree to Wayfern's
|
||||
Terms and Conditions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please review the Terms and Conditions at:
|
||||
</p>
|
||||
<a
|
||||
href="https://wayfern.com/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm font-medium block"
|
||||
>
|
||||
https://wayfern.com/terms-and-conditions
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
By clicking "I Accept", you agree to be bound by these terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<LoadingButton onClick={handleAccept} isLoading={isAccepting}>
|
||||
I Accept
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface TrialStatusActive {
|
||||
type: "Active";
|
||||
remaining_seconds: number;
|
||||
days_remaining: number;
|
||||
hours_remaining: number;
|
||||
minutes_remaining: number;
|
||||
}
|
||||
|
||||
export interface TrialStatusExpired {
|
||||
type: "Expired";
|
||||
}
|
||||
|
||||
export type TrialStatus = TrialStatusActive | TrialStatusExpired;
|
||||
|
||||
interface UseCommercialTrialReturn {
|
||||
trialStatus: TrialStatus | null;
|
||||
hasAcknowledged: boolean;
|
||||
isLoading: boolean;
|
||||
checkTrialStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCommercialTrial(): UseCommercialTrialReturn {
|
||||
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null);
|
||||
const [hasAcknowledged, setHasAcknowledged] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const checkTrialStatus = useCallback(async () => {
|
||||
try {
|
||||
const [status, acknowledged] = await Promise.all([
|
||||
invoke<TrialStatus>("get_commercial_trial_status"),
|
||||
invoke<boolean>("has_acknowledged_trial_expiration"),
|
||||
]);
|
||||
setTrialStatus(status);
|
||||
setHasAcknowledged(acknowledged);
|
||||
} catch (error) {
|
||||
console.error("Failed to check trial status:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkTrialStatus();
|
||||
|
||||
// Check trial status every minute to update the countdown
|
||||
const interval = setInterval(checkTrialStatus, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkTrialStatus]);
|
||||
|
||||
return {
|
||||
trialStatus,
|
||||
hasAcknowledged,
|
||||
isLoading,
|
||||
checkTrialStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface UseWayfernTermsReturn {
|
||||
termsAccepted: boolean | null;
|
||||
isLoading: boolean;
|
||||
checkTerms: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWayfernTerms(): UseWayfernTermsReturn {
|
||||
const [termsAccepted, setTermsAccepted] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const checkTerms = useCallback(async () => {
|
||||
try {
|
||||
const accepted = await invoke<boolean>("check_wayfern_terms_accepted");
|
||||
setTermsAccepted(accepted);
|
||||
} catch (error) {
|
||||
console.error("Failed to check terms acceptance:", error);
|
||||
setTermsAccepted(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkTerms();
|
||||
}, [checkTerms]);
|
||||
|
||||
return {
|
||||
termsAccepted,
|
||||
isLoading,
|
||||
checkTerms,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user