feat(packaging): fit the app icon to the macOS icon grid

The full-bleed logo looked bigger than every other Dock icon. Add
tools/icons/make-macos-icon.py, which centres an 832 px rounded square on a
transparent 1024 px canvas and writes the result as jDeploy's icon.png, as
src/main/resources/images/spo-macos-1024x1024.png (the Dock icon set at
runtime) and as a multi-resolution src/main/packaging/macos/pholio.icns.

Also name the unbundled macOS process "Pholio" through LaunchServices
(MacOsProcessName, via the Foreign Function API) and log why the Dock icon
is skipped instead of returning silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
This commit is contained in:
2026-09-30 10:20:52 -04:00
co-authored by Claude Opus 5.5
parent 94145f033f
commit 369ba04d80
7 changed files with 234 additions and 5 deletions
+5
View File
@@ -34,6 +34,11 @@ Native installers with a bundled JRE are produced by jDeploy, driven from two fi
Java version and the JavaFX flag from the POM into
`package.json`. It works by shelling out to `npm pkg set`, which is why it sits behind a profile — a plain
`mvn package` must not require Node. Every other key in `package.json` is hand-maintained.
* **Icon.** jDeploy takes the app icon from `icon.png` next to `package.json`, one file for every platform (it
builds the macOS `.icns` itself). It is the macOS-grid version — an 832 px rounded square centred on a
1024 px transparent canvas — generated with `src/main/resources/images/spo-macos-1024x1024.png` and
`src/main/packaging/macos/pholio.icns` by `python3 tools/icons/make-macos-icon.py`; edit the script, not
the images.
* **Releasing.** Push a `v<semver>` tag (the leading `v` is stripped to form the app version); the workflow builds the bundles and attaches them to that
release. Pushing a `*-snapshot` branch publishes a rolling prerelease instead. Branch and tag names must be at most 16 characters of `[A-Za-z0-9._-]` — the
jDeploy action silently skips anything else.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 151 KiB

