diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d384d..17300be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to maCopy. Format loosely follows ## [Unreleased] +### Changed +- Menu-bar glyph is a clipboard with an infinity mark (template image so + macOS tints it). Replaces the dark app icon that collapsed into a grey pill. + +### Added +- Settings gear next to Search in the picker (tray right-click is easy to miss) + +### Fixed +- Tray click no longer opens on mouse-down and closes on mouse-up. + ### Fixed - Search queries with path punctuation (`/idobkin`, `github.com`, …) no longer hit FTS5 syntax errors and leave the picker stuck on the full diff --git a/README.md b/README.md index 2e029a9..9f92a74 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ macOS clipboard manager in the menu bar. Built with Tauri 2, React, TypeScript, | Area | Behavior | |------|----------| -| Menu bar | No dock icon; lives in the tray | +| Menu bar | No dock icon; tray glyph is a clipboard with ∞ | | Global hotkey | `` Ctrl+` `` opens the window (does not steal macOS Cmd+` window-cycle); configurable in Settings | | Clipboard monitoring | Watches pasteboard changeCount; captures text, images, and file paths | | List performance | Truncated previews in list API; image blobs stay in SQLite until paste | diff --git a/ROADMAP.md b/ROADMAP.md index 1414870..e5d7687 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,7 @@ Living list of shipping ideas. Checked items are done (or landed in the current - [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 +- [x] **Menu-bar template icon** — clipboard + ∞, tinted by macOS (not the dark app icon) ## Next up (high value) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f006d5c..7492519 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -21,8 +21,9 @@ Current version: see [CHANGELOG.md](../CHANGELOG.md). Feature backlog: 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. +4. Look for the maCopy icon in the menu bar (top-right) — a clipboard + with an infinity mark. No Dock icon — that's expected, it's a + menu-bar-only app. ### Option B — build it yourself @@ -42,9 +43,10 @@ login** to stick across rebuilds (see step 4 below). ### First-run checklist -- [ ] App icon visible in the menu bar +- [ ] App icon visible in the menu bar (clipboard + ∞) - [ ] Accessibility permission granted (Settings → Privacy & Security → Accessibility) - [ ] Global hotkey `` Ctrl+` `` opens/closes the picker +- [ ] Gear next to Search opens the Settings panel - [ ] (Optional) Settings → **Launch at login** turned on --- @@ -103,7 +105,9 @@ On macOS, **Control+click** opens the context menu (same as right-click). Use ** ### Settings panel -Menu bar icon → **Settings…** +Gear button next to Search in the picker, or menu-bar icon → **Settings…** +(Control-click on the tray icon opens the picker, not Settings — use +two-finger / right-click, or the gear.) | Setting | What it does | |---|---| diff --git a/docs/LAUNCH-AT-LOGIN.md b/docs/LAUNCH-AT-LOGIN.md index 78903c4..f870e2a 100644 --- a/docs/LAUNCH-AT-LOGIN.md +++ b/docs/LAUNCH-AT-LOGIN.md @@ -11,7 +11,8 @@ maCopy can register itself as a macOS **Login Item** so it starts when you log i open src-tauri/target/release/bundle/macos/maCopy.app ``` -2. Menu bar tray → **Settings…** → turn on **Launch at login**. +2. Open Settings (gear next to Search in the picker, or tray menu → + **Settings…**) → turn on **Launch at login**. 3. Confirm in System Settings → **General** → **Login Items & Extensions** → maCopy is listed. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9e316cb..bb6be83 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -41,7 +41,7 @@ Checklist beyond “it works on my machine.” Items marked done are already in- | Area | Idea | |---|---| | Updates | Sparkle or Tauri updater + signed feeds | -| Branding | Final icon, dmg background, website one-pager | +| Branding | dmg background, website one-pager (tray + app icon: clipboard + ∞) | | Privacy | Short privacy policy (local-only DB; no telemetry) | | Support | Issue templates, FAQ (hotkey conflict with cycle-windows) | | Onboarding | Empty-state tips; `` Ctrl+` `` callout | diff --git a/docs/TESTING.md b/docs/TESTING.md index 8a8fbc9..8b5b6c2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -26,7 +26,7 @@ Current counts (approx.): **~72 frontend** + **~67 Rust** unit tests. - ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste - ContextMenu: Paste / Paste plain / transforms / Pin / Delete - SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable** -- SearchBar basics +- SearchBar basics + Settings gear - Transforms: plain text strip, JSON pretty, collapse / oneline Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard, autostart). diff --git a/package.json b/package.json index 7a3482a..2486484 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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", + "icons": "node scripts/render-icons.mjs", "test:rust": "cd src-tauri && cargo test", "test:all": "npm run test && npm run test:rust", "lint": "tsc --noEmit", diff --git a/scripts/render-icons.mjs b/scripts/render-icons.mjs new file mode 100644 index 0000000..12f4bd8 --- /dev/null +++ b/scripts/render-icons.mjs @@ -0,0 +1,131 @@ +/** + * Rasterize clipboard-infinity.svg into tray templates + app icons. + * + * node scripts/render-icons.mjs + */ +import { chromium } from "playwright"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const exec = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const icons = path.join(root, "src-tauri/icons"); + +const GLYPH = ` + + + +`; + +function pageHtml() { + return ` + + +
+`; +} + +async function shot(page, size, kind) { + await page.evaluate(({ size, kind }) => window.draw({ size, kind }), { size, kind }); + const el = page.locator("#host svg"); + await el.waitFor(); + return el.screenshot({ omitBackground: true }); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 1200, height: 1200 }, + deviceScaleFactor: 1, + }); + await page.setContent(pageHtml()); + + const write = async (name, buf) => { + const dest = path.join(icons, name); + await writeFile(dest, buf); + console.log("wrote", dest, buf.length); + }; + + await write("menubarTemplate.png", await shot(page, 22, "template")); + await write("menubarTemplate@2x.png", await shot(page, 44, "template")); + + const appSizes = { + "32x32.png": 32, + "64x64.png": 64, + "128x128.png": 128, + "128x128@2x.png": 256, + "icon.png": 512, + "Square30x30Logo.png": 30, + "Square44x44Logo.png": 44, + "Square71x71Logo.png": 71, + "Square89x89Logo.png": 89, + "Square107x107Logo.png": 107, + "Square142x142Logo.png": 142, + "Square150x150Logo.png": 150, + "Square284x284Logo.png": 284, + "Square310x310Logo.png": 310, + "StoreLogo.png": 50, + }; + for (const [name, size] of Object.entries(appSizes)) { + await write(name, await shot(page, size, "app")); + } + + const iconset = path.join(icons, "icon.iconset"); + await rm(iconset, { recursive: true, force: true }); + await mkdir(iconset, { recursive: true }); + const icnsMap = { + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, + "icon_256x256@2x.png": 512, + "icon_512x512.png": 512, + "icon_512x512@2x.png": 1024, + }; + for (const [name, size] of Object.entries(icnsMap)) { + await writeFile(path.join(iconset, name), await shot(page, size, "app")); + } + await exec("iconutil", ["-c", "icns", iconset, "-o", path.join(icons, "icon.icns")]); + console.log("wrote icon.icns"); + await rm(iconset, { recursive: true, force: true }); + + await exec("magick", [ + path.join(icons, "icon.png"), + "-define", + "icon:auto-resize=256,128,64,48,32,16", + path.join(icons, "icon.ico"), + ]); + console.log("wrote icon.ico"); + + await browser.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index a230305..2b7626f 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 156c76e..03ddf17 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index f5a4629..0b27448 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index f87e95d..3e71932 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 44fcebb..83a5c4d 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 68b1beb..c45e1fd 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 809a57a..e002a1b 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 78c0ad8..49a3de3 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index f1c7419..0fc9ea7 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f5ce32d..79bb3af 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 1347dfa..cfae4d9 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 9dfdab0..933c1c8 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 59e8910..0448d94 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 54f5773..bafdceb 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/clipboard-infinity.svg b/src-tauri/icons/clipboard-infinity.svg new file mode 100644 index 0000000..c9fa3f7 --- /dev/null +++ b/src-tauri/icons/clipboard-infinity.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 3193631..5aee6de 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 0b15a99..36e994c 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 137254e..5555a84 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/menubarTemplate.png b/src-tauri/icons/menubarTemplate.png new file mode 100644 index 0000000..7afef94 Binary files /dev/null and b/src-tauri/icons/menubarTemplate.png differ diff --git a/src-tauri/icons/menubarTemplate@2x.png b/src-tauri/icons/menubarTemplate@2x.png new file mode 100644 index 0000000..dc56335 Binary files /dev/null and b/src-tauri/icons/menubarTemplate@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ae2254..67fb0ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,11 +6,13 @@ mod redact; use commands::{DbState, LastHashState, PausedState}; use db::Database; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::{ + image::Image, menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, - tray::TrayIconBuilder, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, WindowEvent, }; use tauri_plugin_autostart::MacosLauncher; @@ -161,6 +163,26 @@ fn toggle_window(app: &tauri::AppHandle) { } } +/// Tray `Click` fires on both mouse-down and mouse-up. Only the down edge +/// should toggle — otherwise press opens and release immediately closes. +fn is_tray_primary_down(event: &TrayIconEvent) -> bool { + matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Down, + .. + } + ) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + fn position_window(window: &tauri::WebviewWindow, position: &str) { let get_monitor_info = |w: &tauri::WebviewWindow| -> Option<(f64, f64, f64)> { @@ -271,6 +293,7 @@ pub fn run() { commands::set_hotkey, ]) .setup(move |app| { + let ignore_blur_until = Arc::new(AtomicU64::new(0)); let hotkey_str = db .get_settings() .map(|s| s.hotkey) @@ -290,8 +313,11 @@ pub fn run() { let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show_i, &pause_i, &sep, &settings_i, &quit_i])?; + let tray_icon = Image::from_bytes(include_bytes!("../icons/menubarTemplate@2x.png")) + .expect("embedded menu-bar template"); let _tray = TrayIconBuilder::with_id("macopy-tray") - .icon(app.default_window_icon().unwrap().clone()) + .icon(tray_icon) + .icon_as_template(true) .menu(&menu) .show_menu_on_left_click(false) .tooltip("maCopy — Clipboard Manager") @@ -316,8 +342,15 @@ pub fn run() { _ => {} } }) - .on_tray_icon_event(|tray, event| { - if let tauri::tray::TrayIconEvent::Click { .. } = event { + .on_tray_icon_event({ + let ignore_blur_until = ignore_blur_until.clone(); + move |tray, event| { + if !is_tray_primary_down(&event) { + return; + } + // Mouse-up lands on the menu bar and steals focus; don't + // treat that as "clicked outside". + ignore_blur_until.store(now_millis() + 500, Ordering::Relaxed); toggle_window(tray.app_handle()); } }) @@ -328,13 +361,17 @@ pub fn run() { if let Some(window) = app.get_webview_window("main") { let w = window.clone(); + let ignore_blur_until = ignore_blur_until.clone(); window.on_window_event(move |event| { if let WindowEvent::Focused(false) = event { + if now_millis() < ignore_blur_until.load(Ordering::Relaxed) { + return; + } // 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)); + std::thread::sleep(Duration::from_millis(180)); if !w2.is_focused().unwrap_or(false) { let _ = w2.hide(); } @@ -423,4 +460,32 @@ mod tests { assert!(parse_hotkey("cmd+banana").is_none()); assert!(parse_hotkey("cmd+v+x").is_none()); } + + #[test] + fn tray_toggle_is_left_mouse_down_only() { + let down = TrayIconEvent::Click { + id: tauri::tray::TrayIconId::new("t"), + position: tauri::PhysicalPosition::new(0.0, 0.0), + rect: tauri::Rect::default(), + button: MouseButton::Left, + button_state: MouseButtonState::Down, + }; + let up = TrayIconEvent::Click { + id: tauri::tray::TrayIconId::new("t"), + position: tauri::PhysicalPosition::new(0.0, 0.0), + rect: tauri::Rect::default(), + button: MouseButton::Left, + button_state: MouseButtonState::Up, + }; + let right = TrayIconEvent::Click { + id: tauri::tray::TrayIconId::new("t"), + position: tauri::PhysicalPosition::new(0.0, 0.0), + rect: tauri::Rect::default(), + button: MouseButton::Right, + button_state: MouseButtonState::Down, + }; + assert!(is_tray_primary_down(&down)); + assert!(!is_tray_primary_down(&up)); + assert!(!is_tray_primary_down(&right)); + } } diff --git a/src/App.tsx b/src/App.tsx index b75d270..cd037fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -356,7 +356,7 @@ export default function App() { /> ) : ( <> -
+
{ @@ -365,6 +365,7 @@ export default function App() { setAnchorIndex(0); setSelectedIds(new Set()); }} + onOpenSettings={() => setShowSettings(true)} />
diff --git a/src/components/SearchBar.test.tsx b/src/components/SearchBar.test.tsx index 1eb5074..ecf7a4d 100644 --- a/src/components/SearchBar.test.tsx +++ b/src/components/SearchBar.test.tsx @@ -31,4 +31,17 @@ describe("SearchBar", () => { const input = screen.getByPlaceholderText("Search…"); expect(input).toBeInTheDocument(); }); + + it("calls onOpenSettings when the gear is pressed", async () => { + const onOpenSettings = vi.fn(); + const user = userEvent.setup(); + render( {}} onOpenSettings={onOpenSettings} />); + await user.click(screen.getByRole("button", { name: "Settings" })); + expect(onOpenSettings).toHaveBeenCalledOnce(); + }); + + it("hides the settings gear when onOpenSettings is omitted", () => { + render( {}} />); + expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); + }); }); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 7f81ac4..878917a 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -3,9 +3,10 @@ import { useEffect, useRef } from "react"; interface Props { value: string; onChange: (value: string) => void; + onOpenSettings?: () => void; } -export default function SearchBar({ value, onChange }: Props) { +export default function SearchBar({ value, onChange, onOpenSettings }: Props) { const inputRef = useRef(null); useEffect(() => { @@ -18,30 +19,63 @@ export default function SearchBar({ value, onChange }: Props) { }, []); return ( -
- - +
+ + + + onChange(e.target.value)} + placeholder="Search…" + className="w-full pl-8 pr-3 py-1.5 bg-surface-hover border border-border rounded-md + text-[13px] text-text-primary placeholder:text-text-secondary + focus:outline-none focus:border-accent/50 transition-colors" /> - - onChange(e.target.value)} - placeholder="Search…" - className="w-full pl-8 pr-3 py-1.5 bg-surface-hover border border-border rounded-md - text-[13px] text-text-primary placeholder:text-text-secondary - focus:outline-none focus:border-accent/50 transition-colors" - /> +
+ {onOpenSettings && ( + + )}
); }