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:
@@ -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.
|
||||
|
||||
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 |
@@ -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()
|
||||
Reference in New Issue
Block a user