Add copy-only, color swatches, DB speed pragmas, and living docs
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 3s
CI / node-ci (pull_request) Successful in 17s

- Copy only (right-click -> Copy): write to clipboard without
  auto-pasting or hiding the window
- Color swatches: hex/rgb/hsl entries show a preview chip
- SQLite WAL + synchronous=NORMAL so the UI can read while the
  clipboard poller writes
- Memoize list rows (React.memo) so selection/focus changes don't
  re-render every row's preview
- Add docs/GUIDE.md (living install + usage doc), docs/SHARING.md,
  docs/TESTING.md, docs/LAUNCH-AT-LOGIN.md, docs/PRODUCT.md,
  CHANGELOG.md, ROADMAP.md
- Add homelab Gitea Actions CI (.gitea/workflows/ci.yml) + gitleaks
  allowlist
- Bump version to 0.2.0
This commit is contained in:
ilia 2026-07-14 21:58:31 -04:00
parent 7bb3b5fc89
commit e9461ed4f3
39 changed files with 3245 additions and 339 deletions

100
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,100 @@
---
# Homelab CI — Node/pages lane (git-ci-01) + secret scan (git-ci-02)
name: CI
on:
push:
branches: [master, main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
skip-ci-check:
runs-on: [homelab, self-hosted, linux]
container:
image: node:20-bookworm
outputs:
should-skip: ${{ steps.check.outputs.skip }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- id: check
run: |
SKIP=0
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-$(git log -1 --pretty=%B 2>/dev/null || true)}"
echo "$BRANCH" "$MSG" | grep -qi '@skipci' && SKIP=1
echo "skip=$SKIP" >> $GITHUB_OUTPUT
node-ci:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux, node]
container:
image: node:20-bookworm
steps:
- uses: actions/checkout@v4
- name: Node / static site CI
run: |
set -e
if [ ! -f package.json ]; then
echo "No package.json — static/HTML repo; skip npm build pipeline"
if ls ./*.html >/dev/null 2>&1 || [ -f index.html ]; then
echo "Found HTML entrypoint(s) — OK for static site"
else
echo "No HTML files at repo root (advisory only)"
fi
exit 0
fi
if [ -f package-lock.json ]; then
if ! npm ci; then
echo "npm ci failed (lock file out of sync?) — falling back to npm install"
rm -rf node_modules
npm install
fi
else
npm install
fi
if [ -f playwright.config.ts ] || [ -f playwright.config.js ] || [ -f playwright.config.mjs ] \
|| grep -q '@playwright/test' package.json 2>/dev/null; then
npx playwright install --with-deps chromium
else
echo "No Playwright — skip browser install"
fi
npm run lint --if-present || echo "Lint failed (advisory — fix in follow-up)"
npm test --if-present || echo "Tests failed (advisory — fix in follow-up)"
export CI=true
export NEXTAUTH_SECRET="${NEXTAUTH_SECRET:-ci-build-placeholder-not-for-production}"
export AUTH_SECRET="${AUTH_SECRET:-$NEXTAUTH_SECRET}"
export NEXTAUTH_URL="${NEXTAUTH_URL:-http://localhost:3000}"
export DATABASE_URL="${DATABASE_URL:-postgresql://ci:ci@127.0.0.1:5432/ci?schema=public}"
npm run build --if-present
npm audit --audit-level=high || true
env:
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
secret-scan:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux, heavy]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
run: |
extra=""
if [ -f .gitleaks.toml ]; then
extra="--config /repo/.gitleaks.toml"
fi
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-banner --redact ${extra}

26
.gitleaks.toml Normal file
View File

@ -0,0 +1,26 @@
# Homelab bootstrap — gitleaks allowlist (tests, examples, placeholders)
#
# IMPORTANT: `useDefault = true` is required — without it gitleaks loads ONLY
# this file (title + allowlist) with ZERO detection rules, so it would never
# flag a real secret.
title = "homelab gitea bootstrap"
[extend]
useDefault = true
[allowlist]
description = "Test fixtures and example configs are not production secrets"
paths = [
'''(?i).*\.test\.(ts|tsx|js|jsx|py)$''',
'''(?i).*\.spec\.(ts|tsx|js|jsx)$''',
'''(?i).*/tests/.*''',
'''(?i).*/__tests__/.*''',
'''(?i).*\.example\.(yml|yaml|env|json|toml)$''',
'''(?i).*vault\.example\.(yml|yaml)$''',
'''(?i).*\.env\.example$''',
]
regexes = [
'''(?i)(invalid|fake|dummy|placeholder|example|changeme|change_me|not-a-real)''',
'''(?i)sk-or-invalid''',
'''(?i)msk-or-invalid''',
]

52
CHANGELOG.md Normal file
View File

@ -0,0 +1,52 @@
# Changelog
All notable changes to maCopy. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/).
## [0.2.0] — 2026-07-14
### Added
- Configurable global hotkey (default `` Ctrl+` ``; migrates existing
users off the old `Cmd+\`` default, which conflicted with macOS
window-cycling)
- Image thumbnails in the picker, with backfill for existing entries
- Sensitive data redaction — passwords/cards/banking previews show
`••••` in the list; the full value is still stored and still pastes
- Paste transforms (trim, case, collapse whitespace, pretty JSON,
single line) via right-click → Transform
- Paste plain / without formatting (`⌥`/`Alt`+click or right-click)
- **Copy only** — right-click → Copy puts content on the clipboard
without auto-pasting or hiding the window
- Color swatches — hex/rgb/hsl entries show a small preview chip
- Launch at login, wired to macOS Login Items
- `docs/GUIDE.md` — living install + usage reference
- `docs/SHARING.md` — where the app lives and how to share a build
- `docs/TESTING.md`, `docs/LAUNCH-AT-LOGIN.md`, `docs/PRODUCT.md`
- Visual walkthrough script (`npm run test:visual`) and headed test
runner (`npm run test:headed`)
### Changed
- Reliable left-click paste (mousedown instead of click, delayed
window-blur hide) — fixes intermittent missed pastes
- Image paste now writes a real clipboard image instead of text
### Performance
- Clipboard polling watches macOS `NSPasteboard` `changeCount` instead
of hashing clipboard contents on a fixed timer — eliminates busy CPU
usage while idle
- Picker list/search IPC returns truncated text previews and image
thumbnails only; full content (including image blobs) is fetched
lazily, only when an item is actually pasted
- Frontend refreshes on `clipboard-changed` / window-focus events plus
a cheap `latest_entry_id` poll, instead of reloading all rows every
second
- SQLite opened with `journal_mode=WAL` + `synchronous=NORMAL` so the
UI can read while the background poller writes
- List rows are memoized (`React.memo`) so selection/focus changes no
longer re-render every row's preview
## [0.1.0] — initial
- Menu bar clipboard manager: capture text/images/files, full-text
search (SQLite FTS5), multi-select, pin, paste-and-refocus via
AppleScript, resizable window, dark/light mode, auto-trim history

View File

@ -9,8 +9,9 @@ A fast, native macOS clipboard manager that lives in your menu bar. Built with T
## Features
- **Menu bar app** — no dock icon, stays out of your way
- **Global hotkey**`Cmd+Shift+V` opens the window from anywhere
- **Clipboard monitoring** — polls every 500ms for text, images, and file paths
- **Global hotkey** — `` Ctrl+` `` opens the window (doesnt steal macOS Cmd+` window-cycle); configurable in Settings
- **Clipboard monitoring** — watches the macOS pasteboard changeCount (no busy hashing while idle); captures text, images, and file paths
- **Fast picker** — list API returns truncated previews only (image blobs stay in SQLite until paste); refreshes on copy / window focus instead of reloading everything every second
- **Full-text search** — instant filtering via SQLite FTS5
- **Quick paste**`Cmd+1` through `Cmd+9` to paste the Nth item directly into the previous app
- **Paste & return** — clicking an entry copies it to clipboard, hides the window, and auto-pastes into the previously-focused app
@ -19,11 +20,27 @@ A fast, native macOS clipboard manager that lives in your menu bar. Built with T
- **Context menu** — right-click for Paste, Pin/Unpin, Delete (with multi-select support)
- **Resizable window** — drag edges to resize; size is remembered between sessions
- **Window positioning** — choose where the window appears: near cursor, center, or any corner (configurable in Settings)
- **Auto-trim** — keeps up to 50K entries (configurable: 1K/5K/10K/50K)
- **Auto-trim** — keeps up to 50K entries (configurable: 1K/5K/10K/50K); oversized images are skipped
- **Dark/light mode** — follows macOS system appearance
- **Privacy** — whitespace-only entries are ignored; pause monitoring from the tray
- **Privacy** — whitespace-only entries are ignored; password/card/banking previews are redacted (full value still pastes); pause monitoring from the tray
- **Paste plain / transforms** — ⌥-click or right-click for plain text and transforms (trim, case, JSON, …)
- **Copy only** — right-click → Copy puts content on the clipboard without auto-pasting or hiding the window
- **Color swatches** — hex/rgb/hsl entries (e.g. `#3366ff`) show a small color chip in the list
- **Launch at login** — Settings toggle registers a macOS Login Item (use the built `.app`)
- **Image thumbnails** — list shows small thumbs; full images stay in the DB until paste
## Prerequisites
## Docs
| Doc | |
|---|---|
| [docs/GUIDE.md](docs/GUIDE.md) | **Install + usage guide** (the living doc — start here if you just want to use the app) |
| [docs/README.md](docs/README.md) | Docs index |
| [docs/TESTING.md](docs/TESTING.md) | Automated tests |
| [docs/LAUNCH-AT-LOGIN.md](docs/LAUNCH-AT-LOGIN.md) | Start when Mac logs in |
| [docs/PRODUCT.md](docs/PRODUCT.md) | Professional / shipping checklist |
| [docs/SHARING.md](docs/SHARING.md) | Where the app lives + how to share it with others |
| [CHANGELOG.md](CHANGELOG.md) | Version history |
| [ROADMAP.md](ROADMAP.md) | Next features + brainstorm |
- **macOS** 10.15+
- **Rust** 1.77+ — [install via rustup](https://rustup.rs)
@ -34,7 +51,7 @@ A fast, native macOS clipboard manager that lives in your menu bar. Built with T
## Quick Start
```bash
git clone gitea@10.0.30.169:ilia/maCopy.git
git clone gitea@git.levkin.ca:ilia/maCopy.git
cd maCopy
npm install
npm run tauri dev
@ -46,8 +63,11 @@ The app will compile the Rust backend, start the Vite dev server, and launch the
| Action | How |
|---|---|
| Open/close window | Click tray icon or press `Cmd+Shift+V` |
| Open/close window | Click tray icon or press `` Ctrl+` `` (configurable in Settings) |
| Paste an entry | Click it, or press `Enter` |
| Paste plain (no formatting) | `⌥`/`Alt`+click, or right-click → Paste plain |
| Copy without pasting | Right-click → Copy |
| Transforms | Right-click → Transform (trim, case, JSON, …) |
| Quick paste | `Cmd+1` through `Cmd+9` |
| Search | Just start typing |
| Select multiple | `Cmd+Click` or `Shift+Arrow` |
@ -55,6 +75,8 @@ The app will compile the Rust backend, start the Vite dev server, and launch the
| Delete | `Backspace` or `Delete` (on selected items) |
| Pin/unpin | Right-click → Pin/Unpin |
| Settings | Tray icon → Settings… |
| Change hotkey | Settings → Global hotkey → press new shortcut |
| Launch at login | Settings → Launch at login (see [docs/LAUNCH-AT-LOGIN.md](docs/LAUNCH-AT-LOGIN.md)) |
| Dismiss | `Escape` or click outside |
## Development
@ -99,8 +121,8 @@ maCopy/
|---|---|
| `npm run tauri dev` | Run in development mode with hot reload |
| `npm run tauri build` | Build a release `.app` bundle |
| `npm test` | Run frontend tests (Vitest, 47 tests) |
| `npm run test:rust` | Run Rust backend tests (27 tests) |
| `npm test` | Run frontend tests (Vitest, ~60 tests) |
| `npm run test:rust` | Run Rust backend tests (~52 tests) |
| `npm run test:all` | Run all tests (frontend + backend) |
| `npm run lint` | TypeScript type-check |
| `npm run check` | Lint + all tests |
@ -121,21 +143,21 @@ maCopy/
## Architecture
### Clipboard Polling
### Clipboard monitoring
A dedicated Rust thread polls the system clipboard every 500ms using the `arboard` crate. Each new clipboard value is hashed (SHA-256) and compared against the last known hash to avoid duplicates. Text, images (stored as base64 PNG data URIs), and file paths are all captured.
A background Rust thread watches the macOS pasteboard **changeCount** and only reads the clipboard when it changes. Text, images (full PNG + thumbnail), and file paths are captured. List IPC returns truncated / redacted previews and image thumbnails — not multiMB blobs.
### Paste & Return
When you select an entry, maCopy writes it to the system clipboard, hides its window, waits 250ms for macOS to refocus the previous app, then simulates `Cmd+V` via AppleScript. This requires Accessibility permission.
When you select an entry, maCopy writes it to the system clipboard (text or image), hides its window, waits briefly for macOS to refocus the previous app, then simulates `Cmd+V` via AppleScript. This requires Accessibility permission. Optional transforms (plain, trim, JSON, …) run before the write.
### SQLite + FTS5
The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type.
The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. Opened with `journal_mode=WAL` + `synchronous=NORMAL` so the UI can read while the background poller writes, without blocking on full fsyncs.
### Window Behavior
The window uses `titleBarStyle: "overlay"` for native resize handles while keeping the frameless aesthetic. It's always-on-top and hides on blur. Position is determined by the user's setting (near cursor via CoreGraphics, center, or a screen corner).
The window uses `titleBarStyle: "overlay"` for native resize handles while keeping the frameless aesthetic. It's always-on-top and hides on blur (with a short delay so clicks register). Position is determined by the user's setting (near cursor via CoreGraphics, center, or a screen corner).
## Data Storage
@ -147,27 +169,12 @@ The SQLite database is stored at:
## Testing
### Frontend (47 tests)
See **[docs/TESTING.md](docs/TESTING.md)** for the full map.
```bash
npm test
```
Tests cover App integration, SearchBar, ClipboardList (including multi-select), ContextMenu, and SettingsPanel. Tauri APIs are mocked in `src/test/setup.ts`.
### Backend (27 tests)
```bash
npm run test:rust
```
Tests use in-memory SQLite databases and cover: CRUD operations, FTS5 search, auto-trim with pin preservation, settings persistence, SHA-256 hashing, and PNG encoding.
### All tests
```bash
npm run test:all # 74 total tests
npm run check # lint + all tests
npm test # ~60 frontend tests
npm run test:rust # ~52 Rust tests
npm run check # lint + all tests (~112)
```
## Building for Release
@ -178,6 +185,13 @@ npm run tauri build
The built `.app` bundle will be in `src-tauri/target/release/bundle/macos/`.
Copy it to `/Applications`, then enable **Launch at login** in Settings — details in [docs/LAUNCH-AT-LOGIN.md](docs/LAUNCH-AT-LOGIN.md).
## Roadmap & product readiness
- Feature backlog: [ROADMAP.md](ROADMAP.md)
- Professional shipping checklist: [docs/PRODUCT.md](docs/PRODUCT.md)
## License
MIT

84
ROADMAP.md Normal file
View File

@ -0,0 +1,84 @@
# maCopy roadmap
Living list of shipping ideas. Checked items are done (or landed in the current branch).
## Done recently
- [x] Global hotkey `` Ctrl+` `` (avoids macOS Cmd+` window-cycle); configurable
- [x] Performance: pasteboard changeCount, list previews, no full-image list payloads
- [x] Image thumbnails in the picker (+ backfill)
- [x] Reliable left-click paste (mousedown + delayed blur hide)
- [x] Image paste via real clipboard image write
- [x] **Sensitive redaction** — save passwords / cards / banking / secrets fully, show `••••` in the list; paste still uses the real value
- [x] **Paste transforms** — right-click → Transform (trim, case, collapse, JSON pretty, single line)
- [x] **Paste plain** — ⌥/Alt+click or “Paste plain” (strip HTML-ish formatting / entities)
- [x] Launch at login wired to macOS Login Items
- [x] Visible `npm run test:visual` walkthrough
- [x] **Copy only** — right-click → Copy puts content on the clipboard without auto-paste/hide
- [x] **Color swatches** — hex/rgb/hsl entries show a preview chip (Ditto/Pastebot-style)
- [x] **SQLite speed pragmas** — WAL + synchronous=NORMAL so reads don't block on writer fsyncs
- [x] **Memoized list rows** — selection/focus changes no longer re-render every row's preview
## Next up (high value)
- [ ] **Exclude apps** — dont capture from 1Password / banking / Terminal patterns (macOS frontmost app filter)
- [ ] **Signed + notarized release** + install to `/Applications` (see [docs/SHARING.md](docs/SHARING.md))
- [ ] **Accessibility first-run guide**
- [ ] **CI** on `npm run check`
- [ ] **Default transform preference** in Settings
- [ ] **Snippets / favorites** — named, always-available text (separate from history)
- [ ] **Ignore patterns** — regex list that auto-drops or auto-redacts matches
- [ ] **Auto-delete window for sensitive** — purge redacted items after N minutes (optional)
- [ ] **Virtualized list** — if history UI grows past a few hundred rows
## Later / brainstorm
### Privacy & trust
- Encrypt DB at rest (macOS Keychain-wrapped key)
- Per-entry “never show again” / blacklist hash
- Paste confirmation for sensitive items (`Confirm paste?`)
- Clear sensitive on lock / sleep
- Dont sync sensitive items if/when sync exists
### Capture quality
- HTML + plain dual capture with “prefer plain” default
- File lists / Finder copy
- Link enrichment (title/favicon for URLs)
- OCR on image entries → searchable text (private, on-device)
### Paste power
- Paste stack / sequential paste (next item on each paste)
- Paste as keystrokes (for fields that block paste)
- Numbered paste queue from multi-select order
- Date/time templates, UUID, lorem in snippets
- Shell-escape / URL-encode / base64 transforms
- “Edit before paste” quick editor sheet
### UX polish
- Compact vs comfortable density
- Pin board / tabs (All · Pins · Images · Sensitive)
- Drag item onto another app
- Keyboard: Tab into transforms, `/` transform palette
- Menu bar: last N items quick-paste submenu
- Sound / haptic optional on paste
### Sync & backup
- Optional Tailscale / local LAN sync between machines
- Export/import encrypted archive
- Time-machine-friendly atomic DB
### Power-user system
- Alfred / Raycast callback URL or CLI (`macopy paste 3 --plain`)
- Shortcuts / URL scheme
- AppleScript / JXA bridge
### Hardening
- Sandbox-friendly Accessibility prompt flow + in-app guide
- Crash-safe write-ahead DB
- Telemetry: none (keep it that way); optional local diagnostics export
## Non-goals (for now)
- Cloud sync through a third-party host
- Browser extension dependency
- AI “summarize my clipboard” calling external APIs by default

155
docs/GUIDE.md Normal file
View File

@ -0,0 +1,155 @@
# maCopy — Install & Usage Guide
The single living reference for **installing** and **using** maCopy.
Update this whenever a shortcut, setting, or behavior changes — this is
the doc a new user (including future-you) should be able to follow start
to finish without reading any source code.
Current version: see [CHANGELOG.md](../CHANGELOG.md). Feature backlog:
[ROADMAP.md](../ROADMAP.md).
---
## 1. Install
### Option A — you already have a built DMG/app
1. Double-click the DMG, drag **maCopy.app** into **Applications**.
2. First launch: Gatekeeper will likely warn the app is unverified
(it's ad-hoc signed, not notarized yet — see
[docs/SHARING.md](SHARING.md)). Right-click the app → **Open** → confirm.
3. macOS will prompt for **Accessibility** permission the first time you
try to paste — this is required for auto-paste (`Cmd+V` simulation).
System Settings → Privacy & Security → Accessibility → enable maCopy.
4. Look for the maCopy icon in the menu bar (top-right). No Dock icon —
that's expected, it's a menu-bar-only app.
### Option B — build it yourself
Requirements: macOS 10.15+, [Rust](https://rustup.rs) 1.77+, Node 18+,
Xcode Command Line Tools (`xcode-select --install`).
```bash
git clone gitea@git.levkin.ca:ilia/maCopy.git
cd maCopy
npm install
npm run tauri build
open src-tauri/target/release/bundle/macos/maCopy.app
```
Copy the resulting `.app` to `/Applications` if you want **Launch at
login** to stick across rebuilds (see step 4 below).
### First-run checklist
- [ ] App icon visible in the menu bar
- [ ] Accessibility permission granted (Settings → Privacy & Security → Accessibility)
- [ ] Global hotkey `` Ctrl+` `` opens/closes the picker
- [ ] (Optional) Settings → **Launch at login** turned on
---
## 2. Everyday usage
### Opening the picker
| Action | How |
|---|---|
| Open/close | Click the menu bar icon, or press `` Ctrl+` `` |
| Dismiss | `Escape`, or click outside the window |
The hotkey is configurable — Settings → **Global hotkey** → click the
field → press your new combo.
### Pasting
| Action | How |
|---|---|
| Paste an item into the app you were just using | Click it, or select + `Enter` |
| Paste **plain** (strip formatting) | `⌥`/`Alt` + click, or right-click → **Paste plain** |
| Paste with a transform | Right-click → **Transform** → pick one |
| Paste the Nth item directly (no picker needed) | `Cmd+1``Cmd+9` while picker is open |
| Copy to clipboard **without** auto-pasting | Right-click → **Copy** |
**Transforms** (right-click → Transform): trim whitespace, UPPERCASE /
lowercase / Title Case, collapse whitespace/newlines, pretty-print JSON,
join to a single line. Transforms apply just before the paste — the
saved history entry is never modified.
### Selecting & managing history
| Action | How |
|---|---|
| Multi-select | `Cmd+Click` to toggle one, `Shift+Click`/`Shift+Arrow` for a range, `Cmd+A` for all |
| Paste multiple selected items (joined with newlines) | `Enter` |
| Delete | `Backspace`/`Delete` with items selected |
| Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** |
| Search | Just start typing — instant full-text search |
### What you'll see in the list
- **Images** show a small thumbnail; the full-resolution image stays in
the database and is only loaded when you actually paste.
- **Colors** — an entry that's *exactly* a hex/rgb/hsl value (e.g.
`#3366ff`) shows a small color chip next to it.
- **Sensitive items** (passwords, card numbers, banking details) show
`••` and a redacted preview in the list — the real value is still
stored and still pastes correctly. See "Privacy" below.
- **PIN** badge — pinned items, always sorted to the top, never
auto-trimmed.
- `⌘1``⌘9` badges on the first nine rows — quick-paste reference.
### Settings panel
Menu bar icon → **Settings…**
| Setting | What it does |
|---|---|
| Global hotkey | Re-bind the open/close shortcut |
| Show images | Toggle thumbnail rendering in the list (off = faster on huge histories) |
| Max history | 1K / 5K / 10K / 50K entries kept before auto-trim (pinned items are exempt) |
| Window position | Near cursor, center, or a screen corner |
| Launch at login | Registers/unregisters a macOS Login Item — see [docs/LAUNCH-AT-LOGIN.md](LAUNCH-AT-LOGIN.md) |
| Pause monitoring | Tray menu checkbox — temporarily stop capturing new clipboard content |
---
## 3. Privacy — what gets captured and how
- Whitespace-only clipboard content is ignored (never saved).
- Password/card/banking-looking text is detected and **redacted in the
list preview only** — the real value is still in the database (needed
so paste still works) and still pastes correctly.
- No network calls, no telemetry, no accounts. Everything lives in:
```
~/Library/Application Support/maCopy/clipboard.db
```
- You can pause monitoring any time from the tray menu.
- Full picture, including what's *not yet* implemented (encryption at
rest, per-app exclusions): [ROADMAP.md](../ROADMAP.md) → "Privacy & trust".
---
## 4. Troubleshooting
| Symptom | Fix |
|---|---|
| Hotkey does nothing | Settings → check it isn't a duplicate of a macOS shortcut (the old default `Cmd+\`` conflicted with window-cycling — current default is `` Ctrl+` ``) |
| Paste doesn't happen (item copies but old app doesn't receive it) | Grant **Accessibility** permission — System Settings → Privacy & Security → Accessibility → add/enable maCopy |
| App won't open ("cannot verify developer") | Right-click → Open, or `xattr -cr /Applications/maCopy.app` — see [docs/SHARING.md](SHARING.md) |
| Launch at login doesn't stick after rebuild | You're running a dev/rebuilt binary at a new path — install the `.app` under `/Applications` and re-toggle the setting |
| App feels slow / using lots of memory | Update to the latest build — see the performance notes in [README.md](../README.md#architecture); file it as a regression if it persists |
---
## 5. Where things live (quick reference)
| What | Path |
|---|---|
| App (after build) | `src-tauri/target/release/bundle/macos/maCopy.app` |
| Installer DMG | `src-tauri/target/release/bundle/dmg/maCopy_<version>_aarch64.dmg` |
| Your clipboard database | `~/Library/Application Support/maCopy/clipboard.db` |
| Source | this repo |
Sharing the app with someone else: [docs/SHARING.md](SHARING.md).
Full doc index: [docs/README.md](README.md).

45
docs/LAUNCH-AT-LOGIN.md Normal file
View File

@ -0,0 +1,45 @@
# Launch at login (start when Mac loads)
maCopy can register itself as a macOS **Login Item** so it starts when you log in.
## In the app (preferred)
1. Build and install a real app bundle (dev mode often cannot register Login Items reliably):
```bash
npm run tauri build
open src-tauri/target/release/bundle/macos/maCopy.app
```
2. Menu bar tray → **Settings…** → turn on **Launch at login**.
3. Confirm in System Settings → **General****Login Items & Extensions** → maCopy is listed.
Turning the toggle off removes the Login Item.
## How it works
- Settings UI calls `@tauri-apps/plugin-autostart` (`enable` / `disable` / `isEnabled`).
- Preference is also stored in SQLite (`launch_at_login`) for display consistency.
- Backend plugin is initialized with `MacosLauncher::LaunchAgent`.
## Manual fallback
If the toggle fails:
1. System Settings → General → Login Items → **+** → choose `maCopy.app`
2. Or: right-click maCopy in the Dock (while open) → Options → **Open at Login**
## Requirements for it to stick
| Need | Why |
|---|---|
| Installed `.app` under `/Applications` (or a stable path) | Login Items point at a path; rebuilds under `target/` get orphaned |
| Quit `tauri dev` when testing login | Dont mix debug binary paths with the release app |
| Accessibility still granted after update | Auto-paste needs Privacy → Accessibility → maCopy |
## Verify after reboot
1. Restart Mac / log out and in
2. Copy some text
3. Press `` Ctrl+` `` — picker should appear without launching manually

75
docs/PRODUCT.md Normal file
View File

@ -0,0 +1,75 @@
# Making maCopy a professional product
Checklist beyond “it works on my machine.” Items marked done are already in-repo.
## Already in good shape
- [x] Native menu-bar UX (Accessory activation, tray)
- [x] Configurable global hotkey
- [x] Automated unit tests + `npm run check`
- [x] Performance-minded clipboard monitoring + list payloads
- [x] Image thumbnails; sensitive redaction (preview)
- [x] Paste plain + transforms
- [x] Living docs: README, ROADMAP, TESTING, Launch-at-login
- [x] Launch at login wired to macOS Login Items (release `.app`)
## Ship blockers (do these for v1)
1. **Installable release path**
- Copy `maCopy.app``/Applications`
- Document version / changelog (`CHANGELOG.md`)
2. **Code signing + notarization**
- Apple Developer ID Application certificate
- `codesign` + `notarytool` (or Tauri bundler config)
- Without this: Gatekeeper blocks random users
3. **First-run permissions guide**
- In-app sheet: Accessibility for paste, optional screen recording later
- Deep link / button to open System Settings pane
4. **Stable autostart**
- Only advertise Launch at login after install to `/Applications`
- Re-register Login Item after updates that change the bundle path
5. **CI**
- GitHub/Gitea Action: `npm run check` on every PR
- Optional macOS runner for `tauri build` artifacts
## Product polish (v1.x)
| Area | Idea |
|---|---|
| Updates | Sparkle or Tauri updater + signed feeds |
| Branding | Final icon, dmg background, website one-pager |
| Privacy | Short privacy policy (local-only DB; no telemetry) |
| Support | Issue templates, FAQ (hotkey conflict with cycle-windows) |
| Onboarding | Empty-state tips; `` Ctrl+` `` callout |
| Crash reports | Optional local-only panic log export |
| Quality | E2E smoke script (open → copy → paste) on macOS CI |
## Trust & privacy (especially with redaction)
- Be explicit: **sensitive items are stored** (encrypted-at-rest still TODO) — redaction is UI-only
- Offer “purge sensitive after N minutes” (on roadmap)
- Exclude password-manager apps from capture (on roadmap)
## Distribution options
| Channel | Notes |
|---|---|
| Direct DMG / zip | Simple; needs notarization |
| Homebrew cask | Great for power users once signed |
| Mac App Store | Heavier review; clipboard monitors are tricky under sandbox |
| Private TestFlight-style | Not really for menu-bar utilities; skip |
## Suggested “done for professional v1”
- [ ] Signed + notarized `.app` / DMG
- [ ] Installed under `/Applications` + Launch at login verified after reboot
- [ ] CI green on `npm run check`
- [ ] Accessibility onboarding UI
- [ ] CHANGELOG + version bump process
- [ ] Privacy blurb in README + Settings
Everything else can ride the [ROADMAP](../ROADMAP.md).

18
docs/README.md Normal file
View File

@ -0,0 +1,18 @@
# maCopy docs index
Living product docs for shipping and maintaining maCopy.
| Doc | Purpose |
|---|---|
| [docs/GUIDE.md](./GUIDE.md) | **Start here** — install + full usage reference (living doc) |
| [README.md](../README.md) | Dev-oriented: architecture, project structure, how to run tests |
| [CHANGELOG.md](../CHANGELOG.md) | What shipped in each version |
| [ROADMAP.md](../ROADMAP.md) | Shipped vs next vs brainstorm |
| [docs/TESTING.md](./TESTING.md) | Test stack, commands, coverage map |
| [docs/LAUNCH-AT-LOGIN.md](./LAUNCH-AT-LOGIN.md) | Start on Mac login |
| [docs/SHARING.md](./SHARING.md) | Where the app lives + how to share a build |
| [docs/PRODUCT.md](./PRODUCT.md) | What “professional product” still needs |
Keep these current when behavior changes — **`docs/GUIDE.md` is the
living doc for end users**, README + ROADMAP are the living docs for
development.

90
docs/SHARING.md Normal file
View File

@ -0,0 +1,90 @@
# Sharing maCopy with someone else
## Where the app lives on your Mac
| What | Path |
|---|---|
| Source code | `~/Documents/code/maCopy` (this repo) |
| Built `.app` (after `npm run tauri build`) | `src-tauri/target/release/bundle/macos/maCopy.app` |
| Built `.dmg` installer | `src-tauri/target/release/bundle/dmg/maCopy_<version>_aarch64.dmg` |
| Clipboard database (your data, never shipped) | `~/Library/Application Support/maCopy/clipboard.db` |
| Vault/secrets | none — maCopy has no network calls and no accounts |
The `.app` and `.dmg` are build **artifacts**: they're regenerated by
`npm run tauri build` and are git-ignored. Nothing under `target/` is
committed to the repo.
## Fastest way to share it today (unsigned)
1. Build it: `npm run tauri build`
2. Grab the DMG: `src-tauri/target/release/bundle/dmg/maCopy_0.1.0_aarch64.dmg`
3. Send that file (AirDrop, USB, file share — whatever).
### What the other person needs to do
The build is **ad-hoc signed** (`codesign -dv` shows `Signature=adhoc`,
`TeamIdentifier=not set`) — not signed with an Apple Developer ID and not
notarized. macOS Gatekeeper will refuse to open it with the default
"Apple could not verify… malware" dialog on a *different* Mac (your own
Mac trusts it already because you built it there). This is normal for any
indie app that isn't notarized — it's not a maCopy-specific bug.
The recipient has two options:
**Option A — right-click override (easiest, no Terminal)**
1. Copy `maCopy.app` to `/Applications`.
2. Right-click (or Control-click) the app → **Open** → confirm **Open** in
the dialog. This works even when double-click is blocked, and only
needs to be done once.
**Option B — clear the quarantine flag (Terminal)**
```bash
xattr -cr /Applications/maCopy.app
```
Removes the "downloaded from the internet" quarantine attribute that
triggers Gatekeeper's warning, then the app opens normally.
Either way, once it's launched once and allowed, it behaves like any
other menu-bar app — including **Launch at login** in Settings.
## The "real" way — signed + notarized (for wider distribution)
If you want to share this with people who shouldn't have to right-click
or run Terminal commands (e.g. distributing beyond just you/family), you
need:
1. An Apple Developer ID ($99/yr Apple Developer Program).
2. A "Developer ID Application" certificate in your keychain.
3. Tauri's bundler config filled in (`tauri.conf.json``bundle.macOS.signingIdentity`)
or a manual `codesign --deep --sign "Developer ID Application: ..."` step.
4. Notarization via `xcrun notarytool submit ... --wait` (or `tauri-plugin-updater`'s
signing helpers), then `xcrun stapler staple maCopy.app`.
This is tracked as a ship-blocker in [docs/PRODUCT.md](PRODUCT.md) — not
done yet because it requires an Apple Developer account, which is a cost/
identity decision for you to make, not something that can be automated
away.
## Other distribution ideas (once signed)
| Channel | Effort | Notes |
|---|---|---|
| Direct DMG (current) | none | Works today; Gatekeeper friction as above |
| Private Gitea release / file share | low | Attach the DMG to a Gitea release on your own instance |
| Homebrew cask (`brew install --cask`) | medium | Needs a signed release + a cask formula in a tap |
| Mac App Store | high | Sandbox rules make background clipboard monitoring + AppleScript paste tricky; likely not worth it for this app |
## Sharing the source instead of a binary
If the other person can build it themselves (has Rust + Node), just give
them repo access:
```bash
git clone gitea@git.levkin.ca:ilia/maCopy.git
cd maCopy
npm install
npm run tauri build
```
No signing needed for this path since it's *their* build on *their* Mac —
same Gatekeeper trust maCopy already has on yours.

56
docs/TESTING.md Normal file
View File

@ -0,0 +1,56 @@
# Testing
maCopy has an automated suite. There is no separate “live” Storybook/UI docs site yet — product behavior is documented in README / ROADMAP, and verified by tests.
## Commands
| Command | What it runs |
|---|---|
| `npm test` | Frontend (Vitest + Testing Library, jsdom — CI default) |
| `npm run test:ui` | Same tests in the Vitest UI (open in your browser) |
| `npm run test:headed` | Unit tests in Chromium — still mostly a flash (DOM mount/unmount) |
| `npm run test:visual` | **Watchable** headed Playwright walkthrough of the real UI (`visual.html`) |
| `npm run test:watch` | Frontend in watch mode |
| `npm run test:rust` | Rust unit tests (`cargo test`) |
| `npm run test:all` | Frontend + Rust |
| `npm run lint` | TypeScript `tsc --noEmit` |
| `npm run check` | Lint + all tests (CI-style gate) |
Current counts (approx.): **~60 frontend** + **~52 Rust** unit tests.
## What is covered
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
- App: load entries, paste invoke, delete via keyboard
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs, Alt=plain paste
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
- SearchBar basics
- Transforms: plain text strip, JSON pretty, collapse / oneline
Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard, autostart).
### Backend (`src-tauri/src/**` `#[cfg(test)]`)
- SQLite CRUD, FTS5, trim + pin preservation, settings
- List previews: truncate text, omit image blobs, return thumbnails
- Sensitive redaction (card / password / banking) while `get_entry` stays full
- Hotkey parser (`cmd+\`` etc.)
- Image thumbnail / PNG helpers
- Clipboard content hashing fingerprints
## What is *not* covered (yet)
- End-to-end UI on a real Mac (no Playwright against the Tauri window)
- Accessibility permission / AppleScript paste in CI
- Notarized release smoke tests
- Performance benchmarks in CI
## Recommended workflow
```bash
npm run check # before every PR / commit
npm run tauri dev # manual smoke: hotkey, paste, redaction, thumbs
npm run tauri build # release .app when testing Login Items / signing
```

304
package-lock.json generated
View File

@ -24,7 +24,10 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/ui": "^4.1.10",
"jsdom": "^29.1.1",
"playwright": "^1.61.1",
"tailwindcss": "^4",
"typescript": "^5.5.3",
"vite": "^6",
@ -381,6 +384,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -1044,6 +1054,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -2217,17 +2234,64 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/browser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@blazediff/core": "1.9.1",
"@vitest/mocker": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.1.0",
"ws": "^8.19.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "4.1.10",
"@vitest/mocker": "4.1.10",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.1.10"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -2236,13 +2300,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.6",
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2263,9 +2327,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2276,13 +2340,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.6",
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
@ -2290,14 +2354,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2306,23 +2370,45 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"node_modules/@vitest/ui": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
"integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.10",
"fflate": "^0.8.2",
"flatted": "^3.4.2",
"pathe": "^2.0.3",
"sirv": "^3.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -2696,6 +2782,20 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3184,6 +3284,16 @@
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3268,6 +3378,63 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@ -3474,6 +3641,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3603,6 +3785,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
@ -3760,19 +3952,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -3800,12 +3992,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@ -3914,6 +4106,28 @@
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "macopy",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,6 +10,9 @@
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui --open --watch",
"test:headed": "vitest --config vitest.browser.config.ts --watch=false --browser.headless=false",
"test:visual": "node scripts/visual-walkthrough.mjs",
"test:rust": "cd src-tauri && cargo test",
"test:all": "npm run test && npm run test:rust",
"lint": "tsc --noEmit",
@ -32,7 +35,10 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/ui": "^4.1.10",
"jsdom": "^29.1.1",
"playwright": "^1.61.1",
"tailwindcss": "^4",
"typescript": "^5.5.3",
"vite": "^6",

View File

@ -0,0 +1,102 @@
/**
* Headed visual walkthrough opens Chromium (headless: false) so you can watch.
*
* npm run test:visual
*/
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
const PORT = 1420;
const BASE = `http://127.0.0.1:${PORT}/visual.html`;
async function portOpen(port) {
try {
const res = await fetch(`http://127.0.0.1:${port}/visual.html`);
return res.status < 500;
} catch {
return false;
}
}
async function ensureVite() {
if (await portOpen(PORT)) return null;
const child = spawn("npm", ["run", "dev", "--", "--host", "127.0.0.1"], {
cwd: process.cwd(),
stdio: "ignore",
detached: true,
});
for (let i = 0; i < 40; i++) {
await sleep(250);
if (await portOpen(PORT)) return child;
}
throw new Error("Vite did not start on :1420");
}
async function main() {
const vite = await ensureVite();
console.log("Opening headed Chromium — watch the window…\n");
const browser = await chromium.launch({
headless: false,
slowMo: 400,
});
const page = await browser.newPage({ viewport: { width: 480, height: 640 } });
await page.goto(BASE, { waitUntil: "networkidle" });
// 1) Show banner
await page.waitForSelector('[data-testid="banner"]');
await sleep(800);
// 2) Type in search
const input = page.getByPlaceholder("Search…");
await input.click();
await input.fill("Meeting");
await sleep(900);
await input.fill("");
await sleep(500);
// 3) Hover / focus list rows
await page.getByText("Meeting notes").hover();
await sleep(600);
// 4) Open context menu + Paste plain
await page.getByTestId("open-menu").click();
await sleep(700);
await page.getByText("Paste plain").click();
await sleep(1000);
// 5) Click redacted password row (pastes full secret into banner)
await page.getByText("(password)").click({ modifiers: [] });
await sleep(1200);
// 6) Alt-click card row for plain
await page.getByText("(card)").click({ modifiers: ["Alt"] });
await sleep(1200);
// 7) Show transform menu → Pretty JSON not relevant; show uppercase on meeting
await page.getByText("Meeting notes").click({ button: "right" });
await sleep(600);
await page.getByText("UPPERCASE").click();
await sleep(1500);
const banner = await page.getByTestId("banner").innerText();
console.log("Final banner:", banner);
console.log("\nLeaving the window open for 5s so you can look…");
await sleep(5000);
await browser.close();
if (vite) {
try {
process.kill(-vite.pid);
} catch {
/* ignore */
}
}
console.log("Visual walkthrough finished.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

26
src-tauri/Cargo.lock generated
View File

@ -1965,7 +1965,11 @@ dependencies = [
"dirs 5.0.1",
"hex",
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"png 0.17.16",
"regex",
"rusqlite",
"serde",
"serde_json",
@ -2156,10 +2160,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@ -2179,6 +2190,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@ -2239,6 +2251,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@ -2262,6 +2287,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]

View File

@ -1,6 +1,6 @@
[package]
name = "macopy"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[lib]
@ -26,10 +26,14 @@ base64 = "0.22"
log = "0.4"
png = "0.17"
dirs = "5"
regex = "1"
tokio = { version = "1", features = ["time", "macros", "process"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.24"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
[features]
custom-protocol = ["tauri/custom-protocol"]

View File

@ -1,10 +1,17 @@
use crate::db::Database;
use crate::image_util::{encode_rgba_to_png, make_thumbnail_data_uri};
use arboard::Clipboard;
use base64::Engine;
use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{AppHandle, Emitter};
/// Skip encoding RGBA buffers larger than this (keeps history DB usable).
const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
/// Cap stored full PNG size; list uses a separate tiny thumbnail.
const MAX_STORED_PNG_BYTES: usize = 1_500_000;
/// Hash arbitrary bytes for deduplication.
pub fn hash_content(data: &[u8]) -> String {
@ -13,15 +20,43 @@ pub fn hash_content(data: &[u8]) -> String {
hex::encode(hasher.finalize())
}
/// Cheap image fingerprint: dimensions + length + edge samples (not full-buffer SHA).
fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update((width as u64).to_le_bytes());
hasher.update((height as u64).to_le_bytes());
hasher.update((bytes.len() as u64).to_le_bytes());
let n = bytes.len();
let head = n.min(4096);
hasher.update(&bytes[..head]);
if n > 8192 {
hasher.update(&bytes[n - 4096..]);
} else if n > head {
hasher.update(&bytes[head..]);
}
hex::encode(hasher.finalize())
}
/// Shared state so paste_and_refocus can update the hash the polling
/// thread compares against, preventing re-insertion of pasted content.
pub type LastHash = Arc<Mutex<String>>;
/// Spawns a background thread that polls the system clipboard every 500ms.
/// When new content is detected (by comparing SHA-256 hashes), it is persisted
/// to SQLite. The `paused` flag lets the user freeze monitoring from the tray.
#[cfg(target_os = "macos")]
fn pasteboard_change_count() -> Option<isize> {
use objc2_app_kit::NSPasteboard;
let pb = NSPasteboard::generalPasteboard();
Some(pb.changeCount())
}
#[cfg(not(target_os = "macos"))]
fn pasteboard_change_count() -> Option<isize> {
None
}
/// Spawns a background thread that polls the system clipboard.
/// Uses the macOS pasteboard changeCount to avoid reading/hashing while idle.
/// Returns the shared `LastHash` so other commands can update it.
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
pub fn start_polling(app: AppHandle, db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
let initial = db.latest_hash().ok().flatten().unwrap_or_default();
let last_hash: LastHash = Arc::new(Mutex::new(initial));
let hash_for_thread = last_hash.clone();
@ -35,17 +70,28 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
}
};
let mut last_change: Option<isize> = None;
loop {
std::thread::sleep(Duration::from_millis(500));
std::thread::sleep(Duration::from_millis(750));
if paused.load(Ordering::Relaxed) {
continue;
}
// Skip all clipboard I/O when macOS reports no pasteboard change.
if let Some(count) = pasteboard_change_count() {
if last_change == Some(count) {
continue;
}
last_change = Some(count);
}
let current_hash = hash_for_thread.lock().unwrap().clone();
if let Ok(text) = clipboard.get_text() {
if !text.trim().is_empty() {
// Prefer text. Only probe image when text is absent.
match clipboard.get_text() {
Ok(text) if !text.trim().is_empty() => {
let h = hash_content(text.as_bytes());
if h != current_hash {
*hash_for_thread.lock().unwrap() = h.clone();
@ -59,28 +105,67 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
"text"
};
if let Err(e) = db.insert_entry(&text, content_type, &h) {
if let Err(e) = db.insert_entry(&text, content_type, &h, None) {
log::error!("DB insert error: {}", e);
} else {
let _ = app.emit("clipboard-changed", ());
}
trim_if_needed(&db);
}
}
} else if let Ok(img) = clipboard.get_image() {
let h = hash_content(&img.bytes);
if h != current_hash {
*hash_for_thread.lock().unwrap() = h.clone();
if let Ok(png_data) = encode_rgba_to_png(
img.width as u32,
img.height as u32,
&img.bytes,
) {
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
let data_uri = format!("data:image/png;base64,{}", b64);
if let Err(e) = db.insert_entry(&data_uri, "image", &h) {
log::error!("DB insert error (image): {}", e);
_ => {
if let Ok(img) = clipboard.get_image() {
if img.bytes.len() > MAX_IMAGE_BYTES {
log::warn!(
"Skipping oversized clipboard image ({} bytes)",
img.bytes.len()
);
continue;
}
let h = fingerprint_image(img.width, img.height, &img.bytes);
if h == current_hash {
continue;
}
*hash_for_thread.lock().unwrap() = h.clone();
let w = img.width as u32;
let hgt = img.height as u32;
let thumb = make_thumbnail_data_uri(w, hgt, &img.bytes).ok();
match encode_rgba_to_png(w, hgt, &img.bytes) {
Ok(png_data) if png_data.len() <= MAX_STORED_PNG_BYTES => {
let b64 =
base64::engine::general_purpose::STANDARD.encode(&png_data);
let data_uri = format!("data:image/png;base64,{}", b64);
if let Err(e) =
db.insert_entry(&data_uri, "image", &h, thumb.as_deref())
{
log::error!("DB insert error (image): {}", e);
} else {
let _ = app.emit("clipboard-changed", ());
}
trim_if_needed(&db);
}
Ok(png_data) => {
// Still store a thumbnail-only entry so the list can show something.
if let Some(ref t) = thumb {
let stub = format!("[image {}x{}]", w, hgt);
if db
.insert_entry(&stub, "image", &h, Some(t))
.is_ok()
{
let _ = app.emit("clipboard-changed", ());
trim_if_needed(&db);
}
}
log::warn!(
"Stored thumbnail only for large PNG ({} bytes encoded)",
png_data.len()
);
}
Err(e) => log::error!("PNG encode failed: {}", e),
}
trim_if_needed(&db);
}
}
}
@ -90,18 +175,6 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
last_hash
}
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(rgba)?;
}
Ok(buf)
}
fn trim_if_needed(db: &Database) {
if let Ok(settings) = db.get_settings() {
let _ = db.trim_entries(settings.max_history);
@ -133,11 +206,28 @@ mod tests {
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn fingerprint_image_stable_for_same_buffer() {
let bytes = vec![1u8; 20_000];
assert_eq!(
fingerprint_image(100, 50, &bytes),
fingerprint_image(100, 50, &bytes)
);
}
#[test]
fn fingerprint_image_changes_with_dimensions() {
let bytes = vec![1u8; 20_000];
assert_ne!(
fingerprint_image(100, 50, &bytes),
fingerprint_image(101, 50, &bytes)
);
}
#[test]
fn encode_rgba_to_png_produces_valid_png() {
let rgba = vec![
255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
];
let result = encode_rgba_to_png(2, 2, &rgba);
assert!(result.is_ok());

View File

@ -1,4 +1,4 @@
use crate::clipboard::{hash_content, LastHash};
use crate::clipboard::LastHash;
use crate::db::{ClipboardEntry, Database, Settings};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@ -10,7 +10,7 @@ pub struct LastHashState(pub LastHash);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
db.0.get_entries(limit.unwrap_or(10000))
db.0.get_entries(limit.unwrap_or(crate::db::DISPLAY_LIMIT))
.map_err(|e| e.to_string())
}
@ -19,10 +19,22 @@ pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>)
if query.trim().is_empty() {
return get_entries(db, limit);
}
db.0.search_entries(&query, limit.unwrap_or(10000))
db.0.search_entries(&query, limit.unwrap_or(crate::db::DISPLAY_LIMIT))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_entry(db: State<'_, DbState>, id: i64) -> Result<ClipboardEntry, String> {
db.0.get_entry(id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("entry {} not found", id))
}
#[tauri::command]
pub fn latest_entry_id(db: State<'_, DbState>) -> Result<Option<i64>, String> {
db.0.latest_entry_id().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_entry(db: State<'_, DbState>, id: i64) -> Result<(), String> {
db.0.delete_entry(id).map_err(|e| e.to_string())
@ -66,6 +78,85 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu
.map_err(|e| e.to_string())
}
/// Validate, persist, and re-register the global toggle hotkey.
/// On registration failure the previous hotkey is restored.
#[tauri::command]
pub fn set_hotkey(
app: tauri::AppHandle,
db: State<'_, DbState>,
hotkey: String,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
let new_shortcut = crate::parse_hotkey(&hotkey)
.ok_or_else(|| format!("Invalid hotkey: {}", hotkey))?;
let old = db.0.get_settings().map_err(|e| e.to_string())?.hotkey;
let gs = app.global_shortcut();
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
let _ = gs.unregister(old_shortcut);
}
if let Err(e) = gs.register(new_shortcut) {
// Roll back so the app keeps a working hotkey
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
let _ = gs.register(old_shortcut);
}
return Err(format!("Could not register '{}': {}", hotkey, e));
}
db.0.set_setting("hotkey", &hotkey).map_err(|e| e.to_string())
}
/// Write content to the system clipboard and update the shared hash so the
/// polling thread doesn't re-insert it. Shared by copy-only and paste-and-refocus.
fn write_clipboard(
last_hash: &LastHash,
content: &str,
kind: &str,
) -> Result<(), String> {
{
let h = crate::clipboard::hash_content(content.as_bytes());
*last_hash.lock().unwrap() = h;
}
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("clipboard open failed: {}", e))?;
if kind == "image" && content.starts_with("data:image/") {
let (w, h, rgba) = crate::image_util::data_uri_to_rgba(content)?;
let img = crate::image_util::to_arboard_image(w, h, rgba);
clipboard
.set_image(img)
.map_err(|e| format!("clipboard image write failed: {}", e))?;
} else {
clipboard
.set_text(content)
.map_err(|e| format!("clipboard write failed: {}", e))?;
}
Ok(())
}
/// Copy content to the clipboard without pasting or hiding the window —
/// Ditto/Pastebot-style "copy only" for building up a paste elsewhere.
#[tauri::command]
pub fn copy_to_clipboard(
last_hash: State<'_, LastHashState>,
content: String,
content_type: Option<String>,
) -> Result<(), String> {
let kind = content_type.unwrap_or_else(|| {
if content.starts_with("data:image/") {
"image".into()
} else {
"text".into()
}
});
write_clipboard(&last_hash.0, &content, &kind)
}
/// Write content to the system clipboard, update the shared hash so the
/// polling thread doesn't re-insert it, hide the window, then simulate
/// Cmd+V via AppleScript to paste into the previously-focused app.
@ -74,26 +165,23 @@ pub async fn paste_and_refocus(
app: tauri::AppHandle,
last_hash: State<'_, LastHashState>,
content: String,
content_type: Option<String>,
) -> Result<(), String> {
// Write to clipboard and update the shared hash BEFORE hiding,
// so the polling thread never sees a "new" entry.
{
let h = hash_content(content.as_bytes());
*last_hash.0.lock().unwrap() = h;
}
let kind = content_type.unwrap_or_else(|| {
if content.starts_with("data:image/") {
"image".into()
} else {
"text".into()
}
});
// arboard must be created on the current thread
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("clipboard open failed: {}", e))?;
clipboard
.set_text(&content)
.map_err(|e| format!("clipboard write failed: {}", e))?;
write_clipboard(&last_hash.0, &content, &kind)?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
#[cfg(target_os = "macos")]
{
@ -102,7 +190,7 @@ tell application "System Events"
set frontApp to name of first application process whose frontmost is true
end tell
tell application frontApp to activate
delay 0.1
delay 0.08
tell application "System Events"
keystroke "v" using command down
end tell

View File

@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
/// Max chars sent to the UI for text previews. Full content stays in SQLite.
pub const PREVIEW_MAX_CHARS: usize = 200;
/// Soft cap for how many rows a list/search IPC response returns.
pub const DISPLAY_LIMIT: i64 = 150;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClipboardEntry {
pub id: i64,
@ -11,6 +17,12 @@ pub struct ClipboardEntry {
pub created_at: String,
pub pinned: bool,
pub content_hash: String,
/// True when `content` is a preview stub (text truncated or image blob omitted).
#[serde(default)]
pub truncated: bool,
/// True when list preview is redacted (password / card / banking); paste still uses full value.
#[serde(default)]
pub sensitive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -21,8 +33,13 @@ pub struct Settings {
pub window_position: String,
pub window_width: i64,
pub window_height: i64,
pub hotkey: String,
}
pub const DEFAULT_HOTKEY: &str = "ctrl+`";
/// Previous default — conflicts with macOS “cycle windows of front app”.
pub const LEGACY_DEFAULT_HOTKEY: &str = "cmd+`";
impl Default for Settings {
fn default() -> Self {
Self {
@ -32,6 +49,7 @@ impl Default for Settings {
window_position: "cursor".to_string(),
window_width: 420,
window_height: 560,
hotkey: DEFAULT_HOTKEY.to_string(),
}
}
}
@ -48,6 +66,7 @@ impl Database {
}
let conn = Connection::open(&db_path)?;
Self::apply_speed_pragmas(&conn);
let db = Self {
conn: Mutex::new(conn),
};
@ -55,6 +74,17 @@ impl Database {
Ok(db)
}
/// WAL lets the UI read while the polling thread writes; NORMAL sync is
/// safe for local app data and much faster than the FULL default.
fn apply_speed_pragmas(conn: &Connection) {
let _ = conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 67108864;",
);
}
/// In-memory database for unit tests.
#[cfg(test)]
pub fn in_memory() -> SqlResult<Self> {
@ -75,13 +105,14 @@ impl Database {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard_entries (
" CREATE TABLE IF NOT EXISTS clipboard_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'text',
created_at TEXT NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL
content_hash TEXT NOT NULL,
thumbnail TEXT
);
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
@ -114,6 +145,12 @@ impl Database {
);",
)?;
// Migrate existing DBs that predate the thumbnail column.
let _ = conn.execute(
"ALTER TABLE clipboard_entries ADD COLUMN thumbnail TEXT",
[],
);
// Seed default settings if absent
let defaults = Settings::default();
conn.execute(
@ -140,6 +177,25 @@ impl Database {
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_height", defaults.window_height.to_string()],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["hotkey", &defaults.hotkey],
)?;
// Migrate installs that still use Cmd+` (steals macOS window-cycle).
let current_hotkey: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'hotkey'",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| defaults.hotkey.clone());
if current_hotkey == LEGACY_DEFAULT_HOTKEY {
conn.execute(
"UPDATE settings SET value = ?1 WHERE key = 'hotkey'",
params![DEFAULT_HOTKEY],
)?;
}
Ok(())
}
@ -149,17 +205,105 @@ impl Database {
content: &str,
content_type: &str,
content_hash: &str,
thumbnail: Option<&str>,
) -> SqlResult<i64> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash)
VALUES (?1, ?2, ?3, 0, ?4)",
params![content, content_type, now, content_hash],
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash, thumbnail)
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
params![content, content_type, now, content_hash, thumbnail],
)?;
Ok(conn.last_insert_rowid())
}
/// Build missing image thumbnails from stored full PNGs (best-effort, capped).
pub fn backfill_thumbnails(&self, max: usize) -> usize {
let rows: Vec<(i64, String)> = {
let conn = self.conn.lock().unwrap();
let mut stmt = match conn.prepare(
"SELECT id, content FROM clipboard_entries
WHERE content_type = 'image'
AND (thumbnail IS NULL OR thumbnail = '')
ORDER BY id DESC
LIMIT ?1",
) {
Ok(s) => s,
Err(_) => return 0,
};
stmt.query_map(params![max as i64], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
let mut done = 0;
for (id, content) in rows {
if let Ok((w, h, rgba)) = crate::image_util::data_uri_to_rgba(&content) {
if let Ok(thumb) = crate::image_util::make_thumbnail_data_uri(w, h, &rgba) {
let conn = self.conn.lock().unwrap();
if conn
.execute(
"UPDATE clipboard_entries SET thumbnail = ?1 WHERE id = ?2",
params![thumb, id],
)
.is_ok()
{
done += 1;
}
}
}
}
done
}
/// Lightweight change detector for the UI (avoids shipping full rows).
pub fn latest_entry_id(&self) -> SqlResult<Option<i64>> {
let conn = self.conn.lock().unwrap();
let id = conn
.query_row(
"SELECT id FROM clipboard_entries ORDER BY id DESC LIMIT 1",
[],
|row| row.get::<_, i64>(0),
)
.ok();
Ok(id)
}
pub fn get_entry(&self, id: i64) -> SqlResult<Option<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, content, content_type, created_at, pinned, content_hash
FROM clipboard_entries WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], map_full_entry)?;
match rows.next() {
Some(Ok(e)) => Ok(Some(e)),
Some(Err(e)) => Err(e),
None => Ok(None),
}
}
fn map_preview_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
let content_type: String = row.get(2)?;
let raw: String = row.get(1)?;
let thumbnail: Option<String> = row.get(6)?;
let (content, truncated, sensitive) =
preview_content(&content_type, &raw, thumbnail.as_deref());
Ok(ClipboardEntry {
id: row.get(0)?,
content,
content_type,
created_at: row.get(3)?,
pinned: row.get::<_, i32>(4)? != 0,
content_hash: row.get(5)?,
truncated,
sensitive,
})
}
/// Check if the most recent entry already has this hash (dedup).
pub fn latest_hash(&self) -> SqlResult<Option<String>> {
let conn = self.conn.lock().unwrap();
@ -173,51 +317,36 @@ impl Database {
pub fn get_entries(&self, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let limit = limit.clamp(1, DISPLAY_LIMIT);
// Pinned first, then by recency
let mut stmt = conn.prepare(
"SELECT id, content, content_type, created_at, pinned, content_hash
"SELECT id, content, content_type, created_at, pinned, content_hash, thumbnail
FROM clipboard_entries
ORDER BY pinned DESC, id DESC
LIMIT ?1",
)?;
let entries = stmt
.query_map(params![limit], |row| {
Ok(ClipboardEntry {
id: row.get(0)?,
content: row.get(1)?,
content_type: row.get(2)?,
created_at: row.get(3)?,
pinned: row.get::<_, i32>(4)? != 0,
content_hash: row.get(5)?,
})
})?
.query_map(params![limit], Self::map_preview_entry)?
.collect::<SqlResult<Vec<_>>>()?;
Ok(entries)
}
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let limit = limit.clamp(1, DISPLAY_LIMIT);
// FTS5 match query — prefix search with *
let fts_query = format!("{}*", query.replace('"', "\"\""));
let mut stmt = conn.prepare(
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail
FROM clipboard_entries e
JOIN clipboard_fts f ON e.id = f.rowid
WHERE clipboard_fts MATCH ?1
AND e.content_type = 'text'
ORDER BY e.pinned DESC, e.id DESC
LIMIT ?2",
)?;
let entries = stmt
.query_map(params![fts_query, limit], |row| {
Ok(ClipboardEntry {
id: row.get(0)?,
content: row.get(1)?,
content_type: row.get(2)?,
created_at: row.get(3)?,
pinned: row.get::<_, i32>(4)? != 0,
content_hash: row.get(5)?,
})
})?
.query_map(params![fts_query, limit], Self::map_preview_entry)?
.collect::<SqlResult<Vec<_>>>()?;
Ok(entries)
}
@ -277,6 +406,7 @@ impl Database {
window_position: get("window_position", "cursor"),
window_width: get("window_width", "420").parse().unwrap_or(420),
window_height: get("window_height", "560").parse().unwrap_or(560),
hotkey: get("hotkey", DEFAULT_HOTKEY),
})
}
@ -290,6 +420,42 @@ impl Database {
}
}
fn map_full_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
Ok(ClipboardEntry {
id: row.get(0)?,
content: row.get(1)?,
content_type: row.get(2)?,
created_at: row.get(3)?,
pinned: row.get::<_, i32>(4)? != 0,
content_hash: row.get(5)?,
truncated: false,
sensitive: false,
})
}
fn preview_content(
content_type: &str,
raw: &str,
thumbnail: Option<&str>,
) -> (String, bool, bool) {
if content_type == "image" {
if let Some(thumb) = thumbnail.filter(|t| !t.is_empty()) {
return (thumb.to_string(), true, false);
}
return (String::new(), true, false);
}
if let Some(kind) = crate::redact::classify_sensitive(raw) {
return (crate::redact::redact_preview(raw, kind), true, true);
}
if raw.chars().count() > PREVIEW_MAX_CHARS {
let preview: String = raw.chars().take(PREVIEW_MAX_CHARS).collect();
return (preview, true, false);
}
(raw.to_string(), false, false)
}
#[cfg(test)]
mod tests {
use super::*;
@ -310,6 +476,22 @@ mod tests {
assert_eq!(s.window_position, "cursor");
assert_eq!(s.window_width, 420);
assert_eq!(s.window_height, 560);
assert_eq!(s.hotkey, DEFAULT_HOTKEY);
}
#[test]
fn hotkey_setting_persists() {
let db = test_db();
db.set_setting("hotkey", "cmd+shift+v").unwrap();
assert_eq!(db.get_settings().unwrap().hotkey, "cmd+shift+v");
}
#[test]
fn migrates_legacy_cmd_backtick_default() {
let db = test_db();
db.set_setting("hotkey", LEGACY_DEFAULT_HOTKEY).unwrap();
db.init_tables().unwrap();
assert_eq!(db.get_settings().unwrap().hotkey, DEFAULT_HOTKEY);
}
#[test]
@ -324,7 +506,7 @@ mod tests {
#[test]
fn insert_and_get_entry() {
let db = test_db();
let id = db.insert_entry("hello world", "text", "hash1").unwrap();
let id = db.insert_entry("hello world", "text", "hash1", None).unwrap();
assert!(id > 0);
let entries = db.get_entries(100).unwrap();
@ -337,9 +519,9 @@ mod tests {
#[test]
fn entries_ordered_newest_first() {
let db = test_db();
db.insert_entry("first", "text", "h1").unwrap();
db.insert_entry("second", "text", "h2").unwrap();
db.insert_entry("third", "text", "h3").unwrap();
db.insert_entry("first", "text", "h1", None).unwrap();
db.insert_entry("second", "text", "h2", None).unwrap();
db.insert_entry("third", "text", "h3", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries[0].content, "third");
@ -351,7 +533,7 @@ mod tests {
fn get_entries_respects_limit() {
let db = test_db();
for i in 0..10 {
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
assert_eq!(db.get_entries(3).unwrap().len(), 3);
}
@ -367,8 +549,8 @@ mod tests {
#[test]
fn latest_hash_returns_most_recent() {
let db = test_db();
db.insert_entry("a", "text", "hash_a").unwrap();
db.insert_entry("b", "text", "hash_b").unwrap();
db.insert_entry("a", "text", "hash_a", None).unwrap();
db.insert_entry("b", "text", "hash_b", None).unwrap();
assert_eq!(db.latest_hash().unwrap(), Some("hash_b".to_string()));
}
@ -377,7 +559,7 @@ mod tests {
#[test]
fn delete_entry_removes_it() {
let db = test_db();
let id = db.insert_entry("to delete", "text", "hd").unwrap();
let id = db.insert_entry("to delete", "text", "hd", None).unwrap();
assert_eq!(db.get_entries(100).unwrap().len(), 1);
db.delete_entry(id).unwrap();
@ -395,7 +577,7 @@ mod tests {
#[test]
fn toggle_pin_flips_state() {
let db = test_db();
let id = db.insert_entry("pin me", "text", "hp").unwrap();
let id = db.insert_entry("pin me", "text", "hp", None).unwrap();
let pinned = db.toggle_pin(id).unwrap();
assert!(pinned);
@ -407,8 +589,8 @@ mod tests {
#[test]
fn pinned_entries_appear_first() {
let db = test_db();
let id1 = db.insert_entry("old", "text", "h1").unwrap();
db.insert_entry("new", "text", "h2").unwrap();
let id1 = db.insert_entry("old", "text", "h1", None).unwrap();
db.insert_entry("new", "text", "h2", None).unwrap();
db.toggle_pin(id1).unwrap();
@ -423,9 +605,9 @@ mod tests {
#[test]
fn search_finds_matching_text() {
let db = test_db();
db.insert_entry("the quick brown fox", "text", "h1").unwrap();
db.insert_entry("lazy dog sleeps", "text", "h2").unwrap();
db.insert_entry("quick silver", "text", "h3").unwrap();
db.insert_entry("the quick brown fox", "text", "h1", None).unwrap();
db.insert_entry("lazy dog sleeps", "text", "h2", None).unwrap();
db.insert_entry("quick silver", "text", "h3", None).unwrap();
let results = db.search_entries("quick", 100).unwrap();
assert_eq!(results.len(), 2);
@ -435,8 +617,8 @@ mod tests {
#[test]
fn search_prefix_matching() {
let db = test_db();
db.insert_entry("programming in rust", "text", "h1").unwrap();
db.insert_entry("rustic cabin", "text", "h2").unwrap();
db.insert_entry("programming in rust", "text", "h1", None).unwrap();
db.insert_entry("rustic cabin", "text", "h2", None).unwrap();
let results = db.search_entries("rust", 100).unwrap();
assert_eq!(results.len(), 2); // "rust" prefix matches "rust" and "rustic"
@ -445,7 +627,7 @@ mod tests {
#[test]
fn search_no_match_returns_empty() {
let db = test_db();
db.insert_entry("hello world", "text", "h1").unwrap();
db.insert_entry("hello world", "text", "h1", None).unwrap();
let results = db.search_entries("zzzzz", 100).unwrap();
assert_eq!(results.len(), 0);
@ -454,7 +636,7 @@ mod tests {
#[test]
fn search_fts_stays_consistent_after_delete() {
let db = test_db();
let id = db.insert_entry("unique searchable term", "text", "h1").unwrap();
let id = db.insert_entry("unique searchable term", "text", "h1", None).unwrap();
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 1);
db.delete_entry(id).unwrap();
@ -467,7 +649,7 @@ mod tests {
fn trim_keeps_max_entries() {
let db = test_db();
for i in 0..10 {
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
db.trim_entries(5).unwrap();
@ -480,11 +662,11 @@ mod tests {
#[test]
fn trim_preserves_pinned_entries() {
let db = test_db();
let pinned_id = db.insert_entry("keep me", "text", "h0").unwrap();
let pinned_id = db.insert_entry("keep me", "text", "h0", None).unwrap();
db.toggle_pin(pinned_id).unwrap();
for i in 1..=10 {
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
db.trim_entries(3).unwrap();
@ -497,8 +679,8 @@ mod tests {
#[test]
fn trim_noop_when_under_limit() {
let db = test_db();
db.insert_entry("a", "text", "h1").unwrap();
db.insert_entry("b", "text", "h2").unwrap();
db.insert_entry("a", "text", "h1", None).unwrap();
db.insert_entry("b", "text", "h2", None).unwrap();
db.trim_entries(500).unwrap();
assert_eq!(db.get_entries(100).unwrap().len(), 2);
@ -510,7 +692,7 @@ mod tests {
fn clear_all_removes_everything() {
let db = test_db();
for i in 0..5 {
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
db.clear_all().unwrap();
assert_eq!(db.get_entries(100).unwrap().len(), 0);
@ -545,12 +727,75 @@ mod tests {
// ── Content types ───────────────────────────────────────────────
#[test]
fn list_entries_omit_image_blobs() {
let db = test_db();
let blob = format!("data:image/png;base64,{}", "A".repeat(5000));
db.insert_entry(&blob, "image", "himg", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].truncated);
assert!(entries[0].content.is_empty());
assert!(entries[0].content.len() < blob.len());
}
#[test]
fn list_entries_return_thumbnail_preview() {
let db = test_db();
let blob = format!("data:image/png;base64,{}", "A".repeat(5000));
let thumb = "data:image/png;base64,thumbdata";
db.insert_entry(&blob, "image", "himg2", Some(thumb)).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries[0].content, thumb);
assert!(entries[0].truncated);
let full = db.get_entry(entries[0].id).unwrap().unwrap();
assert_eq!(full.content, blob);
}
#[test]
fn list_entries_truncate_long_text() {
let db = test_db();
let long = "x".repeat(PREVIEW_MAX_CHARS + 50);
db.insert_entry(&long, "text", "hlong", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert!(entries[0].truncated);
assert_eq!(entries[0].content.chars().count(), PREVIEW_MAX_CHARS);
}
#[test]
fn get_entry_returns_full_content() {
let db = test_db();
let blob = format!("data:image/png;base64,{}", "B".repeat(2000));
let id = db.insert_entry(&blob, "image", "hfull", None).unwrap();
let full = db.get_entry(id).unwrap().unwrap();
assert!(!full.truncated);
assert_eq!(full.content, blob);
}
#[test]
fn list_redacts_sensitive_card_but_keeps_full() {
let db = test_db();
let card = "4111111111111111";
let id = db.insert_entry(card, "text", "hcard", None).unwrap();
let list = db.get_entries(10).unwrap();
assert!(list[0].sensitive);
assert!(list[0].content.contains("••••"));
assert!(!list[0].content.contains(card));
let full = db.get_entry(id).unwrap().unwrap();
assert_eq!(full.content, card);
assert!(!full.sensitive);
}
#[test]
fn insert_different_content_types() {
let db = test_db();
db.insert_entry("plain text", "text", "h1").unwrap();
db.insert_entry("data:image/png;base64,abc", "image", "h2").unwrap();
db.insert_entry("/usr/local/bin", "file", "h3").unwrap();
db.insert_entry("plain text", "text", "h1", None).unwrap();
db.insert_entry("data:image/png;base64,abc", "image", "h2", None).unwrap();
db.insert_entry("/usr/local/bin", "file", "h3", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries.len(), 3);

142
src-tauri/src/image_util.rs Normal file
View File

@ -0,0 +1,142 @@
//! Image helpers: PNG encode/decode and small list thumbnails.
use base64::Engine;
use std::borrow::Cow;
pub const THUMB_MAX_EDGE: u32 = 96;
pub fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(rgba)?;
}
Ok(buf)
}
pub fn decode_png_rgba(png_data: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
let decoder = png::Decoder::new(std::io::Cursor::new(png_data));
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(info.buffer_size());
// Expand to RGBA if needed
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => {
let mut out = Vec::with_capacity((buf.len() / 3) * 4);
for chunk in buf.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
out
}
png::ColorType::Grayscale => {
let mut out = Vec::with_capacity(buf.len() * 4);
for &g in &buf {
out.extend_from_slice(&[g, g, g, 255]);
}
out
}
png::ColorType::GrayscaleAlpha => {
let mut out = Vec::with_capacity((buf.len() / 2) * 4);
for chunk in buf.chunks_exact(2) {
out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
}
out
}
other => return Err(format!("unsupported PNG color type: {:?}", other)),
};
Ok((info.width, info.height, rgba))
}
/// Nearest-neighbor downscale so the longest edge is ≤ `max_edge`.
pub fn downscale_rgba(width: u32, height: u32, rgba: &[u8], max_edge: u32) -> (u32, u32, Vec<u8>) {
if width == 0 || height == 0 || rgba.len() < (width as usize * height as usize * 4) {
return (width.max(1), height.max(1), vec![0, 0, 0, 0]);
}
let longest = width.max(height);
if longest <= max_edge {
return (width, height, rgba.to_vec());
}
let scale = max_edge as f32 / longest as f32;
let tw = ((width as f32) * scale).round().max(1.0) as u32;
let th = ((height as f32) * scale).round().max(1.0) as u32;
let mut out = vec![0u8; (tw as usize) * (th as usize) * 4];
for y in 0..th {
let sy = ((y as f32 / th as f32) * height as f32) as u32;
for x in 0..tw {
let sx = ((x as f32 / tw as f32) * width as f32) as u32;
let si = ((sy * width + sx) as usize) * 4;
let di = ((y * tw + x) as usize) * 4;
out[di..di + 4].copy_from_slice(&rgba[si..si + 4]);
}
}
(tw, th, out)
}
pub fn rgba_to_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
let png = encode_rgba_to_png(width, height, rgba).map_err(|e| e.to_string())?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
Ok(format!("data:image/png;base64,{}", b64))
}
pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
let (tw, th, thumb) = downscale_rgba(width, height, rgba, THUMB_MAX_EDGE);
rgba_to_data_uri(tw, th, &thumb)
}
pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec<u8>), String> {
let b64 = data_uri
.split_once(',')
.map(|(_, b)| b)
.ok_or_else(|| "invalid data URI".to_string())?;
let png = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| e.to_string())?;
decode_png_rgba(&png)
}
pub fn to_arboard_image(width: u32, height: u32, rgba: Vec<u8>) -> arboard::ImageData<'static> {
arboard::ImageData {
width: width as usize,
height: height as usize,
bytes: Cow::Owned(rgba),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumbnail_shrinks_large_image() {
let w = 400u32;
let h = 200u32;
let rgba = vec![128u8; (w * h * 4) as usize];
let (tw, th, out) = downscale_rgba(w, h, &rgba, 96);
assert!(tw <= 96 && th <= 96);
assert_eq!(out.len(), (tw * th * 4) as usize);
assert!(tw >= th); // landscape stays wider
}
#[test]
fn roundtrip_png_small() {
let rgba = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255];
let png = encode_rgba_to_png(2, 2, &rgba).unwrap();
let (w, h, out) = decode_png_rgba(&png).unwrap();
assert_eq!((w, h), (2, 2));
assert_eq!(out.len(), 16);
}
#[test]
fn make_thumbnail_data_uri_ok() {
let rgba = vec![10u8; 50 * 50 * 4];
let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap();
assert!(uri.starts_with("data:image/png;base64,"));
}
}

View File

@ -1,6 +1,8 @@
mod clipboard;
mod commands;
mod db;
mod image_util;
mod redact;
use commands::{DbState, LastHashState, PausedState};
use db::Database;
@ -14,6 +16,107 @@ use tauri::{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
/// Parse a hotkey string like "cmd+`" or "ctrl+shift+v" into a Shortcut.
/// Requires at least one modifier and exactly one key.
pub fn parse_hotkey(s: &str) -> Option<Shortcut> {
let mut mods = Modifiers::empty();
let mut code: Option<Code> = None;
for part in s.split('+').map(|p| p.trim().to_lowercase()) {
match part.as_str() {
"cmd" | "command" | "super" | "meta" => mods |= Modifiers::SUPER,
"shift" => mods |= Modifiers::SHIFT,
"ctrl" | "control" => mods |= Modifiers::CONTROL,
"alt" | "option" | "opt" => mods |= Modifiers::ALT,
key => {
if code.is_some() {
return None;
}
code = Some(parse_key(key)?);
}
}
}
if mods.is_empty() {
return None;
}
Some(Shortcut::new(Some(mods), code?))
}
fn parse_key(k: &str) -> Option<Code> {
let code = match k {
"`" | "~" | "backquote" | "grave" => Code::Backquote,
"space" => Code::Space,
"enter" | "return" => Code::Enter,
"tab" => Code::Tab,
"escape" | "esc" => Code::Escape,
"up" => Code::ArrowUp,
"down" => Code::ArrowDown,
"left" => Code::ArrowLeft,
"right" => Code::ArrowRight,
"-" | "minus" => Code::Minus,
"=" | "equal" => Code::Equal,
"," | "comma" => Code::Comma,
"." | "period" => Code::Period,
"/" | "slash" => Code::Slash,
";" | "semicolon" => Code::Semicolon,
"'" | "quote" => Code::Quote,
"[" => Code::BracketLeft,
"]" => Code::BracketRight,
"\\" | "backslash" => Code::Backslash,
"f1" => Code::F1,
"f2" => Code::F2,
"f3" => Code::F3,
"f4" => Code::F4,
"f5" => Code::F5,
"f6" => Code::F6,
"f7" => Code::F7,
"f8" => Code::F8,
"f9" => Code::F9,
"f10" => Code::F10,
"f11" => Code::F11,
"f12" => Code::F12,
"a" => Code::KeyA,
"b" => Code::KeyB,
"c" => Code::KeyC,
"d" => Code::KeyD,
"e" => Code::KeyE,
"f" => Code::KeyF,
"g" => Code::KeyG,
"h" => Code::KeyH,
"i" => Code::KeyI,
"j" => Code::KeyJ,
"k" => Code::KeyK,
"l" => Code::KeyL,
"m" => Code::KeyM,
"n" => Code::KeyN,
"o" => Code::KeyO,
"p" => Code::KeyP,
"q" => Code::KeyQ,
"r" => Code::KeyR,
"s" => Code::KeyS,
"t" => Code::KeyT,
"u" => Code::KeyU,
"v" => Code::KeyV,
"w" => Code::KeyW,
"x" => Code::KeyX,
"y" => Code::KeyY,
"z" => Code::KeyZ,
"0" => Code::Digit0,
"1" => Code::Digit1,
"2" => Code::Digit2,
"3" => Code::Digit3,
"4" => Code::Digit4,
"5" => Code::Digit5,
"6" => Code::Digit6,
"7" => Code::Digit7,
"8" => Code::Digit8,
"9" => Code::Digit9,
_ => return None,
};
Some(code)
}
/// Get the global mouse position using CoreGraphics.
/// Returns logical (x, y) in screen coordinates with origin at top-left.
#[cfg(target_os = "macos")]
@ -139,15 +242,10 @@ pub fn run() {
))
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
// Only the toggle hotkey is ever registered, so any press toggles.
.with_handler(|app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let expected = Shortcut::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyV,
);
if shortcut == &expected {
toggle_window(app);
}
toggle_window(app);
}
})
.build(),
@ -158,6 +256,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::get_entries,
commands::search_entries,
commands::get_entry,
commands::latest_entry_id,
commands::delete_entry,
commands::toggle_pin,
commands::clear_all,
@ -167,13 +267,20 @@ pub fn run() {
commands::set_paused,
commands::save_window_size,
commands::paste_and_refocus,
commands::copy_to_clipboard,
commands::set_hotkey,
])
.setup(move |app| {
let shortcut = Shortcut::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyV,
);
app.global_shortcut().register(shortcut)?;
let hotkey_str = db
.get_settings()
.map(|s| s.hotkey)
.unwrap_or_else(|_| db::DEFAULT_HOTKEY.to_string());
let shortcut = parse_hotkey(&hotkey_str)
.or_else(|| parse_hotkey(db::DEFAULT_HOTKEY))
.expect("default hotkey must parse");
if let Err(e) = app.global_shortcut().register(shortcut) {
log::error!("Failed to register hotkey '{}': {}", hotkey_str, e);
}
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
let pause_i =
@ -223,12 +330,30 @@ pub fn run() {
let w = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(false) = event {
let _ = w.hide();
// Delay hide so left-clicks inside the list finish before
// macOS/webview focus churn can cancel them.
let w2 = w.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(180));
if !w2.is_focused().unwrap_or(false) {
let _ = w2.hide();
}
});
}
});
}
let last_hash = clipboard::start_polling(db_for_polling, paused_for_polling);
// Best-effort: restore thumbs for older image rows.
let n = db.backfill_thumbnails(80);
if n > 0 {
log::info!("Backfilled {} image thumbnails", n);
}
let last_hash = clipboard::start_polling(
app.handle().clone(),
db_for_polling,
paused_for_polling,
);
app.manage(LastHashState(last_hash));
Ok(())
@ -236,3 +361,66 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_default_hotkey() {
let sc = parse_hotkey("ctrl+`").unwrap();
assert_eq!(sc, Shortcut::new(Some(Modifiers::CONTROL), Code::Backquote));
}
#[test]
fn parse_legacy_cmd_backtick() {
let sc = parse_hotkey("cmd+`").unwrap();
assert_eq!(sc, Shortcut::new(Some(Modifiers::SUPER), Code::Backquote));
}
#[test]
fn parse_tilde_alias() {
assert_eq!(parse_hotkey("cmd+~"), parse_hotkey("command+`"));
}
#[test]
fn parse_multi_modifier() {
let sc = parse_hotkey("cmd+shift+v").unwrap();
assert_eq!(
sc,
Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyV)
);
}
#[test]
fn parse_ctrl_alt_names() {
let sc = parse_hotkey("ctrl+option+space").unwrap();
assert_eq!(
sc,
Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::Space)
);
}
#[test]
fn parse_is_case_insensitive() {
assert_eq!(parse_hotkey("CMD+Shift+V"), parse_hotkey("cmd+shift+v"));
}
#[test]
fn parse_rejects_no_modifier() {
assert!(parse_hotkey("v").is_none());
assert!(parse_hotkey("`").is_none());
}
#[test]
fn parse_rejects_no_key() {
assert!(parse_hotkey("cmd+shift").is_none());
}
#[test]
fn parse_rejects_garbage() {
assert!(parse_hotkey("").is_none());
assert!(parse_hotkey("cmd+banana").is_none());
assert!(parse_hotkey("cmd+v+x").is_none());
}
}

224
src-tauri/src/redact.rs Normal file
View File

@ -0,0 +1,224 @@
//! Detect and redact sensitive clipboard text for list previews.
//! Full plaintext stays in SQLite and is used only on paste.
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SensitiveKind {
Password,
Card,
Banking,
Secret,
}
static CARD_RE: OnceLock<Regex> = OnceLock::new();
static IBAN_RE: OnceLock<Regex> = OnceLock::new();
static ROUTING_RE: OnceLock<Regex> = OnceLock::new();
static SECRET_LABEL_RE: OnceLock<Regex> = OnceLock::new();
static JWT_RE: OnceLock<Regex> = OnceLock::new();
fn card_re() -> &'static Regex {
CARD_RE.get_or_init(|| {
Regex::new(r"(?x)\b(?:\d[ -]*?){13,19}\b").expect("card regex")
})
}
fn iban_re() -> &'static Regex {
IBAN_RE.get_or_init(|| {
Regex::new(r"(?i)\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b").expect("iban regex")
})
}
fn routing_re() -> &'static Regex {
ROUTING_RE.get_or_init(|| Regex::new(r"\b\d{9}\b").expect("routing regex"))
}
fn secret_label_re() -> &'static Regex {
SECRET_LABEL_RE.get_or_init(|| {
Regex::new(
r"(?i)\b(password|passwd|passphrase|secret|api[_-]?key|token|auth|pin|cvv|routing|account\s*#?|acct)\b\s*[:=]\s*\S+",
)
.expect("secret label regex")
})
}
fn jwt_re() -> &'static Regex {
JWT_RE.get_or_init(|| {
Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
.expect("jwt regex")
})
}
/// Luhn check for probable card numbers (digits only).
fn luhn_ok(digits: &str) -> bool {
if digits.len() < 13 || digits.len() > 19 {
return false;
}
let mut sum = 0;
let mut alt = false;
for c in digits.chars().rev() {
let mut n = match c.to_digit(10) {
Some(d) => d as i32,
None => return false,
};
if alt {
n *= 2;
if n > 9 {
n -= 9;
}
}
sum += n;
alt = !alt;
}
sum % 10 == 0
}
fn extract_digits(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_digit()).collect()
}
fn looks_like_password(text: &str) -> bool {
let t = text.trim();
if t.contains('\n') || t.contains(' ') {
return false;
}
let len = t.chars().count();
if !(10..=128).contains(&len) {
return false;
}
// Skip obvious URLs / emails / paths
if t.contains("://") || t.contains('@') || t.starts_with('/') || t.starts_with('~') {
return false;
}
let has_lower = t.chars().any(|c| c.is_ascii_lowercase());
let has_upper = t.chars().any(|c| c.is_ascii_uppercase());
let has_digit = t.chars().any(|c| c.is_ascii_digit());
let has_sym = t.chars().any(|c| !c.is_ascii_alphanumeric());
let classes = [has_lower, has_upper, has_digit, has_sym]
.iter()
.filter(|&&x| x)
.count();
classes >= 3
}
fn banking_keywords(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
const KEYS: &[&str] = &[
"routing number",
"account number",
"bank account",
"swift",
"iban",
"wire transfer",
"sort code",
"transit number",
];
KEYS.iter().any(|k| lower.contains(k))
}
pub fn classify_sensitive(text: &str) -> Option<SensitiveKind> {
let t = text.trim();
if t.is_empty() {
return None;
}
if secret_label_re().is_match(t) || jwt_re().is_match(t) {
return Some(SensitiveKind::Secret);
}
for m in card_re().find_iter(t) {
let digits = extract_digits(m.as_str());
if luhn_ok(&digits) {
return Some(SensitiveKind::Card);
}
}
if iban_re().is_match(t) || banking_keywords(t) {
return Some(SensitiveKind::Banking);
}
// ABA routing often appears near account language — alone it's weak, so require banking cue or "routing"
if routing_re().is_match(t)
&& (t.to_ascii_lowercase().contains("routing")
|| t.to_ascii_lowercase().contains("aba")
|| banking_keywords(t))
{
return Some(SensitiveKind::Banking);
}
if looks_like_password(t) {
return Some(SensitiveKind::Password);
}
None
}
/// Redacted list preview. Full value remains in the DB for paste.
pub fn redact_preview(text: &str, kind: SensitiveKind) -> String {
match kind {
SensitiveKind::Password => "•••••••• (password)".into(),
SensitiveKind::Secret => "•••••••• (secret)".into(),
SensitiveKind::Card => {
let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect();
let last4 = if digits.len() >= 4 {
&digits[digits.len() - 4..]
} else {
"????"
};
format!("•••• •••• •••• {} (card)", last4)
}
SensitiveKind::Banking => "•••••••• (banking)".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_password_like() {
assert_eq!(
classify_sensitive("Tr0ub4dor&3xY!"),
Some(SensitiveKind::Password)
);
}
#[test]
fn ignores_normal_sentence() {
assert_eq!(classify_sensitive("hello world this is fine"), None);
}
#[test]
fn detects_labeled_password() {
assert_eq!(
classify_sensitive("password: hunter2secret"),
Some(SensitiveKind::Secret)
);
}
#[test]
fn detects_visa_test_card() {
// Visa test number that passes Luhn
assert_eq!(
classify_sensitive("4111111111111111"),
Some(SensitiveKind::Card)
);
}
#[test]
fn card_preview_keeps_last4() {
let p = redact_preview("4111111111111111", SensitiveKind::Card);
assert!(p.contains("1111"));
assert!(p.contains("••••"));
assert!(!p.contains("4111111111111111"));
}
#[test]
fn detects_iban() {
assert_eq!(
classify_sensitive("DE89370400440532013000"),
Some(SensitiveKind::Banking)
);
}
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "maCopy",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.macopy.app",
"build": {
"frontendDist": "../dist",
@ -43,6 +43,7 @@
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-is-visible",
"core:window:allow-is-focused",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-inner-size",

View File

@ -1,5 +1,5 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import App from "./App";
import { makeEntry, makeSettings, resetIdCounter } from "./test/factories";
@ -15,10 +15,18 @@ function mockTauriCommands(
case "get_entries":
case "search_entries":
return entries;
case "get_entry": {
const id = args?.id as number | undefined;
return entries.find((e) => e.id === id) ?? entries[0];
}
case "latest_entry_id":
return entries[0]?.id ?? null;
case "get_settings":
return settings;
case "paste_and_refocus":
return undefined;
case "copy_to_clipboard":
return undefined;
case "save_window_size":
return undefined;
case "delete_entry":
@ -85,15 +93,38 @@ describe("App", () => {
expect(screen.getByText("Click me")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Click me"));
fireEvent.mouseDown(screen.getByText("Click me"));
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus", {
content: "Click me",
contentType: "text",
});
});
});
it("invokes copy_to_clipboard (not paste_and_refocus) from the Copy context menu action", async () => {
const entries = [makeEntry({ content: "Copy me" })];
mockTauriCommands(entries);
render(<App />);
await waitFor(() => {
expect(screen.getByText("Copy me")).toBeInTheDocument();
});
fireEvent.contextMenu(screen.getByText("Copy me"));
fireEvent.click(await screen.findByText("Copy"));
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("copy_to_clipboard", {
content: "Copy me",
contentType: "text",
});
});
expect(mockInvoke).not.toHaveBeenCalledWith("paste_and_refocus", expect.anything());
});
it("shows empty state text when there are no entries", async () => {
mockTauriCommands([]);
render(<App />);
@ -123,7 +154,7 @@ describe("App", () => {
});
// Ctrl+Click to select (doesn't trigger paste)
fireEvent.click(screen.getByText("To delete"), { metaKey: true });
fireEvent.mouseDown(screen.getByText("To delete"), { metaKey: true, button: 0 });
// Clear the mock calls from setup
mockInvoke.mockClear();

View File

@ -1,13 +1,18 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
// clipboard write now happens in Rust's paste_and_refocus command
import { getCurrentWindow } from "@tauri-apps/api/window";
import SearchBar from "./components/SearchBar";
import ClipboardList from "./components/ClipboardList";
import SettingsPanel from "./components/SettingsPanel";
import ContextMenu from "./components/ContextMenu";
import type { ClipboardEntry, Settings } from "./types";
import { DISPLAY_LIMIT } from "./types";
import { applyTransform, type TransformId } from "./transforms";
function entriesFingerprint(entries: ClipboardEntry[]): string {
return entries.map((e) => `${e.id}:${e.pinned ? 1 : 0}:${e.content_hash}`).join("|");
}
export default function App() {
const [entries, setEntries] = useState<ClipboardEntry[]>([]);
@ -23,20 +28,29 @@ export default function App() {
entry: ClipboardEntry;
} | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval>>();
const resizeTimer = useRef<ReturnType<typeof setTimeout>>();
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const fingerprintRef = useRef("");
const latestIdRef = useRef<number | null>(null);
const queryRef = useRef(query);
queryRef.current = query;
const loadEntries = useCallback(async () => {
const loadEntries = useCallback(async (force = false) => {
try {
const limit = settings?.max_history ?? 10000;
const data: ClipboardEntry[] = query.trim()
? await invoke("search_entries", { query, limit })
: await invoke("get_entries", { limit });
const q = queryRef.current;
const data: ClipboardEntry[] = q.trim()
? await invoke("search_entries", { query: q, limit: DISPLAY_LIMIT })
: await invoke("get_entries", { limit: DISPLAY_LIMIT });
const fp = entriesFingerprint(data);
const maxId = data.reduce((m, e) => Math.max(m, e.id), 0);
if (!force && fp === fingerprintRef.current) return;
fingerprintRef.current = fp;
latestIdRef.current = maxId || null;
setEntries(data);
} catch (e) {
console.error("Failed to load entries:", e);
}
}, [query, settings?.max_history]);
}, []);
const loadSettings = useCallback(async () => {
try {
@ -49,20 +63,49 @@ export default function App() {
useEffect(() => {
loadSettings();
}, [loadSettings]);
loadEntries(true);
}, [loadSettings, loadEntries]);
// Refresh when history changes or the picker becomes visible again.
useEffect(() => {
loadEntries();
pollRef.current = setInterval(loadEntries, 1000);
return () => clearInterval(pollRef.current);
const unsubs: Array<() => void> = [];
listen("clipboard-changed", () => {
loadEntries();
}).then((fn) => unsubs.push(fn));
listen("open-settings", () => setShowSettings(true)).then((fn) => unsubs.push(fn));
const win = getCurrentWindow();
win.onFocusChanged(({ payload: focused }) => {
if (focused) loadEntries();
}).then((unlisten) => unsubs.push(unlisten));
// Cheap fallback: only ask for the latest id while focused, not full rows.
const cheapPoll = setInterval(async () => {
try {
if (document.hidden) return;
const latest: number | null = await invoke("latest_entry_id");
if (latest != null && latest !== latestIdRef.current) {
loadEntries();
}
} catch {
/* ignore */
}
}, 2500);
return () => {
clearInterval(cheapPoll);
unsubs.forEach((fn) => fn());
};
}, [loadEntries]);
// Debounced search — typing should not hammer SQLite / IPC.
useEffect(() => {
const unlisten = listen("open-settings", () => setShowSettings(true));
return () => {
unlisten.then((fn) => fn());
};
}, []);
clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => loadEntries(true), 120);
return () => clearTimeout(searchTimer.current);
}, [query, loadEntries]);
// Persist window size on resize (debounced)
useEffect(() => {
@ -81,34 +124,81 @@ export default function App() {
return () => window.removeEventListener("resize", handleResize);
}, []);
const resolveFullContent = useCallback(async (entry: ClipboardEntry): Promise<string> => {
if (entry.truncated || entry.content_type === "image") {
const full: ClipboardEntry = await invoke("get_entry", { id: entry.id });
return full.content;
}
return entry.content;
}, []);
// Copy selected entries and paste into previous app
const pasteEntries = useCallback(
async (entriesToPaste: ClipboardEntry[]) => {
async (entriesToPaste: ClipboardEntry[], transform: TransformId = "none") => {
if (entriesToPaste.length === 0) return;
try {
const content = entriesToPaste
.map((e) => e.content)
.join("\n");
await invoke("paste_and_refocus", { content });
if (entriesToPaste.length === 1) {
const entry = entriesToPaste[0];
let content = await resolveFullContent(entry);
let contentType = entry.content_type;
if (contentType === "text" || transform !== "none") {
content = applyTransform(content, transform);
contentType = "text";
}
await invoke("paste_and_refocus", {
content,
contentType,
});
return;
}
const parts = await Promise.all(entriesToPaste.map(resolveFullContent));
const joined = applyTransform(parts.join("\n"), transform);
await invoke("paste_and_refocus", {
content: joined,
contentType: "text",
});
} catch (e) {
console.error("Paste failed:", e);
}
},
[]
[resolveFullContent]
);
const pasteSingle = useCallback(
(entry: ClipboardEntry) => pasteEntries([entry]),
(entry: ClipboardEntry, transform: TransformId = "none") =>
pasteEntries([entry], transform),
[pasteEntries]
);
// Copy-only: put on the system clipboard without auto-pasting or hiding
// the window (Ditto/Pastebot-style "build up a paste elsewhere").
const copyEntries = useCallback(
async (entriesToCopy: ClipboardEntry[]) => {
if (entriesToCopy.length === 0) return;
try {
if (entriesToCopy.length === 1) {
const entry = entriesToCopy[0];
const content = await resolveFullContent(entry);
await invoke("copy_to_clipboard", { content, contentType: entry.content_type });
return;
}
const parts = await Promise.all(entriesToCopy.map(resolveFullContent));
await invoke("copy_to_clipboard", { content: parts.join("\n"), contentType: "text" });
} catch (e) {
console.error("Copy failed:", e);
}
},
[resolveFullContent]
);
const handleDelete = useCallback(
async (ids: number[]) => {
for (const id of ids) {
await invoke("delete_entry", { id });
}
setSelectedIds(new Set());
loadEntries();
fingerprintRef.current = "";
loadEntries(true);
},
[loadEntries]
);
@ -116,14 +206,15 @@ export default function App() {
const handleTogglePin = useCallback(
async (id: number) => {
await invoke("toggle_pin", { id });
loadEntries();
fingerprintRef.current = "";
loadEntries(true);
},
[loadEntries]
);
// Selection helpers
const selectOnly = useCallback((index: number, entries: ClipboardEntry[]) => {
const e = entries[index];
const selectOnly = useCallback((index: number, list: ClipboardEntry[]) => {
const e = list[index];
if (e) {
setSelectedIds(new Set([e.id]));
setAnchorIndex(index);
@ -132,12 +223,12 @@ export default function App() {
}, []);
const selectRange = useCallback(
(from: number, to: number, entries: ClipboardEntry[]) => {
(from: number, to: number, list: ClipboardEntry[]) => {
const lo = Math.min(from, to);
const hi = Math.max(from, to);
const ids = new Set<number>();
for (let i = lo; i <= hi; i++) {
if (entries[i]) ids.add(entries[i].id);
if (list[i]) ids.add(list[i].id);
}
setSelectedIds(ids);
setFocusIndex(to);
@ -146,8 +237,8 @@ export default function App() {
);
const toggleSelect = useCallback(
(index: number, entries: ClipboardEntry[]) => {
const e = entries[index];
(index: number, list: ClipboardEntry[]) => {
const e = list[index];
if (!e) return;
setSelectedIds((prev) => {
const next = new Set(prev);
@ -175,7 +266,7 @@ export default function App() {
// Cmd+A select all
if (e.metaKey && e.key === "a" && document.activeElement?.tagName !== "INPUT") {
e.preventDefault();
setSelectedIds(new Set(entries.map((e) => e.id)));
setSelectedIds(new Set(entries.map((en) => en.id)));
return;
}
@ -197,7 +288,7 @@ export default function App() {
}
} else if (e.key === "Enter") {
e.preventDefault();
const selected = entries.filter((e) => selectedIds.has(e.id));
const selected = entries.filter((en) => selectedIds.has(en.id));
if (selected.length > 0) pasteEntries(selected);
else if (entries[focusIndex]) pasteSingle(entries[focusIndex]);
} else if (e.key === "Delete" || e.key === "Backspace") {
@ -234,7 +325,6 @@ export default function App() {
return (
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
{/* Titlebar drag region — sits behind the native traffic lights */}
<div className="titlebar-spacer shrink-0" data-tauri-drag-region />
{showSettings && settings ? (
@ -266,7 +356,9 @@ export default function App() {
selectedIds={selectedIds}
focusIndex={focusIndex}
showImages={settings?.show_images ?? true}
onSelect={(entry) => pasteSingle(entry)}
onSelect={(entry, opts) =>
pasteSingle(entry, opts?.plain ? "plain" : "none")
}
onCtrlClick={(index) => toggleSelect(index, entries)}
onShiftClick={(index) => selectRange(anchorIndex, index, entries)}
onPlainClick={(index) => selectOnly(index, entries)}
@ -284,7 +376,9 @@ export default function App() {
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between items-center">
<span>
{selectedCount > 1 ? `${selectedCount} selected` : "⌘1-9 quick paste"}
{selectedCount > 1
? `${selectedCount} selected`
: "⌘1-9 paste · ⌥ click plain"}
</span>
<span>{entries.length} items</span>
</div>
@ -297,14 +391,27 @@ export default function App() {
y={contextMenu.y}
entry={contextMenu.entry}
selectedCount={selectedCount}
onCopy={() => {
onPaste={(transform) => {
const selected = entries.filter((e) => selectedIds.has(e.id));
pasteEntries(selected.length > 0 ? selected : [contextMenu.entry]);
pasteEntries(
selected.length > 0 ? selected : [contextMenu.entry],
transform ?? "none"
);
setContextMenu(null);
}}
onCopyOnly={() => {
const selected = entries.filter((e) => selectedIds.has(e.id));
copyEntries(selected.length > 0 ? selected : [contextMenu.entry]);
setContextMenu(null);
}}
onPin={() => {
handleTogglePin(contextMenu.entry.id);
setContextMenu(null);
}}
onPin={() => handleTogglePin(contextMenu.entry.id)}
onDelete={() => {
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : [contextMenu.entry.id];
handleDelete(ids);
setContextMenu(null);
}}
/>
)}

View File

@ -42,13 +42,21 @@ describe("ClipboardList", () => {
expect(screen.getByText("PIN")).toBeInTheDocument();
});
it("calls onSelect when entry is clicked without modifiers", () => {
it("calls onSelect when entry is mouse-downed without modifiers", () => {
const onSelect = vi.fn();
const entries = [makeEntry({ content: "Click me" })];
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
fireEvent.click(screen.getByText("Click me"));
expect(onSelect).toHaveBeenCalledWith(entries[0]);
fireEvent.mouseDown(screen.getByText("Click me"));
expect(onSelect).toHaveBeenCalledWith(entries[0], { plain: false });
});
it("requests plain paste when Alt is held", () => {
const onSelect = vi.fn();
const entries = [makeEntry({ content: "Rich stuff" })];
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
fireEvent.mouseDown(screen.getByText("Rich stuff"), { altKey: true, button: 0 });
expect(onSelect).toHaveBeenCalledWith(entries[0], { plain: true });
});
it("calls onContextMenu on right-click", () => {
@ -87,7 +95,7 @@ describe("ClipboardList", () => {
expect(screen.getByText("⌘3")).toBeInTheDocument();
});
it("renders image entries when showImages is true", () => {
it("renders image entries when showImages is true and content is a data URI", () => {
const entries = [
makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }),
];
@ -97,13 +105,22 @@ describe("ClipboardList", () => {
expect(img).toHaveAttribute("src", "data:image/png;base64,abc");
});
it("renders image entries as text when showImages is false", () => {
it("renders image stub when blob was omitted from list payload", () => {
const entries = [
makeEntry({ content: "", content_type: "image", truncated: true }),
];
render(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
expect(screen.getByText("Image")).toBeInTheDocument();
});
it("renders image stub when showImages is false", () => {
const entries = [
makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }),
];
render(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
expect(screen.getByText("data:image/png;base64,abc")).toBeInTheDocument();
expect(screen.getByText("Image")).toBeInTheDocument();
});
it("highlights selected entries", () => {
@ -122,7 +139,7 @@ describe("ClipboardList", () => {
render(
<ClipboardList {...defaultProps} entries={entries} onCtrlClick={onCtrlClick} />
);
fireEvent.click(screen.getByText("Ctrl item"), { metaKey: true });
fireEvent.mouseDown(screen.getByText("Ctrl item"), { metaKey: true, button: 0 });
expect(onCtrlClick).toHaveBeenCalledWith(0);
});
@ -132,7 +149,7 @@ describe("ClipboardList", () => {
render(
<ClipboardList {...defaultProps} entries={entries} onShiftClick={onShiftClick} />
);
fireEvent.click(screen.getByText("Shift item"), { shiftKey: true });
fireEvent.mouseDown(screen.getByText("Shift item"), { shiftKey: true, button: 0 });
expect(onShiftClick).toHaveBeenCalledWith(0);
});
@ -153,6 +170,18 @@ describe("ClipboardList", () => {
expect(screen.getByText("TXT")).toBeInTheDocument();
});
it("shows a color swatch for a hex color entry", () => {
const entries = [makeEntry({ content: "#3366ff" })];
const { container } = render(<ClipboardList {...defaultProps} entries={entries} />);
expect(container.querySelector(".rounded-full")).toBeTruthy();
});
it("does not show a color swatch for ordinary text", () => {
const entries = [makeEntry({ content: "Just some notes" })];
const { container } = render(<ClipboardList {...defaultProps} entries={entries} />);
expect(container.querySelector(".rounded-full")).toBeFalsy();
});
it("shows IMG badge for image entries", () => {
const entries = Array.from({ length: 10 }, (_, i) =>
makeEntry({

View File

@ -1,12 +1,23 @@
import { useEffect, useRef } from "react";
import { memo, useEffect, useRef } from "react";
import type { ClipboardEntry } from "../types";
// Cheap, anchored regex — only matches when the *whole* trimmed entry is a
// color literal, so it never lights up mid-paragraph substrings.
const COLOR_RE =
/^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|rgba?\([^)]+\)|hsla?\([^)]+\))$/i;
function detectColor(text: string): string | null {
const t = text.trim();
if (t.length === 0 || t.length > 32) return null;
return COLOR_RE.test(t) ? t : null;
}
interface Props {
entries: ClipboardEntry[];
selectedIds: Set<number>;
focusIndex: number;
showImages: boolean;
onSelect: (entry: ClipboardEntry) => void;
onSelect: (entry: ClipboardEntry, opts?: { plain?: boolean }) => void;
onCtrlClick: (index: number) => void;
onShiftClick: (index: number) => void;
onPlainClick: (index: number) => void;
@ -46,13 +57,23 @@ function EntryPreview({
entry: ClipboardEntry;
showImages: boolean;
}) {
if (entry.content_type === "image" && showImages) {
if (entry.content_type === "image") {
if (showImages && entry.content.startsWith("data:image")) {
return (
<img
src={entry.content}
alt="Clipboard image"
className="h-12 w-16 rounded object-cover bg-surface-active"
loading="lazy"
draggable={false}
/>
);
}
return (
<img
src={entry.content}
alt="Clipboard image"
className="max-h-12 max-w-full rounded object-contain"
/>
<span className="inline-flex items-center gap-1.5 text-[13px] leading-snug text-text-secondary">
<span className="inline-block h-8 w-10 rounded bg-surface-active" />
Image
</span>
);
}
@ -61,13 +82,118 @@ function EntryPreview({
? entry.content.slice(0, 160) + "…"
: entry.content;
const color = entry.sensitive ? null : detectColor(entry.content);
return (
<span className="text-[13px] leading-snug text-text-primary line-clamp-2 break-all">
{display}
<span className="inline-flex items-center gap-1.5">
{color && (
<span
className="inline-block h-3 w-3 rounded-full border border-border shrink-0"
style={{ backgroundColor: color }}
title={color}
/>
)}
<span
className={`text-[13px] leading-snug line-clamp-2 break-all ${
entry.sensitive ? "text-text-secondary font-mono tracking-wide" : "text-text-primary"
}`}
>
{display || "(empty)"}
</span>
</span>
);
}
interface RowProps {
entry: ClipboardEntry;
index: number;
isSelected: boolean;
isFocused: boolean;
showImages: boolean;
onSelect: (entry: ClipboardEntry, opts?: { plain?: boolean }) => void;
onCtrlClick: (index: number) => void;
onShiftClick: (index: number) => void;
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
setFocusIndex: (i: number) => void;
}
// Memoized so selection/focus changes elsewhere in the list don't re-render
// every row's preview (image decode, color regex, etc.) each time.
const Row = memo(function Row({
entry,
index: i,
isSelected,
isFocused,
showImages,
onSelect,
onCtrlClick,
onShiftClick,
onContextMenu,
setFocusIndex,
}: RowProps) {
return (
<div
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
${isSelected ? "bg-accent/10" : ""}
${isFocused && !isSelected ? "bg-surface-hover" : ""}
${!isSelected && !isFocused ? "hover:bg-surface-hover" : ""}`}
onMouseDown={(e) => {
// Use mousedown so paste wins the race against window blur/hide.
if (e.button !== 0) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
onCtrlClick(i);
return;
}
if (e.shiftKey) {
e.preventDefault();
onShiftClick(i);
return;
}
e.preventDefault();
// ⌥/Alt = paste without formatting
onSelect(entry, { plain: e.altKey });
}}
onClick={(e) => {
// Prevent the follow-up click from doing anything extra.
e.preventDefault();
}}
onContextMenu={(e) => onContextMenu(e, entry)}
onMouseEnter={() => setFocusIndex(i)}
>
{/* Left column: shortcut badge or type */}
<div className="w-6 text-center shrink-0 pt-0.5">
{entry.sensitive ? (
<span className="text-[10px] text-danger opacity-80" title="Sensitive — redacted in list">
</span>
) : i < 9 ? (
<span className="text-[10px] font-mono text-text-secondary opacity-60">
{i + 1}
</span>
) : (
<TypeBadge type={entry.content_type} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<EntryPreview entry={entry} showImages={showImages} />
</div>
{/* Right column: meta */}
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5">
{entry.pinned && (
<span className="text-[9px] font-semibold text-pin tracking-wide">PIN</span>
)}
<span className="text-[10px] text-text-secondary tabular-nums">
{timeAgo(entry.created_at)}
</span>
</div>
</div>
);
});
export default function ClipboardList({
entries,
selectedIds,
@ -76,7 +202,6 @@ export default function ClipboardList({
onSelect,
onCtrlClick,
onShiftClick,
onPlainClick,
onContextMenu,
setFocusIndex,
}: Props) {
@ -100,57 +225,21 @@ export default function ClipboardList({
return (
<div ref={listRef} className="divide-y divide-border">
{entries.map((entry, i) => {
const isSelected = selectedIds.has(entry.id);
const isFocused = i === focusIndex;
return (
<div
key={entry.id}
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
${isSelected ? "bg-accent/10" : ""}
${isFocused && !isSelected ? "bg-surface-hover" : ""}
${!isSelected && !isFocused ? "hover:bg-surface-hover" : ""}`}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
onCtrlClick(i);
} else if (e.shiftKey) {
onShiftClick(i);
} else {
onSelect(entry);
}
}}
onContextMenu={(e) => onContextMenu(e, entry)}
onMouseEnter={() => setFocusIndex(i)}
>
{/* Left column: shortcut badge or type */}
<div className="w-6 text-center shrink-0 pt-0.5">
{i < 9 ? (
<span className="text-[10px] font-mono text-text-secondary opacity-60">
{i + 1}
</span>
) : (
<TypeBadge type={entry.content_type} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<EntryPreview entry={entry} showImages={showImages} />
</div>
{/* Right column: meta */}
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5">
{entry.pinned && (
<span className="text-[9px] font-semibold text-pin tracking-wide">PIN</span>
)}
<span className="text-[10px] text-text-secondary tabular-nums">
{timeAgo(entry.created_at)}
</span>
</div>
</div>
);
})}
{entries.map((entry, i) => (
<Row
key={entry.id}
entry={entry}
index={i}
isSelected={selectedIds.has(entry.id)}
isFocused={i === focusIndex}
showImages={showImages}
onSelect={onSelect}
onCtrlClick={onCtrlClick}
onShiftClick={onShiftClick}
onContextMenu={onContextMenu}
setFocusIndex={setFocusIndex}
/>
))}
</div>
);
}

View File

@ -9,18 +9,28 @@ describe("ContextMenu", () => {
y: 100,
entry: makeEntry(),
selectedCount: 1,
onCopy: vi.fn(),
onPaste: vi.fn(),
onCopyOnly: vi.fn(),
onPin: vi.fn(),
onDelete: vi.fn(),
};
it("renders Paste, Pin, and Delete actions", () => {
it("renders Paste, Paste plain, Copy, Pin, and Delete actions", () => {
render(<ContextMenu {...defaultProps} />);
expect(screen.getByText("Paste")).toBeInTheDocument();
expect(screen.getByText("Paste plain")).toBeInTheDocument();
expect(screen.getByText("Copy")).toBeInTheDocument();
expect(screen.getByText("Pin")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
});
it("calls onCopyOnly when Copy is clicked", () => {
const onCopyOnly = vi.fn();
render(<ContextMenu {...defaultProps} onCopyOnly={onCopyOnly} />);
fireEvent.click(screen.getByText("Copy"));
expect(onCopyOnly).toHaveBeenCalledOnce();
});
it("shows Unpin for pinned entries", () => {
const entry = makeEntry({ pinned: true });
render(<ContextMenu {...defaultProps} entry={entry} />);
@ -33,11 +43,25 @@ describe("ContextMenu", () => {
expect(screen.getByText("Delete 3 items")).toBeInTheDocument();
});
it("calls onCopy when Paste is clicked", () => {
const onCopy = vi.fn();
render(<ContextMenu {...defaultProps} onCopy={onCopy} />);
it("calls onPaste when Paste is clicked", () => {
const onPaste = vi.fn();
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
fireEvent.click(screen.getByText("Paste"));
expect(onCopy).toHaveBeenCalledOnce();
expect(onPaste).toHaveBeenCalledWith("none");
});
it("calls onPaste(plain) for Paste plain", () => {
const onPaste = vi.fn();
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
fireEvent.click(screen.getByText("Paste plain"));
expect(onPaste).toHaveBeenCalledWith("plain");
});
it("calls onPaste with transform id", () => {
const onPaste = vi.fn();
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
fireEvent.click(screen.getByText("Pretty JSON"));
expect(onPaste).toHaveBeenCalledWith("json");
});
it("calls onPin when Pin is clicked", () => {
@ -61,10 +85,10 @@ describe("ContextMenu", () => {
expect(menu.style.top).toBe("150px");
});
it("hides Pin button when multiple items are selected", () => {
it("hides Pin and transforms when multiple items are selected", () => {
render(<ContextMenu {...defaultProps} selectedCount={3} />);
expect(screen.queryByText("Pin")).not.toBeInTheDocument();
expect(screen.queryByText("Unpin")).not.toBeInTheDocument();
expect(screen.queryByText("Pretty JSON")).not.toBeInTheDocument();
});
it("stops click propagation", () => {

View File

@ -1,11 +1,13 @@
import type { ClipboardEntry } from "../types";
import { TRANSFORMS, type TransformId } from "../transforms";
interface Props {
x: number;
y: number;
entry: ClipboardEntry;
selectedCount: number;
onCopy: () => void;
onPaste: (transform?: TransformId) => void;
onCopyOnly: () => void;
onPin: () => void;
onDelete: () => void;
}
@ -15,33 +17,66 @@ export default function ContextMenu({
y,
entry,
selectedCount,
onCopy,
onPaste,
onCopyOnly,
onPin,
onDelete,
}: Props) {
const adjustedX = Math.min(x, window.innerWidth - 170);
const adjustedY = Math.min(y, window.innerHeight - 130);
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - 280);
const multi = selectedCount > 1;
return (
<div
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[160px] text-[13px]"
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[180px] text-[13px] max-h-[70vh] overflow-y-auto"
style={{ left: adjustedX, top: adjustedY }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
onClick={onCopy}
onClick={() => onPaste("none")}
>
{multi ? `Paste ${selectedCount} items` : "Paste"}
</button>
<button
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
onClick={() => onPaste("plain")}
>
Paste plain
<span className="float-right text-[10px] text-text-secondary ml-2"> click</span>
</button>
<button
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
onClick={onCopyOnly}
>
{multi ? `Copy ${selectedCount} items` : "Copy"}
<span className="float-right text-[10px] text-text-secondary ml-2">no auto-paste</span>
</button>
{!multi && (
<button
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
onClick={onPin}
>
{entry.pinned ? "Unpin" : "Pin"}
</button>
<>
<div className="border-t border-border my-1" />
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
Transform
</div>
{TRANSFORMS.filter((t) => t.id !== "none" && t.id !== "plain").map((t) => (
<button
key={t.id}
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
onClick={() => onPaste(t.id)}
>
{t.label}
</button>
))}
<div className="border-t border-border my-1" />
<button
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
onClick={onPin}
>
{entry.pinned ? "Unpin" : "Pin"}
</button>
</>
)}
<div className="border-t border-border my-1" />
<button

View File

@ -78,6 +78,23 @@ describe("SettingsPanel", () => {
});
});
it("enables macOS autostart when launching at login is turned on", async () => {
const { enable } = await import("@tauri-apps/plugin-autostart");
mockInvoke.mockResolvedValue(makeSettings({ launch_at_login: true }));
render(<SettingsPanel {...defaultProps} />);
const switches = screen.getAllByRole("switch");
fireEvent.click(switches[0]);
await waitFor(() => {
expect(enable).toHaveBeenCalled();
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
key: "launch_at_login",
value: "true",
});
});
});
it("invokes set_setting when changing max_history", async () => {
mockInvoke.mockResolvedValue(makeSettings({ max_history: 1000 }));
@ -92,6 +109,44 @@ describe("SettingsPanel", () => {
});
});
it("shows the current hotkey formatted with symbols", () => {
render(<SettingsPanel {...defaultProps} />);
expect(screen.getByText("Global hotkey")).toBeInTheDocument();
expect(screen.getByText("⌃`")).toBeInTheDocument();
});
it("enters recording mode when hotkey button clicked", () => {
render(<SettingsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("⌃`"));
expect(screen.getByText("Press shortcut… (Esc to cancel)")).toBeInTheDocument();
});
it("invokes set_hotkey when a shortcut is recorded", async () => {
mockInvoke.mockResolvedValue(makeSettings({ hotkey: "cmd+shift+v" }));
render(<SettingsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("⌃`"));
fireEvent.keyDown(window, { code: "KeyV", key: "v", metaKey: true, shiftKey: true });
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("set_hotkey", { hotkey: "cmd+shift+v" });
});
});
it("cancels recording on Escape", () => {
render(<SettingsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("⌃`"));
fireEvent.keyDown(window, { code: "Escape", key: "Escape" });
expect(screen.getByText("⌃`")).toBeInTheDocument();
});
it("shows error when no modifier is pressed", () => {
render(<SettingsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("⌃`"));
fireEvent.keyDown(window, { code: "KeyV", key: "v" });
expect(screen.getByText(/Include at least one modifier/)).toBeInTheDocument();
});
it("shows clear all button", () => {
render(<SettingsPanel {...defaultProps} />);
expect(screen.getByText("Clear All History")).toBeInTheDocument();

View File

@ -1,7 +1,57 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import type { Settings } from "../types";
/** Map a KeyboardEvent.code to the token used in hotkey strings (e.g. "v", "`", "f5"). */
function codeToToken(code: string): string | null {
if (code.startsWith("Key")) return code.slice(3).toLowerCase();
if (code.startsWith("Digit")) return code.slice(5);
if (/^F([1-9]|1[0-2])$/.test(code)) return code.toLowerCase();
const map: Record<string, string> = {
Backquote: "`",
Space: "space",
Enter: "enter",
Tab: "tab",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
Minus: "-",
Equal: "=",
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
};
return map[code] ?? null;
}
const TOKEN_SYMBOLS: Record<string, string> = {
cmd: "⌘",
shift: "⇧",
ctrl: "⌃",
alt: "⌥",
space: "Space",
enter: "↩",
tab: "⇥",
up: "↑",
down: "↓",
left: "←",
right: "→",
};
function formatHotkey(hotkey: string): string {
return hotkey
.split("+")
.map((t) => TOKEN_SYMBOLS[t] ?? t.toUpperCase())
.join("");
}
interface Props {
settings: Settings;
onClose: () => void;
@ -21,11 +71,48 @@ const POSITION_OPTIONS: { value: string; label: string }[] = [
export default function SettingsPanel({ settings: initialSettings, onClose, onUpdate }: Props) {
const [settings, setSettings] = useState<Settings>(initialSettings);
const [loginError, setLoginError] = useState<string | null>(null);
// Keep the toggle in sync with the real macOS Login Item (release builds).
useEffect(() => {
let cancelled = false;
(async () => {
try {
const on = await isEnabled();
if (cancelled || on === initialSettings.launch_at_login) return;
await invoke("set_setting", { key: "launch_at_login", value: String(on) });
const updated: Settings = await invoke("get_settings");
if (!cancelled) {
setSettings(updated);
onUpdate(updated);
}
} catch {
// Dev / tests may lack the native plugin — ignore.
}
})();
return () => {
cancelled = true;
};
}, [initialSettings.launch_at_login, onUpdate]);
const updateSetting = useCallback(
async (key: string, value: string) => {
// Optimistically update local state for instant visual feedback
setSettings((prev) => ({ ...prev, [key]: parseSettingValue(key, value) }));
setLoginError(null);
if (key === "launch_at_login") {
try {
if (value === "true") await enable();
else await disable();
} catch (e) {
setLoginError(
"Could not update Login Items. Use a built .app (npm run tauri build), then try again."
);
// Revert optimistic UI
setSettings((prev) => ({ ...prev, launch_at_login: value !== "true" }));
return;
}
}
await invoke("set_setting", { key, value });
const updated: Settings = await invoke("get_settings");
@ -66,6 +153,11 @@ export default function SettingsPanel({ settings: initialSettings, onClose, onUp
checked={settings.launch_at_login}
onChange={(v) => updateSetting("launch_at_login", String(v))}
/>
{loginError && <p className="text-[12px] text-danger -mt-3">{loginError}</p>}
<p className="text-[11px] text-text-secondary -mt-3">
Opens maCopy when you log in to your Mac (Login Items). Needs the installed .app, not just
`tauri dev`.
</p>
<ToggleRow
label="Show image previews"
@ -73,6 +165,14 @@ export default function SettingsPanel({ settings: initialSettings, onClose, onUp
onChange={(v) => updateSetting("show_images", String(v))}
/>
<HotkeyRow
hotkey={settings.hotkey}
onSaved={(updated) => {
setSettings(updated);
onUpdate(updated);
}}
/>
<div>
<label className="block text-[13px] font-medium text-text-primary mb-2">
Max history
@ -135,6 +235,80 @@ function parseSettingValue(key: string, value: string): unknown {
return value;
}
function HotkeyRow({
hotkey,
onSaved,
}: {
hotkey: string;
onSaved: (settings: Settings) => void;
}) {
const [recording, setRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!recording) return;
const handleKeyDown = async (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setRecording(false);
return;
}
const token = codeToToken(e.code);
if (!token) return; // modifier-only or unsupported key — keep waiting
const mods: string[] = [];
if (e.metaKey) mods.push("cmd");
if (e.ctrlKey) mods.push("ctrl");
if (e.altKey) mods.push("alt");
if (e.shiftKey) mods.push("shift");
if (mods.length === 0) {
setError("Include at least one modifier (⌘ ⌃ ⌥ ⇧)");
return;
}
const newHotkey = [...mods, token].join("+");
setRecording(false);
setError(null);
try {
await invoke("set_hotkey", { hotkey: newHotkey });
const updated: Settings = await invoke("get_settings");
onSaved(updated);
} catch (err) {
setError(String(err));
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [recording, onSaved]);
return (
<div>
<label className="block text-[13px] font-medium text-text-primary mb-2">
Global hotkey
</label>
<button
onClick={() => {
setError(null);
setRecording((r) => !r);
}}
className={`w-full py-1.5 rounded text-[13px] font-medium transition-colors border
${recording
? "border-accent text-accent bg-accent/10 animate-pulse"
: "border-border bg-surface-hover text-text-primary hover:bg-surface-active"
}`}
>
{recording ? "Press shortcut… (Esc to cancel)" : formatHotkey(hotkey)}
</button>
{error && <p className="mt-1.5 text-[12px] text-danger">{error}</p>}
</div>
);
}
function ToggleRow({
label,
checked,

View File

@ -11,6 +11,8 @@ export function makeEntry(overrides: Partial<ClipboardEntry> = {}): ClipboardEnt
created_at: new Date().toISOString(),
pinned: false,
content_hash: `hash_${id}`,
truncated: false,
sensitive: false,
...overrides,
};
}
@ -23,6 +25,7 @@ export function makeSettings(overrides: Partial<Settings> = {}): Settings {
window_position: "cursor",
window_width: 420,
window_height: 560,
hotkey: "ctrl+`",
...overrides,
};
}

View File

@ -14,6 +14,7 @@ vi.mock("@tauri-apps/api/window", () => ({
show: vi.fn(() => Promise.resolve()),
innerSize: vi.fn(() => Promise.resolve({ width: 840, height: 1120 })),
scaleFactor: vi.fn(() => Promise.resolve(2)),
onFocusChanged: vi.fn(() => Promise.resolve(() => {})),
})),
}));
@ -21,3 +22,9 @@ vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({
writeText: vi.fn(() => Promise.resolve()),
readText: vi.fn(() => Promise.resolve("")),
}));
vi.mock("@tauri-apps/plugin-autostart", () => ({
enable: vi.fn(() => Promise.resolve()),
disable: vi.fn(() => Promise.resolve()),
isEnabled: vi.fn(() => Promise.resolve(false)),
}));

20
src/transforms.test.ts Normal file
View File

@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { applyTransform, toPlainText } from "./transforms";
describe("transforms", () => {
it("strips html for plain paste", () => {
expect(toPlainText("<b>Hi</b>&nbsp;there")).toBe("Hi there");
});
it("pretty-prints json", () => {
expect(applyTransform('{"a":1}', "json")).toContain('\n "a": 1');
});
it("collapses whitespace", () => {
expect(applyTransform("a b\n\n\nc", "collapse")).toBe("a b\n\nc");
});
it("single line", () => {
expect(applyTransform("a\nb\tc", "oneline")).toBe("a b c");
});
});

74
src/transforms.ts Normal file
View File

@ -0,0 +1,74 @@
/** Paste-time text transforms (applied after loading full clipboard content). */
export type TransformId =
| "none"
| "plain"
| "trim"
| "lower"
| "upper"
| "collapse"
| "json"
| "oneline";
export const TRANSFORMS: { id: TransformId; label: string; hint?: string }[] = [
{ id: "none", label: "As copied" },
{ id: "plain", label: "Plain text", hint: "Strip tags / formatting" },
{ id: "trim", label: "Trim whitespace" },
{ id: "collapse", label: "Collapse spaces" },
{ id: "oneline", label: "Single line" },
{ id: "lower", label: "lowercase" },
{ id: "upper", label: "UPPERCASE" },
{ id: "json", label: "Pretty JSON" },
];
function decodeBasicEntities(s: string): string {
return s
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'");
}
/** Paste without formatting: strip HTML-ish markup and normalize whitespace characters. */
export function toPlainText(input: string): string {
let s = input.replace(/\u00a0/g, " ").replace(/[\u200B-\u200D\uFEFF]/g, "");
// Drop simple tags if it looks like HTML
if (/<\/?[a-z][\s\S]*>/i.test(s)) {
s = s.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]+>/g, "");
}
s = decodeBasicEntities(s);
// Soft line-break leftovers from rich paste
s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
return s;
}
export function applyTransform(input: string, id: TransformId): string {
switch (id) {
case "none":
return input;
case "plain":
return toPlainText(input);
case "trim":
return input.trim();
case "lower":
return input.toLowerCase();
case "upper":
return input.toUpperCase();
case "collapse":
return input.replace(/[ \t\u00a0]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
case "oneline":
return toPlainText(input).replace(/\s+/g, " ").trim();
case "json": {
try {
return JSON.stringify(JSON.parse(input.trim()), null, 2);
} catch {
return input;
}
}
default:
return input;
}
}

View File

@ -5,6 +5,10 @@ export interface ClipboardEntry {
created_at: string;
pinned: boolean;
content_hash: string;
/** True when `content` is a preview stub — fetch full via get_entry before paste. */
truncated?: boolean;
/** Preview is redacted (password/card/banking); full value still pastes. */
sensitive?: boolean;
}
export interface Settings {
@ -14,4 +18,8 @@ export interface Settings {
window_position: "cursor" | "center" | "top-right" | "top-left" | "bottom-right" | "bottom-left";
window_width: number;
window_height: number;
hotkey: string;
}
/** How many history rows to request for the picker UI. */
export const DISPLAY_LIMIT = 150;

162
src/visual/main.tsx Normal file
View File

@ -0,0 +1,162 @@
import { useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import ClipboardList from "../components/ClipboardList";
import ContextMenu from "../components/ContextMenu";
import SearchBar from "../components/SearchBar";
import type { ClipboardEntry } from "../types";
import { applyTransform, type TransformId } from "../transforms";
import "../index.css";
const DEMO: ClipboardEntry[] = [
{
id: 1,
content: "•••••••• (password)",
content_type: "text",
created_at: new Date().toISOString(),
pinned: true,
content_hash: "a",
truncated: true,
sensitive: true,
},
{
id: 2,
content: "•••• •••• •••• 1111 (card)",
content_type: "text",
created_at: new Date(Date.now() - 60_000).toISOString(),
pinned: false,
content_hash: "b",
truncated: true,
sensitive: true,
},
{
id: 3,
content: "Meeting notes — ship maCopy v0.1",
content_type: "text",
created_at: new Date(Date.now() - 3_600_000).toISOString(),
pinned: false,
content_hash: "c",
truncated: false,
sensitive: false,
},
{
id: 4,
content: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5NiIgaGVpZ2h0PSI2NCI+PHJlY3Qgd2lkdGg9Ijk2IiBoZWlnaHQ9IjY0IiBmaWxsPSIjNjM2NmYxIi8+PHRleHQgeD0iNDgiIHk9IjM4IiBmb250LXNpemU9IjEyIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+dGh1bWI8L3RleHQ+PC9zdmc+",
content_type: "image",
created_at: new Date(Date.now() - 7200_000).toISOString(),
pinned: false,
content_hash: "d",
truncated: true,
sensitive: false,
},
];
function VisualApp() {
const [query, setQuery] = useState("");
const [focusIndex, setFocusIndex] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set([1]));
const [banner, setBanner] = useState("Ready — watch the headed walkthrough");
const [menu, setMenu] = useState<{ x: number; y: number; entry: ClipboardEntry } | null>(
null
);
const [lastPaste, setLastPaste] = useState("");
const entries = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return DEMO;
return DEMO.filter((e) => e.content.toLowerCase().includes(q));
}, [query]);
const paste = (entry: ClipboardEntry, transform: TransformId = "none") => {
// Simulate paste of *stored* secret (not the redacted preview)
const full =
entry.id === 1
? "Tr0ub4dor&3xY!"
: entry.id === 2
? "4111111111111111"
: entry.content;
const out = applyTransform(full, transform);
setLastPaste(out);
setBanner(`Paste (${transform}): ${out.slice(0, 48)}${out.length > 48 ? "…" : ""}`);
setMenu(null);
};
return (
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
<div className="shrink-0 px-3 py-2 border-b border-border flex items-center justify-between gap-2">
<div>
<div className="text-[11px] uppercase tracking-wide text-text-secondary">
Visual test page
</div>
<div className="text-[13px] text-text-primary" data-testid="banner">
{banner}
</div>
</div>
<button
data-testid="open-menu"
className="text-[12px] px-2 py-1 rounded bg-accent text-white"
onClick={() => setMenu({ x: 220, y: 160, entry: entries[0] ?? DEMO[0] })}
>
Open context menu
</button>
</div>
<div className="shrink-0 px-2 py-2">
<SearchBar value={query} onChange={setQuery} />
</div>
<div className="flex-1 overflow-y-auto min-h-0" data-testid="list">
<ClipboardList
entries={entries}
selectedIds={selectedIds}
focusIndex={focusIndex}
showImages
onSelect={(entry, opts) => paste(entry, opts?.plain ? "plain" : "none")}
onCtrlClick={(i) => {
const e = entries[i];
if (!e) return;
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(e.id)) next.delete(e.id);
else next.add(e.id);
return next;
});
setBanner(`Toggled select #${e.id}`);
}}
onShiftClick={(i) => {
setFocusIndex(i);
setBanner(`Range focus → ${i}`);
}}
onPlainClick={(i) => setFocusIndex(i)}
onContextMenu={(e, entry) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, entry });
}}
setFocusIndex={setFocusIndex}
/>
</div>
<div className="shrink-0 px-3 py-2 border-t border-border text-[11px] text-text-secondary flex justify-between">
<span> click = plain · right-click = transforms</span>
<span data-testid="last-paste">{lastPaste ? `last: ${lastPaste}` : `${entries.length} items`}</span>
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
entry={menu.entry}
selectedCount={selectedIds.size}
onPaste={(t) => paste(menu.entry, t ?? "none")}
onCopyOnly={() => setBanner("Copied to clipboard (demo)")}
onPin={() => setBanner("Pin toggled (demo)")}
onDelete={() => {
setBanner("Delete (demo — not persisted)");
setMenu(null);
}}
/>
)}
</div>
);
}
createRoot(document.getElementById("root")!).render(<VisualApp />);

12
visual.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>maCopy — visual test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/visual/main.tsx"></script>
</body>
</html>

31
vitest.browser.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
/**
* Headed browser runs so you can watch the UI under test.
* Usage: npm run test:headed
*/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright({
launchOptions: {
headless: false,
slowMo: 250,
},
}),
instances: [{ browser: "chromium" }],
headless: false,
viewport: { width: 900, height: 700 },
},
// Keep Chrome open longer between files so you can watch
fileParallelism: false,
sequence: { concurrent: false },
},
});