Преглед на файлове

Merge branch 'm4-app-shell' of bajdev/mpckit into main

bajdev преди 2 дни
родител
ревизия
190e83be4c
променени са 70 файла, в които са добавени 4173 реда и са изтрити 3 реда
  1. 6 0
      .gitignore
  2. 25 0
      README.md
  3. 48 0
      Taskfile.yml
  4. 398 0
      build/Taskfile.yml
  5. 9 0
      build/appicon.icon/Assets/wails_icon_vector.svg
  6. 51 0
      build/appicon.icon/icon.json
  7. BIN
      build/appicon.png
  8. 87 0
      build/config.yml
  9. BIN
      build/darwin/Assets.car
  10. 35 0
      build/darwin/Info.dev.plist
  11. 30 0
      build/darwin/Info.plist
  12. 233 0
      build/darwin/Taskfile.yml
  13. BIN
      build/darwin/dmg-background.png
  14. BIN
      build/darwin/dmg-file-icon.icns
  15. BIN
      build/darwin/dmg-file-icon.png
  16. BIN
      build/darwin/icons.icns
  17. 116 0
      build/ios/Assets.xcassets
  18. 61 0
      build/ios/Info.dev.plist
  19. 80 0
      build/ios/Info.plist
  20. 53 0
      build/ios/LaunchScreen.storyboard
  21. 11 0
      build/ios/entitlements.plist
  22. 230 0
      build/ios/project.pbxproj
  23. 220 0
      build/linux/Taskfile.yml
  24. 35 0
      build/linux/appimage/build.sh
  25. 13 0
      build/linux/desktop
  26. 80 0
      build/linux/nfpm/nfpm.yaml
  27. 21 0
      build/linux/nfpm/scripts/postinstall.sh
  28. 1 0
      build/linux/nfpm/scripts/postremove.sh
  29. 1 0
      build/linux/nfpm/scripts/preinstall.sh
  30. 1 0
      build/linux/nfpm/scripts/preremove.sh
  31. 195 0
      build/windows/Taskfile.yml
  32. BIN
      build/windows/icon.ico
  33. 17 0
      build/windows/info.json
  34. 55 0
      build/windows/msix/app_manifest.xml
  35. 54 0
      build/windows/msix/template.xml
  36. 119 0
      build/windows/nsis/project.nsi
  37. 263 0
      build/windows/nsis/wails_tools.nsh
  38. 22 0
      build/windows/wails.exe.manifest
  39. 2 2
      docs/build-plan.md
  40. 5 0
      frontend/.npmrc
  41. 6 0
      frontend/bindings/events/index.ts
  42. 15 0
      frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/index.ts
  43. 91 0
      frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/models.ts
  44. 44 0
      frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/service.ts
  45. 9 0
      frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts
  46. 2 0
      frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
  47. 0 0
      frontend/dist/.gitkeep
  48. 12 0
      frontend/index.html
  49. 27 0
      frontend/package.json
  50. 273 0
      frontend/src/App.svelte
  51. 234 0
      frontend/src/app.css
  52. 88 0
      frontend/src/lib/AboutModal.svelte
  53. 52 0
      frontend/src/lib/BankBar.svelte
  54. 85 0
      frontend/src/lib/PadPanel.svelte
  55. 57 0
      frontend/src/lib/SequencePanel.svelte
  56. 95 0
      frontend/src/lib/SlotGrid.svelte
  57. 9 0
      frontend/src/lib/api.ts
  58. 40 0
      frontend/src/lib/slots.ts
  59. 39 0
      frontend/src/lib/store.svelte.ts
  60. 12 0
      frontend/src/main.ts
  61. 2 0
      frontend/src/vite-env.d.ts
  62. 7 0
      frontend/svelte.config.js
  63. 24 0
      frontend/tsconfig.json
  64. 25 0
      frontend/vite.config.ts
  65. 12 1
      go.mod
  66. 23 0
      go.sum
  67. 172 0
      internal/app/app.go
  68. 51 0
      internal/app/app_test.go
  69. 46 0
      main.go
  70. 44 0
      pull-requests/m4-app-shell/m4-app-shell.md

+ 6 - 0
.gitignore

