feat(app-backend): build tauri desktop app backend, clipboard listener, and system tray

This commit is contained in:
Stevan Freeborn
2026-07-23 12:01:54 -05:00
parent 792e6bd859
commit 2f99665e99
76 changed files with 7226 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+1
View File
@@ -0,0 +1 @@
tab_spaces = 2
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "clean-copy"
version = "0.1.0"
description = "A Tauri App"
authors = ["Stevan Freeborn"]
edition = "2021"
default-run = "clean-copy"
[lib]
name = "tauri_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
shared = { path = "../../shared" }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
serde = { workspace = true }
serde_json = { workspace = true }
tauri-plugin-store = "2.4.3"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-notification = "2.3.3"
tauri-plugin-process = "2.3.1"
tauri-plugin-autostart = "2.5.1"
winreg = "0.56.0"
ed25519-dalek = "3.0.0"
base64 = "0.22.1"
sha2 = "0.11.0"
ureq = { version = "2.9", features = ["json"] }
getrandom = "0.2"
ctrlc = "3.4"
log = { workspace = true }
tauri-plugin-log = "2.0.0"
clipboard-win = { version = "5.4.1", features = ["monitor"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["*"],
"permissions": [
"core:default",
"opener:default",
"core:window:default",
"notification:default",
"global-shortcut:default",
"clipboard-manager:default",
"process:default",
"autostart:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

+212
View File
@@ -0,0 +1,212 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{Signer, SigningKey};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
#[derive(Serialize, Deserialize)]
struct LicensePayload {
product_id: String,
license_key: String,
hwid: String,
}
fn generate_random_bytes(buf: &mut [u8]) -> Result<(), String> {
getrandom::getrandom(buf).map_err(|e| format!("Failed to generate secure random bytes: {}", e))
}
fn find_workspace_root() -> PathBuf {
let mut dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
loop {
if dir.join("licensing_profiles.json").exists() || dir.join("src-tauri").exists() {
return dir;
}
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
} else {
break;
}
}
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
return;
}
match args[1].as_str() {
"generate-keys" => {
let profile_name = if args.len() > 2 { &args[2] } else { "key" };
let workspace_root = find_workspace_root();
let default_output_dir = workspace_root.join("licensing");
let output_dir_path = if args.len() > 3 {
PathBuf::from(&args[3])
} else {
default_output_dir
};
let mut key_bytes = [0u8; 32];
if let Err(e) = generate_random_bytes(&mut key_bytes) {
println!("Failed to generate secure random bytes: {}", e);
return;
}
let signing_key = SigningKey::from_bytes(&key_bytes);
let verifying_key = signing_key.verifying_key();
let priv_hex = key_bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
let pub_hex = verifying_key
.to_bytes()
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
println!("Generated Keypair for Profile: {}", profile_name);
println!("Private Key (HEX): {}", priv_hex);
println!("Public Key (HEX): {}", pub_hex);
println!("Public Key (Rust Bytes): {:?}", verifying_key.to_bytes());
if let Err(e) = fs::create_dir_all(&output_dir_path) {
println!(
"Failed to create output directory {}: {}",
output_dir_path.display(),
e
);
return;
}
let priv_file = output_dir_path.join(format!("{}.priv", profile_name));
let pub_file = output_dir_path.join(format!("{}.pub", profile_name));
if let Err(e) = fs::write(&priv_file, &priv_hex) {
println!(
"Failed to write private key to {}: {}",
priv_file.display(),
e
);
return;
}
if let Err(e) = fs::write(&pub_file, &pub_hex) {
println!(
"Failed to write public key to {}: {}",
pub_file.display(),
e
);
return;
}
println!(
"Successfully persisted keys under: {}",
output_dir_path.display()
);
}
"issue" => {
if args.len() < 4 {
println!("Usage: license-tool issue <private-key-hex> <hwid> [product_id] [license_key]");
return;
}
let priv_key_hex = &args[2];
let hwid = &args[3];
let product_id = if args.len() > 4 {
&args[4]
} else {
"cleancopy"
};
let license_key = if args.len() > 5 {
&args[5]
} else {
"manual_activation"
};
let mut priv_key_bytes = Vec::new();
let mut chars = priv_key_hex.chars().peekable();
while chars.peek().is_some() {
let chunk: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&chunk, 16) {
priv_key_bytes.push(byte);
} else {
println!("Invalid hex character in private key");
return;
}
}
let priv_key_arr: [u8; 32] = match priv_key_bytes.try_into() {
Ok(arr) => arr,
Err(_) => {
println!("Private key must be exactly 32 bytes (64 hex characters)");
return;
}
};
let signing_key = SigningKey::from_bytes(&priv_key_arr);
let payload = LicensePayload {
product_id: product_id.to_string(),
license_key: license_key.to_string(),
hwid: hwid.to_string(),
};
let payload_str = serde_json::to_string(&payload).unwrap();
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
let signature = signing_key.sign(payload_b64.as_bytes());
let signature_b64 = STANDARD.encode(signature.to_bytes());
println!("LICENSE KEY:\n{}.{}", payload_b64, signature_b64);
}
"deactivate" => {
let appdata = env::var("APPDATA").unwrap_or_else(|_| "".to_string());
if appdata.is_empty() {
println!("Error: APPDATA environment variable not found.");
return;
}
let mut path = PathBuf::from(appdata);
path.push("com.cleancopy.app");
path.push("license.lic");
if path.exists() {
match fs::remove_file(&path) {
Ok(_) => println!(
"Successfully deactivated license. Deleted: {}",
path.display()
),
Err(e) => println!("Error deleting license file: {}", e),
}
} else {
println!(
"No active license found to deactivate at: {}",
path.display()
);
}
}
_ => print_usage(),
}
}
fn print_usage() {
println!("CleanCopy License Tool");
println!("Usage:");
println!(" license-tool generate-keys [profile_name] [output_dir]");
println!(" license-tool issue <private-key-hex> <hwid> [product_id] [license_key]");
println!(" license-tool deactivate");
}
@@ -0,0 +1,223 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{Signer, SigningKey};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
#[derive(Serialize, Deserialize)]
struct LicensePayload {
product_id: String,
license_key: String,
hwid: String,
}
fn find_workspace_root() -> PathBuf {
let mut dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
loop {
if dir.join("licensing_profiles.json").exists() || dir.join("src-tauri").exists() {
return dir;
}
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
} else {
break;
}
}
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn main() -> Result<(), String> {
ctrlc::set_handler(move || {
println!("\n[Mock License Server] Shutting down gracefully. Releasing port 3000.");
std::process::exit(0);
})
.map_err(|e| format!("Error setting Ctrl-C handler: {}", e))?;
let listener = TcpListener::bind("127.0.0.1:3000")
.map_err(|e| format!("Failed to bind to port 3000: {}", e))?;
println!("[Mock License Server] Listening on http://localhost:3000");
println!("[Mock License Server] Press Ctrl+C to stop.");
let workspace_root = find_workspace_root();
let dev_priv_path = workspace_root.join("licensing").join("development.priv");
let priv_key_hex = fs::read_to_string(&dev_priv_path)
.map_err(|e| format!(
"Failed to read development private key from {}: {}.\nMake sure you have run 'cargo run --bin license_tool generate-keys development' first.",
dev_priv_path.display(), e
))?;
let priv_key_hex = priv_key_hex.trim();
let mut priv_key_bytes = Vec::new();
let mut chars = priv_key_hex.chars().peekable();
while chars.peek().is_some() {
let chunk: String = chars.by_ref().take(2).collect();
let byte = u8::from_str_radix(&chunk, 16)
.map_err(|e| format!("Invalid hex byte in private key: {}", e))?;
priv_key_bytes.push(byte);
}
let priv_key_arr: [u8; 32] = priv_key_bytes
.try_into()
.map_err(|_| "Private key must be exactly 32 bytes (64 hex characters)".to_string())?;
let signing_key = SigningKey::from_bytes(&priv_key_arr);
println!("[Mock License Server] Successfully loaded development private key from disk.");
for stream in listener.incoming() {
let mut stream = match stream {
Ok(s) => s,
Err(e) => {
println!("[Mock License Server] Connection failed: {}", e);
continue;
}
};
let mut request_bytes = Vec::new();
let mut temp_buffer = [0u8; 1024];
let mut body_start_idx = None;
let mut content_length = None;
loop {
let n = match stream.read(&mut temp_buffer) {
Ok(0) => break, // Connection closed
Ok(n) => n,
Err(e) => {
println!("[Mock License Server] Read error: {}", e);
break;
}
};
request_bytes.extend_from_slice(&temp_buffer[..n]);
let req_str = String::from_utf8_lossy(&request_bytes);
if let Some(idx) = req_str.find("\r\n\r\n") {
body_start_idx = Some(idx + 4);
for line in req_str[..idx].lines() {
if line.to_lowercase().starts_with("content-length:") {
if let Some(val_str) = line.split(':').nth(1) {
if let Ok(len) = val_str.trim().parse::<usize>() {
content_length = Some(len);
}
}
}
}
break;
}
}
if let (Some(start), Some(len)) = (body_start_idx, content_length) {
while request_bytes.len() < start + len {
let n = match stream.read(&mut temp_buffer) {
Ok(0) => break,
Ok(n) => n,
Err(e) => {
println!("[Mock License Server] Read body error: {}", e);
break;
}
};
request_bytes.extend_from_slice(&temp_buffer[..n]);
}
let req_str = String::from_utf8_lossy(&request_bytes);
let body = &req_str[start..start + len];
#[derive(Deserialize)]
struct ActivationRequest {
product_id: String,
license_key: String,
hwid: String,
}
if let Ok(req_data) = serde_json::from_str::<ActivationRequest>(body.trim()) {
if req_data.license_key.starts_with("pi_invalid") {
let response_body = serde_json::json!({
"error": "Activation failed: Invalid Stripe Mock Payment ID."
})
.to_string();
let response = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
let _ = stream.write_all(response.as_bytes());
println!(
"[Mock License Server] Rejected key '{}' for product '{}' on machine '{}'",
req_data.license_key, req_data.product_id, req_data.hwid
);
} else {
let payload = LicensePayload {
product_id: req_data.product_id,
license_key: req_data.license_key.clone(),
hwid: req_data.hwid.clone(),
};
let payload_str = serde_json::to_string(&payload).unwrap();
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
let signature = signing_key.sign(payload_b64.as_bytes());
let signature_b64 = STANDARD.encode(signature.to_bytes());
let token = format!("{}.{}", payload_b64, signature_b64);
let response_body = serde_json::json!({
"license_token": token
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
let _ = stream.write_all(response.as_bytes());
println!(
"[Mock License Server] Successfully activated license for product '{}' on machine '{}'",
payload.product_id, payload.hwid
);
}
} else {
let response_body = serde_json::json!({
"error": "Invalid request payload format."
})
.to_string();
let response = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
let _ = stream.write_all(response.as_bytes());
println!(
"[Mock License Server] Failed parsing request JSON payload body: '{}'",
body
);
}
} else {
let response = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = stream.write_all(response.as_bytes());
println!("[Mock License Server] Invalid HTTP request headers or missing Content-Length");
}
}
Ok(())
}
+57
View File
@@ -0,0 +1,57 @@
pub trait ClipboardProvider {
fn read_text(&self) -> Result<String, String>;
fn write_text(&self, text: &str) -> Result<(), String>;
}
pub struct MockClipboard {
content: std::cell::RefCell<String>,
}
impl MockClipboard {
pub fn new(initial: &str) -> Self {
Self {
content: std::cell::RefCell::new(initial.to_string()),
}
}
}
impl ClipboardProvider for MockClipboard {
fn read_text(&self) -> Result<String, String> {
Ok(self.content.borrow().clone())
}
fn write_text(&self, text: &str) -> Result<(), String> {
*self.content.borrow_mut() = text.to_string();
Ok(())
}
}
pub struct SystemClipboard {
app_handle: tauri::AppHandle,
}
impl SystemClipboard {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self { app_handle }
}
}
impl ClipboardProvider for SystemClipboard {
fn read_text(&self) -> Result<String, String> {
use tauri_plugin_clipboard_manager::ClipboardExt;
self
.app_handle
.clipboard()
.read_text()
.map_err(|e| e.to_string())
}
fn write_text(&self, text: &str) -> Result<(), String> {
use tauri_plugin_clipboard_manager::ClipboardExt;
self
.app_handle
.clipboard()
.write_text(text.to_string())
.map_err(|e| e.to_string())
}
}
+88
View File
@@ -0,0 +1,88 @@
use shared::types::CleanConfig;
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tauri::Manager;
fn get_config_path(app: &tauri::AppHandle) -> PathBuf {
let mut path = app
.path()
.app_config_dir()
.unwrap_or_else(|_| std::env::current_dir().unwrap());
let _ = fs::create_dir_all(&path);
path.push("config.json");
path
}
static CONFIG_CACHE: OnceLock<Mutex<Option<CleanConfig>>> = OnceLock::new();
fn cache() -> &'static Mutex<Option<CleanConfig>> {
CONFIG_CACHE.get_or_init(|| Mutex::new(None))
}
pub fn load_config(app: &tauri::AppHandle) -> CleanConfig {
if let Ok(guard) = cache().lock() {
if let Some(ref config) = *guard {
return config.clone();
}
}
let path = get_config_path(app);
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<CleanConfig>(&content) {
log::info!("Loaded configuration from {}", path.display());
if let Ok(mut guard) = cache().lock() {
*guard = Some(config.clone());
}
return config;
}
}
log::warn!(
"Configuration file at {} was malformed. Reverting to defaults.",
path.display()
);
} else {
log::info!(
"No configuration file found at {}. Generating defaults.",
path.display()
);
}
let default_config = CleanConfig::default();
if let Ok(mut guard) = cache().lock() {
*guard = Some(default_config.clone());
}
if let Err(e) = save_config(app, &default_config) {
log::error!("Failed to save default configuration: {}", e);
}
default_config
}
pub fn save_config(app: &tauri::AppHandle, config: &CleanConfig) -> Result<(), String> {
let path = get_config_path(app);
let content = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
fs::write(&path, content).map_err(|e| {
let err_msg = format!("Failed to write configuration: {}", e);
log::error!("{}", err_msg);
err_msg
})?;
if let Ok(mut guard) = cache().lock() {
*guard = Some(config.clone());
}
log::info!("Saved configuration to {}", path.display());
Ok(())
}
#[tauri::command]
pub fn get_config(app: tauri::AppHandle) -> CleanConfig {
load_config(&app)
}
+534
View File
@@ -0,0 +1,534 @@
pub mod clipboard;
pub mod config;
pub mod license;
use crate::license::{
activate_license_command, check_active_license, get_hwid, get_hwid_command,
is_license_active_command, verify_and_save_offline_token,
};
use shared::cleanser::sanitize_text;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_notification::NotificationExt;
use crate::config::{get_config, load_config, save_config};
use shared::types::CleanConfig;
#[cfg(target_os = "windows")]
#[link(name = "user32")]
extern "system" {
fn MessageBeep(uType: u32) -> i32;
fn keybd_event(bVk: u8, bScan: u8, dwFlags: u32, dwExtraInfo: usize);
}
#[cfg(target_os = "windows")]
fn simulate_copy() {
unsafe {
keybd_event(0x10, 0, 2, 0);
keybd_event(0x12, 0, 2, 0);
keybd_event(0x11, 0, 0, 0);
keybd_event(0x43, 0, 0, 0);
keybd_event(0x43, 0, 2, 0);
keybd_event(0x11, 0, 2, 0);
}
}
#[cfg(not(target_os = "windows"))]
fn simulate_copy() {}
fn is_hotkey_ctrl_c(hotkey_str: &str) -> bool {
match Shortcut::from_str(hotkey_str) {
Ok(s) => {
use tauri_plugin_global_shortcut::{Code, Modifiers};
s.mods == Modifiers::CONTROL && s.key == Code::KeyC
}
Err(_) => false,
}
}
pub struct ClipboardMonitorState {
pub shutdown: std::sync::Mutex<Option<clipboard_win::monitor::Shutdown>>,
}
#[cfg(target_os = "windows")]
fn start_clipboard_monitor(app: AppHandle) -> clipboard_win::monitor::Shutdown {
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut monitor = match clipboard_win::Monitor::new() {
Ok(m) => m,
Err(e) => {
log::error!("Failed to create clipboard monitor: {}", e);
return;
}
};
let shutdown = monitor.shutdown_channel();
let _ = tx.send(shutdown);
log::info!("Clipboard monitor started.");
loop {
match monitor.recv() {
Ok(true) => {
log::info!("Clipboard change detected, sanitizing...");
let _ = execute_clean(app.clone(), false);
}
Ok(false) => {
log::info!("Clipboard monitor shutting down.");
break;
}
Err(e) => {
log::error!("Clipboard monitor error: {}", e);
break;
}
}
}
});
rx.recv()
.expect("Failed to receive clipboard monitor shutdown handle")
}
#[cfg(not(target_os = "windows"))]
fn start_clipboard_monitor(_app: AppHandle) -> ! {
panic!("Clipboard monitor is only supported on Windows");
}
use crate::clipboard::{ClipboardProvider, SystemClipboard};
pub struct HotkeyState {
pub active_hotkey: Mutex<String>,
pub is_conflict: Mutex<bool>,
}
#[tauri::command]
fn get_hotkey_status(state: State<'_, HotkeyState>) -> Result<(String, bool), String> {
let hotkey = state
.active_hotkey
.lock()
.map_err(|e| e.to_string())?
.clone();
let is_conflict = *state.is_conflict.lock().map_err(|e| e.to_string())?;
Ok((hotkey, is_conflict))
}
pub fn register_hotkey(app: &AppHandle, hotkey_str: &str) -> Result<(), String> {
if is_hotkey_ctrl_c(hotkey_str) {
log::info!("Hotkey is Ctrl+C — clipboard monitor handles this case.");
return Ok(());
}
log::info!("Registering global hotkey: {}", hotkey_str);
let shortcut = Shortcut::from_str(hotkey_str).map_err(|e| e.to_string())?;
let _ = app.global_shortcut().unregister_all();
if let Err(e) = app.global_shortcut().register(shortcut) {
let err_msg = format!("Hotkey conflict: {}", e);
log::error!("{}", err_msg);
let _ = app
.notification()
.builder()
.title("CleanCopy - Shortcut Error")
.body(format!(
"Failed to register hotkey '{}'. It might be in use.",
hotkey_str
))
.show();
return Err(err_msg);
}
log::info!("Hotkey '{}' registered successfully.", hotkey_str);
Ok(())
}
#[tauri::command]
fn execute_clean(app: AppHandle, simulate_copy_first: bool) -> Result<String, String> {
log::info!(
"execute_clean invoked (simulate_copy_first={})",
simulate_copy_first
);
if !check_active_license(&app) {
log::warn!("execute_clean blocked: application is unregistered");
return Err("CleanCopy is unregistered. Please activate in Settings.".to_string());
}
if simulate_copy_first {
log::info!("Simulating copy event (Ctrl+C)...");
simulate_copy();
}
std::thread::sleep(std::time::Duration::from_millis(100));
let clipboard = SystemClipboard::new(app.clone());
let original_text = match clipboard.read_text() {
Ok(text) => text,
Err(e) => {
log::error!("Failed to read clipboard: {}", e);
return Err(format!("Failed to read clipboard: {}", e));
}
};
if original_text.is_empty() {
log::info!("Clipboard is empty. Skipping sanitization.");
return Ok("Clipboard is empty".to_string());
}
let config = load_config(&app);
let sanitized_text = sanitize_text(&original_text, &config);
if sanitized_text != original_text {
clipboard.write_text(&sanitized_text).map_err(|e| {
log::error!("Failed to write cleaned text to clipboard: {}", e);
e
})?;
log::info!(
"Clipboard sanitized successfully. Original length: {}, Cleaned length: {}",
original_text.len(),
sanitized_text.len()
);
if config.play_sound {
#[cfg(target_os = "windows")]
unsafe {
MessageBeep(0);
}
let _ = app
.notification()
.builder()
.title("CleanCopy")
.body("Clipboard sanitized!")
.show();
}
Ok("Clipboard sanitized".to_string())
} else {
log::info!("Clipboard text did not match any cleaning rules. Skipping.");
Ok("No cleaning required".to_string())
}
}
use serde::Serialize;
#[derive(Serialize)]
struct InitialState {
config: CleanConfig,
hotkey_conflict: bool,
hwid: String,
is_licensed: bool,
}
#[tauri::command]
fn get_initial_state(
app: AppHandle,
state: State<'_, HotkeyState>,
) -> Result<InitialState, String> {
log::info!("get_initial_state invoked");
let config = load_config(&app);
let is_conflict = *state.is_conflict.lock().map_err(|e| e.to_string())?;
let hwid = get_hwid(&app);
let is_licensed = check_active_license(&app);
Ok(InitialState {
config,
hotkey_conflict: is_conflict,
hwid,
is_licensed,
})
}
#[tauri::command]
fn show_settings_window(window: tauri::WebviewWindow) {
log::info!("show_settings_window invoked from frontend");
let _ = window.show();
let _ = window.set_focus();
}
pub fn open_settings_window(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("settings") {
let _ = window.show();
let _ = window.set_focus();
} else {
let window = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("index.html".into()))
.title("CleanCopy Settings")
.inner_size(450.0, 600.0)
.resizable(false)
.visible(false)
.build()
.map_err(|e| e.to_string())?;
let _ = window.show();
let _ = window.set_focus();
}
Ok(())
}
#[tauri::command]
fn update_config(
app: AppHandle,
state: State<'_, HotkeyState>,
monitor_state: State<'_, ClipboardMonitorState>,
config: CleanConfig,
) -> Result<(), String> {
#[cfg(desktop)]
{
use tauri_plugin_autostart::ManagerExt;
let autostart_manager = app.autolaunch();
if config.autostart {
let _ = autostart_manager.enable();
} else {
let _ = autostart_manager.disable();
}
}
let was_ctrl_c = {
let h = state.active_hotkey.lock().map_err(|e| e.to_string())?;
is_hotkey_ctrl_c(&h)
};
let now_ctrl_c = is_hotkey_ctrl_c(&config.hotkey);
let mut conflict = false;
if was_ctrl_c && !now_ctrl_c {
if let Ok(mut s) = monitor_state.shutdown.lock() {
if let Some(shutdown) = s.take() {
drop(shutdown);
}
}
if register_hotkey(&app, &config.hotkey).is_err() {
conflict = true;
}
} else if !was_ctrl_c && now_ctrl_c {
let _ = app.global_shortcut().unregister_all();
let shutdown = start_clipboard_monitor(app.clone());
if let Ok(mut s) = monitor_state.shutdown.lock() {
*s = Some(shutdown);
}
} else if !was_ctrl_c && !now_ctrl_c {
if register_hotkey(&app, &config.hotkey).is_err() {
conflict = true;
}
}
if let Ok(mut h) = state.active_hotkey.lock() {
*h = config.hotkey.clone();
}
if let Ok(mut c) = state.is_conflict.lock() {
*c = conflict;
}
save_config(&app, &config)?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--minimized"]),
))
.plugin(
tauri_plugin_log::Builder::new()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("app".to_string()),
}),
])
.level(log::LevelFilter::Info)
.build(),
)
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _shortcut, event| {
if event.state() == ShortcutState::Pressed {
log::info!("Global shortcut press event captured.");
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
if check_active_license(&app_handle) {
log::info!("License active. Triggering clipboard cleaning.");
let _ = execute_clean(app_handle, true);
} else {
log::warn!("License inactive. Intercepting shortcut and showing settings window.");
let _ = open_settings_window(&app_handle);
}
});
}
})
.build(),
)
.manage(HotkeyState {
active_hotkey: Mutex::new("Alt+Shift+C".to_string()),
is_conflict: Mutex::new(false),
})
.manage(ClipboardMonitorState {
shutdown: std::sync::Mutex::new(None),
})
.invoke_handler(tauri::generate_handler![
execute_clean,
get_config,
update_config,
get_hotkey_status,
get_hwid_command,
activate_license_command,
is_license_active_command,
verify_and_save_offline_token,
get_initial_state,
show_settings_window,
])
.setup(|app| {
let app_handle = app.handle().clone();
let config = load_config(&app_handle);
let state = app.state::<HotkeyState>();
let monitor_state = app.state::<ClipboardMonitorState>();
let mut conflict = false;
if is_hotkey_ctrl_c(&config.hotkey) {
let shutdown = start_clipboard_monitor(app_handle.clone());
if let Ok(mut s) = monitor_state.shutdown.lock() {
*s = Some(shutdown);
}
} else if register_hotkey(&app_handle, &config.hotkey).is_err() {
conflict = true;
}
if let Ok(mut h) = state.active_hotkey.lock() {
*h = config.hotkey.clone();
}
if let Ok(mut c) = state.is_conflict.lock() {
*c = conflict;
}
let clean_i = MenuItem::with_id(
&app_handle,
"clean",
"Clean Clipboard Now",
true,
None::<&str>,
)?;
let settings_i =
MenuItem::with_id(&app_handle, "settings", "Settings...", true, None::<&str>)?;
let quit_i = MenuItem::with_id(&app_handle, "quit", "Quit CleanCopy", true, None::<&str>)?;
let menu = Menu::with_items(
&app_handle,
&[
&clean_i,
&settings_i,
&PredefinedMenuItem::separator(&app_handle)?,
&quit_i,
],
)?;
let icon_bytes = include_bytes!("../icons/32x32.png");
let icon = tauri::image::Image::from_bytes(icon_bytes)
.map_err(|e| format!("Failed to parse icon bytes: {}", e))?;
let _tray = TrayIconBuilder::with_id("tray")
.icon(icon)
.menu(&menu)
.on_menu_event(move |app_h, event| match event.id.as_ref() {
"quit" => {
SHOULD_EXIT.store(true, Ordering::SeqCst);
app_h.exit(0);
}
"settings" => {
let _ = open_settings_window(app_h);
}
"clean" => {
let _ = execute_clean(app_h.clone(), false);
}
_ => {}
})
.build(app)?;
#[cfg(not(debug_assertions))]
{
if !check_active_license(&app_handle) {
let _ = open_settings_window(&app_handle);
}
}
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
if !SHOULD_EXIT.load(Ordering::SeqCst) {
api.prevent_exit();
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_hotkey_ctrl_c_returns_true_for_ctrl_c() {
assert!(is_hotkey_ctrl_c("Ctrl+C"));
}
#[test]
fn test_is_hotkey_ctrl_c_returns_false_for_alt_shift_c() {
assert!(!is_hotkey_ctrl_c("Alt+Shift+C"));
}
#[test]
fn test_is_hotkey_ctrl_c_returns_false_for_ctrl_shift_c() {
assert!(!is_hotkey_ctrl_c("Ctrl+Shift+C"));
}
#[test]
fn test_is_hotkey_ctrl_c_returns_false_for_ctrl_alt_c() {
assert!(!is_hotkey_ctrl_c("Ctrl+Alt+C"));
}
#[test]
fn test_is_hotkey_ctrl_c_returns_false_for_ctrl_v() {
assert!(!is_hotkey_ctrl_c("Ctrl+V"));
}
#[test]
fn test_is_hotkey_ctrl_c_returns_false_for_invalid_string() {
assert!(!is_hotkey_ctrl_c("not-a-hotkey"));
}
}
+266
View File
@@ -0,0 +1,266 @@
use shared::licensing::{
get_activation_url, get_all_valid_key_bytes, set_license_active, verify_license_with_any_key,
};
use std::fs;
use std::path::PathBuf;
use std::sync::OnceLock;
use tauri::Manager;
use winreg::enums::*;
use winreg::RegKey;
static HWID_CACHE: OnceLock<String> = OnceLock::new();
pub fn get_hwid(app: &tauri::AppHandle) -> String {
if let Some(cached) = HWID_CACHE.get() {
return cached.clone();
}
let mut combined = String::new();
#[cfg(target_os = "windows")]
{
if let Ok(hklm) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\Microsoft\\Cryptography")
{
if let Ok(guid) = hklm.get_value::<String, _>("MachineGuid") {
combined.push_str(&guid);
}
}
if let Ok(hklm) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("HARDWARE\\DESCRIPTION\\System\\BIOS")
{
if let Ok(serial) = hklm.get_value::<String, _>("BaseBoardSerialNumber") {
combined.push_str(&serial);
}
}
}
if combined.is_empty() {
combined = load_or_create_fallback_hwid(app);
}
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(combined.as_bytes());
let result = hasher.finalize();
let hwid = result
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
let _ = HWID_CACHE.set(hwid.clone());
hwid
}
fn load_or_create_fallback_hwid(app: &tauri::AppHandle) -> String {
let mut path = app
.path()
.app_config_dir()
.unwrap_or_else(|_| std::env::current_dir().unwrap());
let _ = fs::create_dir_all(&path);
path.push("fallback_hwid");
if let Ok(existing) = fs::read_to_string(&path) {
let trimmed = existing.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
let mut buf = [0u8; 16];
getrandom::getrandom(&mut buf).expect("Failed to generate random bytes for fallback HWID");
let uuid = format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
buf[0], buf[1], buf[2], buf[3],
buf[4], buf[5],
buf[6], buf[7],
buf[8], buf[9],
buf[10], buf[11], buf[12], buf[13], buf[14], buf[15]
);
let _ = fs::write(&path, &uuid);
uuid
}
pub fn get_license_path(app: &tauri::AppHandle) -> PathBuf {
let mut path = app
.path()
.app_config_dir()
.unwrap_or_else(|_| std::env::current_dir().unwrap());
let _ = fs::create_dir_all(&path);
path.push("license.lic");
path
}
pub fn check_active_license(app: &tauri::AppHandle) -> bool {
if shared::licensing::is_license_active() {
return true;
}
let path = get_license_path(app);
if path.exists() {
match fs::read_to_string(&path) {
Ok(token) => {
let hwid = get_hwid(app);
match verify_license_with_any_key(&token, &get_all_valid_key_bytes(), &hwid) {
Ok(_) => {
log::info!("Local license validation succeeded for HWID: {}", hwid);
set_license_active(true);
return true;
}
Err(e) => {
log::warn!(
"Local license validation failed: {}. Path: {}",
e,
path.display()
);
}
}
}
Err(e) => {
log::error!("Failed to read license file from {}: {}", path.display(), e);
}
}
} else {
log::info!(
"No license file found at {}. App is unregistered.",
path.display()
);
}
false
}
pub fn activate_license(app: &tauri::AppHandle, license_key: &str) -> Result<(), String> {
let hwid = get_hwid(app);
let url = get_activation_url();
log::info!("Initiating license activation. URL: '{}'", url);
let payload = serde_json::json!({
"product_id": "cleancopy",
"license_key": license_key.trim(),
"hwid": hwid
});
let response_result = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build()
.post(url)
.set("Accept", "application/json")
.set("Content-Type", "application/json")
.send_json(payload);
let response = match response_result {
Ok(res) => res,
Err(ureq::Error::Status(code, res)) => {
log::warn!("Activation server returned status code: {}", code);
res
}
Err(e) => {
let err_msg = format!("Network request failed: {}", e);
log::error!("{}", err_msg);
return Err(err_msg);
}
};
#[derive(serde::Deserialize)]
struct ActivationResponse {
license_token: Option<String>,
error: Option<String>,
}
let res_data: ActivationResponse = response.into_json().map_err(|e| {
let err_msg = format!("Failed to parse response JSON: {}", e);
log::error!("{}", err_msg);
err_msg
})?;
if let Some(err) = res_data.error {
log::warn!("Activation server rejected license: {}", err);
return Err(err);
}
let token = res_data.license_token.ok_or_else(|| {
let err_msg = "No license token returned from server".to_string();
log::error!("{}", err_msg);
err_msg
})?;
verify_license_with_any_key(&token, &get_all_valid_key_bytes(), &hwid).map_err(|e| {
let err_msg = format!("Retrieved token signature verification failed: {}", e);
log::error!("{}", err_msg);
err_msg
})?;
let path = get_license_path(app);
fs::write(&path, token.trim()).map_err(|e| {
let err_msg = format!("Failed to save license token to {}: {}", path.display(), e);
log::error!("{}", err_msg);
err_msg
})?;
log::info!(
"Successfully activated license. Token saved to {}",
path.display()
);
set_license_active(true);
Ok(())
}
#[tauri::command]
pub fn get_hwid_command(app: tauri::AppHandle) -> String {
get_hwid(&app)
}
#[tauri::command]
pub fn activate_license_command(app: tauri::AppHandle, license_key: String) -> Result<(), String> {
log::info!("activate_license_command invoked from frontend UI");
activate_license(&app, &license_key)
}
#[tauri::command]
pub fn verify_and_save_offline_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
log::info!("verify_and_save_offline_token invoked from frontend UI");
let hwid = get_hwid(&app);
let keys = get_all_valid_key_bytes();
verify_license_with_any_key(&token, &keys, &hwid).map_err(|e| {
let err_msg = format!("Offline token verification failed: {}", e);
log::error!("{}", err_msg);
err_msg
})?;
let path = get_license_path(&app);
fs::write(&path, token.trim()).map_err(|e| {
let err_msg = format!("Failed to save offline token to {}: {}", path.display(), e);
log::error!("{}", err_msg);
err_msg
})?;
log::info!("Successfully saved offline token to {}", path.display());
set_license_active(true);
Ok(())
}
#[tauri::command]
pub fn is_license_active_command(app: tauri::AppHandle) -> bool {
check_active_license(&app)
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_app_lib::run()
}
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CleanCopy",
"version": "0.1.0",
"identifier": "com.cleancopy.app",
"build": {
"beforeDevCommand": "cd .. && npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "cd .. && npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}