diff --git a/.gitignore b/.gitignore index 16e855a..c9c1bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /target .superpowers/ .claude/settings.local.json + +# `tauri icon` side-products for platforms this app doesn't ship on +crates/openwith-gui/src-tauri/icons/ios/ +crates/openwith-gui/src-tauri/icons/android/ diff --git a/CLAUDE.md b/CLAUDE.md index c5d08e7..9f1703f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,7 @@ crates/ - GUI: single `get_snapshot` command returns apps + associations (with sibling-UTI conflict data) + contested schemes in one call; the frontend is a plain render-to-innerHTML loop with `data-action` event delegation, no framework. Versions are lockstep: `tauri.conf.json` omits `version` so the app version comes from `workspace.package` in the root Cargo.toml. - `openwith-core::history` is the shared change log (capped at 500 events, best-effort writes that never fail the triggering change). CLI, GUI, and core import all record into it; the GUI Profiles panel shows export/import events, the menu-bar popover shows set events with per-entry Undo, and `openwith history`/`openwith undo` read the same file. - The GUI is two windows off one Vite bundle: `main` and a hidden transparent `menubar` popover (requires `macOSPrivateApi: true`). The popover hides on blur and is toggled by the tray icon or a configurable global shortcut (default ⌥⌘O; `set_toggle_shortcut` swaps the registration at runtime, the saved accelerator is re-applied at bootstrap). A **Pin** button suspends hide-on-blur for one showing (backend `PopoverPinned` AtomicBool, reset on every toggle) so a file can be dragged in from Finder — without it the click into Finder blurs and hides the panel. Focus events are unreliable for the transparent panel, so the backend emits `popover-shown` on every open and the popover refreshes from that, not just from focus. A backend `AppsCache` (refreshed by `get_snapshot`) keeps popover lookups instant. +- App icons come from the 2026-07 logo renders in `artifacts/` (`logo-mono-glyph.png`, `logo-icon-dark.png`, `logo-icon-light.png` — AI renders with **no alpha**, so masking is scripted, not manual): light → 1024 master → `tauri icon` set; dark → `icons/icon-dark.png`; mono glyph → `icons/tray-template.png` (64×44 black+alpha template, glyph ≈34px tall). The Dock icon follows the app's *resolved* appearance at runtime — `set_dock_icon_dark` swaps `NSApplication.applicationIconImage` between `icon.png`/`icon-dark.png` (macOS only re-renders bundle icons for the system appearance), invoked from `theme.ts` on every theme change. `icon.icns` is **hand-built**: the ≤64px slots use a legibility variant (header dots inpainted away, no shadow, larger glyph), so rerunning `tauri icon` clobbers it. All of these regenerate with `uv run scripts/gen-icons.py` (run it *after* any `tauri icon` invocation; it prints the `tauri icon` command for the PNG set). - GUI settings live in localStorage (`openwith.settings`). The Settings pane mirrors the design prototype's full layout; controls whose feature ships in a later 0.5.x phase (launch at login, menu bar) render disabled with an "arrives in v0.5.1" note rather than as silently-dead toggles. - GUI visual source of truth is the claude.design prototype "OpenWith GUI Explorations" (project 14225854-984c-4e5c-8d2b-8c9ce38a1624), variants 1c (light) / 2a (dark): glyph tab icons (⌸ ⊞ ⤴ ⇅ — never emoji), 2-char initial chips (20px rows / 26px app list / 52px detail), fixed mid accent oklch(0.62 0.14 45) for tab underline + toggles, inverted toast. Check UI changes against it before shipping. @@ -160,7 +161,7 @@ Run against the built .app (not just `tauri dev`) before tagging any release wit - [ ] Close the main window, reopen via Dock click AND via popover "Open main window" — repeat ×3 - [ ] Toggle "Show in menu bar" off/on ×3 — exactly one tray icon at every step - [ ] Hide Dock icon on/off; then turn the tray off while the Dock is hidden — Dock icon must come back -- [ ] Appearance: flip System/Light/Dark with the popover open — both windows restyle +- [ ] Appearance: flip System/Light/Dark with the popover open — both windows restyle AND the Dock icon flips light/dark (System follows macOS) - [ ] Every Settings toggle: launch at login, confirm before applying, warn on UTI conflicts, show bundle IDs, relaunch Finder, check automatically, channel, open-on-tab - [ ] Set a default from the Extensions sheet; verify with `openwith current `; Undo from the toast; verify again - [ ] Toasts dismiss themselves (~5s, ~8s with an Undo button) without being replaced diff --git a/Cargo.lock b/Cargo.lock index 92ba4b4..78046f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2421,9 +2421,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "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]] @@ -2443,6 +2451,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -2503,6 +2512,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.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2624,7 +2646,7 @@ dependencies = [ [[package]] name = "openwith-cli" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", "clap", @@ -2640,7 +2662,7 @@ dependencies = [ [[package]] name = "openwith-core" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", "core-foundation", @@ -2652,9 +2674,12 @@ dependencies = [ [[package]] name = "openwith-gui" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", + "objc2", + "objc2-app-kit", + "objc2-foundation", "openwith-core", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 6275fb9..d9c98da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/openwith-core", "crates/openwith-cli", "crates/openwith-gui/src-tauri"] [workspace.package] -version = "0.5.3" +version = "0.5.4" edition = "2024" license = "MIT" repository = "https://github.com/ColeMei/openwith" diff --git a/artifacts/logo-icon-dark.png b/artifacts/logo-icon-dark.png new file mode 100644 index 0000000..9ba2140 Binary files /dev/null and b/artifacts/logo-icon-dark.png differ diff --git a/artifacts/logo-icon-light.png b/artifacts/logo-icon-light.png new file mode 100644 index 0000000..affeccd Binary files /dev/null and b/artifacts/logo-icon-light.png differ diff --git a/artifacts/logo-mono-glyph.png b/artifacts/logo-mono-glyph.png new file mode 100644 index 0000000..463a8ef Binary files /dev/null and b/artifacts/logo-mono-glyph.png differ diff --git a/crates/openwith-gui/package.json b/crates/openwith-gui/package.json index e0cb095..79c08d9 100644 --- a/crates/openwith-gui/package.json +++ b/crates/openwith-gui/package.json @@ -1,7 +1,7 @@ { "name": "openwith-gui", "private": true, - "version": "0.5.3", + "version": "0.5.4", "type": "module", "scripts": { "dev": "vite", diff --git a/crates/openwith-gui/src-tauri/Cargo.toml b/crates/openwith-gui/src-tauri/Cargo.toml index 8c0b89d..b1ef388 100644 --- a/crates/openwith-gui/src-tauri/Cargo.toml +++ b/crates/openwith-gui/src-tauri/Cargo.toml @@ -25,3 +25,10 @@ tauri-plugin-positioner = { version = "2", features = ["tray-icon"] } tauri-plugin-autostart = "2" serde.workspace = true serde_json.workspace = true + +# Runtime Dock-icon swap (NSApplication.applicationIconImage); same versions +# tauri already pulls in transitively. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] } +objc2-foundation = { version = "0.3", features = ["NSData"] } diff --git a/crates/openwith-gui/src-tauri/capabilities/default.json b/crates/openwith-gui/src-tauri/capabilities/default.json index a02011a..de80fdc 100644 --- a/crates/openwith-gui/src-tauri/capabilities/default.json +++ b/crates/openwith-gui/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "autostart:allow-is-enabled", "core:window:allow-hide", "core:window:allow-show", - "core:window:allow-set-focus" + "core:window:allow-set-focus", + "core:window:allow-start-dragging" ] } diff --git a/crates/openwith-gui/src-tauri/icons/128x128.png b/crates/openwith-gui/src-tauri/icons/128x128.png index 4cd5f87..4742d0a 100644 Binary files a/crates/openwith-gui/src-tauri/icons/128x128.png and b/crates/openwith-gui/src-tauri/icons/128x128.png differ diff --git a/crates/openwith-gui/src-tauri/icons/128x128@2x.png b/crates/openwith-gui/src-tauri/icons/128x128@2x.png index 4e7a24f..b4f6d48 100644 Binary files a/crates/openwith-gui/src-tauri/icons/128x128@2x.png and b/crates/openwith-gui/src-tauri/icons/128x128@2x.png differ diff --git a/crates/openwith-gui/src-tauri/icons/32x32.png b/crates/openwith-gui/src-tauri/icons/32x32.png index 2c0c89a..1a4a28c 100644 Binary files a/crates/openwith-gui/src-tauri/icons/32x32.png and b/crates/openwith-gui/src-tauri/icons/32x32.png differ diff --git a/crates/openwith-gui/src-tauri/icons/64x64.png b/crates/openwith-gui/src-tauri/icons/64x64.png index 1f1faee..ae5b293 100644 Binary files a/crates/openwith-gui/src-tauri/icons/64x64.png and b/crates/openwith-gui/src-tauri/icons/64x64.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square107x107Logo.png b/crates/openwith-gui/src-tauri/icons/Square107x107Logo.png index fd22b61..d56226b 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square107x107Logo.png and b/crates/openwith-gui/src-tauri/icons/Square107x107Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square142x142Logo.png b/crates/openwith-gui/src-tauri/icons/Square142x142Logo.png index 9a6cd78..153e3b6 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square142x142Logo.png and b/crates/openwith-gui/src-tauri/icons/Square142x142Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square150x150Logo.png b/crates/openwith-gui/src-tauri/icons/Square150x150Logo.png index a5f3aae..4df5b55 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square150x150Logo.png and b/crates/openwith-gui/src-tauri/icons/Square150x150Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square284x284Logo.png b/crates/openwith-gui/src-tauri/icons/Square284x284Logo.png index 8f88232..e750d87 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square284x284Logo.png and b/crates/openwith-gui/src-tauri/icons/Square284x284Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square30x30Logo.png b/crates/openwith-gui/src-tauri/icons/Square30x30Logo.png index 93a538a..de16dfd 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square30x30Logo.png and b/crates/openwith-gui/src-tauri/icons/Square30x30Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square310x310Logo.png b/crates/openwith-gui/src-tauri/icons/Square310x310Logo.png index de2b210..614c046 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square310x310Logo.png and b/crates/openwith-gui/src-tauri/icons/Square310x310Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square44x44Logo.png b/crates/openwith-gui/src-tauri/icons/Square44x44Logo.png index a58ef03..b2951f6 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square44x44Logo.png and b/crates/openwith-gui/src-tauri/icons/Square44x44Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square71x71Logo.png b/crates/openwith-gui/src-tauri/icons/Square71x71Logo.png index 6989e6a..48857c9 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square71x71Logo.png and b/crates/openwith-gui/src-tauri/icons/Square71x71Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/Square89x89Logo.png b/crates/openwith-gui/src-tauri/icons/Square89x89Logo.png index 148dc32..5af9b58 100644 Binary files a/crates/openwith-gui/src-tauri/icons/Square89x89Logo.png and b/crates/openwith-gui/src-tauri/icons/Square89x89Logo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/StoreLogo.png b/crates/openwith-gui/src-tauri/icons/StoreLogo.png index a1dc6ff..e684829 100644 Binary files a/crates/openwith-gui/src-tauri/icons/StoreLogo.png and b/crates/openwith-gui/src-tauri/icons/StoreLogo.png differ diff --git a/crates/openwith-gui/src-tauri/icons/icon-dark.png b/crates/openwith-gui/src-tauri/icons/icon-dark.png new file mode 100644 index 0000000..489ccf0 Binary files /dev/null and b/crates/openwith-gui/src-tauri/icons/icon-dark.png differ diff --git a/crates/openwith-gui/src-tauri/icons/icon.icns b/crates/openwith-gui/src-tauri/icons/icon.icns index c2e42f5..03706f2 100644 Binary files a/crates/openwith-gui/src-tauri/icons/icon.icns and b/crates/openwith-gui/src-tauri/icons/icon.icns differ diff --git a/crates/openwith-gui/src-tauri/icons/icon.ico b/crates/openwith-gui/src-tauri/icons/icon.ico index 927f944..ac0ec60 100644 Binary files a/crates/openwith-gui/src-tauri/icons/icon.ico and b/crates/openwith-gui/src-tauri/icons/icon.ico differ diff --git a/crates/openwith-gui/src-tauri/icons/icon.png b/crates/openwith-gui/src-tauri/icons/icon.png index 5d6fbdf..8a3a4f8 100644 Binary files a/crates/openwith-gui/src-tauri/icons/icon.png and b/crates/openwith-gui/src-tauri/icons/icon.png differ diff --git a/crates/openwith-gui/src-tauri/icons/tray-template.png b/crates/openwith-gui/src-tauri/icons/tray-template.png index 2faf76d..c4be6de 100644 Binary files a/crates/openwith-gui/src-tauri/icons/tray-template.png and b/crates/openwith-gui/src-tauri/icons/tray-template.png differ diff --git a/crates/openwith-gui/src-tauri/icons/tray-template.svg b/crates/openwith-gui/src-tauri/icons/tray-template.svg index 3a1458d..f9bc5cf 100644 --- a/crates/openwith-gui/src-tauri/icons/tray-template.svg +++ b/crates/openwith-gui/src-tauri/icons/tray-template.svg @@ -1,7 +1,8 @@ - + diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index 036cf0f..1a659d5 100644 --- a/crates/openwith-gui/src-tauri/src/commands.rs +++ b/crates/openwith-gui/src-tauri/src/commands.rs @@ -352,6 +352,30 @@ pub fn set_dock_visible(app: AppHandle, visible: bool) -> Result<(), String> { Ok(()) } +/// Swap the Dock icon between the light and dark masters. macOS only +/// re-renders bundle icons for the *system* appearance, so following the +/// in-app Appearance setting has to happen at runtime. Driven by +/// theme.ts whenever the resolved theme changes. +#[tauri::command] +pub fn set_dock_icon_dark(app: AppHandle, dark: bool) -> Result<(), String> { + app.run_on_main_thread(move || { + use objc2::{AllocAnyThread, MainThreadMarker}; + use objc2_app_kit::{NSApplication, NSImage}; + use objc2_foundation::NSData; + + static LIGHT: &[u8] = include_bytes!("../icons/icon.png"); + static DARK: &[u8] = include_bytes!("../icons/icon-dark.png"); + let mtm = MainThreadMarker::new().expect("run_on_main_thread runs on main"); + let data = NSData::with_bytes(if dark { DARK } else { LIGHT }); + if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) { + unsafe { + NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&image)); + } + } + }) + .map_err(|e| e.to_string()) +} + #[derive(Serialize)] pub struct HistoryEventDto { pub kind: String, diff --git a/crates/openwith-gui/src-tauri/src/lib.rs b/crates/openwith-gui/src-tauri/src/lib.rs index ad15c48..875604b 100644 --- a/crates/openwith-gui/src-tauri/src/lib.rs +++ b/crates/openwith-gui/src-tauri/src/lib.rs @@ -73,6 +73,7 @@ pub fn run() { commands::quit_app, commands::set_tray_enabled, commands::set_dock_visible, + commands::set_dock_icon_dark, commands::set_popover_pinned, commands::set_toggle_shortcut, ]) diff --git a/crates/openwith-gui/src/api.ts b/crates/openwith-gui/src/api.ts index a3adde2..297976a 100644 --- a/crates/openwith-gui/src/api.ts +++ b/crates/openwith-gui/src/api.ts @@ -122,6 +122,8 @@ export const api = { invoke("set_tray_enabled", { enabled }), setDockVisible: (visible: boolean) => invoke("set_dock_visible", { visible }), + setDockIconDark: (dark: boolean) => + invoke("set_dock_icon_dark", { dark }), setPopoverPinned: (pinned: boolean) => invoke("set_popover_pinned", { pinned }), setToggleShortcut: (accelerator: string) => diff --git a/crates/openwith-gui/src/app.ts b/crates/openwith-gui/src/app.ts index a95e65c..d3df8d7 100644 --- a/crates/openwith-gui/src/app.ts +++ b/crates/openwith-gui/src/app.ts @@ -53,9 +53,12 @@ function renderHeader(): string { `, ).join(""); + // data-tauri-drag-region only fires on the element itself, not children: + // it must sit on every surface that should drag the window (bar + wordmark), + // while the tab buttons stay grabbable-free by simply not carrying it. return ` -
- OpenWith +
+ OpenWith
${tabs} diff --git a/crates/openwith-gui/src/styles.css b/crates/openwith-gui/src/styles.css index bc2d9e1..14d65cb 100644 --- a/crates/openwith-gui/src/styles.css +++ b/crates/openwith-gui/src/styles.css @@ -147,14 +147,15 @@ body { font-family: ui-monospace, Menlo, monospace; } -/* header + tabs; left padding clears the native overlay traffic lights */ +/* header + tabs; left padding clears the native overlay traffic lights. + Window dragging comes from data-tauri-drag-region in the markup — + -webkit-app-region is Electron-only and does nothing in WKWebView. */ .header { flex: none; display: flex; align-items: center; gap: 8px; padding: 14px 18px 12px 84px; - -webkit-app-region: drag; } .header .tabs { @@ -162,7 +163,6 @@ body { display: flex; gap: 16px; align-items: baseline; - -webkit-app-region: no-drag; } .tab { diff --git a/crates/openwith-gui/src/theme.ts b/crates/openwith-gui/src/theme.ts index d1bf598..b869268 100644 --- a/crates/openwith-gui/src/theme.ts +++ b/crates/openwith-gui/src/theme.ts @@ -3,6 +3,8 @@ * settings; the resolved theme is stamped as data-theme on , which * styles.css keys its dark palette off. */ +import { api } from "./api"; + const SETTINGS_KEY = "openwith.settings"; export type Appearance = "system" | "light" | "dark"; @@ -25,6 +27,10 @@ export function applyTheme(): void { const dark = appearance === "system" ? systemDark.matches : appearance === "dark"; document.documentElement.dataset.theme = dark ? "dark" : "light"; + // The Dock icon follows the resolved theme too — macOS only swaps bundle + // icons for the *system* appearance, so the app does it itself. Both + // windows call this on a theme change; the swap is idempotent. + api.setDockIconDark(dark).catch(() => {}); } // Follow the OS live while the setting is "system". diff --git a/scripts/gen-icons.py b/scripts/gen-icons.py new file mode 100644 index 0000000..562be97 --- /dev/null +++ b/scripts/gen-icons.py @@ -0,0 +1,215 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["pillow"] +# /// +"""Regenerate all GUI icon assets from the logo renders in artifacts/. + +The renders are AI images with NO alpha channel, so every asset is derived +by scripted masking — never hand-edit the outputs. + +Sources: + artifacts/logo-icon-light.png light squircle render on white -> app icon (light) + artifacts/logo-icon-dark.png dark squircle render on near-black -> Dock dark variant + artifacts/logo-mono-glyph.png mono glyph on baked checkerboard -> menu-bar template + +Outputs (crates/openwith-gui/src-tauri/icons/): + icon-dark.png 1024 dark master (runtime Dock swap, set_dock_icon_dark) + tray-template.png 64x44 black+alpha template image (glyph ~34px tall) + icon.icns hand-built: >=128px slots keep the full render; <=64px + slots use a legibility variant (header dots inpainted + away, no drop shadow, glyph at ~92% of canvas, unsharp) + +Plus a 1024 light master at /app-icon-1024.png for the PNG set: +run (cd crates/openwith-gui && npm run tauri icon ) +BEFORE this script — `tauri icon` clobbers icon.icns with a naive one, and +this script rebuilds it (and the PNG masters `tauri icon` doesn't touch). + +Usage: uv run scripts/gen-icons.py +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter + +REPO = Path(__file__).resolve().parent.parent +ART = REPO / "artifacts" +ICONS = REPO / "crates/openwith-gui/src-tauri/icons" + +CANVAS, ARTS = 1024, 824 # Apple grid: 824px art centered on a 1024 canvas + + +def differs(p, q, t): + return max(abs(a - b) for a, b in zip(p, q)) > t + + +def cut_squircle(path: Path, thresh: int) -> Image.Image: + """Locate the rendered squircle on its solid background (scan the center + row/column for pixels that differ from the corner color) and crop it.""" + src = Image.open(path).convert("RGB") + w, h = src.size + bg = src.getpixel((5, 5)) + row = [x for x in range(w) if differs(src.getpixel((x, h // 2)), bg, thresh)] + col = [y for y in range(h) if differs(src.getpixel((w // 2, y)), bg, thresh)] + left, top, side = row[0], col[0], row[-1] - row[0] + 1 + print(f"{path.name}: left={left} top={top} side={side}") + return src.crop((left, top, left + side, top + side)) + + +def squircle_mask(size: int) -> Image.Image: + """Rounded-rect alpha mask (macOS-style radius), supersampled for clean + edges, inset 1px so no background fringe survives at the corners.""" + ss = 4 + m = Image.new("L", (size * ss,) * 2, 0) + ImageDraw.Draw(m).rounded_rectangle( + (ss, ss, size * ss - 1 - ss, size * ss - 1 - ss), + radius=int(size * ss * 0.225), + fill=255, + ) + return m.resize((size, size), Image.LANCZOS) + + +def masked_art(art: Image.Image, size: int) -> Image.Image: + out = art.resize((size, size), Image.LANCZOS).convert("RGBA") + out.putalpha(squircle_mask(size)) + return out + + +def master_1024(art: Image.Image) -> Image.Image: + """824px masked art centered on a transparent 1024 canvas with a soft + Apple-template-style drop shadow.""" + icon = masked_art(art, ARTS) + off = (CANVAS - ARTS) // 2 + sh = Image.new("L", (CANVAS, CANVAS), 0) + sh.paste(icon.getchannel("A"), (off, off + 12)) + sh = sh.filter(ImageFilter.GaussianBlur(16)).point(lambda a: int(a * 0.30)) + shadow = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0)) + shadow.putalpha(sh) + canvas = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0)) + canvas.alpha_composite(shadow) + canvas.alpha_composite(icon, (off, off)) + return canvas + + +def remove_header_dots(art: Image.Image) -> Image.Image: + """Inpaint the three window-header dots for the small icns slots (at + <=64px they are sub-pixel noise). A dot pixel is white-ish with blue + visible in all four directions; fill from the nearest blue in its row.""" + px = art.load() + sz = art.size[0] + + def is_blue(p): + return p[2] > 140 and p[2] - p[0] > 50 + + def is_whiteish(p): + return min(p) > 170 and max(p) - min(p) < 55 + + def blue_dir(x, y, dx, dy): + for d in (18, 26, 34, 44): + nx, ny = x + dx * d, y + dy * d + if 0 <= nx < sz and 0 <= ny < sz and is_blue(px[nx, ny]): + return True + return False + + cand = { + (x, y) + for y in range(sz // 2) + for x in range(sz // 2, sz) + if is_whiteish(px[x, y]) + and all(blue_dir(x, y, *d) for d in ((1, 0), (-1, 0), (0, 1), (0, -1))) + } + print(f"header dots: {len(cand)} px") + dilated = {(x + dx, y + dy) for x, y in cand for dx in range(-4, 5) for dy in range(-4, 5)} + + def nearest_blue(x, y): + for d in range(6, 90): + for nx in (x + d, x - d): + if 0 <= nx < sz and is_blue(px[nx, y]): + return px[nx, y] + return None + + out = art.copy() + opx = out.load() + for x, y in dilated: + if 0 <= x < sz and 0 <= y < sz and (c := nearest_blue(x, y)): + opx[x, y] = c + return out + + +def small_rep(dotless: Image.Image, size: int) -> Image.Image: + """Legibility variant for <=64px: no shadow, art at ~92% of the canvas, + slight unsharp mask.""" + margin = max(1, round(size * 0.04)) + art = masked_art(dotless, size - 2 * margin) + art = art.filter(ImageFilter.UnsharpMask(radius=1, percent=60, threshold=2)) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + canvas.alpha_composite(art, (margin, margin)) + return canvas + + +def build_icns(light_art: Image.Image, master: Image.Image) -> None: + dotless = remove_header_dots(light_art) + with tempfile.TemporaryDirectory() as tmp: + iconset = Path(tmp) / "icon.iconset" + iconset.mkdir() + reps = { + "icon_16x16.png": small_rep(dotless, 16), + "icon_16x16@2x.png": small_rep(dotless, 32), + "icon_32x32.png": small_rep(dotless, 32), + "icon_32x32@2x.png": small_rep(dotless, 64), + "icon_128x128.png": master.resize((128, 128), Image.LANCZOS), + "icon_128x128@2x.png": master.resize((256, 256), Image.LANCZOS), + "icon_256x256.png": master.resize((256, 256), Image.LANCZOS), + "icon_256x256@2x.png": master.resize((512, 512), Image.LANCZOS), + "icon_512x512.png": master.resize((512, 512), Image.LANCZOS), + "icon_512x512@2x.png": master, + } + for name, img in reps.items(): + img.save(iconset / name) + subprocess.run( + ["iconutil", "-c", "icns", str(iconset), "-o", str(ICONS / "icon.icns")], + check=True, + ) + print("icon.icns rebuilt") + + +def build_tray_template() -> None: + """logo-mono-glyph.png -> 64x44 template image. The checkerboard is baked-in pixels, + so alpha comes from a luminance ramp (dark glyph -> opaque black).""" + g = Image.open(ART / "logo-mono-glyph.png").convert("L") + alpha = g.point(lambda l: 0 if l >= 200 else 255 if l <= 110 else int(255 * (200 - l) / 90)) + glyph = alpha.crop(alpha.getbbox()) + tw, th, maxw, maxh = 64, 44, 62, 34 + scale = min(maxw / glyph.width, maxh / glyph.height) + nw, nh = round(glyph.width * scale), round(glyph.height * scale) + glyph = glyph.resize((nw, nh), Image.LANCZOS) + tray = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) + black = Image.new("RGBA", (nw, nh), (0, 0, 0, 255)) + black.putalpha(glyph) + tray.alpha_composite(black, ((tw - nw) // 2, (th - nh) // 2)) + tray.save(ICONS / "tray-template.png") + print(f"tray-template.png rebuilt ({nw}x{nh} glyph on {tw}x{th})") + + +def main() -> int: + light_art = cut_squircle(ART / "logo-icon-light.png", thresh=10) + dark_art = cut_squircle(ART / "logo-icon-dark.png", thresh=12) + + light_master = master_1024(light_art) + out = Path(tempfile.gettempdir()) / "openwith-app-icon-1024.png" + light_master.save(out) + print(f"light master -> {out}") + print(f" (PNG set: cd crates/openwith-gui && npm run tauri icon {out})") + + master_1024(dark_art).save(ICONS / "icon-dark.png") + print("icon-dark.png rebuilt") + + build_icns(light_art, light_master) + build_tray_template() + return 0 + + +if __name__ == "__main__": + sys.exit(main())