feat(app-backend): build tauri desktop app backend, clipboard listener, and system tray
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
tab_spaces = 2
|
||||
@@ -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"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 893 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 338 KiB |
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri_app_lib::run()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||