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"]
|
||||||
|
}
|
||||||