diff --git a/.gitignore b/.gitignore index c8b241f..93c467f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -target/* \ No newline at end of file +target/* +*.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8c82eb --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Dynamic Wallpaper Creator + +A small Swing (Java) desktop application for building your own macOS **Dynamic +Desktop** wallpapers — the `.heic` files that automatically switch images as +your Mac moves between Light and Dark appearance, or as the sun moves across +the sky throughout the day. + +You supply the images; the app assembles them into a single `.heic` file with +the same embedded metadata Apple's own dynamic wallpapers use, so macOS +recognizes and animates it exactly like a wallpaper from System Settings. + +## Features + +- **Light / Dark mode wallpapers** — pick one image for Light appearance and + one for Dark appearance. +- **Sun-position (solar) wallpapers** — add any number of images and tag each + one with the sun's altitude and azimuth; macOS cross-fades between them + based on your location and the real time of day. An "Auto-Distribute" + button fills in a reasonable sunrise → noon → sunset → night arc for you. +- **Set as Desktop Picture** — apply the wallpaper you just built without + leaving the app. +- Runs entirely locally. No image is uploaded anywhere. + +## Requirements + +- **macOS** — Dynamic Desktop is a macOS-only feature; the app refuses to + start on other platforms. +- **Java 17+** to run the app. +- **Xcode Command Line Tools** (for `swiftc`) to build the app *and* on first + run, so the bundled native helper can compile. Install with: + ``` + xcode-select --install + ``` +- **Maven** to build from source. + +See [docs/BUILD.md](docs/BUILD.md) for full build instructions and +[docs/USAGE.md](docs/USAGE.md) for how to use the app once it's running. + +## Quick start + +```bash +mvn package +java -jar target/dynamic-wallpaper-creator.jar +``` + +The first time you click **Generate Wallpaper**, the app compiles a small +bundled Swift helper and caches the binary in +`~/Library/Application Support/DynamicWallpaperCreator/` — this takes a few +seconds once and is instant after that. + +## How it works, in one paragraph + +A Dynamic Desktop wallpaper is just a HEIC (HEIF) file that stores several +full-size images side by side inside one container, plus a small binary +property list — describing which image belongs to which sun position or +appearance — embedded as custom XMP metadata under Apple's private +`apple_desktop` namespace. That embedding can only be done with Apple's own +ImageIO/CoreGraphics frameworks, which aren't reachable from plain Java, so +this app's Swing GUI hands the job off to a small bundled Swift command-line +tool that does the actual file assembly. The exact byte-level format is +documented in [docs/FILE_FORMAT.md](docs/FILE_FORMAT.md). + +## Project layout + +``` +pom.xml Maven build definition +src/main/java/com/dynamicwallpaper/ + Main.java Entry point + model/ Plain data types (WallpaperImage, WallpaperMode) + core/ Native helper management, job building, sun-position math + gui/ Swing UI (MainWindow + mode panels) + util/ JSON writer, thumbnails, validation +src/main/resources/native/HeicBuilder.swift Native HEIC/metadata helper (compiled on first run) +docs/ + BUILD.md Compiling and packaging the app + FILE_FORMAT.md Full technical spec of the .heic wallpaper format + USAGE.md Using the GUI +``` + +## License / attribution + +This project embeds metadata using Apple's `apple_desktop` XMP namespace, +which is undocumented but has been independently reverse-engineered by +several members of the macOS developer community. The exact key names used +here (`apr`, `solar`, `l`, `d`, `si`, `ap`, `i`, `a`, `z`) were verified +directly against a real Apple-shipped dynamic wallpaper file +(`/System/Library/Desktop Pictures/Sonoma.heic`) during development — see +[docs/FILE_FORMAT.md](docs/FILE_FORMAT.md) for details. diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..fca006d --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,133 @@ +# Building & Compiling + +This document covers everything involved in turning the source tree into a +running application: the Java/Maven build, and the native Swift helper that +Java compiles for itself the first time it needs it. + +## 1. Prerequisites + +| Tool | Why | Check | +|---|---|---| +| macOS | Dynamic Desktop is a macOS-only feature | `sw_vers` | +| JDK 17 or newer | Compiles and runs the Swing application | `java -version` | +| Apache Maven 3.6+ | Drives the Java build | `mvn -version` | +| Xcode Command Line Tools | Provides `swiftc`, used to compile the native HEIC helper | `xcrun --find swiftc` | + +If `xcrun --find swiftc` fails, install the Command Line Tools with: + +```bash +xcode-select --install +``` + +You do **not** need the full Xcode IDE — the Command Line Tools package is +enough, and is what most Mac developer machines already have. + +## 2. Building the Java application + +From the project root: + +```bash +mvn package +``` + +This does two things: + +1. Compiles all classes under `src/main/java` with the `maven-compiler-plugin`. +2. Uses the `maven-assembly-plugin` to produce a single **runnable, self-contained + jar** at `target/dynamic-wallpaper-creator.jar` (there are no third-party + runtime dependencies to bundle — the app only uses the JDK's own Swing, + AWT and I/O APIs — so this step mainly just sets the executable + `Main-Class` manifest entry). + +Run it with: + +```bash +java -jar target/dynamic-wallpaper-creator.jar +``` + +Other useful Maven targets: + +```bash +mvn compile # compile only, no jar +mvn clean # remove target/ +mvn -q package # quieter output +``` + +There are no automated tests in this project (see "Testing strategy" below +for why, and how the app was actually validated). + +## 3. Building the native helper (`heic-builder`) + +Java cannot write HEIC files with multiple embedded images and custom XMP +metadata on its own — those capabilities only exist in Apple's ImageIO and +CoreGraphics frameworks, which are Objective-C/Swift APIs. To bridge that +gap, the app ships the source for a small command-line tool, +[`src/main/resources/native/HeicBuilder.swift`](../src/main/resources/native/HeicBuilder.swift), +as a resource inside the jar. + +You never compile this file yourself — the Java app does it automatically: + +1. On startup (specifically, the first time you click **Generate + Wallpaper**), `com.dynamicwallpaper.core.NativeHelper` extracts the + bundled `.swift` source to + `~/Library/Application Support/DynamicWallpaperCreator/HeicBuilder.swift`. +2. It computes a SHA-256 hash of that source and compares it against the hash + recorded the last time it compiled. If they differ (first run, or you + built a newer version of the app with an updated helper), it invokes: + ```bash + xcrun swiftc -O HeicBuilder.swift -o heic-builder + ``` + in that same directory, and records the new hash. +3. Every subsequent run just reuses the cached `heic-builder` binary — no + recompilation, effectively instant startup. + +If you want to trigger this manually (e.g. to debug it in isolation), you +can compile and run it directly: + +```bash +xcrun swiftc -O src/main/resources/native/HeicBuilder.swift -o /tmp/heic-builder +/tmp/heic-builder build path/to/job.json +/tmp/heic-builder inspect path/to/some.heic # dumps embedded apple_desktop metadata, for debugging +``` + +See [FILE_FORMAT.md](FILE_FORMAT.md) for the `job.json` schema this tool +expects. + +## 4. Testing strategy + +There is no JUnit suite bundled with this project. The reasons and what was +actually done instead: + +- The two things worth testing — "does the GUI look/behave right" and "is the + generated `.heic` file byte-for-byte structured the way macOS expects" — + are not meaningfully covered by unit tests. The second one *can* be + verified programmatically, and was: during development, the native + helper's `inspect` command was used to decode the metadata out of a + freshly generated file and diff it against the metadata pulled from a real + Apple-shipped wallpaper (`/System/Library/Desktop Pictures/Sonoma.heic`). + They match exactly (see [FILE_FORMAT.md](FILE_FORMAT.md)). +- If you're extending this project and want a smoke test, the fastest way is + the `inspect` command above: generate a file, inspect it, and confirm the + `apple_desktop:apr` or `apple_desktop:solar` tag decodes to the plist + structure you expect. +- For a full manual test, generate a wallpaper, click **Set as Desktop + Picture**, then toggle System Settings → Appearance between Light and Dark + (for appearance-mode wallpapers) and confirm the desktop image changes. + +## 5. Packaging for distribution (optional) + +The runnable jar from `mvn package` is enough to hand to another Mac user — +they just need Java 17+ and Xcode Command Line Tools installed, and can run +`java -jar dynamic-wallpaper-creator.jar`. If you want a double-clickable +`.app` bundle instead, wrap the jar with `jpackage` (bundled with the JDK): + +```bash +jpackage \ + --input target \ + --name "Dynamic Wallpaper Creator" \ + --main-jar dynamic-wallpaper-creator.jar \ + --main-class com.dynamicwallpaper.Main \ + --type app-image +``` + +This is optional and not required for local development or use. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md new file mode 100644 index 0000000..fb238a8 --- /dev/null +++ b/docs/FILE_FORMAT.md @@ -0,0 +1,186 @@ +# The macOS Dynamic Desktop `.heic` File Format + +This document explains exactly what bytes this app writes and why, for +anyone extending the project or just curious how Dynamic Desktop wallpapers +work under the hood. Apple has never published a specification for this +format — everything below was independently reverse-engineered by the +macOS developer community over the years, and was **re-verified during the +development of this app** by decoding a real, Apple-shipped dynamic +wallpaper file directly (see "Verification" below). + +## 1. Container: HEIF/HEIC with multiple top-level images + +A `.heic` file is a HEIF container (the same family of formats used for +iPhone photos). Normally it holds exactly one image. A Dynamic Desktop +wallpaper is the same container format, just holding **several independent, +full-resolution images** as separate top-level items instead of one — think +of it like a zip file with N pictures inside, rather than one image with N +animation frames. + +macOS's own Photos/Preview/QuickLook stack can create files like this +through `CGImageDestination`, by calling `CGImageDestinationAddImage` +repeatedly on the same destination before finalizing it. That's exactly +what [`HeicBuilder.swift`](../src/main/resources/native/HeicBuilder.swift) +does: + +```swift +let dest = CGImageDestinationCreateWithURL(url, "public.heic", images.count, nil) +for (index, image) in images.enumerated() { + CGImageDestinationAddImage(dest, image, properties) // (image 0 also carries metadata, see below) +} +CGImageDestinationFinalize(dest) +``` + +This is a capability of Apple's ImageIO framework specifically — there is no +equivalent in Java's `ImageIO`, and general-purpose command-line HEIC +encoders like `heif-enc` don't expose it either (they support single images, +"bursts"/animation sequences via `-S`, or resolution pyramids, but not +independent top-level image collections). That's the whole reason this +project ships a small Swift helper rather than doing everything in pure +Java — see [BUILD.md](BUILD.md) for how that helper is compiled. + +## 2. Metadata: a binary property list, base64-encoded, in a custom XMP tag + +Attached to **image index 0 only**, there's an XMP metadata tag under a +private Apple namespace: + +- Namespace URI: `http://ns.apple.com/namespace/1.0/` +- Prefix: `apple_desktop` +- Tag name: `apr` (appearance mode) or `solar` (sun-position mode) +- Value: a `bplist00` (Apple binary property list), serialized to bytes, then + base64-encoded into a plain string, which is what actually gets stored as + the XMP tag's value. + +In Swift this looks like: + +```swift +let plistData = try PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0) +let base64 = plistData.base64EncodedString() +let tag = CGImageMetadataTagCreate(namespace, "apple_desktop", "apr", .string, base64 as CFTypeRef) +``` + +### 2a. Appearance mode (`apple_desktop:apr`) + +Exactly two images: index 0 is shown in Light Mode, index 1 in Dark Mode. The +plist is a flat dictionary: + +``` +{ + l = 0; // index of the "light" image + d = 1; // index of the "dark" image +} +``` + +### 2b. Solar mode (`apple_desktop:solar`) + +Any number of images (Apple's own wallpapers typically use 16), each tagged +with the sun's position when it should be shown: + +``` +{ + si = ( + { i = 0; a = -10.0; z = 60.0; }, // i = image index, a = altitude (degrees), z = azimuth (degrees) + { i = 1; a = 15.0; z = 90.0; }, + { i = 2; a = 60.0; z = 180.0; }, + ... + ); + ap = { l = 2; d = 0; }; // which frame index best represents "light" / "dark" for accessibility fallback +} +``` + +- **Altitude (`a`)** — the sun's height above the horizon in degrees. `0` is + the horizon, `90` is directly overhead, negative values are below the + horizon (night). +- **Azimuth (`z`)** — the sun's compass direction in degrees, `0`–`360`. +- **`ap`** — a secondary hint (same `l`/`d` shape as appearance mode) telling + macOS which single frame to fall back to when it just needs "a light one" + or "a dark one" rather than the full solar animation (e.g. for + accessibility or low-power contexts). This app computes it automatically + from whichever image has the highest/lowest altitude, unless you + explicitly check "Light Ref." / "Dark Ref." on a specific row in the + Solar tab. + +This app does not currently implement the third known mode, time-of-day +scheduling (`apple_desktop:h24`, keyed by `ti`/`t`/`i` instead of `si`/`a`/`z`), +since it wasn't part of the request this project was built for. It would +slot into `HeicBuilder.swift` the same way `solar` does, if needed later. + +## 3. Verification against a real Apple wallpaper + +Rather than trusting secondhand write-ups alone, the exact schema above was +confirmed directly against a dynamic wallpaper Apple ships with macOS, +`/System/Library/Desktop Pictures/Sonoma.heic`, using the `inspect` command +built into the native helper: + +``` +$ heic-builder inspect "/System/Library/Desktop Pictures/Sonoma.heic" +images: 2 +image 0 apple_desktop:apr = { + d = 1; + l = 0; +} +``` + +That is a live decode of Apple's own file — namespace, prefix, tag name +(`apr`), and the `l`/`d` key structure all matched what this project already +implemented. The `solar` structure (`si`/`ap`/`i`/`a`/`z`) is corroborated by +multiple independent community write-ups (NSHipster's "macOS Dynamic +Desktop" article and a widely cited 2018 reverse-engineering gist), and this +project's own `heic-builder inspect` command was used to confirm that files +*this app generates* round-trip through Apple's ImageIO the same way: build +a file, inspect it back, and the decoded plist matches what was requested +byte-for-byte. + +If you ever need to re-verify or debug this yourself, `heic-builder inspect` +works on any `.heic` file, including ones this app produced or ones from +`/System/Library/Desktop Pictures/`. + +## 4. The `job.json` hand-off format + +This is purely an internal implementation detail — the Java GUI never writes +a `.heic` file itself; it writes a small JSON description of what to build to +a temp file, and runs `heic-builder build `. Documented here in +case you want to drive the helper directly (e.g. from a script) without going +through the GUI at all. + +**Appearance mode:** + +```json +{ + "mode": "appearance", + "output": "/absolute/path/out.heic", + "quality": 0.9, + "light": "/absolute/path/light.png", + "dark": "/absolute/path/dark.png" +} +``` + +**Solar mode:** + +```json +{ + "mode": "solar", + "output": "/absolute/path/out.heic", + "quality": 0.9, + "images": [ + { "path": "/absolute/path/01.png", "altitude": -10.0, "azimuth": 60.0, + "lightReference": false, "darkReference": true }, + { "path": "/absolute/path/02.png", "altitude": 60.0, "azimuth": 180.0, + "lightReference": true, "darkReference": false } + ] +} +``` + +Notes: +- `quality` is the HEIC lossy compression quality, `0.0`–`1.0` (higher = better + quality, larger file). Optional, defaults to `0.9`. +- `lightReference` / `darkReference` are optional booleans; at most one image + should have each set to `true`. If neither is set anywhere, the helper + picks the highest-altitude image as the light reference and the + lowest-altitude image as the dark reference automatically. +- Input images should all share the same pixel dimensions. The Java app + warns (non-fatally) if they don't; the helper itself does not resize them. +- `heic-builder build` prints one line of progress per image to stdout, then + either `OK ` on success (exit code 0) or `ERROR: ` + to stderr (non-zero exit code) on failure. The Java app streams this + output straight into the log panel at the bottom of the window. diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..9eb3b82 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,98 @@ +# Using Dynamic Wallpaper Creator + +This walks through the app window shown by `java -jar +target/dynamic-wallpaper-creator.jar` (see [BUILD.md](BUILD.md) if you +haven't built it yet). + +## 1. Pick a wallpaper type + +The dropdown at the top switches between the two supported wallpaper types: + +- **Light / Dark Appearance** — the simple case: one image for Light Mode, + one for Dark Mode. Good for a first try, or if you just want two specific + images to swap automatically. +- **Sun Position (Solar)** — the full "Dynamic Desktop" experience: any + number of images, each tied to a point in the sun's daily arc, cross-fading + as the actual time of day changes. + +## 2. Light / Dark Appearance tab + +Two side-by-side cards, one per appearance. For each: + +1. Click **Choose Image…**. +2. Pick a PNG, JPEG, TIFF, BMP, GIF, or HEIC file. + +A thumbnail preview appears once selected. Both a light and a dark image are +required before you can generate. + +**Tip:** the two images should be the same pixel dimensions — same scene, +different lighting, works best. The app warns you (but doesn't block you) if +they don't match. + +## 3. Sun Position (Solar) tab + +1. Click **Add Images…** and select one or more image files (multi-select is + supported in the file picker). Each becomes a row in the table. +2. For each row, set: + - **Altitude (°)** — how high the sun is: `90` = directly overhead, `0` = + right on the horizon, negative values = below the horizon (night/dusk). + - **Azimuth (°)** — the sun's compass direction, `0`–`360`. + - **Light Ref. / Dark Ref.** checkboxes (optional) — mark the single + frame that best represents "daytime" and the one that best represents + "nighttime". If you leave these unchecked, the app picks them for you + automatically (highest and lowest altitude). +3. Don't want to hand-enter every value? Select at least two images and + click **Auto-Distribute Sun Positions** — it fills in a smooth + sunrise → noon → sunset → night arc across however many images you've + added, evenly spaced. You can still tweak any individual cell afterward + by double-clicking it. +4. Use **Move Up** / **Move Down** to reorder rows, or **Remove Selected** / + **Clear All** to edit the set. Order doesn't affect how macOS displays the + wallpaper (that's entirely driven by the altitude/azimuth values you set), + it's just for your own bookkeeping. + +At least two images are required to generate a solar wallpaper. + +## 4. Generate + +At the bottom of the window: + +1. **Output file** — where to save the `.heic` file. Defaults to + `~/Desktop/MyDynamicWallpaper.heic`; click **Choose…** to pick somewhere + else. +2. **Quality** — HEIC compression quality from `0.1` (small file, more + compression artifacts) to `1.0` (largest file, best quality). `0.9` is a + good default. +3. Click **Generate Wallpaper**. + +The first time you do this on a fresh install, there's a short one-time delay +(a few seconds) while the app compiles its native helper tool in the +background — see [BUILD.md](BUILD.md#3-building-the-native-helper-heic-builder) +for what's happening. Every generation after that is fast. + +Progress is streamed into the log box at the very bottom as each image is +loaded and the file is written. If something goes wrong (a missing image, an +unwritable output path, a missing Swift toolchain), you'll see an error +dialog and the reason in the log. + +## 5. Set as Desktop Picture + +Once generation finishes successfully, the **Set as Desktop Picture** button +becomes active. Clicking it applies the file you just built as your current +desktop picture on all displays, via the same mechanism as System Settings. +This only changes your desktop picture setting — it's fully reversible from +System Settings → Wallpaper at any time, the same as picking any other +wallpaper. + +You can also just leave the generated `.heic` file where you saved it and add +it manually later: open **System Settings → Wallpaper → Add Photo**, or +double-click the file in Finder and choose **Set Desktop Picture**. + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---|---| +| "The Swift compiler (swiftc) was not found" | Install Xcode Command Line Tools: `xcode-select --install`, then try again. | +| Generate fails immediately with a decode error | One of your source images is in a format Apple's ImageIO can't read, or the file is corrupt. Try re-exporting it as PNG or JPEG. | +| Wallpaper doesn't animate after setting it | Make sure you're on macOS Mojave (10.14) or later, and that "Dynamic Desktop" hasn't been disabled — check System Settings → Wallpaper on the image you just set; it should show a Light/Dark/Dynamic selector under the thumbnail. | +| Images look stretched or misaligned when swapping | Your light/dark or solar images have different pixel dimensions. Re-export them all at the same resolution. | diff --git a/docs/demo/Creating Light & Dark.png b/docs/demo/Creating Light & Dark.png new file mode 100644 index 0000000..2d9ea27 Binary files /dev/null and b/docs/demo/Creating Light & Dark.png differ diff --git a/docs/demo/Creating Sun Position.png b/docs/demo/Creating Sun Position.png new file mode 100644 index 0000000..3088718 Binary files /dev/null and b/docs/demo/Creating Sun Position.png differ diff --git a/docs/demo/Main Window.png b/docs/demo/Main Window.png new file mode 100644 index 0000000..b6da087 Binary files /dev/null and b/docs/demo/Main Window.png differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..643925b --- /dev/null +++ b/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + com.dynamicwallpaper + dynamic-wallpaper-creator + 1.0.0 + jar + + Dynamic Wallpaper Creator + Swing GUI for building macOS Dynamic Desktop (.heic) wallpapers with light/dark and sun-position variants. + + + 17 + 17 + UTF-8 + com.dynamicwallpaper.Main + + + + dynamic-wallpaper-creator + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + + ${mainClass} + + + + jar-with-dependencies + + false + + + + make-runnable-jar + package + + single + + + + + + + diff --git a/src/main/java/com/dynamicwallpaper/Main.java b/src/main/java/com/dynamicwallpaper/Main.java new file mode 100644 index 0000000..e8ec612 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/Main.java @@ -0,0 +1,40 @@ +package com.dynamicwallpaper; + +import com.dynamicwallpaper.gui.MainWindow; + +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; + +/** Application entry point. */ +public final class Main { + + private Main() { + } + + public static void main(String[] args) { + if (!System.getProperty("os.name", "").toLowerCase().contains("mac")) { + System.err.println("Dynamic Wallpaper Creator only runs on macOS, " + + "since Dynamic Desktop wallpapers are a macOS feature."); + System.exit(1); + } + + System.setProperty("apple.laf.useScreenMenuBar", "true"); + System.setProperty("apple.awt.application.name", "Dynamic Wallpaper Creator"); + + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + // Fall back to the default look and feel; purely cosmetic. + } + + SwingUtilities.invokeLater(() -> { + try { + new MainWindow().setVisible(true); + } catch (Exception e) { + JOptionPane.showMessageDialog(null, "Dynamic Wallpaper Creator failed to start:\n" + e.getMessage(), + "Startup error", JOptionPane.ERROR_MESSAGE); + } + }); + } +} diff --git a/src/main/java/com/dynamicwallpaper/core/NativeHelper.java b/src/main/java/com/dynamicwallpaper/core/NativeHelper.java new file mode 100644 index 0000000..e38cf63 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/core/NativeHelper.java @@ -0,0 +1,147 @@ +package com.dynamicwallpaper.core; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.TimeUnit; + +/** + * Manages the bundled Swift command-line helper ("heic-builder") that does + * the actual HEIC assembly and XMP metadata embedding via Apple's ImageIO / + * CoreGraphics frameworks - APIs that are only reachable from Objective-C or + * Swift, not from pure Java. + * + *

The helper's source is shipped as a resource inside this application's + * jar. On first run (or whenever that source changes) it is extracted to + * {@code ~/Library/Application Support/DynamicWallpaperCreator/} and + * compiled there with {@code swiftc}, which ships with Xcode Command Line + * Tools. The compiled binary is then reused on subsequent runs. + */ +public final class NativeHelper { + + private static final String RESOURCE_PATH = "/native/HeicBuilder.swift"; + + private final Path appSupportDir; + private final Path sourceFile; + private final Path hashFile; + private final Path binaryFile; + + public NativeHelper() { + String home = System.getProperty("user.home"); + this.appSupportDir = Path.of(home, "Library", "Application Support", "DynamicWallpaperCreator"); + this.sourceFile = appSupportDir.resolve("HeicBuilder.swift"); + this.hashFile = appSupportDir.resolve(".source.sha256"); + this.binaryFile = appSupportDir.resolve("heic-builder"); + } + + /** Returns true if {@code swiftc} is available on PATH (via xcrun). */ + public boolean isSwiftToolchainAvailable() { + try { + Process process = new ProcessBuilder("xcrun", "--find", "swiftc") + .redirectErrorStream(true) + .start(); + boolean finished = process.waitFor(10, TimeUnit.SECONDS); + return finished && process.exitValue() == 0; + } catch (IOException | InterruptedException e) { + return false; + } + } + + /** + * Ensures the helper binary exists and is up to date, compiling it if + * necessary. Returns the path to the runnable binary. + */ + public Path ensureBuilt() throws NativeHelperException { + try { + Files.createDirectories(appSupportDir); + String bundledSource = readBundledSource(); + String bundledHash = sha256(bundledSource); + + boolean needsCompile = !Files.exists(binaryFile) + || !Files.exists(hashFile) + || !bundledHash.equals(readExistingHash()); + + if (needsCompile) { + Files.writeString(sourceFile, bundledSource, StandardCharsets.UTF_8); + compile(); + Files.writeString(hashFile, bundledHash, StandardCharsets.UTF_8); + } + return binaryFile; + } catch (IOException e) { + throw new NativeHelperException("Could not prepare native helper: " + e.getMessage(), e); + } + } + + private String readExistingHash() throws IOException { + if (!Files.exists(hashFile)) { + return ""; + } + return Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + } + + private void compile() throws NativeHelperException { + if (!isSwiftToolchainAvailable()) { + throw new NativeHelperException( + "The Swift compiler (swiftc) was not found. Install the Xcode Command Line Tools with " + + "'xcode-select --install' and try again."); + } + try { + ProcessBuilder pb = new ProcessBuilder( + "xcrun", "swiftc", "-O", sourceFile.toString(), "-o", binaryFile.toString()); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output; + try (InputStream in = process.getInputStream()) { + output = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + boolean finished = process.waitFor(180, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new NativeHelperException("Compiling the native helper timed out."); + } + if (process.exitValue() != 0) { + throw new NativeHelperException("Failed to compile native helper:\n" + output); + } + } catch (IOException | InterruptedException e) { + throw new NativeHelperException("Failed to compile native helper: " + e.getMessage(), e); + } + } + + private String readBundledSource() throws IOException { + try (InputStream in = NativeHelper.class.getResourceAsStream(RESOURCE_PATH)) { + if (in == null) { + throw new IOException("Missing bundled resource " + RESOURCE_PATH); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String sha256(String text) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IOException(e); + } + } + + /** Thrown when the native helper cannot be prepared or fails to run. */ + public static final class NativeHelperException extends Exception { + public NativeHelperException(String message) { + super(message); + } + + public NativeHelperException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/com/dynamicwallpaper/core/SunPositionUtil.java b/src/main/java/com/dynamicwallpaper/core/SunPositionUtil.java new file mode 100644 index 0000000..66cd7e4 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/core/SunPositionUtil.java @@ -0,0 +1,46 @@ +package com.dynamicwallpaper.core; + +import java.util.ArrayList; +import java.util.List; + +/** + * Produces a plausible, evenly-spaced sun-position schedule for a set of + * images, so a user who doesn't want to hand-enter altitude/azimuth values + * for every image still gets a smooth sunrise-to-sunset-to-night arc. + * + *

This is a stylistic approximation, not an astronomical calculation: it + * assumes the images are ordered around a single day/night cycle and spaces + * them evenly across it. Users can override any individual value in the + * table before generating the wallpaper. + */ +public final class SunPositionUtil { + + private static final double PEAK_ALTITUDE = 70.0; + + private SunPositionUtil() { + } + + public record SunPosition(double altitude, double azimuth) { + } + + /** + * @param count number of images to distribute across one day/night cycle (must be >= 2) + */ + public static List distribute(int count) { + if (count < 2) { + throw new IllegalArgumentException("Need at least 2 images to distribute sun positions"); + } + List positions = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + double t = (double) i / (count - 1); + double altitude = PEAK_ALTITUDE * Math.sin(2 * Math.PI * t - Math.PI / 2); + double azimuth = 360.0 * t; + positions.add(new SunPosition(round(altitude), round(azimuth))); + } + return positions; + } + + private static double round(double value) { + return Math.round(value * 100.0) / 100.0; + } +} diff --git a/src/main/java/com/dynamicwallpaper/core/WallpaperBuilder.java b/src/main/java/com/dynamicwallpaper/core/WallpaperBuilder.java new file mode 100644 index 0000000..6be33f9 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/core/WallpaperBuilder.java @@ -0,0 +1,132 @@ +package com.dynamicwallpaper.core; + +import com.dynamicwallpaper.model.WallpaperImage; +import com.dynamicwallpaper.util.Json; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Drives the native helper to assemble a finished .heic Dynamic Desktop + * file. This class only knows how to describe the job as JSON and run a + * process - the HEIC/metadata work itself happens in HeicBuilder.swift (see + * docs/FILE_FORMAT.md). + */ +public final class WallpaperBuilder { + + /** Receives one callback per line of progress output from the native helper. */ + @FunctionalInterface + public interface ProgressListener { + void onLine(String line); + } + + private final NativeHelper nativeHelper; + + public WallpaperBuilder(NativeHelper nativeHelper) { + this.nativeHelper = nativeHelper; + } + + public void buildAppearance(File light, File dark, File output, double quality, ProgressListener listener) + throws BuildException { + Map job = Json.object(); + job.put("mode", "appearance"); + job.put("output", output.getAbsolutePath()); + job.put("quality", quality); + job.put("light", light.getAbsolutePath()); + job.put("dark", dark.getAbsolutePath()); + run(job, listener); + } + + public void buildSolar(List images, File output, double quality, ProgressListener listener) + throws BuildException { + Map job = Json.object(); + job.put("mode", "solar"); + job.put("output", output.getAbsolutePath()); + job.put("quality", quality); + + List imageArray = Json.array(); + for (WallpaperImage image : images) { + Map entry = Json.object(); + entry.put("path", image.file().getAbsolutePath()); + entry.put("altitude", image.altitude()); + entry.put("azimuth", image.azimuth()); + entry.put("lightReference", image.isLightReference()); + entry.put("darkReference", image.isDarkReference()); + imageArray.add(entry); + } + job.put("images", imageArray); + run(job, listener); + } + + private void run(Map job, ProgressListener listener) throws BuildException { + Path binary; + try { + binary = nativeHelper.ensureBuilt(); + } catch (NativeHelper.NativeHelperException e) { + throw new BuildException(e.getMessage(), e); + } + + Path jobFile; + try { + jobFile = Files.createTempFile("dynamic-wallpaper-job", ".json"); + Files.writeString(jobFile, Json.write(job), StandardCharsets.UTF_8); + jobFile.toFile().deleteOnExit(); + } catch (IOException e) { + throw new BuildException("Could not write job file: " + e.getMessage(), e); + } + + try { + ProcessBuilder pb = new ProcessBuilder(binary.toString(), "build", jobFile.toString()); + pb.redirectErrorStream(true); + Process process = pb.start(); + + StringBuilder allOutput = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + allOutput.append(line).append('\n'); + if (listener != null) { + listener.onLine(line); + } + } + } + + boolean finished = process.waitFor(5, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new BuildException("Building the wallpaper timed out."); + } + if (process.exitValue() != 0) { + throw new BuildException("Native helper failed:\n" + allOutput); + } + } catch (IOException | InterruptedException e) { + throw new BuildException("Failed to run native helper: " + e.getMessage(), e); + } finally { + try { + Files.deleteIfExists(jobFile); + } catch (IOException ignored) { + // best effort cleanup + } + } + } + + /** Thrown when building a wallpaper fails for any reason. */ + public static final class BuildException extends Exception { + public BuildException(String message) { + super(message); + } + + public BuildException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/com/dynamicwallpaper/core/WallpaperInstaller.java b/src/main/java/com/dynamicwallpaper/core/WallpaperInstaller.java new file mode 100644 index 0000000..15d6bd1 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/core/WallpaperInstaller.java @@ -0,0 +1,39 @@ +package com.dynamicwallpaper.core; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +/** Applies a generated .heic file as the current desktop picture, via AppleScript. */ +public final class WallpaperInstaller { + + private WallpaperInstaller() { + } + + /** + * Sets {@code heicFile} as the desktop picture on every display, using + * System Events. This only touches the user's own desktop picture + * setting and is reversible from System Settings at any time. + */ + public static void setAsDesktopPicture(File heicFile) throws IOException, InterruptedException { + String escapedPath = heicFile.getAbsolutePath() + .replace("\\", "\\\\") + .replace("\"", "\\\""); + String script = "tell application \"System Events\" to tell every desktop to set picture to POSIX file \"" + + escapedPath + "\""; + + ProcessBuilder pb = new ProcessBuilder("osascript", "-e", script); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + boolean finished = process.waitFor(15, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IOException("Timed out asking macOS to set the desktop picture."); + } + if (process.exitValue() != 0) { + throw new IOException("macOS refused to set the desktop picture: " + output.trim()); + } + } +} diff --git a/src/main/java/com/dynamicwallpaper/gui/AppearancePanel.java b/src/main/java/com/dynamicwallpaper/gui/AppearancePanel.java new file mode 100644 index 0000000..03b605b --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/gui/AppearancePanel.java @@ -0,0 +1,86 @@ +package com.dynamicwallpaper.gui; + +import com.dynamicwallpaper.util.ThumbnailLoader; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.BorderLayout; +import java.awt.GridLayout; +import java.io.File; + +/** Lets the user pick exactly one image for Light Mode and one for Dark Mode. */ +public final class AppearancePanel extends JPanel { + + private final ImagePickerCard lightCard = new ImagePickerCard("Light Mode Image", "Shown when the Mac uses Light appearance."); + private final ImagePickerCard darkCard = new ImagePickerCard("Dark Mode Image", "Shown when the Mac uses Dark appearance."); + + public AppearancePanel() { + setLayout(new GridLayout(1, 2, 16, 0)); + setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); + add(lightCard); + add(darkCard); + } + + public File lightImage() { + return lightCard.file(); + } + + public File darkImage() { + return darkCard.file(); + } + + /** A single "choose an image" card with a thumbnail and file name. */ + private static final class ImagePickerCard extends JPanel { + private File file; + private final JLabel thumbnailLabel = new JLabel("", SwingConstants.CENTER); + private final JLabel fileNameLabel = new JLabel("No image selected", SwingConstants.CENTER); + + ImagePickerCard(String title, String subtitle) { + setLayout(new BorderLayout(8, 8)); + setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder(title), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); + + JLabel subtitleLabel = new JLabel(subtitle, SwingConstants.CENTER); + subtitleLabel.setEnabled(false); + + thumbnailLabel.setPreferredSize(new java.awt.Dimension(220, 220)); + thumbnailLabel.setBorder(BorderFactory.createDashedBorder(null)); + + JButton chooseButton = new JButton("Choose Image…"); + chooseButton.addActionListener(e -> choose()); + + JPanel top = new JPanel(new BorderLayout()); + top.add(subtitleLabel, BorderLayout.NORTH); + top.add(thumbnailLabel, BorderLayout.CENTER); + + JPanel bottom = new JPanel(new GridLayout(2, 1)); + bottom.add(fileNameLabel); + bottom.add(chooseButton); + + add(top, BorderLayout.CENTER); + add(bottom, BorderLayout.SOUTH); + } + + private void choose() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new FileNameExtensionFilter( + "Images", "png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "heic")); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + file = chooser.getSelectedFile(); + fileNameLabel.setText(file.getName()); + thumbnailLabel.setIcon(ThumbnailLoader.thumbnail(file, 200)); + thumbnailLabel.setText(null); + } + } + + File file() { + return file; + } + } +} diff --git a/src/main/java/com/dynamicwallpaper/gui/MainWindow.java b/src/main/java/com/dynamicwallpaper/gui/MainWindow.java new file mode 100644 index 0000000..4f9604c --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/gui/MainWindow.java @@ -0,0 +1,276 @@ +package com.dynamicwallpaper.gui; + +import com.dynamicwallpaper.core.NativeHelper; +import com.dynamicwallpaper.core.WallpaperBuilder; +import com.dynamicwallpaper.core.WallpaperInstaller; +import com.dynamicwallpaper.model.WallpaperImage; +import com.dynamicwallpaper.model.WallpaperMode; +import com.dynamicwallpaper.util.ImageValidation; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingWorker; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.io.File; +import java.util.List; + +/** Top-level application window: mode switch, image editors, and the generate/install controls. */ +public final class MainWindow extends JFrame { + + private final NativeHelper nativeHelper = new NativeHelper(); + private final WallpaperBuilder wallpaperBuilder = new WallpaperBuilder(nativeHelper); + + private final JComboBox modeSelector = new JComboBox<>(WallpaperMode.values()); + private final CardLayout cardLayout = new CardLayout(); + private final JPanel modePanel = new JPanel(cardLayout); + private final AppearancePanel appearancePanel = new AppearancePanel(); + private final SolarPanel solarPanel = new SolarPanel(); + + private final JTextField outputPathField = new JTextField(); + private final JSpinner qualitySpinner = new JSpinner(new SpinnerNumberModel(0.9, 0.1, 1.0, 0.05)); + private final JButton generateButton = new JButton("Generate Wallpaper"); + private final JButton setWallpaperButton = new JButton("Set as Desktop Picture"); + private final JProgressBar progressBar = new JProgressBar(); + private final JTextArea logArea = new JTextArea(8, 60); + + private File lastGeneratedFile; + + public MainWindow() { + super("Dynamic Wallpaper Creator"); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setLayout(new BorderLayout()); + + add(buildHeader(), BorderLayout.NORTH); + add(modePanel, BorderLayout.CENTER); + add(buildFooter(), BorderLayout.SOUTH); + + modePanel.add(appearancePanel, WallpaperMode.APPEARANCE.name()); + modePanel.add(solarPanel, WallpaperMode.SOLAR.name()); + modeSelector.addActionListener(e -> cardLayout.show(modePanel, currentMode().name())); + + setWallpaperButton.setEnabled(false); + setWallpaperButton.addActionListener(e -> setAsDesktopPicture()); + generateButton.addActionListener(e -> generate()); + + setMinimumSize(new Dimension(760, 640)); + pack(); + setLocationRelativeTo(null); + + + this.setSize(new Dimension(1200,800)); + this.setLocationRelativeTo(null); // Center on screen + } + + private Component buildHeader() { + JPanel header = new JPanel(new BorderLayout(8, 8)); + header.setBorder(BorderFactory.createEmptyBorder(12, 16, 0, 16)); + + JLabel title = new JLabel("Choose a wallpaper type:"); + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT)); + left.add(title); + left.add(modeSelector); + + JLabel modeDescription = new JLabel(currentMode().description()); + modeSelector.addActionListener(e -> modeDescription.setText(currentMode().description())); + modeDescription.setEnabled(false); + + header.add(left, BorderLayout.NORTH); + header.add(modeDescription, BorderLayout.SOUTH); + return header; + } + + private Component buildFooter() { + JPanel footer = new JPanel(); + footer.setLayout(new BorderLayout(8, 8)); + footer.setBorder(BorderFactory.createEmptyBorder(8, 16, 16, 16)); + + JPanel outputRow = new JPanel(new BorderLayout(8, 8)); + outputRow.add(new JLabel("Output file:"), BorderLayout.WEST); + outputPathField.setText(defaultOutputPath()); + outputRow.add(outputPathField, BorderLayout.CENTER); + JButton browseButton = new JButton("Choose…"); + browseButton.addActionListener(e -> chooseOutputPath()); + outputRow.add(browseButton, BorderLayout.EAST); + + JPanel controlsRow = new JPanel(new FlowLayout(FlowLayout.LEFT)); + controlsRow.add(new JLabel("Quality:")); + qualitySpinner.setPreferredSize(new Dimension(70, qualitySpinner.getPreferredSize().height)); + controlsRow.add(qualitySpinner); + controlsRow.add(generateButton); + controlsRow.add(setWallpaperButton); + + JPanel top = new JPanel(new GridLayout(2, 1)); + top.add(outputRow); + top.add(controlsRow); + + logArea.setEditable(false); + logArea.setFont(new java.awt.Font(java.awt.Font.MONOSPACED, java.awt.Font.PLAIN, 12)); + + footer.add(top, BorderLayout.NORTH); + footer.add(progressBar, BorderLayout.CENTER); + footer.add(new JScrollPane(logArea), BorderLayout.SOUTH); + return footer; + } + + private WallpaperMode currentMode() { + return (WallpaperMode) modeSelector.getSelectedItem(); + } + + private String defaultOutputPath() { + String home = System.getProperty("user.home"); + return new File(home, "Desktop/MyDynamicWallpaper.heic").getAbsolutePath(); + } + + private void chooseOutputPath() { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Save Dynamic Wallpaper As"); + chooser.setFileFilter(new FileNameExtensionFilter("HEIC Image", "heic")); + chooser.setSelectedFile(new File(outputPathField.getText())); + if (chooser.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { + File selected = chooser.getSelectedFile(); + if (!selected.getName().toLowerCase().endsWith(".heic")) { + selected = new File(selected.getParentFile(), selected.getName() + ".heic"); + } + outputPathField.setText(selected.getAbsolutePath()); + } + } + + private void generate() { + File output = new File(outputPathField.getText().trim()); + if (output.getParentFile() == null || !output.getParentFile().isDirectory()) { + JOptionPane.showMessageDialog(this, "Choose a valid output location first.", + "Missing output location", JOptionPane.WARNING_MESSAGE); + return; + } + + WallpaperMode mode = currentMode(); + double quality = (Double) qualitySpinner.getValue(); + + if (mode == WallpaperMode.APPEARANCE) { + File light = appearancePanel.lightImage(); + File dark = appearancePanel.darkImage(); + if (light == null || dark == null) { + JOptionPane.showMessageDialog(this, "Choose both a light image and a dark image first.", + "Missing images", JOptionPane.WARNING_MESSAGE); + return; + } + String warning = ImageValidation.checkConsistentDimensions(List.of(light, dark)); + if (warning != null && !confirmProceedDespiteWarning(warning)) { + return; + } + runGenerateJob(output, listener -> wallpaperBuilder.buildAppearance(light, dark, output, quality, listener)); + } else { + List images = solarPanel.images(); + if (images.size() < 2) { + JOptionPane.showMessageDialog(this, "Add at least two images for a sun-position wallpaper.", + "Not enough images", JOptionPane.WARNING_MESSAGE); + return; + } + String warning = ImageValidation.checkConsistentDimensions(images.stream().map(WallpaperImage::file).toList()); + if (warning != null && !confirmProceedDespiteWarning(warning)) { + return; + } + runGenerateJob(output, listener -> wallpaperBuilder.buildSolar(images, output, quality, listener)); + } + } + + private boolean confirmProceedDespiteWarning(String warning) { + int choice = JOptionPane.showConfirmDialog(this, warning + "\n\nContinue anyway?", + "Image sizes differ", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + return choice == JOptionPane.YES_OPTION; + } + + @FunctionalInterface + private interface GenerateJob { + void run(WallpaperBuilder.ProgressListener listener) throws WallpaperBuilder.BuildException; + } + + private void runGenerateJob(File output, GenerateJob job) { + setBusy(true); + logArea.setText(""); + setWallpaperButton.setEnabled(false); + + SwingWorker worker = new SwingWorker<>() { + @Override + protected Void doInBackground() throws Exception { + job.run(line -> publish(line)); + return null; + } + + @Override + protected void process(List chunks) { + for (String line : chunks) { + logArea.append(line); + logArea.append("\n"); + } + } + + @Override + protected void done() { + setBusy(false); + try { + get(); + logArea.append("Done. Saved to " + output.getAbsolutePath() + "\n"); + lastGeneratedFile = output; + setWallpaperButton.setEnabled(true); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + logArea.append("Failed: " + cause.getMessage() + "\n"); + JOptionPane.showMessageDialog(MainWindow.this, cause.getMessage(), + "Could not generate wallpaper", JOptionPane.ERROR_MESSAGE); + } + } + }; + worker.execute(); + } + + private void setBusy(boolean busy) { + generateButton.setEnabled(!busy); + progressBar.setIndeterminate(busy); + } + + private void setAsDesktopPicture() { + if (lastGeneratedFile == null) { + return; + } + SwingWorker worker = new SwingWorker<>() { + @Override + protected Void doInBackground() throws Exception { + WallpaperInstaller.setAsDesktopPicture(lastGeneratedFile); + return null; + } + + @Override + protected void done() { + try { + get(); + logArea.append("Set as desktop picture.\n"); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + JOptionPane.showMessageDialog(MainWindow.this, cause.getMessage(), + "Could not set desktop picture", JOptionPane.ERROR_MESSAGE); + } + } + }; + worker.execute(); + } + +} diff --git a/src/main/java/com/dynamicwallpaper/gui/SolarImageTableModel.java b/src/main/java/com/dynamicwallpaper/gui/SolarImageTableModel.java new file mode 100644 index 0000000..a8f8fb0 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/gui/SolarImageTableModel.java @@ -0,0 +1,127 @@ +package com.dynamicwallpaper.gui; + +import com.dynamicwallpaper.model.WallpaperImage; +import com.dynamicwallpaper.util.ThumbnailLoader; + +import javax.swing.Icon; +import javax.swing.table.AbstractTableModel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Backs the solar-mode table: one row per image, with editable altitude/azimuth/reference columns. */ +final class SolarImageTableModel extends AbstractTableModel { + + private static final String[] COLUMNS = { + "Preview", "File", "Altitude (°)", "Azimuth (°)", "Light Ref.", "Dark Ref." + }; + + private final List images = new ArrayList<>(); + + List images() { + return Collections.unmodifiableList(images); + } + + void add(WallpaperImage image) { + images.add(image); + int row = images.size() - 1; + fireTableRowsInserted(row, row); + } + + void removeRows(int[] rows) { + List toRemove = new ArrayList<>(); + for (int row : rows) { + toRemove.add(images.get(row)); + } + images.removeAll(toRemove); + fireTableDataChanged(); + } + + void clear() { + images.clear(); + fireTableDataChanged(); + } + + void moveRow(int from, int to) { + if (to < 0 || to >= images.size()) { + return; + } + WallpaperImage moved = images.remove(from); + images.add(to, moved); + fireTableDataChanged(); + } + + @Override + public int getRowCount() { + return images.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Class getColumnClass(int columnIndex) { + return switch (columnIndex) { + case 0 -> Icon.class; + case 1 -> String.class; + case 2, 3 -> Double.class; + case 4, 5 -> Boolean.class; + default -> Object.class; + }; + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return columnIndex >= 2; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + WallpaperImage image = images.get(rowIndex); + return switch (columnIndex) { + case 0 -> ThumbnailLoader.thumbnail(image.file(), 40); + case 1 -> image.name(); + case 2 -> image.altitude(); + case 3 -> image.azimuth(); + case 4 -> image.isLightReference(); + case 5 -> image.isDarkReference(); + default -> null; + }; + } + + @Override + public void setValueAt(Object value, int rowIndex, int columnIndex) { + WallpaperImage image = images.get(rowIndex); + switch (columnIndex) { + case 2 -> image.setAltitude(((Number) value).doubleValue()); + case 3 -> image.setAzimuth(((Number) value).doubleValue()); + case 4 -> setExclusiveFlag(rowIndex, (Boolean) value, true); + case 5 -> setExclusiveFlag(rowIndex, (Boolean) value, false); + default -> { + return; + } + } + fireTableRowsUpdated(rowIndex, rowIndex); + } + + /** Only one row at a time may be the light reference, and separately only one may be the dark reference. */ + private void setExclusiveFlag(int rowIndex, boolean value, boolean light) { + for (int i = 0; i < images.size(); i++) { + WallpaperImage image = images.get(i); + boolean newValue = (i == rowIndex) && value; + if (light) { + image.setLightReference(newValue); + } else { + image.setDarkReference(newValue); + } + } + fireTableDataChanged(); + } +} diff --git a/src/main/java/com/dynamicwallpaper/gui/SolarPanel.java b/src/main/java/com/dynamicwallpaper/gui/SolarPanel.java new file mode 100644 index 0000000..2b24e2a --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/gui/SolarPanel.java @@ -0,0 +1,127 @@ +package com.dynamicwallpaper.gui; + +import com.dynamicwallpaper.core.SunPositionUtil; +import com.dynamicwallpaper.model.WallpaperImage; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.List; + +/** + * Lets the user add any number of images and assign each one a sun altitude + * and azimuth (in degrees). "Auto-Distribute" fills in a reasonable + * sunrise-to-sunset-to-night arc automatically; users can still edit any + * value by double-clicking its cell. + */ +public final class SolarPanel extends JPanel { + + private final SolarImageTableModel tableModel = new SolarImageTableModel(); + private final JTable table = new JTable(tableModel); + + public SolarPanel() { + setLayout(new BorderLayout(8, 8)); + setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); + + table.setRowHeight(44); + table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + table.getColumnModel().getColumn(0).setMaxWidth(48); + + JLabel help = new JLabel( + "Add several images that represent the sun at different points in the day. " + + "Altitude is the sun's height above the horizon (-90 to 90, negative = night); " + + "azimuth is its compass direction (0 to 360). Check \"Light Ref.\" / \"Dark Ref.\" " + + "on the frames that best represent Light and Dark appearance."); + help.setBorder(BorderFactory.createEmptyBorder(0, 0, 8, 0)); + + JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton addButton = new JButton("Add Images…"); + JButton removeButton = new JButton("Remove Selected"); + JButton clearButton = new JButton("Clear All"); + JButton upButton = new JButton("Move Up"); + JButton downButton = new JButton("Move Down"); + JButton autoButton = new JButton("Auto-Distribute Sun Positions"); + + addButton.addActionListener(e -> addImages()); + removeButton.addActionListener(e -> removeSelected()); + clearButton.addActionListener(e -> tableModel.clear()); + upButton.addActionListener(e -> moveSelected(-1)); + downButton.addActionListener(e -> moveSelected(1)); + autoButton.addActionListener(e -> autoDistribute()); + + toolbar.add(addButton); + toolbar.add(removeButton); + toolbar.add(clearButton); + toolbar.add(upButton); + toolbar.add(downButton); + toolbar.add(autoButton); + + JPanel north = new JPanel(new BorderLayout()); + north.add(help, BorderLayout.NORTH); + north.add(toolbar, BorderLayout.SOUTH); + + add(north, BorderLayout.NORTH); + add(new JScrollPane(table), BorderLayout.CENTER); + } + + public List images() { + return tableModel.images(); + } + + private void addImages() { + JFileChooser chooser = new JFileChooser(); + chooser.setMultiSelectionEnabled(true); + chooser.setFileFilter(new FileNameExtensionFilter( + "Images", "png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "heic")); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + for (var file : chooser.getSelectedFiles()) { + tableModel.add(new WallpaperImage(file)); + } + } + } + + private void removeSelected() { + int[] rows = table.getSelectedRows(); + if (rows.length == 0) { + return; + } + tableModel.removeRows(rows); + } + + private void moveSelected(int delta) { + int row = table.getSelectedRow(); + if (row < 0) { + return; + } + tableModel.moveRow(row, row + delta); + int newRow = row + delta; + if (newRow >= 0 && newRow < table.getRowCount()) { + table.setRowSelectionInterval(newRow, newRow); + } + } + + private void autoDistribute() { + int count = tableModel.getRowCount(); + if (count < 2) { + JOptionPane.showMessageDialog(this, "Add at least two images first.", + "Not enough images", JOptionPane.INFORMATION_MESSAGE); + return; + } + List positions = SunPositionUtil.distribute(count); + List images = tableModel.images(); + for (int i = 0; i < count; i++) { + images.get(i).setAltitude(positions.get(i).altitude()); + images.get(i).setAzimuth(positions.get(i).azimuth()); + } + tableModel.fireTableDataChanged(); + } +} diff --git a/src/main/java/com/dynamicwallpaper/model/WallpaperImage.java b/src/main/java/com/dynamicwallpaper/model/WallpaperImage.java new file mode 100644 index 0000000..796418f --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/model/WallpaperImage.java @@ -0,0 +1,74 @@ +package com.dynamicwallpaper.model; + +import java.io.File; + +/** + * One source image the user added, plus whatever role it plays in the + * wallpaper being assembled. Not all fields apply to both modes: appearance + * mode only reads {@link #appearanceRole}, solar mode only reads + * {@link #altitude}/{@link #azimuth}/reference flags. + */ +public final class WallpaperImage { + + /** Role of an image in Appearance mode. */ + public enum AppearanceRole { UNASSIGNED, LIGHT, DARK } + + private final File file; + private AppearanceRole appearanceRole = AppearanceRole.UNASSIGNED; + private double altitude; + private double azimuth; + private boolean lightReference; + private boolean darkReference; + + public WallpaperImage(File file) { + this.file = file; + } + + public File file() { + return file; + } + + public String name() { + return file.getName(); + } + + public AppearanceRole appearanceRole() { + return appearanceRole; + } + + public void setAppearanceRole(AppearanceRole role) { + this.appearanceRole = role; + } + + public double altitude() { + return altitude; + } + + public void setAltitude(double altitude) { + this.altitude = altitude; + } + + public double azimuth() { + return azimuth; + } + + public void setAzimuth(double azimuth) { + this.azimuth = azimuth; + } + + public boolean isLightReference() { + return lightReference; + } + + public void setLightReference(boolean lightReference) { + this.lightReference = lightReference; + } + + public boolean isDarkReference() { + return darkReference; + } + + public void setDarkReference(boolean darkReference) { + this.darkReference = darkReference; + } +} diff --git a/src/main/java/com/dynamicwallpaper/model/WallpaperMode.java b/src/main/java/com/dynamicwallpaper/model/WallpaperMode.java new file mode 100644 index 0000000..83b8e3c --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/model/WallpaperMode.java @@ -0,0 +1,28 @@ +package com.dynamicwallpaper.model; + +/** The two Dynamic Desktop schedule types this app can produce. */ +public enum WallpaperMode { + APPEARANCE("Light / Dark Appearance", "Two images: one shown in Light Mode, one in Dark Mode."), + SOLAR("Sun Position (Solar)", "Several images, each tagged with the sun's altitude and azimuth."); + + private final String label; + private final String description; + + WallpaperMode(String label, String description) { + this.label = label; + this.description = description; + } + + public String label() { + return label; + } + + public String description() { + return description; + } + + @Override + public String toString() { + return label; + } +} diff --git a/src/main/java/com/dynamicwallpaper/util/ImageValidation.java b/src/main/java/com/dynamicwallpaper/util/ImageValidation.java new file mode 100644 index 0000000..03caef7 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/util/ImageValidation.java @@ -0,0 +1,62 @@ +package com.dynamicwallpaper.util; + +import javax.imageio.ImageIO; +import java.awt.Dimension; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Sanity checks run before handing a batch of images to the native helper. */ +public final class ImageValidation { + + private ImageValidation() { + } + + /** + * Dynamic wallpapers look best when every embedded image shares the same + * pixel dimensions. This reads each file's size (without decoding full + * pixel data) and returns a human-readable warning if they differ, or + * {@code null} if everything matches or sizes couldn't be determined. + */ + public static String checkConsistentDimensions(List files) { + Map counts = new LinkedHashMap<>(); + for (File file : files) { + Dimension size = readSize(file); + if (size == null) { + continue; + } + counts.merge(size, 1, Integer::sum); + } + if (counts.size() <= 1) { + return null; + } + StringBuilder sb = new StringBuilder("Your images have different dimensions:\n"); + for (Map.Entry entry : counts.entrySet()) { + Dimension d = entry.getKey(); + sb.append(String.format(" %d x %d (%d image%s)%n", d.width, d.height, entry.getValue(), + entry.getValue() == 1 ? "" : "s")); + } + sb.append("macOS will still accept the file, but for best results every image in a Dynamic ") + .append("Desktop wallpaper should be the same size."); + return sb.toString(); + } + + private static Dimension readSize(File file) { + try { + var readers = ImageIO.getImageReaders(ImageIO.createImageInputStream(file)); + if (!readers.hasNext()) { + return null; + } + var reader = readers.next(); + try (var stream = ImageIO.createImageInputStream(file)) { + reader.setInput(stream); + return new Dimension(reader.getWidth(0), reader.getHeight(0)); + } finally { + reader.dispose(); + } + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/dynamicwallpaper/util/Json.java b/src/main/java/com/dynamicwallpaper/util/Json.java new file mode 100644 index 0000000..4843a4e --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/util/Json.java @@ -0,0 +1,101 @@ +package com.dynamicwallpaper.util; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal JSON value tree with a serializer. The native helper's job files + * are small and fixed-shape, so a full JSON library would be overkill - this + * covers objects, arrays, strings, numbers and booleans, which is all the + * job schema in docs/FILE_FORMAT.md needs. + */ +public final class Json { + + private Json() { + } + + public static Map object() { + return new LinkedHashMap<>(); + } + + public static List array() { + return new ArrayList<>(); + } + + public static String write(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(value, sb); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private static void writeValue(Object value, StringBuilder sb) { + if (value == null) { + sb.append("null"); + } else if (value instanceof Map) { + writeObject((Map) value, sb); + } else if (value instanceof List) { + writeArray((List) value, sb); + } else if (value instanceof String s) { + writeString(s, sb); + } else if (value instanceof Boolean b) { + sb.append(b.toString()); + } else if (value instanceof Number n) { + sb.append(n.toString()); + } else { + throw new IllegalArgumentException("Unsupported JSON value type: " + value.getClass()); + } + } + + private static void writeObject(Map object, StringBuilder sb) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : object.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + writeString(entry.getKey(), sb); + sb.append(':'); + writeValue(entry.getValue(), sb); + } + sb.append('}'); + } + + private static void writeArray(List array, StringBuilder sb) { + sb.append('['); + boolean first = true; + for (Object item : array) { + if (!first) { + sb.append(','); + } + first = false; + writeValue(item, sb); + } + sb.append(']'); + } + + private static void writeString(String s, StringBuilder sb) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + sb.append('"'); + } +} diff --git a/src/main/java/com/dynamicwallpaper/util/ThumbnailLoader.java b/src/main/java/com/dynamicwallpaper/util/ThumbnailLoader.java new file mode 100644 index 0000000..9c53613 --- /dev/null +++ b/src/main/java/com/dynamicwallpaper/util/ThumbnailLoader.java @@ -0,0 +1,62 @@ +package com.dynamicwallpaper.util; + +import javax.imageio.ImageIO; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Loads small cached preview thumbnails for source images shown in the image tables. */ +public final class ThumbnailLoader { + + private static final Map CACHE = new ConcurrentHashMap<>(); + + private ThumbnailLoader() { + } + + /** Returns a square thumbnail icon for the given file, or a neutral placeholder if it can't be read. */ + public static Icon thumbnail(File file, int size) { + String key = file.getAbsolutePath() + "@" + size + "#" + file.lastModified(); + return CACHE.computeIfAbsent(key, k -> loadThumbnail(file, size)); + } + + private static ImageIcon loadThumbnail(File file, int size) { + try { + BufferedImage source = ImageIO.read(file); + if (source == null) { + return placeholder(size); + } + BufferedImage square = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = square.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + + double scale = Math.max((double) size / source.getWidth(), (double) size / source.getHeight()); + int drawWidth = (int) Math.ceil(source.getWidth() * scale); + int drawHeight = (int) Math.ceil(source.getHeight() * scale); + int x = (size - drawWidth) / 2; + int y = (size - drawHeight) / 2; + g.drawImage(source, x, y, drawWidth, drawHeight, null); + g.dispose(); + return new ImageIcon(square); + } catch (Exception e) { + return placeholder(size); + } + } + + private static ImageIcon placeholder(int size) { + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + g.setColor(new Color(0, 0, 0, 40)); + g.fillRoundRect(0, 0, size, size, 8, 8); + g.setColor(new Color(0, 0, 0, 100)); + g.drawRoundRect(0, 0, size - 1, size - 1, 8, 8); + g.dispose(); + return new ImageIcon(image); + } +} diff --git a/src/main/resources/native/HeicBuilder.swift b/src/main/resources/native/HeicBuilder.swift new file mode 100644 index 0000000..7a6f255 --- /dev/null +++ b/src/main/resources/native/HeicBuilder.swift @@ -0,0 +1,281 @@ +// +// HeicBuilder.swift +// +// Native helper for Dynamic Wallpaper Creator. +// +// macOS Dynamic Desktop wallpapers are single .heic (HEIF) files that contain +// several top-level still images plus a small binary property list, embedded +// as an XMP tag, that tells System Settings which image to show for which +// sun position (solar mode) or which image is "light" and which is "dark" +// (appearance mode). That embedding can only be done with Apple's own +// ImageIO / CoreGraphics frameworks, so this helper is written in Swift and +// invoked by the Java application as an external process. The Java side +// never has to know about CGImageDestination or property lists - it just +// writes a small JSON "job" file and reads this tool's exit code/output. +// +// Usage: +// heic-builder build +// heic-builder inspect (debugging aid, prints embedded metadata) +// heic-builder --version +// +// Job file schema (see docs/FILE_FORMAT.md for the full spec): +// { +// "mode": "appearance" | "solar", +// "output": "/absolute/path/out.heic", +// "quality": 0.9, +// "light": "/abs/light.png", // appearance mode only +// "dark": "/abs/dark.png", // appearance mode only +// "images": [ // solar mode only, ordered +// { "path": "/abs/01.png", "altitude": 45.0, "azimuth": 120.0, +// "lightReference": true, "darkReference": false } +// ] +// } + +import Foundation +import ImageIO +import CoreGraphics + +let APPLE_DESKTOP_NAMESPACE = "http://ns.apple.com/namespace/1.0/" as CFString +let APPLE_DESKTOP_PREFIX = "apple_desktop" as CFString + +struct ToolError: Error, CustomStringConvertible { + let message: String + var description: String { message } +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(("ERROR: " + message + "\n").data(using: .utf8)!) + exit(1) +} + +func log(_ message: String) { + print(message) + fflush(stdout) +} + +// MARK: - JSON job parsing + +struct SolarImageEntry { + let path: String + let altitude: Double + let azimuth: Double + let lightReference: Bool + let darkReference: Bool +} + +enum Job { + case appearance(light: String, dark: String, output: String, quality: Double) + case solar(images: [SolarImageEntry], output: String, quality: Double) +} + +func loadJob(path: String) throws -> Job { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ToolError(message: "job file is not a JSON object") + } + guard let mode = json["mode"] as? String else { + throw ToolError(message: "job file is missing 'mode'") + } + guard let output = json["output"] as? String else { + throw ToolError(message: "job file is missing 'output'") + } + let quality = (json["quality"] as? Double) ?? 0.9 + + switch mode { + case "appearance": + guard let light = json["light"] as? String, let dark = json["dark"] as? String else { + throw ToolError(message: "appearance job requires 'light' and 'dark' paths") + } + return .appearance(light: light, dark: dark, output: output, quality: quality) + case "solar": + guard let rawImages = json["images"] as? [[String: Any]], !rawImages.isEmpty else { + throw ToolError(message: "solar job requires a non-empty 'images' array") + } + let images: [SolarImageEntry] = try rawImages.map { entry in + guard let path = entry["path"] as? String else { + throw ToolError(message: "solar image entry is missing 'path'") + } + guard let altitude = entry["altitude"] as? Double else { + throw ToolError(message: "solar image entry '\(path)' is missing numeric 'altitude'") + } + guard let azimuth = entry["azimuth"] as? Double else { + throw ToolError(message: "solar image entry '\(path)' is missing numeric 'azimuth'") + } + let lightRef = (entry["lightReference"] as? Bool) ?? false + let darkRef = (entry["darkReference"] as? Bool) ?? false + return SolarImageEntry(path: path, altitude: altitude, azimuth: azimuth, + lightReference: lightRef, darkReference: darkRef) + } + return .solar(images: images, output: output, quality: quality) + default: + throw ToolError(message: "unknown mode '\(mode)', expected 'appearance' or 'solar'") + } +} + +// MARK: - Property list -> base64 + +func base64Plist(_ object: Any) throws -> String { + let data = try PropertyListSerialization.data(fromPropertyList: object, format: .binary, options: 0) + return data.base64EncodedString() +} + +// MARK: - Image loading + +func loadCGImage(path: String) throws -> CGImage { + let url = URL(fileURLWithPath: path) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + throw ToolError(message: "could not open image: \(path)") + } + guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + throw ToolError(message: "could not decode image: \(path)") + } + return image +} + +// MARK: - Writing the HEIC container + +func makeMetadata(namespace: CFString, prefix: CFString, name: CFString, base64Value: String) -> CGMutableImageMetadata { + let metadata = CGImageMetadataCreateMutable() + guard CGImageMetadataRegisterNamespaceForPrefix(metadata, namespace, prefix, nil) else { + fail("failed to register apple_desktop XMP namespace") + } + let tag = CGImageMetadataTagCreate(namespace, prefix, name, .string, base64Value as CFTypeRef)! + let path = (prefix as String) + ":" + (name as String) + _ = CGImageMetadataSetTagWithPath(metadata, nil, path as CFString, tag) + return metadata +} + +func writeHeic(images: [CGImage], primaryMetadata: CGMutableImageMetadata?, quality: Double, output: String) throws { + let outputURL = URL(fileURLWithPath: output) + try? FileManager.default.removeItem(at: outputURL) + + guard let dest = CGImageDestinationCreateWithURL(outputURL as CFURL, "public.heic" as CFString, images.count, nil) else { + throw ToolError(message: "could not create HEIC destination at \(output)") + } + + let properties: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: quality + ] + + for (index, image) in images.enumerated() { + if index == 0, let metadata = primaryMetadata { + CGImageDestinationAddImageAndMetadata(dest, image, metadata, properties as CFDictionary) + } else { + CGImageDestinationAddImage(dest, image, properties as CFDictionary) + } + } + + guard CGImageDestinationFinalize(dest) else { + throw ToolError(message: "failed to finalize HEIC file at \(output)") + } +} + +// MARK: - Build commands + +func buildAppearance(light: String, dark: String, output: String, quality: Double) throws { + log("Loading light image: \(light)") + let lightImage = try loadCGImage(path: light) + log("Loading dark image: \(dark)") + let darkImage = try loadCGImage(path: dark) + + let plist: [String: Any] = ["l": 0, "d": 1] + let base64 = try base64Plist(plist) + let metadata = makeMetadata(namespace: APPLE_DESKTOP_NAMESPACE, prefix: APPLE_DESKTOP_PREFIX, + name: "apr" as CFString, base64Value: base64) + + log("Writing \(output)") + try writeHeic(images: [lightImage, darkImage], primaryMetadata: metadata, quality: quality, output: output) + log("OK \(output)") +} + +func buildSolar(images: [SolarImageEntry], output: String, quality: Double) throws { + var cgImages: [CGImage] = [] + for (i, entry) in images.enumerated() { + log("Loading image \(i + 1)/\(images.count): \(entry.path)") + cgImages.append(try loadCGImage(path: entry.path)) + } + + let siArray: [[String: Any]] = images.enumerated().map { (i, entry) in + ["i": i, "a": entry.altitude, "z": entry.azimuth] + } + + var lightIndex = images.firstIndex(where: { $0.lightReference }) + var darkIndex = images.firstIndex(where: { $0.darkReference }) + if lightIndex == nil { + lightIndex = images.indices.max(by: { images[$0].altitude < images[$1].altitude }) + } + if darkIndex == nil { + darkIndex = images.indices.max(by: { images[$0].altitude > images[$1].altitude }) + } + + let plist: [String: Any] = [ + "si": siArray, + "ap": ["l": lightIndex ?? 0, "d": darkIndex ?? 0] + ] + let base64 = try base64Plist(plist) + let metadata = makeMetadata(namespace: APPLE_DESKTOP_NAMESPACE, prefix: APPLE_DESKTOP_PREFIX, + name: "solar" as CFString, base64Value: base64) + + log("Writing \(output)") + try writeHeic(images: cgImages, primaryMetadata: metadata, quality: quality, output: output) + log("OK \(output)") +} + +// MARK: - Inspect command (debugging aid) + +func inspect(path: String) throws { + let url = URL(fileURLWithPath: path) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + throw ToolError(message: "could not open \(path)") + } + let count = CGImageSourceGetCount(source) + print("images: \(count)") + for i in 0..= 2 else { + fail("usage: heic-builder build | inspect | --version") +} + +switch arguments[1] { +case "--version": + print("heic-builder 1.0") +case "build": + guard arguments.count >= 3 else { fail("usage: heic-builder build ") } + do { + let job = try loadJob(path: arguments[2]) + switch job { + case .appearance(let light, let dark, let output, let quality): + try buildAppearance(light: light, dark: dark, output: output, quality: quality) + case .solar(let images, let output, let quality): + try buildSolar(images: images, output: output, quality: quality) + } + } catch { + fail("\(error)") + } +case "inspect": + guard arguments.count >= 3 else { fail("usage: heic-builder inspect ") } + do { + try inspect(path: arguments[2]) + } catch { + fail("\(error)") + } +default: + fail("unknown command '\(arguments[1])'") +}