@@ -1 +1,7 @@
+.task
+.wails
+bin
+frontend/dist/*
+!frontend/dist/.gitkeep
+frontend/node_modules
 mpckit-checkpoint-a.zip

+ 25 - 0
README.md

@@ -2,6 +2,31 @@
 
 Application to create basic MPC Sample starter projects from your desktop.
 
+## Development
+
+mpckit is a [Wails v3](https://v3.wails.io) app: Go for all file work, Svelte + TypeScript for the window.
+
+You need:
+
+- Go 1.26 or newer
+- Node.js (current LTS) with npm
+- The Wails CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.1.0`
+- On Windows, the WebView2 runtime (already part of Windows 11). On Linux, the GTK and WebKitGTK development packages that `wails3 doctor` lists.
+
+Then, from the repository root (cmd, PowerShell or a shell):
+
+```
+wails3 doctor      # checks the tools above
+wails3 dev         # builds and opens the app; reloads on changes
+wails3 build       # builds bin/mpckit (bin\mpckit.exe on Windows)
+```
+
+The first run installs the frontend packages and generates `frontend/bindings` from the Go service.
+
+Tests: `go test ./...`. On Linux without the GTK packages, use `CGO_ENABLED=0 go test -tags purego ./...` instead.
+
+`go run ./cmd/mpckit-cli` builds a project from the command line, and `go run ./tools/checkpoint-a` builds the device test pack (see its README).
+
 ## Disclaimer
 
 mpckit is an independent project. It is not affiliated with, endorsed by or supported by Akai Professional or inMusic Brands. Akai, MPC and MPC Sample are trademarks of their respective owners and are used here only to describe the device mpckit works with.

+ 48 - 0
Taskfile.yml

@@ -0,0 +1,48 @@
+version: '3'
+
+vars:
+  APP_NAME: "mpckit"
+  BIN_DIR: "bin"
+  PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}'
+  VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
+  # Target OS for build/package/run. Defaults to the host OS, and is overridden
+  # by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The
+  # tasks below dispatch to the matching platform Taskfile via this variable.
+  GOOS: '{{.GOOS | default OS}}'
+
+includes:
+  common: ./build/Taskfile.yml
+  windows: ./build/windows/Taskfile.yml
+  darwin: ./build/darwin/Taskfile.yml
+  linux: ./build/linux/Taskfile.yml
+
+tasks:
+  build:
+    summary: Builds the application
+    cmds:
+      - task: "{{.GOOS}}:build"
+
+  package:
+    summary: Packages a production build of the application
+    cmds:
+      - task: "{{.GOOS}}:package"
+
+  run:
+    summary: Runs the application
+    cmds:
+      - task: "{{.GOOS}}:run"
+
+  dev:
+    summary: Runs the application in development mode
+    cmds:
+      - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
+
+  build:server:
+    summary: Builds the application in server mode (no GUI, HTTP server only)
+    cmds:
+      - task: common:build:server
+
+  run:server:
+    summary: Runs the application in server mode
+    cmds:
+      - task: common:run:server

+ 398 - 0
build/Taskfile.yml

@@ -0,0 +1,398 @@
+version: '3'
+
+tasks:
+  go:mod:tidy:
+    summary: Runs `go mod tidy`
+    internal: true
+    # Universal/multi-arch builds invoke this concurrently from parallel deps;
+    # two `go mod tidy` processes racing on go.mod can corrupt it (#4637).
+    run: once
+    cmds:
+      - go mod tidy
+
+  install:frontend:deps:
+    summary: Install frontend dependencies
+    run: once
+    cmds:
+      - task: install:frontend:deps:{{.PACKAGE_MANAGER}}
+
+  install:frontend:deps:npm:
+    dir: frontend
+    sources:
+      - package.json
+      - package-lock.json
+    generates:
+      - node_modules
+    preconditions:
+      - sh: npm version
+        msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
+    cmds:
+      - npm install
+
+  install:frontend:deps:bun:
+    dir: frontend
+    sources:
+      - package.json
+      - bun.lock
+      - bun.lockb
+    generates:
+      - node_modules
+    preconditions:
+      - sh: bun --version
+        msg: "bun not found"
+    cmds:
+      - bun install
+
+  install:frontend:deps:pnpm:
+    dir: frontend
+    sources:
+      - package.json
+      - pnpm-lock.yaml
+    generates:
+      - node_modules
+    preconditions:
+      - sh: pnpm --version
+        msg: "pnpm not found"
+    cmds:
+      - pnpm install
+
+  install:frontend:deps:yarn:
+    dir: frontend
+    sources:
+      - package.json
+      - yarn.lock
+    status:
+      - test -d node_modules || test -f .pnp.cjs
+    preconditions:
+      - sh: yarn --version
+        msg: "yarn not found"
+    cmds:
+      - yarn install
+
+  build:frontend:
+    label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
+    summary: Build the frontend project
+    # darwin:build:universal runs its per-arch builds as parallel deps, each of
+    # which depends on this task. Without run:once the two executions race:
+    # one regenerates frontend/bindings (-clean deletes it first) while the
+    # other's bundler is reading it, failing intermittently with
+    # 'Could not resolve "./bindings/<pkg>"' (#4637).
+    run: once
+    dir: frontend
+    sources:
+      - "**/*"
+      - exclude: node_modules/**/*
+    generates:
+      - dist/**/*
+    deps:
+      - task: install:frontend:deps
+      - task: generate:bindings
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+    cmds:
+      - task: frontend:run
+        vars:
+          SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
+    env:
+      PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
+
+  frontend:run:
+    summary: Run a frontend script with selected runner
+    cmds:
+      - task: frontend:run:{{.PACKAGE_MANAGER}}
+        vars:
+          SCRIPT: "{{.SCRIPT}}"
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:npm:
+    dir: frontend
+    cmds:
+      - npm run {{.SCRIPT}} -q
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:yarn:
+    dir: frontend
+    cmds:
+      - yarn {{.SCRIPT}}
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:pnpm:
+    dir: frontend
+    cmds:
+      - pnpm run {{.SCRIPT}}
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:bun:
+    dir: frontend
+    cmds:
+      - bun run {{.SCRIPT}}
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:vendor:puppertino:
+    summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
+    sources:
+      - frontend/public/puppertino/puppertino.css
+    generates:
+      - frontend/public/puppertino/puppertino.css
+    cmds:
+      - |
+        set -euo pipefail
+        mkdir -p frontend/public/puppertino
+        # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
+        if [ ! -f frontend/public/puppertino/puppertino.css ]; then
+          echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
+          if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
+            curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
+            echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
+          else
+            echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
+          fi
+        else
+          echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
+        fi
+        # Ensure index.html includes Puppertino CSS and button classes
+        INDEX_HTML=frontend/index.html
+        if [ -f "$INDEX_HTML" ]; then
+          if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
+            # Insert Puppertino link tag after style.css link
+            awk '
+              /href="\/style.css"\/?/ && !x { print; print "    <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
+            ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
+          fi
+          # Replace default .btn with Puppertino primary button classes if present
+          sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
+        fi
+
+
+
+  generate:bindings:
+    summary: Generates bindings for the frontend
+    run: once
+    deps:
+      - task: go:mod:tidy
+    sources:
+      - "**/*.[jt]s"
+      - exclude: frontend/**/*
+      - frontend/bindings/**/*  # Rerun when switching between dev/production mode causes changes in output
+      - "**/*.go"
+      - go.mod
+      - go.sum
+    generates:
+      - frontend/bindings/**/*
+    cmds:
+      - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true{{if eq .OBFUSCATED "true"}} -obfuscated{{end}} -ts -i
+
+  generate:icons:
+    summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
+    run: once
+    dir: build
+    sources:
+      - "appicon.png"
+      - "appicon.icon"
+    generates:
+      - "darwin/icons.icns"
+      - "windows/icon.ico"
+    cmds:
+      - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
+
+  dev:frontend:
+    summary: Runs the frontend in development mode
+    deps:
+      - task: install:frontend:deps
+    cmds:
+      - task: frontend:dev:{{.PACKAGE_MANAGER}}
+
+  frontend:dev:npm:
+    dir: frontend
+    cmds:
+      - npm run dev -- --port {{.VITE_PORT}} --strictPort
+
+  frontend:dev:yarn:
+    dir: frontend
+    cmds:
+      - yarn dev --port {{.VITE_PORT}} --strictPort
+
+  frontend:dev:pnpm:
+    dir: frontend
+    cmds:
+      - pnpm dev --port {{.VITE_PORT}} --strictPort
+
+  frontend:dev:bun:
+    dir: frontend
+    cmds:
+      - bun run dev --port {{.VITE_PORT}} --strictPort
+
+  update:build-assets:
+    summary: Updates the build assets
+    dir: build
+    cmds:
+      - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
+
+  build:server:
+    summary: Builds the application in server mode (no GUI, HTTP server only)
+    desc: |
+      Builds a production server binary by default: -tags server,production,
+      -trimpath and a stripped binary, mirroring the desktop `build` task.
+      Server mode runs as a pure HTTP server without native GUI dependencies.
+
+      Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2]
+        DEV=true        development server (-tags server, no strip, inlining kept)
+        OBFUSCATED=true obfuscated build via garble (requires garble installed)
+        EXTRA_TAGS      additional comma-separated build tags
+    deps:
+      - task: build:frontend
+        vars:
+          DEV:
+            ref: .DEV
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
+    vars:
+      BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
+
+  run:server:
+    summary: Builds and runs a development server (DEV=true)
+    deps:
+      - task: build:server
+        vars:
+          DEV: "true"
+    cmds:
+      - '"./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
+
+  build:docker:
+    summary: Builds a Docker image for server mode deployment
+    desc: |
+      Creates a minimal Docker image containing the production server binary.
+      Defaults to a pure-Go static binary on a distroless/static base. The
+      production frontend is built first so the embedded assets are current.
+
+      Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...]
+      For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.:
+        task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12
+    deps:
+      # Build the production frontend so frontend/dist (embedded by the Go build
+      # inside the image) is present and current in the Docker build context.
+      # Pass the server,production tags so binding generation analyses the same
+      # build the Docker image compiles, not the default-tag build.
+      - task: build:frontend
+        vars:
+          BUILD_FLAGS: "-tags server,production"
+    cmds:
+      - >-
+        docker build
+        --build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}}
+        --build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}}
+        --build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}}
+        -t {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+        -f build/docker/Dockerfile.server .
+    vars:
+      TAG: "{{.TAG}}"
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required. Please install Docker first."
+      - sh: test -f build/docker/Dockerfile.server
+        msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
+
+  run:docker:
+    summary: Builds and runs the Docker image
+    desc: |
+      Builds the Docker image and runs it, exposing port 8080.
+      Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
+      Note: The internal container port is always 8080. The PORT variable
+      only changes the host port mapping. Ensure your app uses port 8080
+      or modify the Dockerfile to match your ServerOptions.Port setting.
+    deps:
+      - task: build:docker
+        vars:
+          TAG:
+            ref: .TAG
+    cmds:
+      - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+    vars:
+      TAG: "{{.TAG}}"
+      PORT: "{{.PORT}}"
+
+  setup:docker:
+    summary: Builds Docker image for cross-compilation (~800MB download)
+    desc: |
+      Builds the Docker image needed for cross-compiling to any platform.
+      Run this once to enable cross-platform builds from any OS.
+    cmds:
+      - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required. Please install Docker first."
+
+  ios:device:list:
+    summary: Lists connected iOS devices (UDIDs)
+    cmds:
+      - xcrun xcdevice list
+
+  ios:run:device:
+    summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
+    vars:
+      PROJECT: '{{.PROJECT}}'   # e.g., build/ios/xcode/<YourProject>.xcodeproj
+      SCHEME: '{{.SCHEME}}'     # e.g., ios.dev
+      CONFIG: '{{.CONFIG | default "Debug"}}'
+      DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
+      UDID: '{{.UDID}}'         # from `task ios:device:list`
+      BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
+      TEAM_ID: '{{.TEAM_ID}}'   # optional, if your project is not already set up for signing
+    preconditions:
+      - sh: xcrun -f xcodebuild
+        msg: "xcodebuild not found. Please install Xcode."
+      - sh: xcrun -f devicectl
+        msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
+      - sh: test -n '{{.PROJECT}}'
+        msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
+      - sh: test -n '{{.SCHEME}}'
+        msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
+      - sh: test -n '{{.UDID}}'
+        msg: "Set UDID to your device UDID (see: task ios:device:list)."
+      - sh: test -n '{{.BUNDLE_ID}}'
+        msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
+    cmds:
+      - |
+        set -euo pipefail
+        echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
+        XCB_ARGS=(
+          -project "{{.PROJECT}}"
+          -scheme "{{.SCHEME}}"
+          -configuration "{{.CONFIG}}"
+          -destination "id={{.UDID}}"
+          -derivedDataPath "{{.DERIVED}}"
+          -allowProvisioningUpdates
+          -allowProvisioningDeviceRegistration
+        )
+        # Optionally inject signing identifiers if provided
+        if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
+        if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
+        xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
+        # If xcpretty isn't installed, run without it
+        if [ "${PIPESTATUS[0]}" -ne 0 ]; then
+          xcodebuild "${XCB_ARGS[@]}" build
+        fi
+        # Find built .app
+        APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
+        if [ -z "$APP_PATH" ]; then
+          echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
+          exit 1
+        fi
+        echo "Installing: $APP_PATH"
+        xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
+        echo "Launching: {{.BUNDLE_ID}}"
+        xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"

+ 9 - 0
build/appicon.icon/Assets/wails_icon_vector.svg

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <g transform="matrix(1,0,0,1,-246,-251)">
+        <g id="Ebene1">
+            <path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
+        </g>
+    </g>
+</svg>

+ 51 - 0
build/appicon.icon/icon.json

@@ -0,0 +1,51 @@
+{
+  "fill" : {
+    "automatic-gradient" : "extended-gray:1.00000,1.00000"
+  },
+  "groups" : [
+    {
+      "layers" : [
+        {
+          "fill-specializations" : [
+            {
+              "appearance" : "dark",
+              "value" : {
+                "solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
+              }
+            },
+            {
+              "appearance" : "tinted",
+              "value" : {
+                "solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
+              }
+            }
+          ],
+          "image-name" : "wails_icon_vector.svg",
+          "name" : "wails_icon_vector",
+          "position" : {
+            "scale" : 0.85,
+            "translation-in-points" : [
+              0.0,
+              0.0
+            ]
+          }
+        }
+      ],
+      "shadow" : {
+        "kind" : "neutral",
+        "opacity" : 0.5
+      },
+      "specular" : true,
+      "translucency" : {
+        "enabled" : true,
+        "value" : 0.5
+      }
+    }
+  ],
+  "supported-platforms" : {
+    "circles" : [
+      "watchOS"
+    ],
+    "squares" : "shared"
+  }
+}

BIN
build/appicon.png


+ 87 - 0
build/config.yml

@@ -0,0 +1,87 @@
+# This file contains the configuration for this project.
+# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
+# Note that this will overwrite any changes you have made to the assets.
+version: '3'
+
+# This information is used to generate the build assets.
+info:
+  companyName: "bajdev" # The name of the company
+  productName: "mpckit" # The name of the application
+  productIdentifier: "com.monoexp.mpckit" # The unique product identifier
+  description: "Desktop kit editor for the MPC Sample" # The application description
+  copyright: "(c) 2026 bajdev" # Copyright text
+  comments: "Not affiliated with Akai Professional" # Comments
+  version: "0.0.1" # The application version
+  # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
+  #                               # Should match the name of your .icon file without the extension
+  #                               # If not set and Assets.car exists, defaults to "appicon"
+
+# iOS build configuration (uncomment to customise iOS project generation)
+# Note: Keys under `ios` OVERRIDE values under `info` when set.
+# ios:
+#   # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
+#   bundleID: "com.mycompany.myproduct"
+#   # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
+#   displayName: "My Product"
+#   # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
+#   version: "0.0.1"
+#   # The company/organisation name for templates and project settings
+#   company: "My Company"
+#   # Additional comments to embed in Info.plist metadata
+#   comments: "Not affiliated with Akai Professional"
+
+# Dev mode configuration
+dev_mode:
+  root_path: .
+  log_level: warn
+  debounce: 1000
+  ignore:
+    dir:
+      - .git
+      - node_modules
+      - frontend
+      - bin
+    file:
+      - .DS_Store
+      - .gitignore
+      - .gitkeep
+      - "*_test.go"
+    watched_extension:
+      - "*.go"
+      - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
+      - "*.ts" # The frontend directory will be excluded entirely by the setting above.
+    git_ignore: true
+  executes:
+    - cmd: wails3 build DEV=true
+      type: blocking
+    - cmd: wails3 task common:dev:frontend
+      type: background
+      # The standard frontend task waits up to 60s for HTTP 200 from
+      # FRONTEND_DEVSERVER_URL before starting the app. Override if needed;
+      # custom background commands can opt in with the same readiness block.
+      # readiness:
+      #   http: "http://localhost:5173/"
+      #   timeout: "60s"
+      #   interval: "200ms"
+    - cmd: wails3 task run
+      type: primary
+      exit_policy: shutdown
+
+# File Associations
+# More information at: https://v3.wails.io/noit/done/yet
+fileAssociations:
+#  - ext: wails
+#    name: Wails
+#    description: Wails Application File
+#    iconName: wailsFileIcon
+#    role: Editor
+#  - ext: jpg
+#    name: JPEG
+#    description: Image File
+#    iconName: jpegFileIcon
+#    role: Editor
+#    mimeType: image/jpeg  # (optional)
+
+# Other data
+other:
+  - name: My Other Data

BIN
build/darwin/Assets.car


+ 35 - 0
build/darwin/Info.dev.plist

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>CFBundleExecutable</key>
+		<string>mpckit</string>
+		<key>CFBundleGetInfoString</key>
+		<string>Not affiliated with Akai Professional</string>
+		<key>CFBundleIconFile</key>
+		<string>icons</string>
+		<key>CFBundleIconName</key>
+		<string>appicon</string>
+		<key>CFBundleIdentifier</key>
+		<string>com.monoexp.mpckit</string>
+		<key>CFBundleName</key>
+		<string>mpckit</string>
+		<key>CFBundlePackageType</key>
+		<string>APPL</string>
+		<key>CFBundleShortVersionString</key>
+		<string>0.0.1</string>
+		<key>CFBundleVersion</key>
+		<string>0.0.1</string>
+		<key>LSMinimumSystemVersion</key>
+		<string>12.0.0</string>
+		<key>NSAppTransportSecurity</key>
+		<dict>
+			<key>NSAllowsLocalNetworking</key>
+			<true/>
+		</dict>
+		<key>NSHighResolutionCapable</key>
+		<string>true</string>
+		<key>NSHumanReadableCopyright</key>
+		<string>(c) 2026 bajdev</string>
+	</dict>
+</plist>

+ 30 - 0
build/darwin/Info.plist

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>CFBundleExecutable</key>
+		<string>mpckit</string>
+		<key>CFBundleGetInfoString</key>
+		<string>Not affiliated with Akai Professional</string>
+		<key>CFBundleIconFile</key>
+		<string>icons</string>
+		<key>CFBundleIconName</key>
+		<string>appicon</string>
+		<key>CFBundleIdentifier</key>
+		<string>com.monoexp.mpckit</string>
+		<key>CFBundleName</key>
+		<string>mpckit</string>
+		<key>CFBundlePackageType</key>
+		<string>APPL</string>
+		<key>CFBundleShortVersionString</key>
+		<string>0.0.1</string>
+		<key>CFBundleVersion</key>
+		<string>0.0.1</string>
+		<key>LSMinimumSystemVersion</key>
+		<string>12.0.0</string>
+		<key>NSHighResolutionCapable</key>
+		<string>true</string>
+		<key>NSHumanReadableCopyright</key>
+		<string>(c) 2026 bajdev</string>
+	</dict>
+</plist>

+ 233 - 0
build/darwin/Taskfile.yml

@@ -0,0 +1,233 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+vars:
+  # Docker image for cross-compilation (used when building on non-macOS)
+  CROSS_IMAGE: wails-cross
+
+tasks:
+  build:
+    summary: Builds the application
+    cmds:
+      # The experimental CGO-free backend (-tags purego, or WAILS_PUREGO=1) needs
+      # no C toolchain, so it builds natively on every host; cgo builds
+      # cross-compile through Docker.
+      - task: '{{if or (eq OS "darwin") (has "purego" (splitList "," (.EXTRA_TAGS | default "")))}}build:native{{else}}build:docker{{end}}'
+        vars:
+          ARCH: '{{.ARCH}}'
+          DEV: '{{.DEV}}'
+          OUTPUT: '{{.OUTPUT}}'
+          EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+          OBFUSCATED: '{{.OBFUSCATED}}'
+          GARBLE_ARGS: '{{.GARBLE_ARGS}}'
+    vars:
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+
+  build:native:
+    summary: Builds the application natively (on macOS, or anywhere with -tags purego)
+    internal: true
+    deps:
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+          DEV:
+            ref: .DEV
+      - task: common:generate:icons
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - wails3 tool prepare-macos-output --dir "{{.BIN_DIR}}"
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"'
+    vars:
+      BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+    env:
+      GOOS: darwin
+      CGO_ENABLED: '{{if (has "purego" (splitList "," (.EXTRA_TAGS | default "")))}}0{{else}}1{{end}}'
+      GOARCH: '{{.ARCH | default ARCH}}'
+      CGO_CFLAGS: "-mmacosx-version-min=12.0"
+      CGO_LDFLAGS: "-mmacosx-version-min=12.0"
+      MACOSX_DEPLOYMENT_TARGET: "12.0"
+
+  build:docker:
+    summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
+    internal: true
+    deps:
+      - task: common:build:frontend
+        vars:
+          OBFUSCATED:
+            ref: .OBFUSCATED
+      - task: common:generate:icons
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required for cross-compilation. Please install Docker."
+      - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+        msg: |
+          Docker image '{{.CROSS_IMAGE}}' not found.
+          Build it first: wails3 task setup:docker
+    cmds:
+      - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
+      - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+        platforms: [linux, darwin]
+      - mkdir -p {{.BIN_DIR}}
+      - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+    vars:
+      DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      # Generate Docker volume mounts: Go module cache + go.mod replace directives
+      # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
+      DOCKER_MOUNTS:
+        sh: 'wails3 tool docker-mounts'
+
+  build:universal:
+    summary: Builds darwin universal binary (arm64 + amd64)
+    deps:
+      - task: build
+        vars:
+          ARCH: amd64
+          OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
+      - task: build
+        vars:
+          ARCH: arm64
+          OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+    cmds:
+      - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
+
+  build:universal:lipo:native:
+    summary: Creates universal binary using native lipo (macOS)
+    internal: true
+    cmds:
+      - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+      - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+  build:universal:lipo:go:
+    summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
+    internal: true
+    cmds:
+      - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+      - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+  package:
+    summary: Packages the application into a `.app` bundle
+    deps:
+      - task: build
+    cmds:
+      - task: create:app:bundle
+
+  package:universal:
+    summary: Packages darwin universal binary (arm64 + amd64)
+    deps:
+      - task: build:universal
+    cmds:
+      - task: create:app:bundle
+
+  package:dmg:
+    summary: Packages the application into a styled `.dmg`
+    deps:
+      - task: package
+    cmds:
+      - task: create:dmg
+
+  create:dmg:
+    summary: Creates a configurable `.dmg` from the `.app` bundle
+    platforms: [darwin]
+    cmds:
+      - >-
+        wails3 tool package --format dmg --name "{{.APP_NAME}}" --out "{{.BIN_DIR}}"
+        --background "{{.DMG_BACKGROUND}}"
+        --volume-icon "{{.DMG_VOLUME_ICON}}"
+        --file-icon "{{.DMG_FILE_ICON}}"
+        --window-width "{{.DMG_WINDOW_WIDTH}}"
+        --window-height "{{.DMG_WINDOW_HEIGHT}}"
+        {{if .DMG_FILES}}--files "{{.DMG_FILES}}"{{end}}
+    vars:
+      DMG_BACKGROUND: '{{.DMG_BACKGROUND | default "build/darwin/dmg-background.png"}}'
+      DMG_VOLUME_ICON: '{{.DMG_VOLUME_ICON | default "build/darwin/icons.icns"}}'
+      DMG_FILE_ICON: '{{.DMG_FILE_ICON | default "build/darwin/dmg-file-icon.icns"}}'
+      DMG_WINDOW_WIDTH: '{{.DMG_WINDOW_WIDTH | default "540"}}'
+      DMG_WINDOW_HEIGHT: '{{.DMG_WINDOW_HEIGHT | default "380"}}'
+      DMG_FILES: '{{.DMG_FILES | default ""}}'
+
+
+  create:app:bundle:
+    summary: Creates an `.app` bundle
+    cmds:
+      # A reused bundle can acquire Finder metadata in synced folders. Build a
+      # fresh bundle so codesign never has to replace a contaminated signature.
+      - test -n "{{.BIN_DIR}}" && test -n "{{.APP_NAME}}" && rm -rf -- "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+      - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+      - |
+        if [ -f build/darwin/Assets.car ]; then
+          cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+        fi
+      - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+      - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
+      - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
+
+  codesign:adhoc:
+    summary: Ad-hoc signs the app bundle (macOS only)
+    internal: true
+    cmds:
+      - xattr -cr "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - codesign --verify --deep --strict "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+
+  codesign:skip:
+    summary: Skips codesigning when cross-compiling
+    internal: true
+    cmds:
+      - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
+
+  run:
+    cmds:
+      - wails3 tool prepare-macos-output --dir "{{.BIN_DIR}}"
+      - test -n "{{.BIN_DIR}}" && test -n "{{.APP_NAME}}" && rm -rf -- "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+      - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+      - |
+        if [ -f build/darwin/Assets.car ]; then
+          cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+        fi
+      - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+      - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
+      - xattr -cr "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - codesign --verify --deep --strict "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - '"{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}"'
+
+  sign:
+    summary: Signs the application bundle with Developer ID
+    desc: |
+      Signs the .app bundle for distribution.
+      Uses signing identity from `wails3 setup` (stored in ~/.config/wails/defaults.yaml).
+      Override with: task darwin:sign -- --identity "Developer ID Application: ..."
+    deps:
+      - task: package
+    cmds:
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" {{.CLI_ARGS}}
+
+  sign:notarize:
+    summary: Signs and notarizes the application bundle
+    desc: |
+      Signs the .app bundle and submits it for notarization.
+      Uses signing identity and keychain profile from `wails3 setup`.
+
+      First-time setup:
+        wails3 setup
+    deps:
+      - task: package
+    cmds:
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --notarize {{.CLI_ARGS}}

BIN
build/darwin/dmg-background.png


BIN
build/darwin/dmg-file-icon.icns


BIN
build/darwin/dmg-file-icon.png


BIN
build/darwin/icons.icns


+ 116 - 0
build/ios/Assets.xcassets

@@ -0,0 +1,116 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  },
+  "images" : [
+    {
+      "filename" : "icon-20@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-20@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-29@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-29@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-40@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-40@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-60@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "60x60"
+    },
+    {
+      "filename" : "icon-60@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "60x60"
+    },
+    {
+      "filename" : "icon-20.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-20@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-29.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-29@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-40.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-40@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-76.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "76x76"
+    },
+    {
+      "filename" : "icon-76@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "76x76"
+    },
+    {
+      "filename" : "icon-83.5@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "83.5x83.5"
+    },
+    {
+      "filename" : "icon-1024.png",
+      "idiom" : "ios-marketing",
+      "scale" : "1x",
+      "size" : "1024x1024"
+    }
+  ]
+}

