icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

commit 76ed5c9c98d9f5f37347fd888ecb8a4ef6692f98
parent cadf5f4dc07baf465a309509f25dd181cce98118
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:39 -0400

wasm: expose the converters to a web page

Running the conversion in the browser means the artwork never leaves the
machine it was drawn on, which is the one claim a hosted converter cannot
make. The page fetches the engine only once a file arrives.

Diffstat:
Acmd/wasm/main.go | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/build.sh | 10++++++++++
Aweb/index.html | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 266 insertions(+), 0 deletions(-)

diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go @@ -0,0 +1,123 @@ +//go:build js && wasm + +// Command wasm exposes the icon encoders and decoders to a web page as the +// global "icns", so a conversion runs in the browser rather than on a +// server. See web/ for a page that uses it. +package main + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + "strings" + "syscall/js" + + "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/ico" +) + +func main() { + api := js.Global().Get("Object").New() + api.Set("convert", js.FuncOf(convert)) + api.Set("inspect", js.FuncOf(inspect)) + js.Global().Set("icns", api) + // The page calls into this, so the program has to stay resident. + select {} +} + +// convert takes the bytes of an image and the name of a format, and returns +// the same artwork written in that format. +func convert(_ js.Value, args []js.Value) (out any) { + defer func() { + if r := recover(); r != nil { + out = failure(fmt.Errorf("converting: %v", r)) + } + }() + if len(args) < 2 { + return failure(errors.New("convert wants an image and a format")) + } + img, _, err := image.Decode(bytes.NewReader(toGo(args[0]))) + if err != nil { + return failure(fmt.Errorf("reading the image: %w", err)) + } + buf := bytes.NewBuffer(nil) + switch format := strings.ToLower(args[1].String()); format { + case "icns": + err = icns.Encode(buf, img) + case "ico": + err = ico.Encode(buf, img) + case "png": + err = png.Encode(buf, img) + case "jpg", "jpeg": + err = jpeg.Encode(buf, img, &jpeg.Options{Quality: 100}) + default: + err = fmt.Errorf("cannot write %s", format) + } + if err != nil { + return failure(err) + } + return success(buf.Bytes()) +} + +// inspect lists the icons an icns or ico file holds, largest first. Anything +// else holds one image and lists as empty. +func inspect(_ js.Value, args []js.Value) (out any) { + defer func() { + if r := recover(); r != nil { + out = failure(fmt.Errorf("inspecting: %v", r)) + } + }() + if len(args) < 1 { + return failure(errors.New("inspect wants an image")) + } + var ( + src = toGo(args[0]) + icons []string + ) + if d, err := icns.NewDecoder(bytes.NewReader(src)); err == nil { + for _, icon := range d.Icons() { + icons = append(icons, icon.String()) + } + } else if d, err := ico.NewDecoder(bytes.NewReader(src)); err == nil { + for _, icon := range d.Icons() { + icons = append(icons, icon.String()) + } + } + list := js.Global().Get("Array").New(len(icons)) + for i, icon := range icons { + list.SetIndex(i, icon) + } + result := js.Global().Get("Object").New() + result.Set("ok", true) + result.Set("icons", list) + return result +} + +// toGo copies a Uint8Array into Go memory, which the wasm boundary requires. +func toGo(v js.Value) []byte { + out := make([]byte, v.Get("length").Int()) + js.CopyBytesToGo(out, v) + return out +} + +// success and failure build the object every entry point returns, so a +// failure reaches the page as a value rather than as a panic crossing the +// boundary. +func success(data []byte) js.Value { + buf := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(buf, data) + out := js.Global().Get("Object").New() + out.Set("ok", true) + out.Set("data", buf) + return out +} + +func failure(err error) js.Value { + out := js.Global().Get("Object").New() + out.Set("ok", false) + out.Set("error", err.Error()) + return out +} diff --git a/web/build.sh b/web/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Builds the browser bundle into web/dist, which is what gets deployed. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +out="$root/web/dist" +mkdir -p "$out" +GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o "$out/main.wasm" "$root/cmd/wasm" +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$out/wasm_exec.js" +cp "$root/web/index.html" "$out/index.html" +ls -la "$out" diff --git a/web/index.html b/web/index.html @@ -0,0 +1,133 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Icon converter</title> +<style> + :root { color-scheme: light dark; --edge: #8883; --accent: #2060c0; } + body { margin: 0; font: 15px/1.5 system-ui, sans-serif; } + main { max-width: 42rem; margin: 0 auto; padding: 2rem 1rem; } + h1 { margin: 0 0 .25rem; font-size: 1.6rem; } + .lede { margin: 0 0 1.5rem; opacity: .75; } + #drop { border: 2px dashed var(--edge); border-radius: .5rem; padding: 3rem 1rem; text-align: center; } + #drop.over { border-color: var(--accent); } + fieldset { border: 1px solid var(--edge); border-radius: .5rem; margin: 1.5rem 0; } + label { margin-right: 1rem; } + #status { min-height: 1.5rem; } + #status.bad { color: #c02020; } + ul { padding-left: 1.25rem; } + li { font-family: ui-monospace, monospace; font-size: .85rem; } + a#download { display: inline-block; margin-top: .5rem; padding: .5rem 1rem; + background: var(--accent); color: #fff; border-radius: .35rem; text-decoration: none; } +</style> +</head> +<body> +<main> + <h1>Icon converter</h1> + <p class="lede">Convert app icons between <code>.icns</code>, <code>.ico</code> and ordinary images. + Everything runs in your browser — your image is never uploaded.</p> + <p class="lede">A demonstration of <code>cmd/wasm</code>, the browser build of this library.</p> + + <div id="drop"> + Drop an image here, or <label><a href="#" id="pick">choose a file</a><input type="file" id="file" hidden></label> + </div> + + <fieldset> + <legend>Convert to</legend> + <label><input type="radio" name="format" value="icns" checked> .icns (macOS)</label> + <label><input type="radio" name="format" value="ico"> .ico (Windows)</label> + <label><input type="radio" name="format" value="png"> .png</label> + <label><input type="radio" name="format" value="jpg"> .jpg</label> + </fieldset> + + <p id="status"></p> + <ul id="icons"></ul> + <a id="download" hidden>Download</a> +</main> + +<script src="wasm_exec.js"></script> +<script> +const $ = (id) => document.getElementById(id); + +// The engine is only fetched once someone actually has a file, so the page +// itself stays small. +let starting; +function engine() { + if (!starting) { + starting = (async () => { + const go = new Go(); + let wasm; + try { + wasm = await WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject); + } catch { + // Some static hosts serve .wasm with the wrong content type, which + // streaming instantiation refuses. + const bytes = await (await fetch('main.wasm')).arrayBuffer(); + wasm = await WebAssembly.instantiate(bytes, go.importObject); + } + // Not awaited: the program installs its API and then parks forever. + go.run(wasm.instance); + if (!window.icns) throw new Error('the converter did not start'); + })(); + } + return starting; +} + +function say(text, bad) { + $('status').textContent = text; + $('status').className = bad ? 'bad' : ''; +} + +async function handle(file) { + $('download').hidden = true; + $('icons').replaceChildren(); + say('Loading the converter…'); + try { + await engine(); + } catch (err) { + say(err.message, true); + return; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + const found = icns.inspect(bytes); + if (found.ok) { + for (const icon of found.icons) { + const li = document.createElement('li'); + li.textContent = icon; + $('icons').appendChild(li); + } + } + const format = document.querySelector('input[name=format]:checked').value; + say('Converting…'); + const out = icns.convert(bytes, format); + if (!out.ok) { + say(out.error, true); + return; + } + const name = file.name.replace(/\.[^.]*$/, '') + '.' + format; + const link = $('download'); + link.href = URL.createObjectURL(new Blob([out.data], { type: 'application/octet-stream' })); + link.download = name; + link.textContent = 'Download ' + name; + link.hidden = false; + say(`${file.name} → ${name} (${out.data.length.toLocaleString()} bytes)`); +} + +const drop = $('drop'); +for (const name of ['dragenter', 'dragover']) { + drop.addEventListener(name, (e) => { e.preventDefault(); drop.classList.add('over'); }); +} +for (const name of ['dragleave', 'drop']) { + drop.addEventListener(name, (e) => { e.preventDefault(); drop.classList.remove('over'); }); +} +drop.addEventListener('drop', (e) => { + if (e.dataTransfer.files.length) handle(e.dataTransfer.files[0]); +}); +$('pick').addEventListener('click', (e) => { e.preventDefault(); $('file').click(); }); +$('file').addEventListener('change', (e) => { + if (e.target.files.length) handle(e.target.files[0]); +}); +</script> +</body> +</html>