feat(app-backend): build tauri desktop app backend, clipboard listener, and system tray
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
dist-ssr
|
||||
node_modules
|
||||
package-lock.json
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -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/`
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||