diff --git a/.gitignore b/.gitignore index d4ea529..b7a5179 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .DS_Store # cargo-bundle output /target/release/bundle +# generated icon extras (just icon-linux / --web) +/dist diff --git a/Cargo.toml b/Cargo.toml index 7f598ee..3e742d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,12 +50,13 @@ codegen-units = 1 # `cargo bundle` config (batteries included). On macOS β†’ Deck.app; cargo # bundle can also emit `deb` on Linux. Run `just bundle`. # cargo install cargo-bundle -# Drop your own 1024x1024 PNG at assets/icon.png and re-bundle to rebrand. +# Rebrand: drop a 1024x1024 image at assets/icon-source.png, run `just icon` +# (bakes the macOS squircle + builds icon.icns), then re-bundle. # --------------------------------------------------------------------------- [package.metadata.bundle] name = "Deck" identifier = "com.example.deck" -icon = ["assets/icon.png"] +icon = ["assets/icon.icns"] category = "public.app-category.productivity" short_description = "A small, snappy native desktop app starter built on GPUI." long_description = "Deck is a tiny, opinionated GPUI starter you can fork to build your own fast, native desktop app on macOS and Linux (and wire your own AI agent into it)." diff --git a/README.md b/README.md index 4fcdac2..3914871 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ You need a stable **Rust** toolchain (`rustup`) and: | πŸ“‹ | Native **menu bar** (App / File / Edit / View) | `main.rs` | | 🟣 | Optional **menu-bar / tray mode**, no dock icon β€” `--features tray` | `tray.rs` | | πŸ”£ | **Lucide** icon set (ISC licensed, bundled) | `gpui-component` | -| πŸ–ΌοΈ | **App icon** pipeline (svg β†’ png β†’ icns) + `cargo bundle` config | `assets/`, `Cargo.toml` | +| πŸ–ΌοΈ | **App icon** pipeline (image β†’ squircle β†’ icns) + `cargo bundle` config | `scripts/`, `assets/`, `Cargo.toml` | The how and why behind each is in **[docs/LEARNINGS.md](docs/LEARNINGS.md)**. @@ -108,7 +108,7 @@ on every push) but isn't daily-driven yet. Linux issues/PRs welcome. 2. **Change the display name** β€” `APP_NAME` in `src/main.rs` (drives the menu bar + window title). 3. **Change the bundle id** β€” `[package.metadata.bundle].identifier` in `Cargo.toml`, and the `QUALIFIER/ORGANIZATION/APPLICATION` consts in `src/settings.rs` (they pick the config-dir path). -4. **Swap the icon** β€” drop a 1024Γ—1024 PNG at `assets/icon.png` (or edit `assets/icon.svg` and run `just icon`). +4. **Swap the icon** β€” drop a 1024Γ—1024 image at `assets/icon-source.png` and run `just icon` (bakes the macOS squircle tile + shadow and rebuilds `assets/icon.icns`), then `just bundle`. 5. **Replace the UI** β€” gut `src/welcome.rs` (and add routes in `src/shell.rs`) and build your thing. Then ship a real app: @@ -150,8 +150,11 @@ and add a `KeyBinding::new(...)`. deck/ β”œβ”€β”€ Cargo.toml deps + [package.metadata.bundle] + the `tray` feature β”œβ”€β”€ justfile run Β· bundle Β· icon Β· fmt Β· check +β”œβ”€β”€ scripts/ +β”‚ └── make-app-icon.py source image β†’ squircle .icns (+ optional Linux/web) β”œβ”€β”€ assets/ -β”‚ β”œβ”€β”€ icon.svg / .png / .icns source icon + generated app icons +β”‚ β”œβ”€β”€ icon-source.png your 1024Β² source art (drop it here) +β”‚ β”œβ”€β”€ icon.png / .icns generated app icons (`just icon`) β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ main.rs bootstrap: window, menus, shortcuts, theme, settings β”‚ β”œβ”€β”€ shell.rs root view: routing (Welcome/Settings) + app state diff --git a/assets/icon-source.png b/assets/icon-source.png new file mode 100644 index 0000000..888551b Binary files /dev/null and b/assets/icon-source.png differ diff --git a/assets/icon.icns b/assets/icon.icns index c674361..ca46f5f 100644 Binary files a/assets/icon.icns and b/assets/icon.icns differ diff --git a/assets/icon.png b/assets/icon.png index 7b6347e..e02ba7e 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index fdbe547..5932477 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -308,18 +308,30 @@ also skip opening the window at launch and open it on "Show" β€” a small extensi --- -## 9. App icons β€” one PNG in, a `.icns` out +## 9. App icons β€” one image in, a `.icns` out macOS app icons are `.icns` bundles. The pipeline keeps a single source of truth: ``` -assets/icon.svg ──cairosvg/qlmanage──▢ assets/icon.png (1024Β²) ──sips+iconutil──▢ assets/icon.icns +assets/icon-source.png ──scripts/make-app-icon.py──▢ assets/icon.png (1024Β² squircle master) + β”‚ β”‚ + squircle mask └─iconutil─▢ assets/icon.icns + + pad + shadow ``` -`just icon` runs the whole chain (uses macOS built-ins `sips` + `iconutil`). And `cargo bundle` will -itself turn a single `icon.png` into the `.icns` at build time β€” so the *minimum* a forker does is -**replace `assets/icon.png`**. Unlike iOS, macOS does **not** auto-mask icons into the squircle, so -`icon.svg` draws the rounded tile itself (inset in the 1024 canvas, ~180px corner radius). +`just icon` runs `scripts/make-app-icon.py` β€” so the *minimum* a forker does is **replace +`assets/icon-source.png`** (any square art: a render, photo, or logo β€” an `.svg` is rasterized +automatically) and run it. Unlike iOS, macOS does **not** auto-mask icons into the squircle, so the +script bakes the rounded tile, the ~100px inset, and the soft drop shadow into the artwork itself +(true continuous-corner superellipse, supersampled). `--linux` / `--web` also emit a freedesktop +hicolor tree and a full-bleed rounded PNG. + +One sharp edge worth knowing: `cargo bundle` 0.11 can build an `.icns` from PNG icons, but a *lone* +1024Β² PNG trips `No matching IconType` (1024 only exists as 512@2x), so the bundle config points at +the finished `icon.icns` directly β€” `iconutil` handles every size, including 1024. + +The script needs Pillow (`pip install pillow`); everything else is macOS built-ins (`iconutil`). For +a hand-built fallback, `sips -z` each size into an `icon.iconset/` then `iconutil -c icns`. --- @@ -335,7 +347,7 @@ The whole bundling story is **one block** in `Cargo.toml`, read by [package.metadata.bundle] name = "Deck" identifier = "com.example.deck" -icon = ["assets/icon.png"] +icon = ["assets/icon.icns"] category = "public.app-category.productivity" osx_minimum_system_version = "11.0" ``` diff --git a/justfile b/justfile index c205bf9..b811ab9 100644 --- a/justfile +++ b/justfile @@ -34,24 +34,14 @@ bundle: open: bundle open "target/release/bundle/osx/Deck.app" -# Regenerate assets/icon.png + assets/icon.icns from assets/icon.svg. -# Needs cairosvg (pip install cairosvg); falls back to qlmanage if missing. -# Uses only macOS built-ins (sips, iconutil) for the .icns step. +# Regenerate assets/icon.png + assets/icon.icns from assets/icon-source.png. +# Drop your own 1024x1024 image at assets/icon-source.png first (any square art β€” +# a render, photo, or logo; an .svg source is rasterized automatically). +# Bakes in the macOS squircle tile + padding + shadow (macOS does NOT auto-mask). +# Needs Pillow (pip install pillow); the .icns step uses macOS iconutil. icon: - #!/usr/bin/env bash - set -euo pipefail - cd assets - if command -v cairosvg >/dev/null; then - cairosvg icon.svg -o icon.png -W 1024 -H 1024 - else - qlmanage -t -s 1024 -o . icon.svg >/dev/null && mv icon.svg.png icon.png - fi - rm -rf icon.iconset && mkdir icon.iconset - for sz in 16 32 64 128 256 512; do - sips -z $sz $sz icon.png --out icon.iconset/icon_${sz}x${sz}.png >/dev/null - sips -z $((sz*2)) $((sz*2)) icon.png --out icon.iconset/icon_${sz}x${sz}@2x.png >/dev/null - done - sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png >/dev/null - iconutil -c icns icon.iconset -o icon.icns - rm -rf icon.iconset - echo "β†’ assets/icon.png + assets/icon.icns regenerated" + python3 scripts/make-app-icon.py + +# Same, plus a freedesktop hicolor tree + .desktop entry under dist/linux/. +icon-linux: + python3 scripts/make-app-icon.py --linux --web diff --git a/scripts/make-app-icon.py b/scripts/make-app-icon.py new file mode 100644 index 0000000..91054ca --- /dev/null +++ b/scripts/make-app-icon.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Turn one source image into a macOS app icon (.icns) β€” and, optionally, Linux +and web icons. + +Why this exists: unlike iOS, macOS does **not** auto-mask app icons into the +rounded "squircle" tile β€” the rounding, inset padding and drop shadow have to be +baked into the artwork. This takes any square-ish source (a render, a photo, a +logo) and bakes them in, then emits a multi-resolution `.icns`. + +Usage: + python3 scripts/make-app-icon.py [SOURCE] [options] + + SOURCE source image. Default: assets/icon-source.png + A .svg source is rasterized first (cairosvg, else qlmanage). + +Options: + --name NAME base name for Linux / .desktop output (default: deck) + --out DIR assets dir for icon.png + icon.icns (default: assets) + --no-mask source is already a finished tile β€” skip squircle/pad/shadow + --no-shadow squircle + padding, but no drop shadow + --linux also emit a freedesktop hicolor tree + .desktop under dist/linux + --web also emit a full-bleed rounded PNG: dist/icon-rounded.png + +Requires: Pillow (`pip install pillow`). The .icns step uses macOS `iconutil`. +""" +import argparse +import math +import os +import subprocess +import sys + +try: + from PIL import Image, ImageDraw, ImageFilter +except ImportError: + sys.exit("error: Pillow is required β€” run `pip install pillow`") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SS = 4 # supersample factor for crisp, anti-aliased mask edges + + +def squircle_mask(size, n=5.0): + """Apple-style continuous-corner squircle as an 'L' alpha mask, size x size. + + A superellipse |x|^n + |y|^n = 1 (n~5 matches Apple's curve) sampled as a + high-res polygon, then downscaled for clean anti-aliasing β€” pure Pillow, no + numpy.""" + hi = size * SS + r = (hi - 1) / 2.0 + pts, steps = [], 720 + for i in range(steps): + t = 2.0 * math.pi * i / steps + ct, st = math.cos(t), math.sin(t) + x = r + r * math.copysign(abs(ct) ** (2.0 / n), ct) + y = r + r * math.copysign(abs(st) ** (2.0 / n), st) + pts.append((x, y)) + m = Image.new("L", (hi, hi), 0) + ImageDraw.Draw(m).polygon(pts, fill=255) + return m.resize((size, size), Image.LANCZOS) + + +def rasterize_svg(src, tmp_png): + """Best-effort SVG -> 1024 PNG using cairosvg, else macOS qlmanage.""" + if subprocess.run(["which", "cairosvg"], capture_output=True).returncode == 0: + subprocess.run(["cairosvg", src, "-o", tmp_png, "-W", "1024", "-H", "1024"], check=True) + else: + d = os.path.dirname(tmp_png) or "." + subprocess.run(["qlmanage", "-t", "-s", "1024", "-o", d, src], + check=True, capture_output=True) + produced = os.path.join(d, os.path.basename(src) + ".png") + os.replace(produced, tmp_png) + return tmp_png + + +def load_source(src): + if src.lower().endswith(".svg"): + tmp = os.path.join(ROOT, "dist", "_icon-source.png") + os.makedirs(os.path.dirname(tmp), exist_ok=True) + src = rasterize_svg(src, tmp) + im = Image.open(src).convert("RGBA") + return im.resize((1024, 1024), Image.LANCZOS) if im.size != (1024, 1024) else im + + +def masked_tile(art, body, shadow): + """1024 canvas: a `body`-px squircle tile centered, with an optional shadow.""" + canvas = 1024 + margin = (canvas - body) // 2 + tile = art.resize((body, body), Image.LANCZOS) + mask = squircle_mask(body) + tile.putalpha(mask) + + out = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + if shadow: + sh_alpha = Image.new("L", (canvas, canvas), 0) + sh_alpha.paste(mask, (margin, margin)) + sh = Image.new("RGBA", (canvas, canvas), (20, 18, 30, 255)) + sh.putalpha(sh_alpha.point(lambda p: int(p * 0.32))) + sh = sh.filter(ImageFilter.GaussianBlur(18)) + out.alpha_composite(sh, (0, 12)) + out.alpha_composite(tile, (margin, margin)) + return out + + +def write_icns(master, out_dir): + iconset = os.path.join(out_dir, "icon.iconset") + os.makedirs(iconset, exist_ok=True) + for sz, name in [(16, "icon_16x16.png"), (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), (1024, "icon_512x512@2x.png")]: + master.resize((sz, sz), Image.LANCZOS).save(os.path.join(iconset, name)) + icns = os.path.join(out_dir, "icon.icns") + subprocess.run(["iconutil", "-c", "icns", iconset, "-o", icns], check=True) + subprocess.run(["rm", "-rf", iconset]) + return icns + + +def write_linux(fullbleed, name): + root = os.path.join(ROOT, "dist", "linux") + made = [] + for s in [16, 22, 24, 32, 48, 64, 128, 256, 512]: + d = os.path.join(root, "hicolor", f"{s}x{s}", "apps") + os.makedirs(d, exist_ok=True) + p = os.path.join(d, f"{name}.png") + fullbleed.resize((s, s), Image.LANCZOS).save(p) + made.append(p) + desktop = os.path.join(root, f"{name}.desktop") + with open(desktop, "w") as f: + f.write("[Desktop Entry]\nType=Application\nName=Deck\n" + "Comment=Native cross-platform desktop app starter built on GPUI\n" + f"Exec={name}\nIcon={name}\nTerminal=false\n" + "Categories=Development;Utility;\n") + return root + + +def main(): + ap = argparse.ArgumentParser(description="Generate macOS/Linux/web app icons from one image.") + ap.add_argument("source", nargs="?", default=os.path.join(ROOT, "assets", "icon-source.png")) + ap.add_argument("--name", default="deck") + ap.add_argument("--out", default=os.path.join(ROOT, "assets")) + ap.add_argument("--no-mask", action="store_true") + ap.add_argument("--no-shadow", action="store_true") + ap.add_argument("--linux", action="store_true") + ap.add_argument("--web", action="store_true") + a = ap.parse_args() + + if not os.path.exists(a.source): + sys.exit(f"error: source not found: {a.source}\n" + "Drop a 1024x1024 image at assets/icon-source.png (or pass a path).") + os.makedirs(a.out, exist_ok=True) + + art = load_source(a.source) + # macOS master: 824/1024 squircle tile + padding + shadow (Apple grid), + # or the raw art if --no-mask (caller supplied a finished tile). + master = art if a.no_mask else masked_tile(art, body=824, shadow=not a.no_shadow) + master_png = os.path.join(a.out, "icon.png") + master.save(master_png) + icns = write_icns(master, a.out) + print(f"icon.png -> {master_png}") + print(f"icon.icns -> {icns}") + + if a.web or a.linux: + fullbleed = art if a.no_mask else art.copy() + if not a.no_mask: + fullbleed.putalpha(squircle_mask(1024)) + if a.web: + web_dir = os.path.join(ROOT, "dist") + os.makedirs(web_dir, exist_ok=True) + web = os.path.join(web_dir, "icon-rounded.png") + fullbleed.save(web) + print(f"web -> {web}") + if a.linux: + print(f"linux -> {write_linux(fullbleed, a.name)}") + + +if __name__ == "__main__": + main()