+ 61 - 0
build/ios/Info.dev.plist

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>CFBundleExecutable</key>
+    <string>mpckit</string>
+    <key>CFBundleIdentifier</key>
+    <string>com.monoexp.mpckit.dev</string>
+    <key>CFBundleName</key>
+    <string>mpckit (Dev)</string>
+    <key>CFBundleDisplayName</key>
+    <string>mpckit (Dev)</string>
+    <key>CFBundlePackageType</key>
+    <string>APPL</string>
+    <key>CFBundleShortVersionString</key>
+    <string>0.0.1-dev</string>
+    <key>CFBundleVersion</key>
+    <string>0.0.1</string>
+    <key>LSRequiresIPhoneOS</key>
+    <true/>
+    <key>MinimumOSVersion</key>
+    <string>15.0</string>
+    <key>UILaunchStoryboardName</key>
+    <string>LaunchScreen</string>
+    <key>UIRequiredDeviceCapabilities</key>
+    <array>
+        <string>arm64</string>
+    </array>
+    <key>UISupportedInterfaceOrientations</key>
+    <array>
+        <string>UIInterfaceOrientationPortrait</string>
+        <string>UIInterfaceOrientationLandscapeLeft</string>
+        <string>UIInterfaceOrientationLandscapeRight</string>
+    </array>
+    <key>UISupportedInterfaceOrientations~ipad</key>
+    <array>
+        <string>UIInterfaceOrientationPortrait</string>
+        <string>UIInterfaceOrientationPortraitUpsideDown</string>
+        <string>UIInterfaceOrientationLandscapeLeft</string>
+        <string>UIInterfaceOrientationLandscapeRight</string>
+    </array>
+    <key>NSAppTransportSecurity</key>
+    <dict>
+        <key>NSAllowsArbitraryLoads</key>
+        <true/>
+        <key>NSAllowsLocalNetworking</key>
+        <true/>
+    </dict>
+    <!-- Development mode enabled -->
+    <key>WailsDevelopmentMode</key>
+    <true/>
+    
+    <key>NSHumanReadableCopyright</key>
+    <string>(c) 2026 bajdev</string>
+    
+    
+    <key>CFBundleGetInfoString</key>
+    <string>Not affiliated with Akai Professional</string>
+    
+</dict>
+</plist>

+ 80 - 0
build/ios/Info.plist

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>CFBundleExecutable</key>
+    <string>mpckit</string>
+    <key>CFBundleIdentifier</key>
+    <string>com.monoexp.mpckit</string>
+    <key>CFBundleName</key>
+    <string>mpckit</string>
+    <key>CFBundleDisplayName</key>
+    <string>mpckit</string>
+    <key>CFBundlePackageType</key>
+    <string>APPL</string>
+    <key>CFBundleShortVersionString</key>
+    <string>0.0.1</string>
+    <key>CFBundleVersion</key>
+    <string>0.0.1</string>
+    <key>LSRequiresIPhoneOS</key>
+    <true/>
+    <key>MinimumOSVersion</key>
+    <string>15.0</string>
+    <key>UILaunchStoryboardName</key>
+    <string>LaunchScreen</string>
+    <key>UIRequiredDeviceCapabilities</key>
+    <array>
+        <string>arm64</string>
+    </array>
+    <key>UISupportedInterfaceOrientations</key>
+    <array>
+        <string>UIInterfaceOrientationPortrait</string>
+        <string>UIInterfaceOrientationLandscapeLeft</string>
+        <string>UIInterfaceOrientationLandscapeRight</string>
+    </array>
+    <key>UISupportedInterfaceOrientations~ipad</key>
+    <array>
+        <string>UIInterfaceOrientationPortrait</string>
+        <string>UIInterfaceOrientationPortraitUpsideDown</string>
+        <string>UIInterfaceOrientationLandscapeLeft</string>
+        <string>UIInterfaceOrientationLandscapeRight</string>
+    </array>
+    <key>NSAppTransportSecurity</key>
+    <dict>
+        <key>NSAllowsArbitraryLoads</key>
+        <false/>
+        <key>NSAllowsLocalNetworking</key>
+        <true/>
+    </dict>
+    
+    
+    <key>NSHumanReadableCopyright</key>
+    <string>(c) 2026 bajdev</string>
+    
+    
+    <key>CFBundleGetInfoString</key>
+    <string>Not affiliated with Akai Professional</string>
+    
+    <!--
+      Privacy usage descriptions. iOS hard-crashes (and the App Store rejects the
+      build) the moment a permission-gated API is called without the matching
+      key. The Wails runtime exposes camera, microphone, photo library, location,
+      motion and biometric APIs; uncomment the key for each feature your app
+      actually uses and replace the string with a real, user-facing reason. Only
+      ship keys for features you use — unused keys draw App Store review scrutiny.
+
+    <key>NSCameraUsageDescription</key>
+    <string>This app uses the camera to capture photos and videos.</string>
+    <key>NSMicrophoneUsageDescription</key>
+    <string>This app uses the microphone when recording video.</string>
+    <key>NSPhotoLibraryUsageDescription</key>
+    <string>This app accesses your photo library to select images.</string>
+    <key>NSLocationWhenInUseUsageDescription</key>
+    <string>This app uses your location while you are using it.</string>
+    <key>NSMotionUsageDescription</key>
+    <string>This app uses motion data from device sensors.</string>
+    <key>NSFaceIDUsageDescription</key>
+    <string>This app uses Face ID to authenticate you.</string>
+    -->
+</dict>
+</plist>

+ 53 - 0
build/ios/LaunchScreen.storyboard

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
+    <device id="retina6_12" orientation="portrait" appearance="light"/>
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
+        <capability name="Safe area layout guides" minToolsVersion="9.0"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="EHf-IW-A2E">
+            <objects>
+                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+                        <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <subviews>
+                            <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="mpckit" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
+                                <rect key="frame" x="0.0" y="397" width="393" height="43"/>
+                                <fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
+                                <nil key="textColor"/>
+                                <nil key="highlightedColor"/>
+                            </label>
+                            
+                            <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Desktop kit editor for the MPC Sample" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
+                                <rect key="frame" x="0.0" y="448" width="393" height="21"/>
+                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
+                                <nil key="textColor"/>
+                                <nil key="highlightedColor"/>
+                            </label>
+                            
+                        </subviews>
+                        <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
+                        <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+                        <constraints>
+                            <constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
+                            <constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/2" constant="-20" id="moa-c2-u7t"/>
+                            <constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
+                            
+                            <constraint firstItem="MN2-I3-ftu" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" symbolic="YES" id="cPy-rs-vsC"/>
+                            <constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="OQL-iM-xY6"/>
+                            <constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="Dti-5h-tvW"/>
+                            
+                        </constraints>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="53" y="375"/>
+        </scene>
+    </scenes>
+</document>

+ 11 - 0
build/ios/entitlements.plist

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <!-- iOS development entitlements. Applied to device builds only
+         (task ios:package IOS_PLATFORM=device). Add capability keys here
+         (e.g. aps-environment) as your app needs them. -->
+    <key>get-task-allow</key>
+    <true/>
+</dict>
+</plist>

+ 230 - 0
build/ios/project.pbxproj

@@ -0,0 +1,230 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {};
+	objectVersion = 56;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; };
+		C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; };
+		C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; };
+		C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; };
+		C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; };
+		C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; };
+		C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; };
+		C0DEBEEF00000000000000F7 /* mpckit.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* mpckit.a */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+		C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
+		C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		C0DEBEEF0000000000000004 /* mpckit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mpckit.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000107 /* mpckit.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "mpckit.a"; path = "../../../bin/mpckit.a"; sourceTree = SOURCE_ROOT; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+		C0DEBEEF0000000000000010 = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000020 /* Products */,
+				C0DEBEEF0000000000000045 /* Frameworks */,
+				C0DEBEEF0000000000000030 /* main */,
+			);
+			sourceTree = "<group>";
+		};
+		C0DEBEEF0000000000000020 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000004 /* mpckit.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		C0DEBEEF0000000000000030 /* main */ = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000002 /* main.m */,
+				C0DEBEEF0000000000000003 /* Info.plist */,
+			);
+			path = main;
+			sourceTree = SOURCE_ROOT;
+		};
+		C0DEBEEF0000000000000045 /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000101 /* UIKit.framework */,
+				C0DEBEEF0000000000000102 /* Foundation.framework */,
+				C0DEBEEF0000000000000103 /* WebKit.framework */,
+				C0DEBEEF0000000000000104 /* Security.framework */,
+				C0DEBEEF0000000000000105 /* CoreFoundation.framework */,
+				C0DEBEEF0000000000000106 /* libresolv.tbd */,
+				C0DEBEEF0000000000000107 /* mpckit.a */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		C0DEBEEF0000000000000040 /* mpckit */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "mpckit" */;
+			buildPhases = (
+				C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */,
+				C0DEBEEF0000000000000050 /* Sources */,
+				C0DEBEEF0000000000000056 /* Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = "mpckit";
+			productName = "mpckit";
+			productReference = C0DEBEEF0000000000000004 /* mpckit.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		C0DEBEEF0000000000000060 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				LastUpgradeCheck = 1500;
+				ORGANIZATIONNAME = "bajdev";
+				TargetAttributes = {
+					C0DEBEEF0000000000000040 = {
+						CreatedOnToolsVersion = 15.0;
+					};
+				};
+			};
+			buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */;
+			compatibilityVersion = "Xcode 15.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+			);
+			mainGroup = C0DEBEEF0000000000000010;
+			productRefGroup = C0DEBEEF0000000000000020 /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				C0DEBEEF0000000000000040 /* mpckit */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		C0DEBEEF0000000000000056 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C0DEBEEF00000000000000F7 /* mpckit.a in Frameworks */,
+				C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */,
+				C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */,
+				C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */,
+				C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */,
+				C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */,
+				C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+			);
+			name = "Prebuild: Wails Go Archive";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nPLATFORM=${PLATFORM_NAME:-iphonesimulator}\nSDK_PATH=${SDKROOT:-$(xcrun --sdk ${PLATFORM} --show-sdk-path)}\nif [ ${PLATFORM} = iphonesimulator ]; then GO_TARGET=arm64-apple-ios15.0-simulator; MIN_FLAG=-mios-simulator-version-min=15.0; else GO_TARGET=arm64-apple-ios15.0; MIN_FLAG=-miphoneos-version-min=15.0; fi\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target ${GO_TARGET} ${MIN_FLAG}\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target ${GO_TARGET}\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n  wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Always rebuild the Go c-archive; Go's build cache keeps this fast\n# and avoids stale binaries when .go sources change.\necho \"Building Go c-archive...\"\ngo build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o \"bin/mpckit.a\"\n";
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		C0DEBEEF0000000000000050 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C0DEBEEF0000000000000001 /* main.m in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+		C0DEBEEF0000000000000090 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				INFOPLIST_FILE = main/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-ObjC",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = "com.monoexp.mpckit";
+				PRODUCT_NAME = "mpckit";
+				CODE_SIGNING_ALLOWED = NO;
+				SDKROOT = iphoneos;
+			};
+			name = Debug;
+		};
+		C0DEBEEF00000000000000A0 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				INFOPLIST_FILE = main/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-ObjC",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = "com.monoexp.mpckit";
+				PRODUCT_NAME = "mpckit";
+				CODE_SIGNING_ALLOWED = NO;
+				SDKROOT = iphoneos;
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "mpckit" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C0DEBEEF0000000000000090 /* Debug */,
+				C0DEBEEF00000000000000A0 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C0DEBEEF0000000000000090 /* Debug */,
+				C0DEBEEF00000000000000A0 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = C0DEBEEF0000000000000060 /* Project object */;
+}

+ 220 - 0
build/linux/Taskfile.yml