@@ -0,0 +1,83 @@
package org.icroco.pholio.ui;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.util.Locale;
/**
* Renames the running process, as macOS shows it in the Dock, Cmd+Tab and Activity Monitor — "Pholio"
* instead of the "java" an unbundled launch (IDE, {@code mvn}, {@code java -jar}) otherwise gets.
*
* <p>None of the usual knobs reach that name once JavaFX owns the native application object: neither the
* {@code apple.awt.application.name} system property (AWT's, ignored when AWT did not create it) nor even
* {@code -Xdock:name} change it — both verified with {@code lsappinfo info -only name <pid>}, which kept
* answering {@code "java"}. What does is asking LaunchServices directly, the same private call Electron makes
* for {@code app.setName}: {@code _LSSetApplicationInformationItem} with {@code _kLSDisplayNameKey}, reached
* here through the Foreign Function API. A packaged {@code .app} is already named by its bundle, so this is a
* harmless no-op re-statement there.
*
* <p>Must run after the application has checked in with LaunchServices, i.e. once the JavaFX toolkit is up —
* {@link PholioFxApplication#start} is. Best effort: a private API can disappear in any macOS release, so any
* failure is logged and otherwise ignored rather than blocking startup over a label.
*
* <p>Needs native access for the unnamed module ({@code --enable-native-access=ALL-UNNAMED}); without it the
* JVM still performs the call but prints a restricted-method warning.
*/
final class MacOsProcessName {
private static final Logger log = LoggerFactory.getLogger(MacOsProcessName.class);
private static final String CORE_SERVICES = "/System/Library/Frameworks/CoreServices.framework/CoreServices";
private static final String CORE_FOUNDATION = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
private static final int DEFAULT_SESSION = -2; // kLSDefaultSessionID
private static final int UTF8_ENCODING = 0x08000100; // kCFStringEncodingUTF8
private MacOsProcessName() {
}
static void set(String name) {
if (!System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("mac")) {
return;
}
try {
Linker linker = Linker.nativeLinker();
Arena arena = Arena.global();
SymbolLookup launchServices = SymbolLookup.libraryLookup(CORE_SERVICES, arena);
SymbolLookup coreFoundation = SymbolLookup.libraryLookup(CORE_FOUNDATION, arena);
MethodHandle createString = linker.downcallHandle(
coreFoundation.find("CFStringCreateWithCString").orElseThrow(),
FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT));
MethodHandle currentApplication = linker.downcallHandle(
launchServices.find("_LSGetCurrentApplicationASN").orElseThrow(),
FunctionDescriptor.of(ValueLayout.ADDRESS));
MethodHandle setInformationItem = linker.downcallHandle(
launchServices.find("_LSSetApplicationInformationItem").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS,
ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
// _kLSDisplayNameKey is a CFStringRef global: its symbol is the address of the pointer, not the string.
MemorySegment displayNameKey = launchServices.find("_kLSDisplayNameKey").orElseThrow()
.reinterpret(ValueLayout.ADDRESS.byteSize())
.get(ValueLayout.ADDRESS, 0);
// Never released on purpose: LaunchServices keeps using it for the lifetime of the process.
MemorySegment value = (MemorySegment) createString.invokeExact(MemorySegment.NULL, arena.allocateFrom(name), UTF8_ENCODING);
MemorySegment asn = (MemorySegment) currentApplication.invokeExact();
int status = (int) setInformationItem.invokeExact(DEFAULT_SESSION, asn, displayNameKey, value, MemorySegment.NULL);
if (status != 0) {
log.warn("LaunchServices refused to rename the process to '{}' (OSStatus {})", name, status);
}
}
catch (Throwable e) {
log.warn("Could not rename the macOS process to '{}'", name, e);
}
}
}
@@ -59,6 +59,7 @@ public class PholioFxApplication extends Application {
@Override
public void start(Stage primaryStage) {
MacOsProcessName.set("Pholio");
setDockIcon();
// JavaFX always calls init() before start(), so context is set by the time this runs.
Objects.requireNonNull(context).getBean(StageManager.class).showMainWindow(primaryStage);
@@ -69,20 +70,30 @@ public class PholioFxApplication extends Application {
*
* <p>{@code stage.getIcons()} does not reach the dock — that icon comes from the AWT {@link Taskbar}
* API instead. A packaged app (jpackage/jDeploy) does not need this: its {@code .app} bundle carries an
* {@code .icns} generated from the repo's {@code icon.png} and the OS uses that directly. Without this,
* a dev run shows the generic Java launcher icon.
* {@code .icns} and the OS uses that directly. Without this, a dev run shows the generic Java launcher
* icon.
*
* <p>{@code spo-macos-1024x1024.png}, not the full-bleed {@code spo-*.png} the window icons use: macOS
* draws every Dock icon as an 832px rounded square centred in a 1024px canvas, and a logo filling the
* whole canvas instead looks visibly bigger than its neighbours. Generated, together with
* {@code src/main/packaging/macos/pholio.icns}, by {@code tools/icons/make-macos-icon.py}.
*/
private void setDockIcon() {
// Logged rather than silently skipped: an icon that never shows up is otherwise indistinguishable
// from one that was never attempted (java.awt.headless=true is the usual culprit).
if (!Taskbar.isTaskbarSupported()) {
log.info("No dock icon: AWT Taskbar unsupported (java.awt.headless={})", System.getProperty("java.awt.headless"));
return;
}
Taskbar taskbar = Taskbar.getTaskbar();
if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) {
log.info("No dock icon: Taskbar.Feature.ICON_IMAGE unsupported on this platform");
return;
}
try (InputStream in = PholioFxApplication.class.getResourceAsStream("/images/spo-512x512.png")) {
taskbar.setIconImage(ImageIO.read(in));
} catch (IOException e) {
try (InputStream in = PholioFxApplication.class.getResourceAsStream("/images/spo-macos-1024x1024.png")) {
taskbar.setIconImage(ImageIO.read(Objects.requireNonNull(in, "spo-macos-1024x1024.png missing")));
log.debug("Dock icon set");
} catch (IOException | RuntimeException e) {
log.warn("Could not set dock icon", e);
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
Builds Pholio's macOS app icon from the full-bleed logo in src/main/resources/images/spo-1024x1024.png.
macOS draws every app icon inside the same 1024 x 1024 grid: an ~832 x 832 rounded square ("squircle")
centred on a transparent canvas, ~96 px of margin on each side (room for the drop shadow). The full-bleed
logo fills the whole canvas instead, which is why it looked bigger than every other icon in the Dock.
Outputs:
src/main/resources/images/spo-macos-1024x1024.png
the 1024 master — the Dock icon PholioFxApplication sets at runtime on macOS
icon.png (repository root)
the same master, as jDeploy's app icon: jDeploy reads this one file, next to package.json, and
derives every platform's icon from it (the .app bundle's .icns included) — it has no per-platform
icon setting, so Windows/Linux installers get this rounded-square version too
src/main/packaging/macos/pholio.icns
every size macOS asks for, 16 to 512 @2x, built with iconutil — for an app bundle; kept out of
src/main/resources since nothing reads it at runtime (ImageIO has no .icns reader)
Requires Pillow and macOS's own iconutil. Run from anywhere: python3 tools/icons/make-macos-icon.py
"""
import math
import shutil
import subprocess
import tempfile
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parents[2]
IMAGES = ROOT / "src" / "main" / "resources" / "images"
SOURCE = IMAGES / "spo-1024x1024.png"
MASTER = IMAGES / "spo-macos-1024x1024.png"
JDEPLOY_ICON = ROOT / "icon.png"
ICNS = ROOT / "src" / "main" / "packaging" / "macos" / "pholio.icns"
CANVAS = 1024
SQUIRCLE = 832 # Apple's icon grid: 832 x 832 body, (1024 - 832) / 2 = 96 px margin
MARGIN = (CANVAS - SQUIRCLE) // 2
EXPONENT = 5.0 # superellipse |x|^n + |y|^n = 1; n = 5 is close to Apple's continuous corner
LOGO_FILL = 0.80 # the logo's visible extent, as a fraction of the squircle's side
SUPERSAMPLE = 4 # the mask is drawn this much larger, then downsampled, for smooth edges
TOP_COLOUR = (255, 255, 255)
BOTTOM_COLOUR = (236, 236, 241)
SHADOW = dict(offset=12, blur=18, opacity=0.30)
def squircle_mask(size: int) -> Image.Image:
"""Anti-aliased superellipse of side `size`, as an 8-bit alpha mask."""
big = size * SUPERSAMPLE
half = big / 2
points = []
steps = 2048
for i in range(steps):
# Parametric superellipse: x = |cos t|^(2/n) * sign(cos t), same for y with sin.
t = 2 * math.pi * i / steps
c, s = math.cos(t), math.sin(t)
x = math.copysign(abs(c) ** (2 / EXPONENT), c)
y = math.copysign(abs(s) ** (2 / EXPONENT), s)
points.append((half + x * half, half + y * half))
mask = Image.new("L", (big, big), 0)
ImageDraw.Draw(mask).polygon(points, fill=255)
return mask.resize((size, size), Image.LANCZOS)
def vertical_gradient(size: int) -> Image.Image:
gradient = Image.new("RGBA", (size, size))
draw = ImageDraw.Draw(gradient)
for y in range(size):
f = y / (size - 1)
colour = tuple(round(a + (b - a) * f) for a, b in zip(TOP_COLOUR, BOTTOM_COLOUR))
draw.line([(0, y), (size, y)], fill=colour + (255,))
return gradient
def build_master() -> Image.Image:
logo = Image.open(SOURCE).convert("RGBA")
# Scale on the logo's visible (non-transparent) extent, not its canvas, so any built-in margin in the
# source does not shrink it twice.
logo = logo.crop(logo.getchannel("A").getbbox())
target = round(SQUIRCLE * LOGO_FILL)
ratio = target / max(logo.size)
logo = logo.resize((round(logo.width * ratio), round(logo.height * ratio)), Image.LANCZOS)
mask = squircle_mask(SQUIRCLE)
body = vertical_gradient(SQUIRCLE)
body.alpha_composite(logo, ((SQUIRCLE - logo.width) // 2, (SQUIRCLE - logo.height) // 2))
body.putalpha(ImageChops.multiply(body.getchannel("A"), mask))
canvas = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
shadow_alpha = Image.new("L", (CANVAS, CANVAS), 0)
shadow_alpha.paste(mask.point(lambda a: round(a * SHADOW["opacity"])), (MARGIN, MARGIN + SHADOW["offset"]))
shadow_alpha = shadow_alpha.filter(ImageFilter.GaussianBlur(SHADOW["blur"]))
shadow = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 255))
shadow.putalpha(shadow_alpha)
canvas.alpha_composite(shadow)
canvas.alpha_composite(body, (MARGIN, MARGIN))
return canvas
def build_icns(master: Image.Image) -> None:
"""The ten entries iconutil expects in an .iconset: 16, 32, 128, 256, 512, each at 1x and 2x."""
with tempfile.TemporaryDirectory() as tmp:
iconset = Path(tmp) / "pholio.iconset"
iconset.mkdir()
for points in (16, 32, 128, 256, 512):
for scale in (1, 2):
pixels = points * scale
suffix = "" if scale == 1 else "@2x"
master.resize((pixels, pixels), Image.LANCZOS).save(iconset / f"icon_{points}x{points}{suffix}.png")
subprocess.run(["iconutil", "--convert", "icns", "--output", str(ICNS), str(iconset)], check=True)
def main() -> None:
if shutil.which("iconutil") is None:
raise SystemExit("iconutil not found: this script has to run on macOS")
master = build_master()
master.save(MASTER)
master.save(JDEPLOY_ICON)
ICNS.parent.mkdir(parents=True, exist_ok=True)
build_icns(master)
print(f"wrote {MASTER.relative_to(ROOT)}, {JDEPLOY_ICON.relative_to(ROOT)} and {ICNS.relative_to(ROOT)}")
if __name__ == "__main__":
main()