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
+4
View File
@@ -0,0 +1,4 @@
dist
dist-ssr
node_modules
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
+342
View File
@@ -0,0 +1,342 @@
# CleanCopy
CleanCopy is a lightweight, headless Windows utility that sits silently in the system tray, automatically sanitizing your clipboard text on trigger. Designed with a strict focus on privacy, developer ergonomics, and performance, CleanCopy operates with a minimal footprint and compiles to a native installer.
## Features
- Automatically removes marketing/analytics trackers (e.g., `utm_*`, `gclid`, `fbclid`, `si`, `msclkid`) from copied URLs while keeping functional query arguments. Users can also define **custom tracking parameters** via a comma-separated input in the Rules tab.
- Converts curly quotes (`"`/`"`/`'`/`'`), en-dashes (``), and em-dashes (`—`) to standard straight quotes (`"`, `'`) and double hyphens (`--`).
- Joins single-newline wraps (frequently introduced by chat apps like WhatsApp or Messenger) into continuous lines while preserving double-newline paragraphs and Markdown layout structure. Also handles **hyphenated line breaks** (e.g., `"inno-\nvation"``"innovation"`).
- Removes zero-width spaces, null bytes, and other invisible Unicode characters that can cause issues when pasting.
- Plays a system beep and shows a Windows notification each time the clipboard is sanitized.
- Optional autostart toggle via `tauri-plugin-autostart`.
- A dark-themed settings panel styled around the VS Code _Lavender Dimmed_ color scheme, using the local _CaskaydiaCove Nerd Font_.
- Integrates secure Ed25519-signature-based offline licensing tied directly to a hardware-fingerprinted machine ID (HWID).
## Getting Started
### Prerequisites
1. **Node.js** (v18+)
2. **Rust & Cargo** (latest stable release)
3. **Windows C++ Build Tools** (Visual Studio Build Tools with C++ desktop workload enabled)
### Installation
Clone the repository and install npm packages from the app directory:
```bash
cd app
npm install
```
### Running in Development
Start the development server (starts Vite and launches the Tauri background tray app):
```bash
npm run tauri dev
```
## Testing & Code Quality
### Running Frontend Tests
Run the Vitest test suite covering React components, state management, and UI behavior:
```bash
npm run test
```
### Linting Frontend Code
Run ESLint across all frontend TypeScript/TSX files:
```bash
npm run lint
```
To auto-fix fixable issues:
```bash
npm run lint:fix
```
### Formatting Frontend Code
Run Prettier to format all frontend files:
```bash
npm run format
```
To check formatting without writing (useful for CI):
```bash
npm run format:check
```
### Running Rust Tests
Run unit tests for clipboard cleansing rules and cryptographic license validation:
```bash
cargo test --workspace
```
### Running Rust Linter
Verify code quality and check for warnings using Cargo Clippy:
```bash
cargo clippy --workspace -- -D warnings
```
### Building Frontend
Verify that the TypeScript compilation and Vite assets packager complete without warning:
```bash
npm run build
```
## Architecture Notes
### Clipboard Simulation
When the global hotkey is pressed (`Alt + Shift + C` by default), CleanCopy uses the Windows `keybd_event` API to simulate a `Ctrl+C` keystroke. It releases the Alt/Shift modifiers first to avoid polluting the copied selection, then waits 150ms for the OS clipboard to update before running the sanitization pipeline.
### Profile-Based Licensing
The file `licensing_profiles.json` is compiled directly into the Rust binary via `include_str!`. Profile selection at runtime:
| Build mode | Profile | Activation URL |
| ----------------------- | ------------- | --------------------------------------------------- |
| `cargo test` | `testing` | `http://localhost:3000/api/activate` |
| `cargo build` (debug) | `development` | `http://localhost:3000/api/activate` |
| `cargo build` (release) | `production` | `https://cleancopy.stevanfreeborn.com/api/activate` |
### License Enforcement
When no valid license is found, CleanCopy locks the UI to the License tab only — the General and Rules tabs are disabled. If the global hotkey is pressed while unlicensed, it opens the settings window to the License tab instead of cleaning the clipboard.
## Licensing System
CleanCopy utilizes a unified, database-free Stripe licensing proxy designed to support a suite of multiple desktop applications.
### The Activation Flow
1. The user buys CleanCopy on your landing page via Stripe Checkout.
2. The user copies their Stripe Payment ID (e.g. `pi_3MtgK...`) or Charge ID (e.g. `ch_3MtgK...`) from their Stripe receipt.
3. The user pastes the Stripe ID into CleanCopy's License tab.
4. CleanCopy pings server. The server:
- Verifies the purchase with Stripe's API.
- Checks that the payment is for the correct `product_id`.
- Signs the user's Hardware ID (HWID) using a private Ed25519 signing key.
- Returns a signed activation token.
5. CleanCopy verifies the signature using the embedded public key, saving it to `license.lic`. On subsequent startups, CleanCopy validates this file offline with zero network requests.
### Local Development Testing
To test the activation flow locally without deploying a live server:
#### Option A: Run the Local Mock Server (Recommended)
1. Start the mock licensing server:
```bash
cd app/src-tauri
cargo run --bin mock_license_server
```
- CleanCopy detects debug mode and automatically redirects its requests to `http://localhost:3000`.
- In the Settings -> License UI, enter any key (e.g. `pi_test_12345`) and click **Activate**.
- To test purchase failures, enter a key starting with `pi_invalid` (e.g. `pi_invalid_999`).
#### Option B: Offline Sign & Bypass
- Copy the HWID displayed in Settings -> License.
- Run the license tool to sign it manually with the private key:
```bash
cd app/src-tauri
cargo run --bin license_tool issue 04a0664dfa85f71119e9f4fc971a953ad49f82b752087deab6bee816c1d37d6d <YOUR_HWID>
```
- Paste the returned token into the activation UI.
- Delete the `license.lic` file from your local AppData directory:
- **Path:** `C:\Users\<user>\AppData\Roaming\com.cleancopy.app\license.lic`
### The CLI License Tool (`license_tool`)
CleanCopy includes a built-in Rust command-line utility `license_tool.rs` for key generation and offline license issuance.
#### Generating Keypairs (`generate-keys`)
Generate fresh, cryptographically secure Ed25519 public/private key pairs. The tool uses `getrandom` to extract secure entropy from the host OS (Windows, macOS, or Linux).
Run the tool with an optional profile name (prefix) and optional target folder:
```bash
cd app/src-tauri
cargo run --bin license_tool generate-keys [profile_name] [output_dir]
```
Example:
```bash
cargo run --bin license_tool generate-keys development
```
- Generates keys and automatically persists them as `licensing/development.priv` and `licensing/development.pub` under your `.gitignored` project root folder.
- Outputs the key hex values directly to the console.
#### Issuing Offline Activation Keys (`issue`)
Issue signed license tokens manually for air-gapped users, secure enterprise environments, or local automated virtual-machine testing.
Run the tool specifying the profile's private key and the user's Hardware ID (HWID):
```bash
cd app/src-tauri
cargo run --bin license_tool issue <private-key-hex> <device-hwid> [product_id] [license_key]
```
Example:
```bash
cargo run --bin license_tool issue 04a0664dfa85f71119e9f4fc971a953ad49f82b752087deab6bee816c1d37d6d 9a0b396c42da0860b246aaa3b8e0cafb86789e062a8947957bb88a46f08477b6
```
- Outputs a period-separated activation token: `PAYLOAD_B64.SIGNATURE_B64`. The customer pastes this token into their Settings panel, which is verified offline against the compiled public key.
#### Purging License Status (`deactivate`)
Easily reset your development machine back to the "Unregistered" state to verify and test the activation UI/flow repeatedly without manually locating files.
Run the tool with the `deactivate` subcommand:
```bash
cd app/src-tauri
cargo run --bin license_tool deactivate
```
- Locates the local AppData directory, checks if `license.lic` is present, and deletes it.
#### Diagnostics: Manually Querying a User's HWID
If a customer is offline and cannot run the app to copy their Hardware ID from the License tab, can instruct them to run this single line in **PowerShell**:
```powershell
$guid = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography').MachineGuid;
$serial = (Get-CimInstance -ClassName Win32_BaseBoard).SerialNumber;
$hasher = [System.Security.Cryptography.HashAlgorithm]::Create('SHA256');
$bytes = [System.Text.Encoding]::UTF8.GetBytes("$guid$serial");
$hash = $hasher.ComputeHash($bytes);
-join ($hash | ForEach-Object { "{0:x2}" -f $_ })
```
This queries the registry and bios serial, SHA-256 hashes them, and prints the matching HWID.
### Key Rotation
If your signing key is compromised or you need to rotate for any reason, CleanCopy supports seamless key rotation via the `deprecated_public_keys` field in `licensing_profiles.json`.
#### How It Works
Each profile contains:
- `public_key_hex`: The current signing key (used for all new activations)
- `deprecated_public_keys`: Array of previous keys that remain valid for existing users
When verifying a license, CleanCopy tries the current key first, then falls back to each deprecated key. Users with tokens signed by an old key continue to work without re-activation.
#### Rotation Steps
- Generate a new keypair:
```bash
cd app/src-tauri
cargo run --bin license_tool generate-keys production
```
- Update `licensing_profiles.json`:
```json
{
"production": {
"activation_url": "https://cleancopy.stevanfreeborn.com/api/activate",
"public_key_hex": "<new key>",
"deprecated_public_keys": ["<old key>"]
}
}
```
- Update the signing key on your activation server to use the new private key.
- Rebuild and distribute the updated app. Existing users' licenses validate against the deprecated key. New activations use the current key.
- After the migration period, remove the old key from `deprecated_public_keys` and rebuild.
## Logging and Diagnostics
CleanCopy uses the `tauri-plugin-log` crate for structured logging at the `Info` level.
### Log Output
Logs are written to two destinations simultaneously:
| Destination | Purpose |
| ------------ | -------------------------------------------------------- |
| **Stdout** | Visible in the terminal when running `npm run tauri dev` |
| **Log file** | Persistent file for production builds |
### Log File Location
The log file is named `app.log` and stored in the Tauri log directory:
| Platform | Path |
| -------- | ------------------------------------------ |
| Windows | `%APPDATA%/com.cleancopy.app/logs/app.log` |
### What Gets Logged
- **Startup**: Config loading, license validation, hotkey registration
- **Clipboard events**: Each clean operation with input/output text
- **License activation**: Proxy requests, token verification results
- **Errors**: File I/O failures, signature verification failures, network errors
### Viewing Logs
**Real-time (development):**
```bash
npm run tauri dev
# Logs appear in the terminal
```
**Log file (production):**
```powershell
# Windows PowerShell
Get-Content "$env:APPDATA\com.cleancopy.app\logs\app.log" -Tail 50 -Wait
```
### Log Level
The log level is hardcoded to `Info`. To change it for debugging, modify the `.level()` call in `app/src-tauri/src/lib.rs`:
```rust
.level(log::LevelFilter::Debug)
.level(log::LevelFilter::Warn)
```
## Production Distribution
To bundle a production-ready MSI installer and release binary:
```bash
npm run tauri build
```
The compiled assets will be bundled in:
`app/src-tauri/target/release/bundle/msi/`
+369
View File
@@ -0,0 +1,369 @@
# CleanCopy E2E Manual Testing Sandbox
Use this document to manually verify that all clipboard sanitization rules work correctly end-to-end.
## How to Use
1. Make sure CleanCopy is running in the system tray.
2. Copy the **"Copy this"** block to your clipboard (`Ctrl+C`).
3. Press the CleanCopy hotkey (`Alt + Shift + C` or your configured shortcut).
4. Paste the result into a text editor and compare with the **Expected Output**.
### Prerequisites
- CleanCopy is running in the system tray (lavender clipboard icon).
- All cleaning rules are **enabled** in Settings -> Rules (unless a scenario says otherwise).
- You have a text editor open to paste and inspect results.
## [URL] Scenario 1: Tracking Parameter Scrubber
Tests the removal of marketing and analytic trackers while preserving legitimate query variables.
### Copy this
```text
https://example.com/shop/item-102?utm_source=newsletter&utm_medium=email&utm_campaign=summer_sale&gclid=CL_12345&fbclid=FB_67890&si=SI_abcde&q=running+shoes&category=activewear
```
### Expected Output
```text
https://example.com/shop/item-102?q=running+shoes&category=activewear
```
_(Tracks `utm_source`, `utm_medium`, `utm_campaign`, `gclid`, `fbclid`, and `si` are stripped; `q` and `category` are kept.)_
## [URL] Scenario 2: Custom URL Parameters
Tests that user-defined custom tracking params (added in Settings -> Rules) are also stripped.
### Setup
In Settings -> Rules, type `foo,bar` in the custom tracking parameters input and wait for the save indicator.
### Copy this
```text
https://example.com/page?foo=abc&bar=123&keep=this&utm_source=x
```
### Expected Output
```text
https://example.com/page?keep=this
```
_(Custom params `foo` and `bar` are stripped alongside built-in `utm_source`.)_
## [URL] Scenario 3: URL with Only Tracked Parameters
Tests that a URL with no legitimate params is cleaned of the entire query string.
### Copy this
```text
https://example.com/article?utm_source=newsletter&utm_medium=email&gclid=abc123&fbclid=xyz789
```
### Expected Output
```text
https://example.com/article
```
_(All params were trackers — the `?` is removed entirely.)_
## [URL] Scenario 4: Multiple URLs in One Clipboard
Tests that all URLs in a single clipboard block get their trackers stripped independently.
### Copy this
```text
Check these links:
https://store.com/deal?utm_source=ad&product=shoes
https://blog.com/post?gclid=123&id=456
```
### Expected Output
```text
Check these links:
https://store.com/deal?product=shoes
https://blog.com/post?id=456
```
_(Both URLs have their trackers stripped independently.)_
## [URL] Scenario 5: Already-Clean URL Passes Through
Tests that a URL with no tracking parameters is left completely untouched.
### Copy this
```text
https://example.com/docs/getting-started?section=install&version=2
```
### Expected Output
```text
https://example.com/docs/getting-started?section=install&version=2
```
_(No trackers present — URL is unchanged.)_
## [TYPOGRAPHY] Scenario 6: Smart Quotes & Dash Typography
Tests the transformation of curly quotes, smart apostrophes, en-dashes, and em-dashes.
### Copy this
```text
"The developer's code," she said — "should compile without warnings." The cost was $5$10.
```
### Expected Output
```text
"The developer's code," she said -- "should compile without warnings." The cost was $5-$10.
```
_(Curly double quotes → `"`, smart apostrophe → `'`, em-dash → `--`, en-dash → `-`.)_
## [TYPOGRAPHY] Scenario 7: Invisible Character Cleanup
Tests removal of zero-width spaces, null bytes, and other invisible Unicode characters.
### Copy this (these contain invisible characters)
```text
Hello World
```
_(Each letter above is separated by zero-width spaces U+200B.)_
### Expected Output
```text
Hello World
```
_(All zero-width spaces are removed, letters join together.)_
## [NEWLINES] Scenario 8: Messenger Line-Break Collapse
Tests that single line wraps from chat apps are joined, while paragraph breaks are preserved.
### Copy this
```text
This is a long sentence that got split
across multiple lines by a chat app's
auto-wrap feature.
This second paragraph should remain on
its own separate line.
```
### Expected Output
```text
This is a long sentence that got split across multiple lines by a chat app's auto-wrap feature.
This second paragraph should remain on its own separate line.
```
_(Single line breaks → space; double-newline paragraph break preserved.)_
## [NEWLINES] Scenario 9: Hyphenated Line Break
Tests that words split with a hyphen at end-of-line are rejoined correctly.
### Copy this
```text
This is an example of inno-
vation that was split across lines.
```
### Expected Output
```text
This is an example of innovation that was split across lines.
```
_(The hyphen at end of line is removed and the word is joined.)_
## [NEWLINES] Scenario 10: Markdown List & Header Preservation
Tests that Markdown structure (headers, lists) is not collapsed.
### Copy this
```text
# Main Header
* Item 1 in list
* Item 2 in list
Here is some body text.
```
### Expected Output
```text
# Main Header
* Item 1 in list
* Item 2 in list
Here is some body text.
```
_(Markdown lists and headers are preserved — the line-joining cleanser skips them.)_
## [COMBINED] Scenario 11: Multiple Cleansers Combined
Tests all rules running simultaneously on a single dirty clipboard block.
### Copy this
```text
"Check this link—it's amazing!"
https://store.com/deal?utm_source=ad&fbclid=999
```
### Expected Output
```text
"Check this link--it's amazing!"
https://store.com/deal
```
_(Whitespace trimmed, quotes/dashes fixed, trackers stripped.)_
## [COMBINED] Scenario 12: Mixed Content Stress Test
Tests all three cleansers on different lines within a single clipboard block.
### Copy this
```text
Here's a message from WhatsApp:
Hey check out this link https://shop.com/sale?utm_source=sms&cid=abc123
The price was $20$50 for the "Premium" plan — totally worth it according
to the review I read yesterday.
```
### Expected Output
```text
Here's a message from WhatsApp:
Hey check out this link https://shop.com/sale?cid=abc123
The price was $20-$50 for the "Premium" plan -- totally worth it according to the review I read yesterday.
```
_(Line break joined, trackers stripped, en-dash → `-`, em-dash → `--`, smart quotes → straight quotes.)_
## [EDGE] Scenario 13: Empty / Whitespace-Only Clipboard
Tests that an empty or whitespace-only clipboard does not cause errors.
### Copy this
```text
```
_(Just whitespace — spaces or tabs.)_
### Expected Output
```text
```
_(Empty string — no crash, no garbage output.)_
## [EDGE] Scenario 14: Non-URL Text Unchanged
Tests that plain text without URLs passes through all cleansers without modification.
### Copy this
```text
The quick brown fox jumps over the lazy dog. Nothing to clean here!
```
### Expected Output
```text
The quick brown fox jumps over the lazy dog. Nothing to clean here!
```
_(No trackers, no smart quotes, no line breaks — text is unchanged.)_
## [UI] Scenario 15: Settings Toggle Each Rule Off
Tests that each cleaning rule can be individually disabled and verified.
### Steps
1. Open Settings -> Rules.
2. Toggle OFF **"Strip tracking parameters"**.
3. Copy a URL with trackers (from Scenario 1).
4. Trigger CleanCopy, paste, and verify trackers are **still present**.
5. Re-enable the toggle and repeat — trackers should now be stripped.
6. Repeat for each rule: Smart Quotes, Line-Break Normalizer.
### Expected
Each rule's toggle independently controls that specific cleaning behavior.
## [UI] Scenario 16: Custom Params Debounce & Save Indicator
Tests that the custom params input debounces correctly and shows save feedback.
### Steps
1. Open Settings -> Rules.
2. Click the custom tracking parameters input.
3. Type `test1`.
4. Observe: a `...` indicator appears while debouncing.
5. Wait ~500ms: a `✓` checkmark appears indicating save.
6. Wait ~1.5s: the `✓` fades out.
7. Type `,test2` (appending to existing text).
8. Repeat the debounce observation.
### Expected
- `...` appears immediately on input change.
- `✓` appears after ~500ms debounce completes.
- `✓` fades out after ~1.5s.
- Input field does not shrink when indicators appear.
## [EDGE] Scenario 17: Ctrl+C Hotkey Uses Clipboard Monitor
Tests that setting the hotkey to `Ctrl+C` switches to the clipboard monitor approach and cleans clipboard changes from any source.
### Steps
1. Open Settings -> General.
2. Start recording a new hotkey.
3. Press `Ctrl+C` to set it as the hotkey.
4. Check the logs for `"Clipboard monitor started."`.
5. Open a text editor and type some text with smart quotes (e.g. `\u201cHello \u2014 World\u201d`).
6. Select the text and press `Ctrl+C`.
7. Paste the clipboard content.
8. Now right-click paste some other text with trackers (e.g. `https://example.com?utm_source=x&id=1`).
9. Paste again and verify trackers are stripped.
10. Change the hotkey back to `Alt+Shift+C`.
11. Check the logs for `"Clipboard monitor shutting down."`.
12. Verify `Alt+Shift+C` works as before.
### Expected
- Log shows `"Clipboard monitor started."` after setting Ctrl+C.
- Ctrl+C copy cleans smart quotes → straight quotes, em-dash → `--`.
- Right-click copy also triggers cleaning (clipboard monitor catches all sources).
- Log shows `"Clipboard monitor shutting down."` after changing away from Ctrl+C.
- Alt+Shift+C works via the global shortcut path as before.
+33
View File
@@ -0,0 +1,33 @@
import eslintReact from "@eslint-react/eslint-plugin";
import eslintJs from "@eslint/js";
import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig(
{ ignores: ["dist/**", "node_modules/**", "src-tauri/**"] },
{
files: ["**/*.{ts,tsx}"],
extends: [
eslintJs.configs.recommended,
...tseslint.configs.recommended,
eslintReact.configs["recommended-typescript"],
],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
prettier,
);
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>CleanCopy Settings</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4880
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "clean-copy-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-global-shortcut": "^2.3.2",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "^5.13.2",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"prettier": "^3.9.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.63.0",
"vite": "^7.0.4",
"vitest": "^4.1.10"
}
}
+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"
]
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}