@@ -0,0 +1,220 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+vars:
+  # Signing configuration - edit these values for your project
+  # PGP_KEY: "path/to/signing-key.asc"
+  # SIGN_ROLE: "builder"  # Options: origin, maint, archive, builder
+  #
+  # Password is stored securely in system keychain. Run: wails3 setup signing
+
+  # Docker image for cross-compiling cgo builds (not needed with -tags purego).
+  CROSS_IMAGE: wails-cross
+
+tasks:
+  build:
+    summary: Builds the application for Linux
+    cmds:
+      # The experimental CGO-free backend (-tags purego, or WAILS_PUREGO=1) uses
+      # the Go toolchain directly on every host and architecture.
+      - task: '{{if or (ne .CGO_ENABLED "1") (and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH))}}build:native{{else}}build:docker{{end}}'
+        vars:
+          ARCH: '{{.ARCH}}'
+          DEV: '{{.DEV}}'
+          OUTPUT: '{{.OUTPUT}}'
+          EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+          OBFUSCATED: '{{.OBFUSCATED}}'
+          GARBLE_ARGS: '{{.GARBLE_ARGS}}'
+          CGO_ENABLED: '{{.CGO_ENABLED}}'
+    vars:
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      # Determine target architecture (defaults to host ARCH if not specified)
+      TARGET_ARCH: '{{.ARCH | default ARCH}}'
+      CGO_ENABLED: '{{.CGO_ENABLED | default (ternary "0" "1" (has "purego" (splitList "," (.EXTRA_TAGS | default ""))))}}'
+      # Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
+      HAS_CC:
+        sh: 'wails3 tool has "gcc|clang"'
+
+  build:native:
+    summary: Builds the application natively on Linux
+    internal: true
+    deps:
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+          DEV:
+            ref: .DEV
+      - task: common:generate:icons
+      - task: generate:dotdesktop
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
+    vars:
+      BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+    env:
+      GOOS: linux
+      CGO_ENABLED: '{{.CGO_ENABLED | default (ternary "0" "1" (has "purego" (splitList "," (.EXTRA_TAGS | default ""))))}}'
+      GOARCH: '{{.ARCH | default ARCH}}'
+
+  build:docker:
+    summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
+    internal: true
+    deps:
+      - task: common:build:frontend
+        vars:
+          OBFUSCATED:
+            ref: .OBFUSCATED
+      - task: common:generate:icons
+      - task: generate:dotdesktop
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required for cross-compilation to Linux. Please install Docker."
+      - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+        msg: |
+          Docker image '{{.CROSS_IMAGE}}' not found.
+          Build it first: wails3 task setup:docker
+    cmds:
+      - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
+      - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+        platforms: [linux, darwin]
+      - mkdir -p {{.BIN_DIR}}
+      - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+    vars:
+      DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      # Generate Docker volume mounts: Go module cache + go.mod replace directives
+      # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
+      DOCKER_MOUNTS:
+        sh: 'wails3 tool docker-mounts'
+
+  package:
+    summary: Packages the application for Linux
+    deps:
+      - task: build
+    cmds:
+      - task: create:appimage
+      - task: create:deb
+      - task: create:rpm
+      - task: create:aur
+
+  create:appimage:
+    summary: Creates an AppImage
+    dir: build/linux/appimage
+    deps:
+      - task: build
+      - task: generate:dotdesktop
+    cmds:
+      - cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
+      - cp ../../appicon.png "{{.APP_NAME}}.png"
+      - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
+    vars:
+      APP_NAME: '{{.APP_NAME}}'
+      APP_BINARY: '../../../bin/{{.APP_NAME}}'
+      ICON: '{{.APP_NAME}}.png'
+      DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
+      OUTPUT_DIR: '../../../bin'
+
+  create:deb:
+    summary: Creates a deb package
+    deps:
+      - task: build
+    cmds:
+      - task: generate:dotdesktop
+      - task: generate:deb
+
+  create:rpm:
+    summary: Creates a rpm package
+    deps:
+      - task: build
+    cmds:
+      - task: generate:dotdesktop
+      - task: generate:rpm
+
+  create:aur:
+    summary: Creates a arch linux packager package
+    deps:
+      - task: build
+    cmds:
+      - task: generate:dotdesktop
+      - task: generate:aur
+
+  generate:deb:
+    summary: Creates a deb package
+    cmds:
+      - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+  generate:rpm:
+    summary: Creates a rpm package
+    cmds:
+      - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+  generate:aur:
+    summary: Creates a arch linux packager package
+    cmds:
+      - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+  generate:dotdesktop:
+    summary: Generates a `.desktop` file
+    dir: build
+    cmds:
+      - mkdir -p {{.ROOT_DIR}}/build/linux/appimage
+      - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
+    vars:
+      APP_NAME: '{{.APP_NAME}}'
+      EXEC: '{{.APP_NAME}}'
+      ICON: '{{.APP_NAME}}'
+      CATEGORIES: 'Development;'
+      OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
+
+  run:
+    cmds:
+      - '{{.BIN_DIR}}/{{.APP_NAME}}'
+
+  sign:deb:
+    summary: Signs the DEB package
+    desc: |
+      Signs the .deb package with a PGP key.
+      Set PGP_KEY in the vars section to override the key configured globally via
+      `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: create:deb
+    cmds:
+      # PGP_KEY is optional: if unset, `wails3 tool sign` falls back to the key
+      # configured globally via `wails3 setup`.
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} {{if .SIGN_ROLE}}--role "{{.SIGN_ROLE}}"{{end}}
+
+  sign:rpm:
+    summary: Signs the RPM package
+    desc: |
+      Signs the .rpm package with a PGP key.
+      Set PGP_KEY in the vars section to override the key configured globally via
+      `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: create:rpm
+    cmds:
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}}
+
+  sign:packages:
+    summary: Signs all Linux packages (DEB and RPM)
+    desc: |
+      Signs both .deb and .rpm packages with a PGP key.
+      Set PGP_KEY in the vars section to override the key configured globally via
+      `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    cmds:
+      - task: sign:deb
+      - task: sign:rpm

+ 35 - 0
build/linux/appimage/build.sh

@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Copyright (c) 2018-Present Lea Anthony
+# SPDX-License-Identifier: MIT
+
+# Fail script on any error
+set -euxo pipefail
+
+# Define variables
+APP_DIR="${APP_NAME}.AppDir"
+
+# Create AppDir structure
+mkdir -p "${APP_DIR}/usr/bin"
+cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
+cp "${ICON_PATH}" "${APP_DIR}/"
+cp "${DESKTOP_FILE}" "${APP_DIR}/"
+
+if [[ $(uname -m) == *x86_64* ]]; then
+    # Download linuxdeploy and make it executable
+    wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
+    chmod +x linuxdeploy-x86_64.AppImage
+
+    # Run linuxdeploy to bundle the application
+    ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
+else
+    # Download linuxdeploy and make it executable (arm64)
+    wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
+    chmod +x linuxdeploy-aarch64.AppImage
+
+    # Run linuxdeploy to bundle the application (arm64)
+    ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
+fi
+
+# Rename the generated AppImage
+mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"
+

+ 13 - 0
build/linux/desktop

@@ -0,0 +1,13 @@
+[Desktop Entry]
+Version=1.0
+Name=mpckit
+Comment=Desktop kit editor for the MPC Sample
+# The Exec line includes %u to pass the URL to the application
+Exec=/usr/local/bin/mpckit %u
+Terminal=false
+Type=Application
+Icon=mpckit
+Categories=Utility;
+StartupWMClass=mpckit
+
+ 

+ 80 - 0
build/linux/nfpm/nfpm.yaml

@@ -0,0 +1,80 @@
+# Feel free to remove those if you don't want/need to use them.
+# Make sure to check the documentation at https://nfpm.goreleaser.com
+#
+# The lines below are called `modelines`. See `:help modeline`
+
+name: "mpckit"
+arch: ${GOARCH}
+platform: "linux"
+version: "0.0.1"
+section: "default"
+priority: "extra"
+maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
+description: "Desktop kit editor for the MPC Sample"
+vendor: "bajdev"
+homepage: "https://wails.io"
+license: "MIT"
+release: "1"
+
+contents:
+  - src: "./bin/mpckit"
+    dst: "/usr/local/bin/mpckit"
+  - src: "./build/appicon.png"
+    dst: "/usr/share/icons/hicolor/128x128/apps/mpckit.png"
+  - src: "./build/linux/mpckit.desktop"
+    dst: "/usr/share/applications/mpckit.desktop"
+
+# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
+depends:
+  - libgtk-4-1
+  - libwebkitgtk-6.0-4
+
+# Distribution-specific overrides for different package formats
+overrides:
+  # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
+  rpm:
+    depends:
+      - gtk4
+      - webkitgtk6.0
+
+  # Arch Linux packages
+  archlinux:
+    depends:
+      - gtk4
+      - webkitgtk-6.0
+
+# scripts section to ensure desktop database is updated after install
+scripts:
+  postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
+  # You can also add preremove, postremove if needed
+  # preremove: "./build/linux/nfpm/scripts/preremove.sh"
+  # postremove: "./build/linux/nfpm/scripts/postremove.sh"
+
+# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
+# replace the depends/overrides above with these:
+#
+# depends:
+#   - libgtk-3-0
+#   - libwebkit2gtk-4.1-0
+# overrides:
+#   rpm:
+#     depends:
+#       - gtk3
+#       - webkit2gtk4.1
+#   archlinux:
+#     depends:
+#       - gtk3
+#       - webkit2gtk-4.1
+#
+# replaces:
+#   - foobar
+# provides:
+#   - bar
+# recommends:
+#   - whatever
+# suggests:
+#   - something-else
+# conflicts:
+#   - not-foo
+#   - not-bar
+# changelog: "changelog.yaml"

+ 21 - 0
build/linux/nfpm/scripts/postinstall.sh

@@ -0,0 +1,21 @@
+#!/bin/sh
+
+# Update desktop database for .desktop file changes
+# This makes the application appear in application menus and registers its capabilities.
+if command -v update-desktop-database >/dev/null 2>&1; then
+  echo "Updating desktop database..."
+  update-desktop-database -q /usr/share/applications
+else
+  echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
+fi
+
+# Update MIME database for custom URL schemes (x-scheme-handler)
+# This ensures the system knows how to handle your custom protocols.
+if command -v update-mime-database >/dev/null 2>&1; then
+  echo "Updating MIME database..."
+  update-mime-database -n /usr/share/mime
+else
+  echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
+fi
+
+exit 0

+ 1 - 0
build/linux/nfpm/scripts/postremove.sh

@@ -0,0 +1 @@
+#!/bin/bash

+ 1 - 0
build/linux/nfpm/scripts/preinstall.sh

@@ -0,0 +1 @@
+#!/bin/bash

+ 1 - 0
build/linux/nfpm/scripts/preremove.sh

@@ -0,0 +1 @@
+#!/bin/bash

+ 195 - 0
build/windows/Taskfile.yml

@@ -0,0 +1,195 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+vars:
+  # Signing configuration - edit these values for your project
+  # SIGN_CERTIFICATE: "path/to/certificate.pfx"
+  # SIGN_THUMBPRINT: "certificate-thumbprint"  # Alternative to SIGN_CERTIFICATE
+  # TIMESTAMP_SERVER: "http://timestamp.digicert.com"
+  #
+  # Password is stored securely in system keychain. Run: wails3 setup signing
+
+  # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
+  CROSS_IMAGE: wails-cross
+
+tasks:
+  build:
+    summary: Builds the application for Windows
+    cmds:
+      # Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
+      - task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
+        vars:
+          ARCH: '{{.ARCH}}'
+          DEV: '{{.DEV}}'
+          EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+          OBFUSCATED: '{{.OBFUSCATED}}'
+          GARBLE_ARGS: '{{.GARBLE_ARGS}}'
+    vars:
+      # Default to CGO_ENABLED=0 if not explicitly set
+      CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+
+  build:native:
+    summary: Builds the application using native Go cross-compilation
+    internal: true
+    deps:
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+          DEV:
+            ref: .DEV
+      - task: common:generate:icons
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - task: generate:syso
+        vars:
+          ARCH: '{{.ARCH}}'
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"'
+      - cmd: powershell Remove-item *.syso
+        platforms: [windows]
+      - cmd: rm -f *.syso
+        platforms: [linux, darwin]
+    vars:
+      BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
+    env:
+      GOOS: windows
+      CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+      GOARCH: '{{.ARCH | default ARCH}}'
+
+  build:docker:
+    summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
+    internal: true
+    deps:
+      - task: common:build:frontend
+        vars:
+          OBFUSCATED:
+            ref: .OBFUSCATED
+      - task: common:generate:icons
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required for CGO cross-compilation. Please install Docker."
+      - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+        msg: |
+          Docker image '{{.CROSS_IMAGE}}' not found.
+          Build it first: wails3 task setup:docker
+    cmds:
+      - task: generate:syso
+        vars:
+          ARCH: '{{.ARCH}}'
+      - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
+      - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+        platforms: [linux, darwin]
+      - rm -f *.syso
+    vars:
+      DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+      # Generate Docker volume mounts: Go module cache + go.mod replace directives
+      # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
+      DOCKER_MOUNTS:
+        sh: 'wails3 tool docker-mounts'
+
+  package:
+    summary: Packages the application
+    cmds:
+      - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
+        vars:
+          INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+    vars:
+      FORMAT: '{{.FORMAT | default "nsis"}}'
+      INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+
+  generate:syso:
+    summary: Generates Windows `.syso` file
+    dir: build
+    cmds:
+      - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
+    vars:
+      ARCH: '{{.ARCH | default ARCH}}'
+
+  create:nsis:installer:
+    summary: Creates an NSIS installer
+    dir: build/windows/nsis
+    deps:
+      - task: build
+    preconditions:
+      - sh: '[ "{{.INSTALL_SCOPE}}" = "user" ] || [ "{{.INSTALL_SCOPE}}" = "machine" ]'
+        msg: "INSTALL_SCOPE must be 'user' or 'machine', got '{{.INSTALL_SCOPE}}'"
+    cmds:
+      # Create the Microsoft WebView2 bootstrapper if it doesn't exist
+      - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
+      - |
+        {{if eq OS "windows"}}
+        makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
+        {{else}}
+        makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
+        {{end}}
+    vars:
+      ARCH: '{{.ARCH | default ARCH}}'
+      ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
+      INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+
+  create:msix:package:
+    summary: Creates an MSIX package
+    deps:
+      - task: build
+    cmds:
+      - |-
+        wails3 tool msix \
+          --config "{{.ROOT_DIR}}/build/config.yml" \
+          --name "{{.APP_NAME}}.exe" \
+          --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
+          --arch "{{.ARCH}}" \
+          --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
+          {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
+          {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
+          {{if eq .USE_MSIX_TOOL "true"}}--use-msix-tool{{else}}--use-makeappx{{end}}
+    vars:
+      ARCH: '{{.ARCH | default ARCH}}'
+      CERT_PATH: '{{.CERT_PATH | default ""}}'
+      PUBLISHER: '{{.PUBLISHER | default ""}}'
+      USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
+
+  install:msix:tools:
+    summary: Installs tools required for MSIX packaging
+    cmds:
+      - wails3 tool msix-install-tools
+
+  run:
+    cmds:
+      - '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
+
+  sign:
+    summary: Signs the Windows executable
+    desc: |
+      Signs the .exe with an Authenticode certificate.
+      Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
+      configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: build
+    cmds:
+      # Certificate vars are optional: if unset, `wails3 tool sign` falls back to
+      # the certificate configured globally via `wails3 setup`.
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}
+
+  sign:installer:
+    summary: Signs the NSIS installer
+    desc: |
+      Creates and signs the NSIS installer.
+      Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
+      configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: create:nsis:installer
+        vars:
+          INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+    vars:
+      INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+    cmds:
+      - wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}

BIN
build/windows/icon.ico


+ 17 - 0
build/windows/info.json

@@ -0,0 +1,17 @@
+{
+	"fixed": {
+		"file_version": "0.0.1",
+		"product_version": "0.0.1"
+	},
+	"info": {
+		"0409": {
+			"FileVersion": "0.0.1",
+			"ProductVersion": "0.0.1",
+			"CompanyName": "bajdev",
+			"FileDescription": "Desktop kit editor for the MPC Sample",
+			"LegalCopyright": "(c) 2026 bajdev",
+			"ProductName": "mpckit",
+			"Comments": "Not affiliated with Akai Professional"
+		}
+	}
+}

+ 55 - 0
build/windows/msix/app_manifest.xml

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Package
+  xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
+  xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
+  xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
+  xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
+  xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
+  IgnorableNamespaces="uap3">
+  
+  <Identity 
+    Name="com.example.mpckit"
+    Publisher="CN=My Company"
+    Version="0.1.0.0"
+    ProcessorArchitecture="x64" />
+  
+  <Properties>
+    <DisplayName>My Product</DisplayName>
+    <PublisherDisplayName>My Company</PublisherDisplayName>
+    <Description>A mpckit application</Description>
+    <Logo>Assets\StoreLogo.png</Logo>
+  </Properties>
+  
+  <Dependencies>
+    <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
+  </Dependencies>
+  
+  <Resources>
+    <Resource Language="en-us" />
+  </Resources>
+  
+  <Applications>
+    <Application Id="com.example.mpckit" Executable="mpckit" EntryPoint="Windows.FullTrustApplication">
+      <uap:VisualElements
+        DisplayName="My Product"
+        Description="A mpckit application"
+        BackgroundColor="transparent"
+        Square150x150Logo="Assets\Square150x150Logo.png"
+        Square44x44Logo="Assets\Square44x44Logo.png">
+        <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
+        <uap:SplashScreen Image="Assets\SplashScreen.png" />
+      </uap:VisualElements>
+      
+      <Extensions>
+        <desktop:Extension Category="windows.fullTrustProcess" Executable="mpckit" />
+        
+        
+      </Extensions>
+    </Application>
+  </Applications>
+  
+  <Capabilities>
+    <rescap:Capability Name="runFullTrust" />
+    
+  </Capabilities>
+</Package>

+ 54 - 0
build/windows/msix/template.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<MsixPackagingToolTemplate
+    xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
+    <Settings
+        AllowTelemetry="false"
+        ApplyACLsToPackageFiles="true"
+        GenerateCommandLineFile="true"
+        AllowPromptForPassword="false">
+    </Settings>
+    <Installer
+        Path="mpckit"
+        Arguments=""
+        InstallLocation="C:\Program Files\My Company\My Product">
+    </Installer>
+    <PackageInformation
+        PackageName="My Product"
+        PackageDisplayName="My Product"
+        PublisherName="CN=My Company"
+        PublisherDisplayName="My Company"
+        Version="0.1.0.0"
+        PackageDescription="A mpckit application">
+        <Capabilities>
+            <Capability Name="runFullTrust" />
+            
+        </Capabilities>
+        <Applications>
+            <Application
+                Id="com.example.mpckit"
+                Description="A mpckit application"
+                DisplayName="My Product"
+                ExecutableName="mpckit"
+                EntryPoint="Windows.FullTrustApplication">
+                
+            </Application>
+        </Applications>
+        <Resources>
+            <Resource Language="en-us" />
+        </Resources>
+        <Dependencies>
+            <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
+        </Dependencies>
+        <Properties>
+            <Framework>false</Framework>
+            <DisplayName>My Product</DisplayName>
+            <PublisherDisplayName>My Company</PublisherDisplayName>
+            <Description>A mpckit application</Description>
+            <Logo>Assets\AppIcon.png</Logo>
+        </Properties>
+    </PackageInformation>
+    <SaveLocation PackagePath="mpckit.msix" />
+    <PackageIntegrity>
+        <CertificatePath></CertificatePath>
+    </PackageIntegrity>
+</MsixPackagingToolTemplate>

+ 119 - 0
build/windows/nsis/project.nsi

@@ -0,0 +1,119 @@
+Unicode true
+
+####
+## Please note: Template replacements don't work in this file. They are provided with default defines like
+## mentioned underneath.
+## If the keyword is not defined, "wails_tools.nsh" will populate them.
+## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
+## from outside of Wails for debugging and development of the installer.
+## 
+## For development first make a wails nsis build to populate the "wails_tools.nsh":
+## > wails build --target windows/amd64 --nsis
+## Then you can call makensis on this file with specifying the path to your binary:
+## For a AMD64 only installer:
+## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
+## For a ARM64 only installer:
+## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
+## For a installer with both architectures:
+## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
+####
+## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
+####
+## !define INFO_PROJECTNAME    "my-project" # Default "mpckit"
+## !define INFO_COMPANYNAME    "My Company" # Default "My Company"
+## !define INFO_PRODUCTNAME    "My Product Name" # Default "My Product"
+## !define INFO_PRODUCTVERSION "1.0.0"     # Default "0.1.0"
+## !define INFO_COPYRIGHT      "(c) Now, My Company" # Default "© 2026, My Company"
+###
+## !define PRODUCT_EXECUTABLE  "Application.exe"      # Default "${INFO_PROJECTNAME}.exe"
+## !define UNINST_KEY_NAME     "UninstKeyInRegistry"  # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
+####
+## !define REQUEST_EXECUTION_LEVEL "admin"            # Default "admin"  see also https://nsis.sourceforge.io/Docs/Chapter4.html
+## !define WAILS_INSTALL_SCOPE     "user"             # Default "machine" - set to "user" for per-user install ($LOCALAPPDATA) without UAC prompt
+####
+## Include the wails tools
+####
+!include "wails_tools.nsh"
+
+# The version information for this two must consist of 4 parts
+VIProductVersion "${INFO_PRODUCTVERSION}.0"
+VIFileVersion    "${INFO_PRODUCTVERSION}.0"
+
+VIAddVersionKey "CompanyName"     "${INFO_COMPANYNAME}"
+VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
+VIAddVersionKey "ProductVersion"  "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "FileVersion"     "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "LegalCopyright"  "${INFO_COPYRIGHT}"
+VIAddVersionKey "ProductName"     "${INFO_PRODUCTNAME}"
+
+# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
+ManifestDPIAware true
+
+!include "MUI.nsh"
+
+!define MUI_ICON "..\icon.ico"
+!define MUI_UNICON "..\icon.ico"
+# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
+!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
+!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
+
+!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
+# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
+!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
+!insertmacro MUI_PAGE_INSTFILES # Installing page.
+!insertmacro MUI_PAGE_FINISH # Finished installation page.
+
+!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
+
+!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
+
+## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
+#!uninstfinalize 'signtool --file "%1"'
+#!finalize 'signtool --file "%1"'
+
+Name "${INFO_PRODUCTNAME}"
+OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
+!if "${WAILS_INSTALL_SCOPE}" == "user"
+    InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
+!else
+    InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}"
+!endif
+ShowInstDetails show # This will always show the installation details.
+
+Function .onInit
+   !insertmacro wails.checkArchitecture
+FunctionEnd
+
+Section
+    !insertmacro wails.setShellContext
+
+    !insertmacro wails.webview2runtime
+
+    SetOutPath $INSTDIR
+    
+    !insertmacro wails.files
+
+    CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+    CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+
+    !insertmacro wails.associateFiles
+    !insertmacro wails.associateCustomProtocols
+    
+    !insertmacro wails.writeUninstaller
+SectionEnd
+
+Section "uninstall" 
+    !insertmacro wails.setShellContext
+
+    RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
+
+    RMDir /r $INSTDIR
+
+    Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
+    Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+
+    !insertmacro wails.unassociateFiles
+    !insertmacro wails.unassociateCustomProtocols
+
+    !insertmacro wails.deleteUninstaller
+SectionEnd

+ 263 - 0
build/windows/nsis/wails_tools.nsh

@@ -0,0 +1,263 @@
+# DO NOT EDIT - Generated automatically by `wails build`
+
+!include "x64.nsh"
+!include "WinVer.nsh"
+!include "FileFunc.nsh"
+
+!ifndef INFO_PROJECTNAME
+    !define INFO_PROJECTNAME "mpckit"
+!endif
+!ifndef INFO_COMPANYNAME
+    !define INFO_COMPANYNAME "bajdev"
+!endif
+!ifndef INFO_PRODUCTNAME
+    !define INFO_PRODUCTNAME "mpckit"
+!endif
+!ifndef INFO_PRODUCTVERSION
+    !define INFO_PRODUCTVERSION "0.0.1"
+!endif
+!ifndef INFO_COPYRIGHT
+    !define INFO_COPYRIGHT "(c) 2026 bajdev"
+!endif
+!ifndef PRODUCT_EXECUTABLE
+    !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
+!endif
+!ifndef UNINST_KEY_NAME
+    !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
+!endif
+!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
+
+!ifndef WAILS_INSTALL_SCOPE
+    !define WAILS_INSTALL_SCOPE "machine"
+!endif
+
+!ifndef REQUEST_EXECUTION_LEVEL
+    !if "${WAILS_INSTALL_SCOPE}" == "user"
+        !define REQUEST_EXECUTION_LEVEL "user"
+    !else
+        !define REQUEST_EXECUTION_LEVEL "admin"
+    !endif
+!endif
+
+RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
+
+!ifdef ARG_WAILS_AMD64_BINARY
+    !define SUPPORTS_AMD64
+!endif
+
+!ifdef ARG_WAILS_ARM64_BINARY
+    !define SUPPORTS_ARM64
+!endif
+
+!ifdef SUPPORTS_AMD64
+    !ifdef SUPPORTS_ARM64
+        !define ARCH "amd64_arm64"
+    !else
+        !define ARCH "amd64"
+    !endif
+!else
+    !ifdef SUPPORTS_ARM64
+        !define ARCH "arm64"
+    !else
+        !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
+    !endif
+!endif
+
+!macro wails.checkArchitecture
+    !ifndef WAILS_WIN10_REQUIRED
+        !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
+    !endif
+
+    !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
+        !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
+    !endif
+
+    ${If} ${AtLeastWin10}
+        !ifdef SUPPORTS_AMD64
+            ${if} ${IsNativeAMD64}
+                Goto ok
+            ${EndIf}
+        !endif
+
+        !ifdef SUPPORTS_ARM64
+            ${if} ${IsNativeARM64}
+                Goto ok
+            ${EndIf}
+        !endif
+
+        IfSilent silentArch notSilentArch
+        silentArch:
+            SetErrorLevel 65
+            Abort
+        notSilentArch:
+            MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
+            Quit
+    ${else}
+        IfSilent silentWin notSilentWin
+        silentWin:
+            SetErrorLevel 64
+            Abort
+        notSilentWin:
+            MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
+            Quit
+    ${EndIf}
+
+    ok:
+!macroend
+
+!macro wails.files
+    !ifdef SUPPORTS_AMD64
+        ${if} ${IsNativeAMD64}
+            File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
+        ${EndIf}
+    !endif
+
+    !ifdef SUPPORTS_ARM64
+        ${if} ${IsNativeARM64}
+            File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
+        ${EndIf}
+    !endif
+!macroend
+
+!macro wails.writeUninstaller
+    WriteUninstaller "$INSTDIR\uninstall.exe"
+
+    SetRegView 64
+    !if "${WAILS_INSTALL_SCOPE}" == "user"
+        WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
+        WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
+        WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
+        WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+        WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+        WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
+
+        ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+        IntFmt $0 "0x%08X" $0
+        WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
+    !else
+        WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
+        WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
+        WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
+        WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+        WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+        WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
+
+        ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+        IntFmt $0 "0x%08X" $0
+        WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
+    !endif
+!macroend
+
+!macro wails.deleteUninstaller
+    Delete "$INSTDIR\uninstall.exe"
+
+    SetRegView 64
+    !if "${WAILS_INSTALL_SCOPE}" == "user"
+        DeleteRegKey HKCU "${UNINST_KEY}"
+    !else
+        DeleteRegKey HKLM "${UNINST_KEY}"
+    !endif
+!macroend
+
+!macro wails.setShellContext
+    ${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
+        SetShellVarContext all
+    ${else}
+        SetShellVarContext current
+    ${EndIf}
+!macroend
+
+# Install webview2 by launching the bootstrapper
+# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
+!macro wails.webview2runtime
+    !ifndef WAILS_SKIP_WEBVIEW2
+    !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
+        !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
+    !endif
+
+    SetRegView 64
+	# If the admin key exists and is not empty then webview2 is already installed
+	ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+    ${If} $0 != ""
+        Goto ok
+    ${EndIf}
+
+    ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
+        # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
+	    ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+        ${If} $0 != ""
+            Goto ok
+        ${EndIf}
+     ${EndIf}
+    
+	SetDetailsPrint both
+    DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
+    SetDetailsPrint listonly
+    
+    InitPluginsDir
+    CreateDirectory "$pluginsdir\webview2bootstrapper"
+    SetOutPath "$pluginsdir\webview2bootstrapper"
+    File "MicrosoftEdgeWebview2Setup.exe"
+    ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
+    
+    SetDetailsPrint both
+    ok:
+    !endif
+!macroend
+
+# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
+!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
+  ; Backup the previously associated file class
+  ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
+
+  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
+
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
+!macroend
+
+!macro APP_UNASSOCIATE EXT FILECLASS
+  ; Backup the previously associated file class
+  ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
+
+  DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
+!macroend
+
+!macro wails.associateFiles
+    ; Create file associations
+    
+!macroend
+
+!macro wails.unassociateFiles
+    ; Delete app associations
+    
+!macroend
+
+!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
+  DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
+!macroend
+
+!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
+  DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
+!macroend
+
+!macro wails.associateCustomProtocols
+    ; Create custom protocols associations
+    
+!macroend
+
+!macro wails.unassociateCustomProtocols
+    ; Delete app custom protocol associations
+    
+!macroend

+ 22 - 0
build/windows/wails.exe.manifest

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
+    <assemblyIdentity type="win32" name="com.monoexp.mpckit" version="0.0.1" processorArchitecture="*"/>
+    <dependency>
+        <dependentAssembly>
+            <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+        </dependentAssembly>
+    </dependency>
+    <asmv3:application>
+        <asmv3:windowsSettings>
+            <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
+            <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
+        </asmv3:windowsSettings>
+    </asmv3:application>
+    <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
+        <security>
+            <requestedPrivileges>
+                <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
+            </requestedPrivileges>
+        </security>
+    </trustInfo>
+</assembly>

+ 2 - 2
docs/build-plan.md

@@ -18,7 +18,7 @@ Not in 1.0: start/end trim and waveform, level and pan, loop and trigger modes,
 ## 2. Stack
 
 - Go for all file work.
-- Wails v3 (released 3.0.0) for the window, dialogs, file drop and the Go-to-frontend bridge.
+- Wails v3 (v3.1.0, the latest stable release when M4 started; it needs Go 1.26) for the window, dialogs, file drop and the Go-to-frontend bridge.
 - Svelte + TypeScript + Vite for the UI (plain Svelte, not SvelteKit).
 - No database, no network access, no settings file in 1.0.
 
@@ -26,7 +26,7 @@ Wails pieces this plan relies on (confirm against the installed version at setup
 - Services: Go structs registered with the app; TypeScript bindings generated with `wails3 generate bindings`.
 - Dialogs: `app.Dialog.OpenFile()` and `SaveFile()`; a cancelled dialog returns an empty string and no error.
 - File drop: `EnableFileDrop: true` on the window, `data-file-drop-target` on each drop element, and a `WindowFilesDropped` event carrying real file paths plus the target element's id. HTML drag-and-drop inside the page keeps working alongside it.
-- Setup: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0`, then `wails3 setup`.
+- Setup: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.1.0`, then `wails3 doctor`. On a Linux machine without the GTK packages, build the CLI with `CGO_ENABLED=0`.
 
 ## 3. Repository layout
 

+ 5 - 0
frontend/.npmrc

@@ -0,0 +1,5 @@
+# Refuse to install package versions published less than 7 days (10080 minutes)
+# ago, reducing exposure to freshly compromised or malicious releases.
+# Honoured by pnpm and bun; npm currently has no native support and ignores it,
+# so this is harmless today and forward-compatible.
+minimum-release-age=10080

+ 6 - 0
frontend/bindings/events/index.ts

@@ -0,0 +1,6 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// Make sure event data creation code is registered with the runtime.
+import "../github.com/wailsapp/wails/v3/internal/eventcreate.js";
+

+ 15 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/index.ts

@@ -0,0 +1,15 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+import * as Service from "./service.js";
+export {
+    Service
+};
+
+export type {
+    About,
+    Limits,
+    Pad,
+    Sequence,
+    State
+} from "./models.js";

+ 91 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/models.ts

@@ -0,0 +1,91 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * About is the text for the About dialog.
+ */
+export interface About {
+    "version": string;
+    "license": string;
+    "repo": string;
+}
+
+/**
+ * Limits are the ranges the UI offers, from the device checks.
+ */
+export interface Limits {
+    "pads": number;
+    "maxSamples": number;
+    "maxSequences": number;
+    "minCoarse": number;
+    "maxCoarse": number;
+    "minFine": number;
+    "maxFine": number;
+    "maxMuteGroup": number;
+    "minBPM": number;
+    "maxBPM": number;
+    "maxBars": number;
+}
+
+/**
+ * Pad is one pad as the frontend shows it.
+ */
+export interface Pad {
+    "slot": number;
+
+    /**
+     * "A01" .. "H16"
+     */
+    "label": string;
+
+    /**
+     * sample name; empty for an empty pad
+     */
+    "name": string;
+
+    /**
+     * pool file name
+     */
+    "file": string;
+    "coarse": number;
+    "fine": number;
+    "muteGroup": number;
+    "hasNotes": boolean;
+}
+
+/**
+ * Sequence is one existing sequence.
+ */
+export interface Sequence {
+    "slot": number;
+
+    /**
+     * slot label, "A01" .. "H16"
+     */
+    "label": string;
+    "name": string;
+    "bpm": number;
+    "bars": number;
+    "hasNotes": boolean;
+}
+
+/**
+ * State is everything the frontend shows about the project.
+ */
+export interface State {
+    "name": string;
+    "dirty": boolean;
+
+    /**
+     * all 128, in slot order
+     */
+    "pads": Pad[] | null;
+
+    /**
+     * existing ones, in slot order
+     */
+    "sequences": Sequence[] | null;
+    "sampleCount": number;
+    "sequenceCount": number;
+    "limits": Limits;
+}

+ 44 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/service.ts

@@ -0,0 +1,44 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * Service is registered with Wails; its exported methods become the
+ * frontend bindings.
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * About returns the About dialog text.
+ */
+export function About(): $CancellablePromise<$models.About> {
+    return $Call.ByID(790948986);
+}
+
+/**
+ * New replaces the current project with a new, empty one.
+ */
+export function New(): $CancellablePromise<$models.State> {
+    return $Call.ByID(869901757);
+}
+
+/**
+ * State returns the current project's state.
+ */
+export function State(): $CancellablePromise<$models.State> {
+    return $Call.ByID(2566861378);
+}
+
+/**
+ * Version returns the app version.
+ */
+export function Version(): $CancellablePromise<string> {
+    return $Call.ByID(3347046459);
+}

+ 9 - 0
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts

@@ -0,0 +1,9 @@
+//@ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+Object.freeze($Create.Events);

+ 2 - 0
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts

@@ -0,0 +1,2 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT

+ 0 - 0
frontend/dist/.gitkeep


+ 12 - 0
frontend/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>mpckit</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 27 - 0
frontend/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "mpckit-frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build:dev": "vite build --minify false --mode development",
+    "build": "vite build --mode production",
+    "preview": "vite preview",
+    "check": "svelte-check --tsconfig ./tsconfig.json"
+  },
+  "dependencies": {
+    "@fontsource/ibm-plex-mono": "^5.3.0",
+    "@fontsource/inter": "^5.3.0",
+    "@fontsource/space-grotesk": "^5.3.0",
+    "@wailsio/runtime": "3.1.0"
+  },
+  "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "^7.0.0",
+    "svelte": "^5.46.4",
+    "svelte-check": "^4.4.8",
+    "tslib": "^2.6.2",
+    "typescript": "^5.2.2",
+    "vite": "^8.2.4"
+  }
+}

+ 273 - 0
frontend/src/App.svelte

@@ -0,0 +1,273 @@
+<script lang="ts">
+  import { onMount } from "svelte";
+  import AboutModal from "./lib/AboutModal.svelte";
+  import BankBar from "./lib/BankBar.svelte";
+  import PadPanel from "./lib/PadPanel.svelte";
+  import SequencePanel from "./lib/SequencePanel.svelte";
+  import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
+  import { app, newProject, refresh, ui, type Tab } from "./lib/store.svelte";
+  import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
+
+  onMount(refresh);
+
+  const st = $derived(app.state);
+
+  // Sequences by slot, for the grid and the panel.
+  const seqBySlot = $derived(new Map((st?.sequences ?? []).map((q) => [q.slot, q])));
+
+  function perBank(filled: (slot: number) => boolean): number[] {
+    return Array.from({ length: BANKS.length }, (_, b) => {
+      let n = 0;
+      for (let i = 0; i < PER_BANK; i++) if (filled(b * PER_BANK + i)) n++;
+      return n;
+    });
+  }
+
+  const padCounts = $derived(perBank((s) => (st?.pads[s]?.file ?? "") !== ""));
+  const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
+
+  const padCells: Cell[] = $derived(
+    st
+      ? gridSlots(ui.padBank).map((slot) => {
+          const p = st.pads[slot];
+          const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : ""].filter(Boolean);
+          return {
+            slot,
+            label: p.label,
+            name: p.name,
+            meta: p.file ? badges.join(" · ") : "",
+            aria: `Pad ${p.label}, ${p.file || "empty"}`,
+          };
+        })
+      : [],
+  );
+
+  const seqCells: Cell[] = $derived(
+    st
+      ? gridSlots(ui.seqBank).map((slot) => {
+          const q = seqBySlot.get(slot);
+          const label = st.pads[slot].label; // pad and sequence slots share labels
+          return {
+            slot,
+            label,
+            name: q?.name ?? "",
+            meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
+            aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
+          };
+        })
+      : [],
+  );
+
+  function pickTab(t: Tab) {
+    ui.tab = t;
+  }
+
+  // Arrow keys move between the two tabs, as in the WAI-ARIA tabs pattern.
+  function tabKey(e: KeyboardEvent) {
+    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+      ui.tab = ui.tab === "pads" ? "sequences" : "pads";
+      document.getElementById("tab-" + ui.tab)?.focus();
+    }
+  }
+</script>
+
+<div class="page">
+  <div class="titlebar">
+    <h1>mpckit</h1>
+    <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
+  </div>
+
+  <header>
+    <div class="name">
+      <label class="eyebrow" for="project-name">Project name</label>
+      <input id="project-name" type="text" value={st?.name ?? ""} readonly />
+    </div>
+    <div class="actions">
+      <button type="button" class="btn" onclick={newProject}>New</button>
+      <button type="button" class="btn" disabled>Load</button>
+      <button type="button" class="btn" disabled>Export zip</button>
+      <button type="button" class="btn primary" disabled>Export to folder</button>
+    </div>
+  </header>
+
+  {#if app.error}
+    <p class="error" role="alert">{app.error}</p>
+  {/if}
+
+  <div class="tabs" role="tablist" aria-label="Views">
+    <button id="tab-pads" type="button" role="tab" aria-selected={ui.tab === "pads"} aria-controls="view-pads" tabindex={ui.tab === "pads" ? 0 : -1} onclick={() => pickTab("pads")} onkeydown={tabKey}>Pads</button>
+    <button id="tab-sequences" type="button" role="tab" aria-selected={ui.tab === "sequences"} aria-controls="view-sequences" tabindex={ui.tab === "sequences" ? 0 : -1} onclick={() => pickTab("sequences")} onkeydown={tabKey}>Sequences</button>
+  </div>
+
+  {#if st}
+    {#if ui.tab === "pads"}
+      <main id="view-pads" class="panels" role="tabpanel" aria-labelledby="tab-pads">
+        <section class="panel grid-panel" aria-label="Pads">
+          <div class="grid-head">
+            <h2 class="eyebrow">Pad bank</h2>
+            <span class="mono summary">Bank {BANKS[ui.padBank]} · {padCounts[ui.padBank]} of 16 filled</span>
+          </div>
+          <BankBar
+            bank={ui.padBank}
+            counts={padCounts}
+            what="pads filled"
+            onpick={(b) => {
+              ui.padBank = b;
+              ui.padSlot = b * PER_BANK;
+            }}
+          />
+          <SlotGrid cells={padCells} selected={ui.padSlot} onpick={(s) => (ui.padSlot = s)} />
+          <p class="hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drop a WAV file on any pad to assign it.</p>
+        </section>
+        <PadPanel pad={st.pads[ui.padSlot]} limits={st.limits} />
+      </main>
+    {:else}
+      <main id="view-sequences" class="panels" role="tabpanel" aria-labelledby="tab-sequences">
+        <section class="panel grid-panel" aria-label="Sequence slots">
+          <div class="grid-head">
+            <h2 class="eyebrow">Sequence bank</h2>
+            <span class="mono summary">Bank {BANKS[ui.seqBank]} · {seqCounts[ui.seqBank]} of 16 used</span>
+          </div>
+          <BankBar
+            bank={ui.seqBank}
+            counts={seqCounts}
+            what="slots used"
+            onpick={(b) => {
+              ui.seqBank = b;
+              ui.seqSlot = b * PER_BANK;
+            }}
+          />
+          <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
+          <p class="hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
+        </section>
+        <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
+      </main>
+    {/if}
+
+    <footer class="mono">
+      <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
+      <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
+      <span>WAV files only</span>
+    </footer>
+  {:else if !app.error}
+    <p class="hint">Loading…</p>
+  {/if}
+
+  {#if ui.aboutOpen}
+    <AboutModal onclose={() => (ui.aboutOpen = false)} />
+  {/if}
+</div>
+
+<style>
+  .page {
+    max-width: 1280px;
+    margin: 0 auto;
+    padding: 24px;
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+  }
+  .titlebar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+    padding-bottom: 12px;
+    border-bottom: 1px solid var(--line);
+  }
+  h1 {
+    margin: 0;
+    font-family: var(--display);
+    font-weight: 700;
+    font-size: 24px;
+    letter-spacing: 0.02em;
+  }
+  .about-link {
+    min-height: 44px;
+    padding: 0 8px;
+    background: transparent;
+    color: var(--accent);
+    border: 0;
+    font: inherit;
+    font-weight: 500;
+    text-decoration: underline;
+    text-underline-offset: 3px;
+    cursor: pointer;
+  }
+  header {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-end;
+    gap: 16px;
+  }
+  .name {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    flex: 1 1 240px;
+    min-width: 0;
+    max-width: 360px;
+  }
+  .name input {
+    height: 44px;
+    padding: 0 12px;
+    background: var(--surface);
+    color: var(--text);
+    border: 1px solid var(--edge);
+    border-radius: 6px;
+    font: inherit;
+    font-weight: 600;
+    width: 100%;
+  }
+  .actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-left: auto;
+  }
+  .tabs {
+    display: flex;
+    gap: 4px;
+    border-bottom: 1px solid var(--line);
+  }
+  .tabs button {
+    height: 48px;
+    padding: 0 22px;
+    background: transparent;
+    color: var(--muted);
+    border: 0;
+    border-bottom: 3px solid transparent;
+    font-family: var(--display);
+    font-size: 16px;
+    font-weight: 700;
+    cursor: pointer;
+  }
+  .tabs button[aria-selected="true"] {
+    color: var(--text);
+    border-bottom-color: var(--accent);
+  }
+  .grid-head {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 12px;
+  }
+  .summary {
+    font-size: 12px;
+    color: var(--muted);
+  }
+  footer {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px 24px;
+    font-size: 12px;
+    color: var(--muted);
+  }
+  .error {
+    margin: 0;
+    padding: 10px 12px;
+    border: 1px solid #e5484d;
+    border-radius: 6px;
+    color: #ffb4b4;
+  }
+</style>

+ 234 - 0
frontend/src/app.css

@@ -0,0 +1,234 @@
+/* Colours and type from the agreed mockup (docs/mockup/Main.dc.html). */
+:root {
+  --bg: #15171a;
+  --surface: #1e2126;
+  --raised: #2a2d33;
+  --line: #353a43;
+  --edge: #4a505b;
+  --text: #f2f3f5;
+  --soft: #c9cdd4;
+  --muted: #a9aeb8;
+  --cell-empty: #22252b;
+  --cell-filled: #333842;
+  --cell-filled-edge: #5a616e;
+  --accent: #f5a524;
+  --accent-hover: #ffc35a;
+  --on-accent: #15171a;
+  --sans: Inter, system-ui, sans-serif;
+  --display: "Space Grotesk", Inter, system-ui, sans-serif;
+  --mono: "IBM Plex Mono", ui-monospace, monospace;
+
+  color-scheme: dark;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--text);
+  font-family: var(--sans);
+  font-size: 14px;
+  line-height: 1.4;
+}
+
+a {
+  color: var(--accent);
+}
+a:hover {
+  color: var(--accent-hover);
+}
+
+:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 2px;
+}
+
+/* Small uppercase section labels. */
+.eyebrow {
+  margin: 0;
+  font-family: var(--display);
+  font-size: 11px;
+  font-weight: 500;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--muted);
+}
+
+.mono {
+  font-family: var(--mono);
+}
+
+.hint {
+  margin: 0;
+  font-size: 12px;
+  color: var(--muted);
+}
+
+/* Buttons. */
+.btn {
+  height: 44px;
+  padding: 0 16px;
+  background: var(--surface);
+  color: var(--text);
+  border: 1px solid var(--edge);
+  border-radius: 6px;
+  font: inherit;
+  font-weight: 500;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+.btn.secondary {
+  background: var(--raised);
+}
+.btn.primary {
+  background: var(--accent);
+  color: var(--on-accent);
+  border-color: var(--accent);
+  font-weight: 600;
+}
+.btn:disabled,
+input:disabled,
+select:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+/* Text, number and select fields. */
+.field {
+  height: 44px;
+  padding: 0 10px;
+  width: 100%;
+  background: var(--bg);
+  color: var(--text);
+  border: 1px solid var(--edge);
+  border-radius: 6px;
+  font: inherit;
+}
+.field.mono {
+  font-family: var(--mono);
+}
+
+/* The two side-by-side panels of each tab. */
+.panels {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20px;
+  align-items: flex-start;
+}
+.panel {
+  min-width: 0;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.panel.grid-panel {
+  flex: 1.4 1 380px;
+  gap: 14px;
+}
+.panel.settings {
+  flex: 1 1 320px;
+}
+
+.panel-head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.panel-head .slot-id {
+  font-family: var(--display);
+  font-size: 28px;
+  font-weight: 700;
+  color: var(--accent);
+}
+.panel-head .title {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.panel-head .title div {
+  font-weight: 600;
+  overflow-wrap: anywhere;
+}
+
+.empty-box {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 20px;
+  border: 1px dashed var(--edge);
+  border-radius: 8px;
+}
+.empty-box p {
+  margin: 0;
+  color: var(--soft);
+}
+.empty-box .btn {
+  align-self: flex-start;
+}
+
+.stack {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.row.divided {
+  padding-top: 12px;
+  border-top: 1px solid var(--line);
+}
+.label {
+  font-size: 12px;
+  color: var(--soft);
+}
+.control {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.control .top {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px;
+}
+.control .value {
+  font-family: var(--mono);
+  font-size: 14px;
+  font-weight: 500;
+}
+.control input[type="range"] {
+  width: 100%;
+  height: 44px;
+  margin: 0;
+  accent-color: var(--accent);
+  cursor: pointer;
+}
+.control .scale {
+  display: flex;
+  justify-content: space-between;
+  font-family: var(--mono);
+  font-size: 11px;
+  color: var(--muted);
+}
+
+.pending {
+  margin: 0;
+  font-size: 12px;
+  color: var(--muted);
+  font-style: italic;
+}

+ 88 - 0
frontend/src/lib/AboutModal.svelte

@@ -0,0 +1,88 @@
+<script lang="ts">
+  import { Browser } from "@wailsio/runtime";
+  import { Service, type About } from "./api";
+
+  let { onclose }: { onclose: () => void } = $props();
+
+  let about = $state<About | null>(null);
+  let closeButton: HTMLButtonElement | undefined = $state();
+
+  $effect(() => {
+    Service.About().then((a) => (about = a));
+    closeButton?.focus();
+  });
+
+  function keydown(e: KeyboardEvent) {
+    if (e.key === "Escape") onclose();
+  }
+
+  function open(e: MouseEvent) {
+    // Open in the system browser, not inside the app window.
+    e.preventDefault();
+    if (about) Browser.OpenURL(about.repo);
+  }
+</script>
+
+<svelte:window onkeydown={keydown} />
+
+<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
+<div class="backdrop" onclick={(e) => e.target === e.currentTarget && onclose()}>
+  <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="about-title">
+    <h2 id="about-title">About mpckit</h2>
+    <p class="lead">A desktop project and kit editor for the MPC Sample. Build kits on your computer, then export them to the device.</p>
+    <div class="facts">
+      <span>Version {about?.version ?? ""}</span>
+      <span>{about?.license ?? ""}</span>
+      {#if about}
+        <a href={about.repo} onclick={open}>{about.repo.replace("https://", "")}</a>
+      {/if}
+    </div>
+    <p class="hint">An independent project, not affiliated with Akai Professional.</p>
+    <button type="button" class="btn primary close" bind:this={closeButton} onclick={onclose}>Close</button>
+  </div>
+</div>
+
+<style>
+  .backdrop {
+    position: fixed;
+    inset: 0;
+    padding: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(10, 11, 13, 0.78);
+  }
+  .dialog {
+    width: 100%;
+    max-width: 440px;
+    padding: 24px;
+    display: flex;
+    flex-direction: column;
+    gap: 14px;
+    background: var(--surface);
+    border: 1px solid var(--edge);
+    border-radius: 12px;
+  }
+  h2 {
+    margin: 0;
+    font-family: var(--display);
+    font-size: 22px;
+    font-weight: 700;
+  }
+  .lead {
+    margin: 0;
+    color: var(--soft);
+  }
+  .facts {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    font-family: var(--mono);
+    font-size: 12px;
+    color: var(--soft);
+  }
+  .close {
+    align-self: flex-end;
+    padding: 0 20px;
+  }
+</style>

+ 52 - 0
frontend/src/lib/BankBar.svelte

@@ -0,0 +1,52 @@
+<script lang="ts">
+  import { BANKS, PER_BANK } from "./slots";
+
+  let {
+    bank,
+    counts,
+    what,
+    onpick,
+  }: {
+    bank: number;
+    counts: number[]; // filled slots per bank
+    what: string; // "pads filled", "slots used"
+    onpick: (bank: number) => void;
+  } = $props();
+</script>
+
+<div class="banks" role="group" aria-label="Banks">
+  {#each BANKS.split("") as letter, i (letter)}
+    <button
+      type="button"
+      class:on={i === bank}
+      aria-pressed={i === bank}
+      aria-label="Bank {letter}, {counts[i]} of {PER_BANK} {what}"
+      onclick={() => onpick(i)}>{letter}</button
+    >
+  {/each}
+</div>
+
+<style>
+  .banks {
+    display: grid;
+    grid-template-columns: repeat(8, minmax(0, 1fr));
+    gap: 6px;
+  }
+  button {
+    height: 44px;
+    padding: 0;
+    background: var(--raised);
+    color: var(--text);
+    border: 1px solid var(--edge);
+    border-radius: 6px;
+    font-family: var(--display);
+    font-size: 15px;
+    font-weight: 700;
+    cursor: pointer;
+  }
+  button.on {
+    background: var(--accent);
+    color: var(--on-accent);
+    border-color: var(--accent);
+  }
+</style>

+ 85 - 0
frontend/src/lib/PadPanel.svelte

@@ -0,0 +1,85 @@
+<script lang="ts">
+  import type { Limits, Pad } from "./api";
+  import { signed } from "./slots";
+
+  // Settings for the selected pad. Shows the pad's values; editing is wired
+  // up in M5, so the controls are disabled for now.
+  let { pad, limits }: { pad: Pad; limits: Limits } = $props();
+
+  const groups = $derived(Array.from({ length: limits.maxMuteGroup }, (_, i) => i + 1));
+</script>
+
+<section class="panel settings" aria-label="Pad settings">
+  <div class="panel-head">
+    <div class="slot-id">{pad.label}</div>
+    <div class="title">
+      <h2 class="eyebrow">Pad settings</h2>
+      <div>{pad.file || "Empty"}</div>
+    </div>
+  </div>
+
+  {#if pad.file === ""}
+    <div class="empty-box">
+      <p>This pad is empty. Drop a WAV file on it, or choose one.</p>
+      <button type="button" class="btn primary" disabled>Choose WAV file</button>
+    </div>
+  {:else}
+    <div class="stack">
+      <div class="row">
+        <button type="button" class="btn primary" disabled>
+          <svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 2 L12 7 L3 12 Z" /></svg>
+          Play
+        </button>
+        <button type="button" class="btn secondary" disabled>Replace</button>
+        <button type="button" class="btn secondary" disabled>Clear</button>
+      </div>
+
+      <div class="control-group stack">
+        <h3 class="eyebrow">Tuning</h3>
+        <div class="control">
+          <div class="top">
+            <label class="label" for="tune-coarse">Semitones</label>
+            <span class="value">{signed(pad.coarse)} st</span>
+          </div>
+          <input id="tune-coarse" type="range" min={limits.minCoarse} max={limits.maxCoarse} step="1" value={pad.coarse} disabled />
+          <div class="scale" aria-hidden="true">
+            <span>{limits.minCoarse}</span><span>0</span><span>+{limits.maxCoarse}</span>
+          </div>
+        </div>
+        <div class="control">
+          <div class="top">
+            <label class="label" for="tune-fine">Cents</label>
+            <span class="value">{signed(pad.fine)} ct</span>
+          </div>
+          <input id="tune-fine" type="range" min={limits.minFine} max={limits.maxFine} step="1" value={pad.fine} disabled />
+          <div class="scale" aria-hidden="true">
+            <span>{limits.minFine}</span><span>0</span><span>+{limits.maxFine}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="control">
+        <label class="eyebrow" for="mute-group">Mute group</label>
+        <select id="mute-group" class="field" value={pad.muteGroup} disabled>
+          <option value={0}>Off</option>
+          {#each groups as g (g)}
+            <option value={g}>Group {g}</option>
+          {/each}
+        </select>
+        <span class="hint">Pads in the same group cut each other off on the device.</span>
+      </div>
+
+      <div class="row divided">
+        <button type="button" class="btn secondary" disabled>Move to pad</button>
+        <button type="button" class="btn secondary" disabled>Copy to pad</button>
+      </div>
+    </div>
+  {/if}
+  <p class="pending">Pad editing is not connected yet (milestones M5 and M6).</p>
+</section>
+
+<style>
+  .control-group {
+    gap: 8px;
+  }
+</style>

+ 57 - 0
frontend/src/lib/SequencePanel.svelte

@@ -0,0 +1,57 @@
+<script lang="ts">
+  import type { Limits, Sequence } from "./api";
+  import { slotLabel } from "./slots";
+
+  // Settings for the selected sequence slot. Shows the values; editing is
+  // wired up in M7, so the controls are disabled for now.
+  let { slot, seq, limits }: { slot: number; seq: Sequence | undefined; limits: Limits } = $props();
+
+  const seqNo = $derived(String(slot + 1).padStart(2, "0"));
+</script>
+
+<section class="panel settings" aria-label="Sequence settings">
+  <div class="panel-head">
+    <div class="slot-id">{slotLabel(slot)}</div>
+    <div class="title">
+      <h2 class="eyebrow">Sequence settings</h2>
+      <div>{seq ? "Sequence " + seqNo : "Empty slot"}</div>
+    </div>
+  </div>
+
+  {#if !seq}
+    <div class="empty-box">
+      <p>No sequence in this slot.</p>
+      <button type="button" class="btn primary" disabled>Add sequence</button>
+    </div>
+  {:else}
+    <div class="stack">
+      <div class="control">
+        <label class="label" for="seq-name">Name</label>
+        <input id="seq-name" class="field" type="text" value={seq.name} disabled />
+      </div>
+      <div class="pair">
+        <div class="control">
+          <label class="label" for="seq-bpm">BPM</label>
+          <input id="seq-bpm" class="field mono" type="number" min={limits.minBPM} max={limits.maxBPM} step="1" value={seq.bpm} disabled />
+        </div>
+        <div class="control">
+          <label class="label" for="seq-bars">Length (bars)</label>
+          <input id="seq-bars" class="field mono" type="number" min="1" max={limits.maxBars} step="1" value={seq.bars} disabled />
+        </div>
+      </div>
+      <span class="hint">Notes are recorded on the device. This screen sets name, tempo and length only.</span>
+      <div class="row divided">
+        <button type="button" class="btn secondary" disabled>Remove sequence</button>
+      </div>
+    </div>
+  {/if}
+  <p class="pending">Sequence editing is not connected yet (milestone M7).</p>
+</section>
+
+<style>
+  .pair {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px;
+  }
+</style>

+ 95 - 0
frontend/src/lib/SlotGrid.svelte

@@ -0,0 +1,95 @@
+<script lang="ts" module>
+  export type Cell = {
+    slot: number;
+    label: string; // "A01"
+    name: string; // sample or sequence name; empty for an empty slot
+    meta: string; // small line under the name
+    aria: string;
+  };
+</script>
+
+<script lang="ts">
+  // A 4x4 grid of pads or sequence slots, used by both tabs.
+  let {
+    cells,
+    selected,
+    onpick,
+  }: {
+    cells: Cell[]; // 16 cells in grid order, top-left first
+    selected: number;
+    onpick: (slot: number) => void;
+  } = $props();
+</script>
+
+<div class="grid">
+  {#each cells as c (c.slot)}
+    <button
+      type="button"
+      class:filled={c.name !== ""}
+      class:on={c.slot === selected}
+      aria-pressed={c.slot === selected}
+      aria-label={c.aria}
+      onclick={() => onpick(c.slot)}
+    >
+      <span class="num">{c.label}</span>
+      <span class="name">{c.name}</span>
+      <span class="meta">{c.meta}</span>
+    </button>
+  {/each}
+</div>
+
+<style>
+  .grid {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: 10px;
+  }
+  button {
+    aspect-ratio: 1 / 1;
+    min-height: 72px;
+    padding: 8px;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 4px;
+    text-align: left;
+    background: var(--cell-empty);
+    color: var(--text);
+    border: 2px solid var(--line);
+    border-radius: 8px;
+    font: inherit;
+    cursor: pointer;
+    overflow: hidden;
+  }
+  button.filled {
+    background: var(--cell-filled);
+    border-color: var(--cell-filled-edge);
+  }
+  button.on {
+    border-color: var(--accent);
+  }
+  .num {
+    font-family: var(--mono);
+    font-size: 11px;
+    font-weight: 500;
+    color: var(--muted);
+  }
+  button.on .num {
+    color: var(--accent);
+  }
+  .name {
+    font-size: 12px;
+    font-weight: 600;
+    line-height: 1.25;
+    overflow: hidden;
+    max-width: 100%;
+    overflow-wrap: anywhere;
+  }
+  .meta {
+    font-family: var(--mono);
+    font-size: 11px;
+    color: var(--soft);
+    min-height: 15px;
+  }
+</style>

+ 9 - 0
frontend/src/lib/api.ts

@@ -0,0 +1,9 @@
+// The Go service and its types, from the generated bindings.
+export { Service } from "../../bindings/git.monoexp.com/bajdev/mpckit/internal/app";
+export type {
+  About,
+  Limits,
+  Pad,
+  Sequence,
+  State,
+} from "../../bindings/git.monoexp.com/bajdev/mpckit/internal/app";

+ 40 - 0
frontend/src/lib/slots.ts

@@ -0,0 +1,40 @@
+// Helpers shared by the pad and sequence grids.
+
+export const BANKS = "ABCDEFGH";
+export const PER_BANK = 16;
+
+/** "A01" for slot 0, "B02" for slot 17. Matches Go's app.Label. */
+export function slotLabel(slot: number): string {
+  const n = (slot % PER_BANK) + 1;
+  return BANKS[Math.floor(slot / PER_BANK)] + (n < 10 ? "0" + n : String(n));
+}
+
+/**
+ * The 16 slots of a bank in grid order, top-left first. Pad 1 sits
+ * bottom-left, as on the device: the top row is pads 13-16.
+ */
+export function gridSlots(bank: number): number[] {
+  const out: number[] = [];
+  for (let row = 3; row >= 0; row--) {
+    for (let col = 0; col < 4; col++) {
+      out.push(bank * PER_BANK + row * 4 + col);
+    }
+  }
+  return out;
+}
+
+/** "+12.40" style tuning text; empty when untuned. */
+export function tuneText(coarse: number, fine: number): string {
+  if (!coarse && !fine) return "";
+  const v = coarse + fine / 100;
+  return (v > 0 ? "+" : "") + v.toFixed(2);
+}
+
+/** "+5" / "-3" / "0". */
+export function signed(n: number): string {
+  return (n > 0 ? "+" : "") + n;
+}
+
+export function bars(n: number): string {
+  return n + (n === 1 ? " bar" : " bars");
+}

+ 39 - 0
frontend/src/lib/store.svelte.ts

@@ -0,0 +1,39 @@
+// Go is the single source of truth: every service call returns a fresh State
+// and the store replaces its copy. UI-only state (tab, bank, selection,
+// About) lives here too and never goes to Go (build plan 4.6).
+import { Service, type State } from "./api";
+
+export type Tab = "pads" | "sequences";
+
+export const app = $state({
+  state: null as State | null,
+  error: "",
+});
+
+export const ui = $state({
+  tab: "pads" as Tab,
+  padBank: 0,
+  padSlot: 0,
+  seqBank: 0,
+  seqSlot: 0,
+  aboutOpen: false,
+});
+
+async function run(call: () => Promise<State>): Promise<void> {
+  try {
+    app.state = await call();
+    app.error = "";
+  } catch (e) {
+    app.error = e instanceof Error ? e.message : String(e);
+  }
+}
+
+/** Fetches the current project from Go. */
+export function refresh(): Promise<void> {
+  return run(() => Service.State());
+}
+
+/** Starts a new, empty project. */
+export function newProject(): Promise<void> {
+  return run(() => Service.New());
+}

+ 12 - 0
frontend/src/main.ts

@@ -0,0 +1,12 @@
+import { mount } from "svelte";
+import "@fontsource/inter/400.css";
+import "@fontsource/inter/500.css";
+import "@fontsource/inter/600.css";
+import "@fontsource/space-grotesk/500.css";
+import "@fontsource/space-grotesk/700.css";
+import "@fontsource/ibm-plex-mono/400.css";
+import "@fontsource/ibm-plex-mono/500.css";
+import "./app.css";
+import App from "./App.svelte";
+
+mount(App, { target: document.getElementById("app")! });

+ 2 - 0
frontend/src/vite-env.d.ts

@@ -0,0 +1,2 @@
+/// <reference types="svelte" />
+/// <reference types="vite/client" />

+ 7 - 0
frontend/svelte.config.js

@@ -0,0 +1,7 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+export default {
+  // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
+  // for more information about preprocessors
+  preprocess: vitePreprocess(),
+}

+ 24 - 0
frontend/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "verbatimModuleSyntax": true,
+    "sourceMap": true,
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    /**
+     * Typecheck JS in `.svelte` and `.js` files by default.
+     * Disable checkJs if you'd like to use dynamic types in JS.
+     * Note that setting allowJs false does not prevent the use
+     * of JS in `.svelte` files.
+     */
+    "allowJs": true,
+    "checkJs": true,
+    "isolatedModules": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "bindings"],
+}

+ 25 - 0
frontend/vite.config.ts

@@ -0,0 +1,25 @@
+import { writeFileSync } from "node:fs";
+import { defineConfig, type Plugin } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import wails from "@wailsio/runtime/plugins/vite";
+
+// main.go embeds frontend/dist, so the folder must exist in a fresh clone for
+// `go build` and `go test ./...` to work. Git keeps it with dist/.gitkeep;
+// Vite empties dist on every build, so put the file back afterwards.
+const keepDist: Plugin = {
+  name: "mpckit-keep-dist",
+  apply: "build",
+  closeBundle() {
+    writeFileSync(new URL("./dist/.gitkeep", import.meta.url), "");
+  },
+};
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  server: {
+    host: "127.0.0.1",
+    port: Number(process.env.WAILS_VITE_PORT) || 9245,
+    strictPort: true,
+  },
+  plugins: [svelte(), wails("./bindings"), keepDist],
+});

+ 12 - 1
go.mod

@@ -1,3 +1,14 @@
 module git.monoexp.com/bajdev/mpckit
 
-go 1.24
+go 1.26.0
+
+require github.com/wailsapp/wails/v3 v3.1.0
+
+require (
+	github.com/coder/websocket v1.8.14 // indirect
+	github.com/ebitengine/purego v0.11.1 // indirect
+	github.com/go-ole/go-ole v1.3.0 // indirect
+	github.com/godbus/dbus/v5 v5.2.2 // indirect
+	golang.org/x/sys v0.48.0 // indirect
+	golang.org/x/term v0.46.0 // indirect
+)

+ 23 - 0
go.sum

@@ -0,0 +1,23 @@
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+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/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
+github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/wailsapp/wails/v3 v3.1.0 h1:ld0AXSicXiTTsIvmTq3qNn9FzCP8vIHRSJdkRLgaAo8=
+github.com/wailsapp/wails/v3 v3.1.0/go.mod h1:tzDY3W1jzN3ksSqfGalTt7K3PwnGfIqtl+IgIXlXz0I=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 172 - 0
internal/app/app.go

@@ -0,0 +1,172 @@
+// Package app is the Wails service the frontend calls. It owns the current
+// project and returns a fresh State after every call; the frontend keeps no
+// project data of its own (build plan 4.6 and 5).
+package app
+
+import (
+	"fmt"
+	"sync"
+
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+)
+
+// Version is set at build time with
+// -ldflags "-X git.monoexp.com/bajdev/mpckit/internal/app.Version=v1.0.0".
+var Version = "dev"
+
+// DefaultName is the name of a new project.
+const DefaultName = "NewKit"
+
+// Pad is one pad as the frontend shows it.
+type Pad struct {
+	Slot      int    `json:"slot"`
+	Label     string `json:"label"` // "A01" .. "H16"
+	Name      string `json:"name"`  // sample name; empty for an empty pad
+	File      string `json:"file"`  // pool file name
+	Coarse    int    `json:"coarse"`
+	Fine      int    `json:"fine"`
+	MuteGroup int    `json:"muteGroup"`
+	HasNotes  bool   `json:"hasNotes"`
+}
+
+// Sequence is one existing sequence.
+type Sequence struct {
+	Slot     int     `json:"slot"`
+	Label    string  `json:"label"` // slot label, "A01" .. "H16"
+	Name     string  `json:"name"`
+	BPM      float64 `json:"bpm"`
+	Bars     int     `json:"bars"`
+	HasNotes bool    `json:"hasNotes"`
+}
+
+// Limits are the ranges the UI offers, from the device checks.
+type Limits struct {
+	Pads         int     `json:"pads"`
+	MaxSamples   int     `json:"maxSamples"`
+	MaxSequences int     `json:"maxSequences"`
+	MinCoarse    int     `json:"minCoarse"`
+	MaxCoarse    int     `json:"maxCoarse"`
+	MinFine      int     `json:"minFine"`
+	MaxFine      int     `json:"maxFine"`
+	MaxMuteGroup int     `json:"maxMuteGroup"`
+	MinBPM       float64 `json:"minBPM"`
+	MaxBPM       float64 `json:"maxBPM"`
+	MaxBars      int     `json:"maxBars"`
+}
+
+// State is everything the frontend shows about the project.
+type State struct {
+	Name          string     `json:"name"`
+	Dirty         bool       `json:"dirty"`
+	Pads          []Pad      `json:"pads"`      // all 128, in slot order
+	Sequences     []Sequence `json:"sequences"` // existing ones, in slot order
+	SampleCount   int        `json:"sampleCount"`
+	SequenceCount int        `json:"sequenceCount"`
+	Limits        Limits     `json:"limits"`
+}
+
+// About is the text for the About dialog.
+type About struct {
+	Version string `json:"version"`
+	License string `json:"license"`
+	Repo    string `json:"repo"`
+}
+
+// Service is registered with Wails; its exported methods become the
+// frontend bindings.
+type Service struct {
+	mu sync.Mutex
+	p  *project.Project
+}
+
+// NewService returns a service holding a new, empty project.
+func NewService() *Service {
+	s := &Service{}
+	s.reset()
+	return s
+}
+
+func (s *Service) reset() {
+	p, err := project.New(DefaultName)
+	if err != nil {
+		panic(err) // the embedded template and a constant name
+	}
+	s.p = p
+}
+
+// New replaces the current project with a new, empty one.
+func (s *Service) New() State {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.reset()
+	return s.state()
+}
+
+// State returns the current project's state.
+func (s *Service) State() State {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.state()
+}
+
+// Version returns the app version.
+func (s *Service) Version() string { return Version }
+
+// About returns the About dialog text.
+func (s *Service) About() About {
+	return About{
+		Version: Version,
+		License: "MIT License, copyright (c) 2026 bajdev",
+		Repo:    "https://git.monoexp.com/bajdev/mpckit",
+	}
+}
+
+// Label returns the label the UI and the device use for a pad or sequence
+// slot: bank letter and two-digit pad number, e.g. 0 -> "A01", 17 -> "B02".
+func Label(slot int) string {
+	return fmt.Sprintf("%c%02d", 'A'+slot/16, slot%16+1)
+}
+
+func (s *Service) state() State {
+	p := s.p
+	st := State{
+		Name:        p.Name,
+		Dirty:       p.Dirty,
+		Pads:        make([]Pad, 0, project.Pads),
+		Sequences:   []Sequence{},
+		SampleCount: p.SampleCount(),
+		Limits: Limits{
+			Pads:         project.Pads,
+			MaxSamples:   project.MaxSamples,
+			MaxSequences: project.MaxSequences,
+			MinCoarse:    project.MinCoarse,
+			MaxCoarse:    project.MaxCoarse,
+			MinFine:      project.MinFine,
+			MaxFine:      project.MaxFine,
+			MaxMuteGroup: project.MaxMuteGroup,
+			MinBPM:       project.MinBPM,
+			MaxBPM:       project.MaxBPM,
+			MaxBars:      project.MaxBars,
+		},
+	}
+	for slot := 0; slot < project.Pads; slot++ {
+		v, err := p.Pad(slot)
+		if err != nil {
+			// A loaded file with an odd pad; show it as empty.
+			st.Pads = append(st.Pads, Pad{Slot: slot, Label: Label(slot)})
+			continue
+		}
+		pad := Pad{Slot: slot, Label: Label(slot), Name: v.SampleName, File: v.File,
+			Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup}
+		if !v.Empty() {
+			pad.HasNotes = p.PadHasNotes(slot)
+		}
+		st.Pads = append(st.Pads, pad)
+	}
+	for _, q := range p.Sequences() {
+		st.Sequences = append(st.Sequences, Sequence{Slot: q.Slot, Label: Label(q.Slot),
+			Name: q.Name, BPM: q.BPM, Bars: q.Bars, HasNotes: q.HasNotes})
+	}
+	st.SequenceCount = len(st.Sequences)
+	return st
+}

+ 51 - 0
internal/app/app_test.go

@@ -0,0 +1,51 @@
+package app
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func TestNewState(t *testing.T) {
+	s := NewService()
+	st := s.State()
+	if st.Name != DefaultName || st.Dirty || st.SampleCount != 0 {
+		t.Errorf("state %q dirty %v samples %d", st.Name, st.Dirty, st.SampleCount)
+	}
+	if len(st.Pads) != 128 || st.Pads[0].Label != "A01" || st.Pads[17].Label != "B02" || st.Pads[127].Label != "H16" {
+		t.Fatalf("pads: %d", len(st.Pads))
+	}
+	for _, p := range st.Pads {
+		if p.File != "" || p.HasNotes {
+			t.Fatalf("pad %s not empty", p.Label)
+		}
+	}
+	if st.SequenceCount != 1 || st.Sequences[0] != (Sequence{Slot: 0, Label: "A01", Name: "Sequence 01", BPM: 128, Bars: 2}) {
+		t.Errorf("sequences %+v", st.Sequences)
+	}
+	if st.Limits.MaxMuteGroup != 16 || st.Limits.MinBPM != 20 || st.Limits.MaxBPM != 999 || st.Limits.MinCoarse != -24 {
+		t.Errorf("limits %+v", st.Limits)
+	}
+	if st2 := s.New(); st2.Name != DefaultName || len(st2.Pads) != 128 {
+		t.Error("New")
+	}
+}
+
+func TestStateJSON(t *testing.T) {
+	b, err := json.Marshal(NewService().State())
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`} {
+		if !strings.Contains(string(b), key) {
+			t.Errorf("JSON lacks %s", key)
+		}
+	}
+}
+
+func TestAbout(t *testing.T) {
+	a := NewService().About()
+	if a.Version != Version || !strings.Contains(a.License, "2026 bajdev") || !strings.HasPrefix(a.Repo, "https://") {
+		t.Errorf("%+v", a)
+	}
+}

