Compare commits

...

3 Commits

Author SHA1 Message Date
f4dba9b75d Merge pull request 'Initial Application' (#1) from development into production
Reviewed-on: #1
2026-07-12 03:00:24 +00:00
35cba9ee0e Demo Images 2026-07-11 22:59:39 -04:00
4ee6ebb18a Initial Application 2026-07-11 22:52:23 -04:00
24 changed files with 2194 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
target/*
target/*
*.DS_Store

88
README.md Normal file
View File

@@ -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.

133
docs/BUILD.md Normal file
View File

@@ -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.

186
docs/FILE_FORMAT.md Normal file
View File

@@ -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 <that file>`. 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 <output path>` on success (exit code 0) or `ERROR: <message>`
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.

98
docs/USAGE.md Normal file
View File

@@ -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. |

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 KiB

BIN
docs/demo/Main Window.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

59
pom.xml Normal file
View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.dynamicwallpaper</groupId>
<artifactId>dynamic-wallpaper-creator</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Dynamic Wallpaper Creator</name>
<description>Swing GUI for building macOS Dynamic Desktop (.heic) wallpapers with light/dark and sun-position variants.</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mainClass>com.dynamicwallpaper.Main</mainClass>
</properties>
<build>
<finalName>dynamic-wallpaper-creator</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<!-- Produces a single runnable jar: java -jar dynamic-wallpaper-creator.jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<configuration>
<archive>
<manifest>
<mainClass>${mainClass}</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
<executions>
<execution>
<id>make-runnable-jar</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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);
}
});
}
}

View File

@@ -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.
*
* <p>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);
}
}
}

View File

@@ -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.
*
* <p>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 &gt;= 2)
*/
public static List<SunPosition> distribute(int count) {
if (count < 2) {
throw new IllegalArgumentException("Need at least 2 images to distribute sun positions");
}
List<SunPosition> 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;
}
}

View File

@@ -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<String, Object> 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<WallpaperImage> images, File output, double quality, ProgressListener listener)
throws BuildException {
Map<String, Object> job = Json.object();
job.put("mode", "solar");
job.put("output", output.getAbsolutePath());
job.put("quality", quality);
List<Object> imageArray = Json.array();
for (WallpaperImage image : images) {
Map<String, Object> 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<String, Object> 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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<WallpaperMode> 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<WallpaperImage> 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<Void, String> worker = new SwingWorker<>() {
@Override
protected Void doInBackground() throws Exception {
job.run(line -> publish(line));
return null;
}
@Override
protected void process(List<String> 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<Void, Void> 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();
}
}

View File

@@ -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<WallpaperImage> images = new ArrayList<>();
List<WallpaperImage> 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<WallpaperImage> 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();
}
}

View File

@@ -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(
"<html>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.</html>");
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<WallpaperImage> 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<SunPositionUtil.SunPosition> positions = SunPositionUtil.distribute(count);
List<WallpaperImage> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<File> files) {
Map<Dimension, Integer> 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<Dimension, Integer> 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;
}
}
}

View File

@@ -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<String, Object> object() {
return new LinkedHashMap<>();
}
public static List<Object> 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<String, Object>) value, sb);
} else if (value instanceof List) {
writeArray((List<Object>) 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<String, Object> object, StringBuilder sb) {
sb.append('{');
boolean first = true;
for (Map.Entry<String, Object> 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<Object> 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('"');
}
}

View File

@@ -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<String, ImageIcon> 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);
}
}

View File

@@ -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 <job.json>
// heic-builder inspect <file.heic> (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..<count {
guard let metadata = CGImageSourceCopyMetadataAtIndex(source, i, nil) else { continue }
let tags = CGImageMetadataCopyTags(metadata) as? [CGImageMetadataTag] ?? []
for tag in tags {
guard let prefix = CGImageMetadataTagCopyPrefix(tag) as String?, prefix == "apple_desktop" else { continue }
let name = CGImageMetadataTagCopyName(tag) as String? ?? "?"
guard let value = CGImageMetadataTagCopyValue(tag) as? String,
let data = Data(base64Encoded: value) else { continue }
var format = PropertyListSerialization.PropertyListFormat.binary
if let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: &format) {
print("image \(i) apple_desktop:\(name) = \(plist)")
}
}
}
}
// MARK: - Entry point
let arguments = CommandLine.arguments
guard arguments.count >= 2 else {
fail("usage: heic-builder build <job.json> | inspect <file.heic> | --version")
}
switch arguments[1] {
case "--version":
print("heic-builder 1.0")
case "build":
guard arguments.count >= 3 else { fail("usage: heic-builder build <job.json>") }
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 <file.heic>") }
do {
try inspect(path: arguments[2])
} catch {
fail("\(error)")
}
default:
fail("unknown command '\(arguments[1])'")
}