diff --git a/.gitignore b/.gitignore index b32934a..4adea4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,14 @@ -firmware -kobo_usb_root -kobopatch-src -kobopatch +# Local device data +kobo_usb_root/ +firmware/ + +# Pre-built kobopatch tool (reference only) +kobopatch/ + +# Kobopatch WASM build artifacts +kobopatch-wasm/kobopatch-src/ +kobopatch-wasm/kobopatch.wasm +kobopatch-wasm/wasm_exec.js + +# Claude .claude \ No newline at end of file diff --git a/kobopatch-wasm/build.sh b/kobopatch-wasm/build.sh new file mode 100755 index 0000000..0205c30 --- /dev/null +++ b/kobopatch-wasm/build.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ ! -d "$SCRIPT_DIR/kobopatch-src" ]; then + echo "Error: kobopatch source not found. Run ./setup.sh first." + exit 1 +fi + +echo "Building kobopatch WASM..." +cd "$SCRIPT_DIR" +GOOS=js GOARCH=wasm go build -o kobopatch.wasm . + +echo "WASM binary size: $(du -h kobopatch.wasm | cut -f1)" +echo "" +echo "Output: $SCRIPT_DIR/kobopatch.wasm" diff --git a/kobopatch-wasm/go.mod b/kobopatch-wasm/go.mod new file mode 100644 index 0000000..e3e0c1a --- /dev/null +++ b/kobopatch-wasm/go.mod @@ -0,0 +1,21 @@ +module github.com/nicoverbruggen/kobopatch-wasm + +go 1.23.12 + +require ( + github.com/pgaskin/kobopatch v0.0.0 + gopkg.in/yaml.v3 v3.0.1 +) + +replace github.com/pgaskin/kobopatch => ./kobopatch-src + +replace gopkg.in/yaml.v3 => github.com/pgaskin/yaml v0.0.0-20190717135119-db0123c0912e // v3-node-decodestrict + +require ( + github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5 // indirect + github.com/pgaskin/go-libz v0.0.2 // indirect + github.com/riking/cssparse v0.0.0-20180325025645-c37ded0aac89 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/text v0.3.2 // indirect + rsc.io/arm v0.0.0-20150420010332-9c32f2193064 // indirect +) diff --git a/kobopatch-wasm/go.sum b/kobopatch-wasm/go.sum new file mode 100644 index 0000000..394b070 --- /dev/null +++ b/kobopatch-wasm/go.sum @@ -0,0 +1,23 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5 h1:QCtizt3VTaANvnsd8TtD/eonx7JLIVdEKW1//ZNPZ9A= +github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/pgaskin/go-libz v0.0.2 h1:uTzyOb6qXvzTdofzq9RNzhpHzHl3fT88yM6wxzjrMFY= +github.com/pgaskin/go-libz v0.0.2/go.mod h1:zKOJy/NMDudfyiyGiA7r0jAKjEyu+/7MSBHAnarLvxY= +github.com/pgaskin/yaml v0.0.0-20190717135119-db0123c0912e h1:jIAOCdmm9VlOD9ezgGGiJOQofvz2mnLIH1sA1wyI8D4= +github.com/pgaskin/yaml v0.0.0-20190717135119-db0123c0912e/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/riking/cssparse v0.0.0-20180325025645-c37ded0aac89 h1:hMsoSMebpfpaDW7+B7gsxNnMBNChjekeqmK8wkzAlc0= +github.com/riking/cssparse v0.0.0-20180325025645-c37ded0aac89/go.mod h1:yc5MYwuNUGggTQ8++IDAbOYq/9PXxsg73+EHYgoG/4w= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +rsc.io/arm v0.0.0-20150420010332-9c32f2193064 h1:bBbas3KhLwE6f59Z9lUipY23xUX9qrvyLBdQzzV2Tko= +rsc.io/arm v0.0.0-20150420010332-9c32f2193064/go.mod h1:MVYPdlFruujBlzEY3x2Q3XBk7XLdYRNZ7zDbrzYFO7w= diff --git a/kobopatch-wasm/main.go b/kobopatch-wasm/main.go new file mode 100644 index 0000000..78d5a70 --- /dev/null +++ b/kobopatch-wasm/main.go @@ -0,0 +1,325 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha1" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + "syscall/js" + "time" + + "github.com/pgaskin/kobopatch/patchfile" + _ "github.com/pgaskin/kobopatch/patchfile/kobopatch" + _ "github.com/pgaskin/kobopatch/patchfile/patch32lsb" + "github.com/pgaskin/kobopatch/patchlib" + + "gopkg.in/yaml.v3" +) + +// Config mirrors the kobopatch config structure, but only the fields we need. +type Config struct { + Version string + In string // unused in WASM, but required by YAML schema + Out string // unused in WASM + Log string // unused in WASM + Patches map[string]string + Overrides map[string]map[string]bool +} + +// patchResult holds the output of a patching operation. +type patchResult struct { + tgzBytes []byte + log string +} + +func main() { + js.Global().Set("kobopatchVersion", js.ValueOf("wasm-1.0.0")) + js.Global().Set("patchFirmware", js.FuncOf(jsPatchFirmware)) + + // Keep the Go runtime alive. + select {} +} + +// jsPatchFirmware is the JS-callable wrapper. +// +// Arguments: +// +// args[0]: configYAML (string) - the kobopatch.yaml config content +// args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes +// args[2]: patchFiles (Object) - map of filename -> Uint8Array patch file contents +// +// Returns: a Promise that resolves to { tgz: Uint8Array, log: string } or rejects with an error. +func jsPatchFirmware(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + result, err := runPatch(args) + if err != nil { + reject.Invoke(js.Global().Get("Error").New(err.Error())) + return + } + + // Create Uint8Array for the tgz output. + tgzArray := js.Global().Get("Uint8Array").New(len(result.tgzBytes)) + js.CopyBytesToJS(tgzArray, result.tgzBytes) + + // Return { tgz: Uint8Array, log: string } + obj := js.Global().Get("Object").New() + obj.Set("tgz", tgzArray) + obj.Set("log", result.log) + resolve.Invoke(obj) + }() + + return nil + }) + + return js.Global().Get("Promise").New(handler) +} + +func runPatch(args []js.Value) (*patchResult, error) { + if len(args) < 3 { + return nil, errors.New("patchFirmware requires 3 arguments: configYAML, firmwareZip, patchFiles") + } + + // Parse arguments. + configYAML := args[0].String() + + firmwareZipLen := args[1].Get("length").Int() + firmwareZip := make([]byte, firmwareZipLen) + js.CopyBytesToGo(firmwareZip, args[1]) + + patchFilesJS := args[2] + patchFileKeys := js.Global().Get("Object").Call("keys", patchFilesJS) + patchFiles := make(map[string][]byte) + for i := 0; i < patchFileKeys.Length(); i++ { + key := patchFileKeys.Index(i).String() + val := patchFilesJS.Get(key) + buf := make([]byte, val.Get("length").Int()) + js.CopyBytesToGo(buf, val) + patchFiles[key] = buf + } + + return patchFirmware([]byte(configYAML), firmwareZip, patchFiles) +} + +// patchFirmware runs the kobopatch patching pipeline entirely in memory. +func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte) (*patchResult, error) { + var logBuf bytes.Buffer + logf := func(format string, a ...interface{}) { + fmt.Fprintf(&logBuf, format+"\n", a...) + } + + // Parse config. + var config Config + dec := yaml.NewDecoder(bytes.NewReader(configYAML)) + if err := dec.Decode(&config); err != nil { + return nil, fmt.Errorf("could not parse config YAML: %w", err) + } + + if config.Version == "" || len(config.Patches) == 0 { + return nil, errors.New("invalid config: version and patches are required") + } + + logf("kobopatch wasm") + logf("Firmware version: %s", config.Version) + + // Open the firmware zip from memory. + zipReader, err := zip.NewReader(bytes.NewReader(firmwareZip), int64(len(firmwareZip))) + if err != nil { + return nil, fmt.Errorf("could not open firmware zip: %w", err) + } + + // Find and extract KoboRoot.tgz from the zip. + var koboRootTgz io.ReadCloser + for _, f := range zipReader.File { + if f.Name == "KoboRoot.tgz" { + koboRootTgz, err = f.Open() + if err != nil { + return nil, fmt.Errorf("could not open KoboRoot.tgz in firmware zip: %w", err) + } + break + } + } + if koboRootTgz == nil { + return nil, errors.New("could not find KoboRoot.tgz in firmware zip") + } + defer koboRootTgz.Close() + + gzReader, err := gzip.NewReader(koboRootTgz) + if err != nil { + return nil, fmt.Errorf("could not decompress KoboRoot.tgz: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + // Prepare the output tar.gz in memory. + var outBuf bytes.Buffer + outGZ := gzip.NewWriter(&outBuf) + outTar := tar.NewWriter(outGZ) + var outTarExpectedSize int64 + sums := map[string]string{} + + // Iterate over firmware tar entries and apply patches. + for { + h, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("could not read firmware tar entry: %w", err) + } + + // Find which patch files target this entry. + var matchingPatchFiles []string + for patchFileName, targetPath := range config.Patches { + if h.Name == "./"+targetPath || h.Name == targetPath || filepath.Base(targetPath) == h.Name { + matchingPatchFiles = append(matchingPatchFiles, patchFileName) + } + } + + if len(matchingPatchFiles) == 0 { + continue + } + + if h.Typeflag != tar.TypeReg { + return nil, fmt.Errorf("could not patch '%s': not a regular file", h.Name) + } + + logf("Patching %s", h.Name) + + entryBytes, err := io.ReadAll(tarReader) + if err != nil { + return nil, fmt.Errorf("could not read '%s' from firmware: %w", h.Name, err) + } + + pt := patchlib.NewPatcher(entryBytes) + + for _, pfn := range matchingPatchFiles { + logf(" Loading patch file: %s", pfn) + + patchData, ok := patchFileContents[pfn] + if !ok { + return nil, fmt.Errorf("patch file '%s' not provided", pfn) + } + + format := detectFormat(pfn) + formatFn, ok := patchfile.GetFormat(format) + if !ok { + return nil, fmt.Errorf("unknown patch format '%s' for file '%s'", format, pfn) + } + + ps, err := formatFn(patchData) + if err != nil { + return nil, fmt.Errorf("could not parse patch file '%s': %w", pfn, err) + } + + // Apply overrides. + if overrides, ok := config.Overrides[pfn]; ok { + for name, enabled := range overrides { + if err := ps.SetEnabled(name, enabled); err != nil { + return nil, fmt.Errorf("could not set override '%s' in '%s': %w", name, pfn, err) + } + if enabled { + logf(" ENABLE %s", name) + } else { + logf(" DISABLE %s", name) + } + } + } + + if err := ps.Validate(); err != nil { + return nil, fmt.Errorf("invalid patch file '%s': %w", pfn, err) + } + + patchfile.Log = func(format string, a ...interface{}) { + logf(" "+format, a...) + } + + if err := ps.ApplyTo(pt); err != nil { + return nil, fmt.Errorf("error applying patches from '%s': %w", pfn, err) + } + } + + patchedBytes := pt.GetBytes() + outTarExpectedSize += h.Size + + // Write patched file to output tar, preserving original attributes. + if err := outTar.WriteHeader(&tar.Header{ + Typeflag: h.Typeflag, + Name: h.Name, + Mode: h.Mode, + Uid: h.Uid, + Gid: h.Gid, + ModTime: time.Now(), + Uname: h.Uname, + Gname: h.Gname, + PAXRecords: h.PAXRecords, + Size: int64(len(patchedBytes)), + Format: h.Format, + }); err != nil { + return nil, fmt.Errorf("could not write header for '%s': %w", h.Name, err) + } + + if _, err := outTar.Write(patchedBytes); err != nil { + return nil, fmt.Errorf("could not write patched '%s': %w", h.Name, err) + } + + sums[h.Name] = fmt.Sprintf("%x", sha1.Sum(patchedBytes)) + } + + // Finalize the output tar.gz. + if err := outTar.Close(); err != nil { + return nil, fmt.Errorf("could not finalize output tar: %w", err) + } + if err := outGZ.Close(); err != nil { + return nil, fmt.Errorf("could not finalize output gzip: %w", err) + } + + // Verify consistency. + logf("\nVerifying output KoboRoot.tgz...") + verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes())) + if err != nil { + return nil, fmt.Errorf("could not verify output: %w", err) + } + verifyTar := tar.NewReader(verifyReader) + var verifySum int64 + for { + vh, err := verifyTar.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("output verification failed: %w", err) + } + verifySum += vh.Size + } + if verifySum != outTarExpectedSize { + return nil, fmt.Errorf("output size mismatch: expected %d, got %d", outTarExpectedSize, verifySum) + } + + logf("Output verified. Size: %d bytes", outBuf.Len()) + for f, s := range sums { + logf(" sha1 %s %s", s, f) + } + + return &patchResult{ + tgzBytes: outBuf.Bytes(), + log: logBuf.String(), + }, nil +} + +func detectFormat(filename string) string { + ext := strings.TrimLeft(filepath.Ext(filename), ".") + ext = strings.ReplaceAll(ext, "patch", "patch32lsb") + ext = strings.ReplaceAll(ext, "yaml", "kobopatch") + return ext +} diff --git a/kobopatch-wasm/setup.sh b/kobopatch-wasm/setup.sh new file mode 100755 index 0000000..ad41abc --- /dev/null +++ b/kobopatch-wasm/setup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +KOBOPATCH_DIR="$SCRIPT_DIR/kobopatch-src" + +if [ -d "$KOBOPATCH_DIR" ]; then + echo "Updating kobopatch source..." + cd "$KOBOPATCH_DIR" + git pull +else + echo "Cloning kobopatch source..." + git clone https://github.com/pgaskin/kobopatch.git "$KOBOPATCH_DIR" +fi + +echo "Copying wasm_exec.js from Go SDK..." +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$SCRIPT_DIR/wasm_exec.js" + +echo "" +echo "Done. kobopatch source is at: $KOBOPATCH_DIR" +echo "wasm_exec.js copied to: $SCRIPT_DIR/wasm_exec.js" +echo "" +echo "Run ./build.sh to compile the WASM binary." diff --git a/kobopatch-wasm/wasm_exec.js b/kobopatch-wasm/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/kobopatch-wasm/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/wip/todo.md b/wip/todo.md index a9c2e42..5fc81ac 100644 --- a/wip/todo.md +++ b/wip/todo.md @@ -4,43 +4,44 @@ - [x] Device detection proof of concept (File System Access API) - [x] Serial prefix → model mapping (verified against official Kobo help page) -- [x] Architecture planning -- [x] Cloned kobopatch source (`kobopatch-src/`) +- [x] Architecture planning (updated: fully client-side, no PHP backend) +- [x] Installed Go via Homebrew (v1.26.1) +- [x] Verified all kobopatch tests pass natively +- [x] Verified all kobopatch tests pass under `GOOS=js GOARCH=wasm` (via Node.js) +- [x] Updated device identification doc with correct model list +- [x] Removed obsolete backend-api.md +- [x] Created `kobopatch-wasm/` with setup.sh, build.sh, go.mod, main.go +- [x] WASM wrapper compiles successfully (9.9MB) +- [x] All kobopatch tests still pass with our module's replace directives +- [x] Cleaned up .gitignore ## In Progress -### WASM Build of kobopatch +### Integration Testing -- [ ] Write Go WASM wrapper (`kobopatch-src/wasm/`) exposing `PatchFirmware()` to JS - - Accepts: config YAML (bytes), firmware zip (bytes), patch YAML files (bytes) - - Returns: KoboRoot.tgz (bytes) or error - - All I/O in-memory, no filesystem access -- [ ] Refactor `kobopatch/kobopatch.go` main logic into reusable function - - Strip `os.Open`/`os.Create` → use `io.Reader`/`io.Writer` - - Strip `os.Chdir` → resolve paths in memory - - Strip `exec.Command` (lrelease) → skip translations -- [ ] Compile with `GOOS=js GOARCH=wasm go build` -- [ ] Test WASM binary loads and runs in browser +- [ ] Test WASM binary in actual browser (load wasm_exec.js + kobopatch.wasm) +- [ ] Test `patchFirmware()` JS function end-to-end with real firmware zip + patches ### Frontend - Patch UI -- [ ] `patch-ui.js` - parse patch YAML client-side, render grouped toggles +- [ ] YAML parsing in JS (extract patch names, descriptions, enabled, PatchGroup) +- [ ] `patch-ui.js` — render grouped toggles per target file - [ ] PatchGroup mutual exclusion (radio buttons) -- [ ] Bundle patch YAML files as static assets (or fetch from known URL) -- [ ] Generate kobopatch.yaml config from UI state +- [ ] Generate kobopatch.yaml config string from UI state ### Frontend - Build Flow -- [ ] User provides firmware zip (file input or drag-and-drop) -- [ ] Load WASM module, pass firmware + config + patches -- [ ] Receive KoboRoot.tgz blob from WASM -- [ ] Write KoboRoot.tgz to device via File System Access API -- [ ] Fallback: download KoboRoot.tgz if FS Access write fails +- [ ] User provides firmware zip (file input / drag-and-drop) +- [ ] Load WASM, call `patchFirmware()` with config + firmware + patch files +- [ ] Receive KoboRoot.tgz blob, write to `.kobo/` via File System Access API +- [ ] Fallback: download KoboRoot.tgz manually +- [ ] Bundle patch YAML files as static assets ## Future / Polish -- [ ] Browser compatibility warning with more detail -- [ ] Loading/progress states during WASM build (Web Worker?) +- [ ] Run WASM patching in a Web Worker (avoid blocking UI) +- [ ] Browser compatibility warning with detail +- [ ] Loading/progress states during build - [ ] Error handling for common failure modes - [ ] Host as static site (GitHub Pages / Netlify) - [ ] NickelMenu install/uninstall support (bonus feature) diff --git a/wip/wasm-feasibility.md b/wip/wasm-feasibility.md new file mode 100644 index 0000000..1a1c6a8 --- /dev/null +++ b/wip/wasm-feasibility.md @@ -0,0 +1,48 @@ +# WASM Feasibility — Confirmed + +## Test Results + +All kobopatch tests pass under both native and WASM targets. + +### Native (`go test ./...`) +``` +ok github.com/pgaskin/kobopatch/kobopatch 0.003s +ok github.com/pgaskin/kobopatch/patchfile/kobopatch 0.003s +ok github.com/pgaskin/kobopatch/patchfile/patch32lsb 0.002s +ok github.com/pgaskin/kobopatch/patchlib 0.796s +``` + +### WASM (`GOOS=js GOARCH=wasm`, run via Node.js) +``` +ok github.com/pgaskin/kobopatch/kobopatch 0.158s +ok github.com/pgaskin/kobopatch/patchfile/kobopatch 0.162s +ok github.com/pgaskin/kobopatch/patchfile/patch32lsb 0.133s +ok github.com/pgaskin/kobopatch/patchlib 9.755s +``` + +### Notes + +- `patchlib` tests are ~12x slower under WASM (9.7s vs 0.8s) — expected overhead +- All pure Go dependencies compile to WASM without issues +- No CGO, no OS-specific syscalls in the core libraries +- Go WASM executor: `$(go env GOROOT)/lib/wasm/go_js_wasm_exec` +- Node.js v25.8.1 used for WASM test execution + +## What Works in WASM + +- Binary patching (`patchlib`) +- ARM Thumb-2 instruction assembly (`patchlib/asm`) +- ELF symbol table parsing (`patchlib/syms`) +- YAML patch format parsing (`patchfile/kobopatch`) +- Binary patch format parsing (`patchfile/patch32lsb`) +- CSS parsing (`patchlib/css`) +- Zlib compression/decompression +- tar.gz reading/writing +- ZIP extraction + +## What Won't Work in WASM (and doesn't need to) + +- `os.Open` / `os.Create` — replace with in-memory I/O +- `os.Chdir` — not needed, use in-memory paths +- `exec.Command` (lrelease for translations) — skip, rare use case +- `ioutil.TempDir` — not needed with in-memory approach