Compare commits
3 Commits
c2bf076dc4
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| f4dba9b75d | |||
| 35cba9ee0e | |||
| 4ee6ebb18a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
target/*
|
target/*
|
||||||
|
*.DS_Store
|
||||||
88
README.md
Normal file
88
README.md
Normal 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
133
docs/BUILD.md
Normal 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
186
docs/FILE_FORMAT.md
Normal 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
98
docs/USAGE.md
Normal 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. |
|
||||||
BIN
docs/demo/Creating Light & Dark.png
Normal file
BIN
docs/demo/Creating Light & Dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/demo/Creating Sun Position.png
Normal file
BIN
docs/demo/Creating Sun Position.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 945 KiB |
BIN
docs/demo/Main Window.png
Normal file
BIN
docs/demo/Main Window.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 902 KiB |
59
pom.xml
Normal file
59
pom.xml
Normal 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>
|
||||||
40
src/main/java/com/dynamicwallpaper/Main.java
Normal file
40
src/main/java/com/dynamicwallpaper/Main.java
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/main/java/com/dynamicwallpaper/core/NativeHelper.java
Normal file
147
src/main/java/com/dynamicwallpaper/core/NativeHelper.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/java/com/dynamicwallpaper/core/SunPositionUtil.java
Normal file
46
src/main/java/com/dynamicwallpaper/core/SunPositionUtil.java
Normal 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 >= 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/main/java/com/dynamicwallpaper/core/WallpaperBuilder.java
Normal file
132
src/main/java/com/dynamicwallpaper/core/WallpaperBuilder.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/main/java/com/dynamicwallpaper/gui/AppearancePanel.java
Normal file
86
src/main/java/com/dynamicwallpaper/gui/AppearancePanel.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/main/java/com/dynamicwallpaper/gui/MainWindow.java
Normal file
276
src/main/java/com/dynamicwallpaper/gui/MainWindow.java
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
127
src/main/java/com/dynamicwallpaper/gui/SolarImageTableModel.java
Normal file
127
src/main/java/com/dynamicwallpaper/gui/SolarImageTableModel.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/main/java/com/dynamicwallpaper/gui/SolarPanel.java
Normal file
127
src/main/java/com/dynamicwallpaper/gui/SolarPanel.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/main/java/com/dynamicwallpaper/model/WallpaperImage.java
Normal file
74
src/main/java/com/dynamicwallpaper/model/WallpaperImage.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main/java/com/dynamicwallpaper/model/WallpaperMode.java
Normal file
28
src/main/java/com/dynamicwallpaper/model/WallpaperMode.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/main/java/com/dynamicwallpaper/util/ImageValidation.java
Normal file
62
src/main/java/com/dynamicwallpaper/util/ImageValidation.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/main/java/com/dynamicwallpaper/util/Json.java
Normal file
101
src/main/java/com/dynamicwallpaper/util/Json.java
Normal 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('"');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/main/java/com/dynamicwallpaper/util/ThumbnailLoader.java
Normal file
62
src/main/java/com/dynamicwallpaper/util/ThumbnailLoader.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/main/resources/native/HeicBuilder.swift
Normal file
281
src/main/resources/native/HeicBuilder.swift
Normal 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])'")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user