Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
.DS_Store
# cargo-bundle output
/target/release/bundle
# generated icon extras (just icon-linux / --web)
/dist
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (svgpng → icns) + `cargo bundle` config | `assets/`, `Cargo.toml` |
| 🖼️ | **App icon** pipeline (imagesquircle → icns) + `cargo bundle` config | `scripts/`, `assets/`, `Cargo.toml` |

The how and why behind each is in **[docs/LEARNINGS.md](docs/LEARNINGS.md)**.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Binary file added assets/icon-source.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/icon.icns
Binary file not shown.
Binary file modified assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 19 additions & 7 deletions docs/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand All @@ -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"
```
Expand Down
30 changes: 10 additions & 20 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
177 changes: 177 additions & 0 deletions scripts/make-app-icon.py
Original file line number Diff line number Diff line change
@@ -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()
Loading