Make your own wallpaper: practical guide
A PrimoEngine wallpaper is a .livewallpaper file: a ZIP holding a manifest.json and a
content/ folder. There are three types:
| Type | What it is | Plan |
|---|---|---|
metal | A Metal (MSL) fragment shader. Lightest on battery. | Free |
web | An HTML/Canvas/WebGL page running sandboxed and offline. | Free |
video | A looping .mp4/.mov/.m4v. | Premium |
You need: a Mac with PrimoEngine, a text editor, and Python 3 (ships with the macOS Command Line
Tools; check with python3 --version in Terminal). Download pack.py: it
validates your wallpaper, computes the checksum and writes the file.
Every field and error is in the Reference.
1. Your first shader in 10 minutes
1. Create this layout:
hello-shader/
manifest.json
content/
shader.metal
2. hello-shader/manifest.json:
{
"schemaVersion": 1,
"id": "com.yourname.hello-shader",
"version": "1.0.0",
"title": "Hello Shader",
"author": { "handle": "@yourname" },
"type": "metal",
"entry": "content/shader.metal",
"minMacOS": "26.0",
"config": [
{ "key": "speed", "type": "float", "min": 0.1, "max": 3.0, "default": 1.0, "label": "Speed" },
{ "key": "tint", "type": "color", "default": "#FFFFFF", "label": "Tint" }
],
"capabilities": { "network": [] }
}
3. hello-shader/content/shader.metal:
#include <metal_stdlib>
using namespace metal;
// Contract with PrimoEngine: keep this struct exactly as-is (32 bytes, buffer 0).
struct Uniforms {
float2 resolution; // drawable size in pixels
float time; // seconds since the wallpaper started
float speed; // config key "speed" (default 1.0)
float4 tint; // config key "tint" as RGB 0..1 (a = 1)
};
struct VOut { float4 position [[position]]; };
// Required: full-screen triangle vertex stage, named exactly v_main.
vertex VOut v_main(uint vid [[vertex_id]]) {
float2 p = float2((vid << 1) & 2, vid & 2);
VOut o;
o.position = float4(p * 2.0 - 1.0, 0.0, 1.0);
return o;
}
// Required: fragment stage, named exactly f_main.
fragment float4 f_main(float4 fragCoord [[position]], constant Uniforms& u [[buffer(0)]]) {
float2 uv = fragCoord.xy / u.resolution; // 0..1, origin top-left
float t = u.time * u.speed;
float v = sin(uv.x * 8.0 + t) + sin(uv.y * 6.0 - t * 0.7)
+ sin(length(uv - 0.5) * 14.0 - t * 1.3);
float3 col = 0.5 + 0.5 * cos(6.28318 * (v * 0.2 + float3(0.0, 0.33, 0.67)));
return float4(col * u.tint.rgb, 1.0);
}
4. Pack it:
python3 pack.py hello-shader
You get hello-shader.livewallpaper. 5. In PrimoEngine, open Installed → + (Import) and pick
the file. It goes straight onto your desktop.
Coming from Shadertoy? iResolution.xy → u.resolution, iTime → u.time, fragCoord is
the same (but y grows downward; flip with uv.y = 1.0 - uv.y if needed), vec2/vec3 →
float2/float3, mod → fmod. There is no iMouse and no textures (iChannel).
Shader rules (import is refused if broken):
- Must contain
f_main, and must containv_main(without it the shader renders black). - Forbidden:
kernel,devicebuffers,threadgroupmemory,atomic_*,#include "file". The check is textual and case-sensitive: avoid those words followed by a space even inside identifiers (my_kernel,device). Comments are ignored. - Only
speedandtintreach the shader. Other settings show in the panel but do nothing.
2. A web wallpaper (Canvas / WebGL)
Layout:
hello-web/
manifest.json
content/
web/
index.html ← plus any assets (js, css, png, jpg, webp, svg, woff2, wasm, mp4…)
hello-web/manifest.json:
{
"schemaVersion": 1,
"id": "com.yourname.hello-web",
"version": "1.0.0",
"title": "Hello Web",
"author": { "handle": "@yourname" },
"type": "web",
"entry": "content/web/index.html",
"minMacOS": "26.0",
"config": [
{ "key": "count", "type": "int", "min": 20, "max": 400, "default": 150, "label": "Particles" },
{ "key": "speed", "type": "float", "min": 0.1, "max": 3.0, "default": 1.0, "label": "Speed" },
{ "key": "trails", "type": "bool", "default": true, "label": "Trails" },
{ "key": "color", "type": "color", "default": "#7FD1FF", "label": "Color" }
],
"capabilities": { "network": [] }
}
hello-web/content/web/index.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #0b0e14; }
canvas { display: block; width: 100vw; height: 100vh; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// Settings: the engine injects window.LiveWallpaper.config before this script runs,
// and calls window.LiveWallpaper.onConfig(cfg) whenever the user changes a value.
// Defaults here keep the page working in a normal browser too.
const cfg = { count: 150, speed: 1, trails: true, color: "#7FD1FF" };
window.LiveWallpaper = window.LiveWallpaper || {};
function readConfig(c) { Object.assign(cfg, c || {}); reset(); }
window.LiveWallpaper.onConfig = readConfig;
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
let W, H, dots = [];
function resize() {
const dpr = window.devicePixelRatio || 1;
W = canvas.width = innerWidth * dpr;
H = canvas.height = innerHeight * dpr;
}
function reset() {
const n = Math.round(cfg.count); // "int" settings arrive as numbers; round them
dots = Array.from({ length: n }, () => ({
x: Math.random() * (W || 1), y: Math.random() * (H || 1),
a: Math.random() * Math.PI * 2, r: 1 + Math.random() * 3,
}));
}
function frame(t) {
ctx.fillStyle = cfg.trails ? "rgba(11,14,20,0.12)" : "#0b0e14";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = cfg.color;
for (const d of dots) {
d.a += 0.01 * cfg.speed;
d.x = (d.x + Math.cos(d.a) * cfg.speed * 1.5 + W) % W;
d.y = (d.y + Math.sin(d.a * 0.7) * cfg.speed * 1.5 + H) % H;
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); ctx.fill();
}
requestAnimationFrame(frame);
}
addEventListener("resize", () => { resize(); reset(); });
resize();
readConfig(window.LiveWallpaper.config);
requestAnimationFrame(frame);
</script>
</body>
</html>
Pack with python3 pack.py hello-web and import.
How the page runs (sandbox):
- No internet. Every network request is blocked. Bundle everything (scripts, fonts, images,
Three.js, …) inside
content/web/. No CDNs. - Files are served from
lwp://wallpaper/…, rooted at theentry’s folder. Use relative paths (./img/photo.jpg); nothing outside that folder is reachable. - No
file://, no navigating away, no camera/microphone/location. localStorage/cookies don’t persist between runs.- The wallpaper sits behind the desktop icons and gets no clicks or mouse input, so don’t rely on interaction.
- Develop by opening
index.htmlin Safari (same engine, WebKit). UserequestAnimationFrame: the app pauses the page whenever the desktop isn’t visible.
3. A video wallpaper (Premium)
Easiest: Installed → + (Import) and pick an .mp4, .mov or .m4v directly. The app wraps it
into a package for you (the title becomes the file name).
To control title/author, package it yourself:
my-video/
manifest.json
content/
video.mp4
{
"schemaVersion": 1,
"id": "com.yourname.waves",
"version": "1.0.0",
"title": "Waves",
"author": { "handle": "@yourname" },
"type": "video",
"entry": "content/video.mp4",
"minMacOS": "26.0",
"capabilities": { "network": [] }
}
Tips: HEVC (H.265) saves battery; match the last frame to the first for an invisible loop; video
plays muted and fills the screen (edges are cropped if the aspect ratio differs). Limit:
256 MB per file. Video has no settings (config is ignored).
4. User settings (config)
Each config entry becomes a control under Customize…:
type | Control | Value delivered | Fields |
|---|---|---|---|
float | slider | number | min (default 0), max (default 1), default |
int | slider | number with decimals (round it yourself) | same as float |
bool | toggle | true/false | default |
color | color picker | string "#RRGGBB" | default |
All take key (required, unique) and label (display text; defaults to key).
enum is not supported yet; the entry is ignored.
- Web: everything arrives in
window.LiveWallpaper.config(a{ key: value }object) andwindow.LiveWallpaper.onConfig(cfg)is called on every change. - Metal: only
speed(float) andtint(color) reach the shader, asu.speedandu.tint.
5. Packaging and the checksum
pack.py does it all: validates, computes the checksum, writes the ZIP. Re-run it whenever
you change anything in content/. If the checksum doesn’t match, the app refuses the package.
Doing it by hand? The v1 algorithm:
- List every file under
content/, path relative tocontent/(e.g.web/index.html), with/. - Sort by UTF-8 bytes, ascending.
- For each, feed a SHA-256 with: the UTF-8 path, one
0x00byte, the file’s bytes. "checksum": "sha256-" + lowercase hex.
Then zip manifest.json and content/ at the root (no wrapping folder is best):
cd hello-web && zip -r -X ../hello-web.livewallpaper manifest.json content -x '*.DS_Store'
Avoid Finder’s “Compress”: it can add __MACOSX/ and .DS_Store, which break the import or the
checksum. An optional thumbnail.png at the ZIP root is used as the in-app thumbnail.
6. Import and share
- Import: Installed → + (Import). Accepts
.livewallpaper,.zipand videos. Importing a package with the sameidreplaces the installed one. That’s how you ship an update (bumpversion). - Share: in an imported wallpaper’s menu, Share… writes a
.livewallpaper. Send it by AirDrop, chat, email, GitHub… The recipient imports it with the same + button. - There’s no server and no moderation: every file, wherever it came from, goes through the same checks and runs in the same sandbox. Only wallpapers you imported can be shared; catalog ones can’t.
Something failed? See the error table.