diff --git a/AGENTS.md b/AGENTS.md index f2b9632..915cee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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. diff --git a/icon.png b/icon.png index e4449a5..fafa5f3 100644 Binary files a/icon.png and b/icon.png differ diff --git a/src/main/java/org/icroco/pholio/ui/MacOsProcessName.java b/src/main/java/org/icroco/pholio/ui/MacOsProcessName.java new file mode 100644 index 0000000..3dfd107 --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/MacOsProcessName.java @@ -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. + * + *

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 }, 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. + * + *

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. + * + *

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); + } + } +} diff --git a/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java b/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java index 43f3af0..b74b39c 100644 --- a/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java +++ b/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java @@ -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 { * *

{@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. + * + *

{@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); } } diff --git a/src/main/packaging/macos/pholio.icns b/src/main/packaging/macos/pholio.icns new file mode 100644 index 0000000..2b44d92 Binary files /dev/null and b/src/main/packaging/macos/pholio.icns differ diff --git a/src/main/resources/images/spo-macos-1024x1024.png b/src/main/resources/images/spo-macos-1024x1024.png new file mode 100644 index 0000000..fafa5f3 Binary files /dev/null and b/src/main/resources/images/spo-macos-1024x1024.png differ diff --git a/tools/icons/make-macos-icon.py b/tools/icons/make-macos-icon.py new file mode 100644 index 0000000..ca448dd --- /dev/null +++ b/tools/icons/make-macos-icon.py @@ -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()