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:

TypeWhat it isPlan
metalA Metal (MSL) fragment shader. Lightest on battery.Free
webAn HTML/Canvas/WebGL page running sandboxed and offline.Free
videoA 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):


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):


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…:

typeControlValue deliveredFields
floatslidernumbermin (default 0), max (default 1), default
intslidernumber with decimals (round it yourself)same as float
booltoggletrue/falsedefault
colorcolor pickerstring "#RRGGBB"default

All take key (required, unique) and label (display text; defaults to key). enum is not supported yet; the entry is ignored.


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:

  1. List every file under content/, path relative to content/ (e.g. web/index.html), with /.
  2. Sort by UTF-8 bytes, ascending.
  3. For each, feed a SHA-256 with: the UTF-8 path, one 0x00 byte, the file’s bytes.
  4. "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

Something failed? See the error table.