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:
| A | cmd/wasm/main.go | | | 123 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | web/build.sh | | | 10 | ++++++++++ |
| A | web/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>