feat: add licensing handling

This commit is contained in:
zhom
2026-01-11 03:58:46 +04:00
parent 75eb2c72a9
commit 2725cf9316
9 changed files with 781 additions and 0 deletions
+135
View File
@@ -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);
}
}
+12
View File
@@ -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
+228
View File
@@ -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();
}
}