+ 46 - 0
main.go

@@ -0,0 +1,46 @@
+// Command mpckit is the desktop kit editor for the MPC Sample.
+package main
+
+import (
+	"embed"
+	"log"
+
+	"github.com/wailsapp/wails/v3/pkg/application"
+
+	"git.monoexp.com/bajdev/mpckit/internal/app"
+)
+
+// The built Svelte app (frontend/dist), embedded in the binary.
+//
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+	a := application.New(application.Options{
+		Name:        "mpckit",
+		Description: "Desktop kit editor for the MPC Sample",
+		Services: []application.Service{
+			application.NewService(app.NewService()),
+		},
+		Assets: application.AssetOptions{
+			Handler: application.AssetFileServerFS(assets),
+		},
+		Mac: application.MacOptions{
+			ApplicationShouldTerminateAfterLastWindowClosed: true,
+		},
+	})
+
+	a.Window.NewWithOptions(application.WebviewWindowOptions{
+		Title:            "mpckit",
+		Width:            1280,
+		Height:           860,
+		MinWidth:         760,
+		MinHeight:        600,
+		BackgroundColour: application.NewRGB(0x15, 0x17, 0x1A),
+		URL:              "/",
+	})
+
+	if err := a.Run(); err != nil {
+		log.Fatal(err)
+	}
+}

