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() {
/>
) : (
<>
-