+ 44 - 0
pull-requests/m4-app-shell/m4-app-shell.md

@@ -0,0 +1,44 @@
+# M4: app shell
+
+Branch: `m4-app-shell` → `main`
+
+## What changed
+
+- **Wails v3.1.0 app** (latest stable; the plan said 3.0.0). It needs Go 1.26, so `go.mod` now says `go 1.26.0`.
+  - `main.go`: one 1280×860 window (minimum 760×600) with the mockup's background colour, the `app.Service`, and the embedded `frontend/dist`.
+  - `Taskfile.yml` and `build/` come from `wails3 init -t svelte`, without the Android, iOS and Docker parts. `build/config.yml` names the product mpckit, (c) 2026 bajdev, "Not affiliated with Akai Professional"; the Windows version info was regenerated from it. Icons are still the Wails defaults (M9).
+- **`internal/app`**: the Wails service (build plan 4.6, 5). For M4: `New()`, `State()`, `Version()`, `About()`. `State` holds the project name, the dirty flag, all 128 pads (label `A01`…`H16`, sample, tuning, mute group, has-notes), the sequences, counts, and the device limits (mute groups 0–16, BPM 20–999, tuning ±24/±90) so the UI never hard-codes them. `Version` is set with `-ldflags` (M9) and is `dev` until then.
+- **Frontend** (Svelte 5 + TypeScript + Vite), following the mockup:
+  - Title row with About; project name; New / Load / Export zip / Export to folder; Pads and Sequences tabs.
+  - Each tab has bank buttons A–H with per-bank counts, the 4×4 grid (pad 1 bottom-left), a settings panel for the selected slot, and a footer with counts.
+  - `lib/store.svelte.ts` holds the Go state (replaced after each call) and UI-only state (tab, bank, selection, About).
+  - Components: `BankBar`, `SlotGrid` (shared by both tabs), `PadPanel`, `SequencePanel`, `AboutModal`.
+  - The About modal shows version, licence, the repo link (opens in the system browser) and the no-affiliation note. It closes with Escape, the Close button or a click outside.
+  - Fonts (Inter, Space Grotesk, IBM Plex Mono, all OFL) come from `@fontsource` packages, so the app needs no network.
+- **Bindings** in `frontend/bindings`, generated by `wails3 generate bindings -ts -i` (regenerated on every build).
+- `frontend/dist/.gitkeep` is committed so `go build` / `go test ./...` work in a fresh clone (main.go embeds dist). A small Vite plugin puts it back after each build.
+- **README**: development setup (Go 1.26, Node LTS, `wails3`, `wails3 dev`). The build plan's Wails version and setup lines are updated.
+
+## What works in M4 and what doesn't yet
+
+Working: data comes from Go (`State()` on start, `New()` from the New button); tabs (click or arrow keys); bank switching; selecting pads and sequence slots; the settings panels showing the selected slot's values; About.
+
+Shown but **disabled** until their milestone, with a note in each panel: Load, Export (M5/M8), the name field, all pad controls (M5/M6), all sequence controls (M7).
+
+## Testing
+
+On the server:
+- `go vet` and `go test` pass for all Go packages. `internal/app` has tests for the new-project state, its JSON shape and About.
+- The main package vets and builds for Windows (`GOOS=windows`, an 18 MB `mpckit.exe`), and vets for Linux and macOS with `-tags purego`.
+
+**Not tested on the server:** it has no Node.js, so the frontend has not been built, type-checked (`svelte-check`) or seen running. Please check on the laptop:
+
+1. `wails3 doctor` is happy; `wails3 dev` builds and opens a window titled mpckit (the first run does `npm install`).
+2. The page matches the mockup's layout and colours. Project name "NewKit"; footer "0 of 128 samples", "1 of 128 sequences".
+3. Pads tab: bank A selected, 16 empty pads with A01 at bottom-left and A16 at top-right; clicking a pad moves the orange outline and the settings panel shows "This pad is empty". Bank buttons switch banks.
+4. Sequences tab: A01 shows "Sequence 01", "128 · 2 bars". Selecting it shows name, BPM 128, 2 bars. Empty slots show "No sequence in this slot".
+5. About opens and closes (Escape, Close, click outside); the repo link opens your browser.
+6. New resets the state (no visible change on a new project, but there should be no error).
+7. `npm run check` in `frontend` (svelte-check) reports no errors.
+
+Please send screenshots or errors. Anything wrong in the Svelte side I'll fix on this branch.