From 6c54d222c741bdebef712b664a422a5fa3697976 Mon Sep 17 00:00:00 2001 From: oveddan Date: Mon, 3 Aug 2026 23:58:08 -0600 Subject: [PATCH 1/4] feat: package mft-config command --- .github/workflows/ci.yml | 39 ++++++++++ README.md | 87 ++++++++++++++++------ package-lock.json | 6 +- package.json | 26 ++++++- scripts/packed-artifact-smoke.mjs | 118 ++++++++++++++++++++++++++++++ src/cli.ts | 22 +++--- src/state.ts | 29 ++++++++ test/state.test.ts | 12 +++ tsconfig.json | 4 +- 9 files changed, 305 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/packed-artifact-smoke.mjs create mode 100644 src/state.ts create mode 100644 test/state.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6dd31e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + timeout-minutes: 15 + strategy: + matrix: + include: + - os: ubuntu-latest + node: 20 + - os: ubuntu-latest + node: 22 + - os: macos-latest + node: 20 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install native MIDI build dependency + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libasound2-dev + - run: npm ci + - run: npm run check + - name: Pack, clean-install, and smoke-test the CLI + run: npm run test:package diff --git a/README.md b/README.md index 9bf835e..8f80573 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,48 @@ Requirements: - a native MIDI toolchain supported by `@julusian/midi` - on Linux, the ALSA development package required by RtMidi +Install the current GitHub version globally: + +```sh +npm install --global --include=dev github:oveddan/mft-api +``` + +This installs the `mft-config` executable. GitHub source installs need +`--include=dev` because their installation-time build uses TypeScript. Production +installs that omit development dependencies should use a prebuilt tarball. + +You can also install a release tarball when one is available: + +```sh +npm install --global ./oveddan-mft-config-0.1.0.tgz +``` + +Confirm the command is ready: + +```sh +mft-config --help +``` + +The former `mft-export` executable remains as a compatibility alias for now. +New scripts and documentation should use `mft-config`; the alias may be removed +in a future major release. + +The package is not published to the npm registry. This repository does not yet +have an explicit host-tool license, so registry publishing remains blocked until +the owner chooses and adds one. The package metadata deliberately uses +`UNLICENSED` and `private: true` in the meantime. `UNLICENSED` does not grant +permission to redistribute or modify the package; usage terms remain pending. +Adding a license must include an explicit decision to keep or remove the +`private` publish guard. + +### Contributor setup + ```sh git clone https://github.com/oveddan/mft-api.git cd mft-api npm install npm run check +npm link ``` Build the CLI after making changes: @@ -60,27 +97,27 @@ Build the CLI after making changes: npm run build ``` -The examples below use `node dist/cli.js`. After packaging or linking the npm -binary, the equivalent command name is `mft-export`. +`npm link` builds the source checkout and links `mft-config` for contributor +testing. End-user commands use the installed executable. ## Read the controller List connected Twisters: ```sh -node dist/cli.js list +mft-config list ``` Export the only connected device: ```sh -node dist/cli.js export --out twister-config.json +mft-config export --out twister-config.json ``` If more than one Twister is connected, use the index shown by `list`: ```sh -node dist/cli.js export --device 1 --out twister-config.json +mft-config export --device 1 --out twister-config.json ``` The JSON includes the firmware and unit identity, detected bank count, all @@ -232,9 +269,9 @@ minutes and are bound to the snapshot hash, firmware version, and device ID. ### Make the top row green when active and purple when inactive ```sh -node dist/cli.js export --out twister-config.json +mft-config export --out twister-config.json -node dist/cli.js plan \ +mft-config plan \ --snapshot twister-config.json \ --set bank.1.encoder.1.colors.active=green \ --set bank.1.encoder.1.colors.inactive=purple \ @@ -246,7 +283,7 @@ node dist/cli.js plan \ --set bank.1.encoder.4.colors.inactive=purple \ --out patch-plan.json -node dist/cli.js apply --plan patch-plan.json --yes +mft-config apply --plan patch-plan.json --yes ``` ### Make the first two push switches toggle on and off @@ -254,15 +291,15 @@ node dist/cli.js apply --plan patch-plan.json --yes Code `1` is `ccToggle`: ```sh -node dist/cli.js export --out twister-config.json +mft-config export --out twister-config.json -node dist/cli.js plan \ +mft-config plan \ --snapshot twister-config.json \ --set bank.1.encoder.1.switch.action.code=1 \ --set bank.1.encoder.2.switch.action.code=1 \ --out patch-plan.json -node dist/cli.js apply --plan patch-plan.json --yes +mft-config apply --plan patch-plan.json --yes ``` ### Change a knob's rotary and push MIDI mappings @@ -271,9 +308,9 @@ This makes bank 2, encoder 5 send CC 20 on channel 3 when turned, and a momentary CC 40 on channel 4 when pressed: ```sh -node dist/cli.js export --out twister-config.json +mft-config export --out twister-config.json -node dist/cli.js plan \ +mft-config plan \ --snapshot twister-config.json \ --set bank.2.encoder.5.encoder.type.code=1 \ --set bank.2.encoder.5.encoder.midiChannel=3 \ @@ -283,15 +320,15 @@ node dist/cli.js plan \ --set bank.2.encoder.5.switch.midiNumber=40 \ --out patch-plan.json -node dist/cli.js apply --plan patch-plan.json --yes +mft-config apply --plan patch-plan.json --yes ``` ### Set a velocity-sensitive spread indicator with a red detent ```sh -node dist/cli.js export --out twister-config.json +mft-config export --out twister-config.json -node dist/cli.js plan \ +mft-config plan \ --snapshot twister-config.json \ --set bank.3.encoder.9.movement.code=2 \ --set bank.3.encoder.9.indicator.code=3 \ @@ -299,15 +336,15 @@ node dist/cli.js plan \ --set bank.3.encoder.9.detent.color=red \ --out patch-plan.json -node dist/cli.js apply --plan patch-plan.json --yes +mft-config apply --plan patch-plan.json --yes ``` ### Change global brightness and sleep behavior ```sh -node dist/cli.js export --out twister-config.json +mft-config export --out twister-config.json -node dist/cli.js plan \ +mft-config plan \ --snapshot twister-config.json \ --set global.brightness.rgb=96 \ --set global.brightness.indicator=80 \ @@ -315,7 +352,7 @@ node dist/cli.js plan \ --set global.sleep.animation.code=0 \ --out patch-plan.json -node dist/cli.js apply --plan patch-plan.json --yes +mft-config apply --plan patch-plan.json --yes ``` Timeout index `4` means 10 minutes; sleep animation code `0` turns the lights @@ -328,12 +365,18 @@ Before sending any configuration frame, `apply`: 1. validates the plan hash and expiry; 2. discovers the controller again and checks its identity and firmware; 3. takes a fresh complete snapshot and rejects stale expected values; -4. saves a timestamped JSON backup under `.mft-state/backups/`; +4. saves a timestamped JSON backup under the per-user state directory; 5. writes targets sequentially without automatic retries; 6. reads the full configuration after every target and compares it with the expected state; and 7. records pending, verified, failed, or unknown outcomes in an append-only - journal. +journal. + +By default, backups, the single-use-plan journal, and the last verified snapshot +are stored under `~/.mft-config/` (or `$XDG_STATE_HOME/mft-config/`). This stable +location prevents changing the working directory from bypassing plan-consumption +checks. Set `MFT_CONFIG_STATE_DIR` to choose an explicit alternative. +The override must be an absolute path. Completed plans are single-use. See [`docs/write-safety.md`](docs/write-safety.md) for protocol-level details and remaining limitations. diff --git a/package-lock.json b/package-lock.json index e2ea9f5..a9f18e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "@djtechtools/mft-export", + "name": "@oveddan/mft-config", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@djtechtools/mft-export", + "name": "@oveddan/mft-config", "version": "0.1.0", + "license": "UNLICENSED", "dependencies": { "@julusian/midi": "^3.0.1" }, "bin": { + "mft-config": "dist/cli.js", "mft-export": "dist/cli.js" }, "devDependencies": { diff --git a/package.json b/package.json index b89843e..9979d86 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,36 @@ { - "name": "@djtechtools/mft-export", + "name": "@oveddan/mft-config", "version": "0.1.0", "private": true, "type": "module", - "description": "Guarded MIDI Fighter Twister configuration exporter and planner", + "description": "Safely read, plan, and write MIDI Fighter Twister configuration", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "git+https://github.com/oveddan/mft-api.git" + }, + "bugs": { + "url": "https://github.com/oveddan/mft-api/issues" + }, + "homepage": "https://github.com/oveddan/mft-api#readme", + "files": [ + "dist/**/*.js", + "docs/write-safety.md", + "schema/*.json", + "README.md" + ], "bin": { + "mft-config": "./dist/cli.js", "mft-export": "./dist/cli.js" }, "scripts": { + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "tsc -p tsconfig.json", "test": "tsx --test test/*.test.ts", - "check": "npm run build && npm test" + "check": "npm run build && npm test", + "prepare": "npm run build", + "prepack": "npm run clean && npm run build", + "test:package": "node scripts/packed-artifact-smoke.mjs" }, "engines": { "node": ">=20" diff --git a/scripts/packed-artifact-smoke.mjs b/scripts/packed-artifact-smoke.mjs new file mode 100644 index 0000000..22dcecb --- /dev/null +++ b/scripts/packed-artifact-smoke.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryDirectory = mkdtempSync(join(tmpdir(), "mft-config-package-")); +let tarballPath; +const npmCli = process.env.npm_execpath; +assert.ok(npmCli, "npm_execpath is required; run this check through npm run test:package"); + +function run(binary, args, options = {}) { + const result = spawnSync(binary, args, { + cwd: options.cwd ?? temporaryDirectory, + encoding: "utf8", + env: process.env, + }); + if (options.status !== undefined) { + assert.equal(result.status, options.status, `${binary} ${args.join(" ")}\n${result.stdout}\n${result.stderr}`); + } + if (options.stdout) assert.match(result.stdout, options.stdout); + if (options.stderr) assert.match(result.stderr, options.stderr); + return result; +} + +try { + const packOutput = execFileSync(process.execPath, [npmCli, "pack", "--json"], { + cwd: repositoryRoot, + encoding: "utf8", + }); + assert.ok(packOutput.trim(), "npm pack did not return its JSON manifest"); + const [packed] = JSON.parse(packOutput); + assert.ok(packed?.filename, "npm pack did not report a tarball filename"); + tarballPath = join(repositoryRoot, packed.filename); + + const paths = packed.files.map((file) => file.path).sort(); + const required = [ + "README.md", + "dist/cli.js", + "dist/applier.js", + "docs/write-safety.md", + "package.json", + "schema/config-export.schema.json", + "schema/patch-plan.schema.json", + ]; + for (const path of required) assert.ok(paths.includes(path), `packed artifact is missing ${path}`); + for (const path of paths) { + assert.match(path, /^(README\.md|package\.json|(?:LICENSE|LICENCE|COPYING)(?:\..+)?|dist\/.+\.js|docs\/write-safety\.md|schema\/[^/]+\.json)$/); + } + assert.ok(!paths.some((path) => /(^|\/)(src|test|scripts|node_modules|\.mft-state)(\/|$)/.test(path))); + + const installRoot = join(temporaryDirectory, "install"); + run(process.execPath, [npmCli, "install", "--prefix", installRoot, "--no-audit", "--no-fund", tarballPath], { status: 0 }); + const command = join(installRoot, "node_modules", ".bin", "mft-config"); + const legacyCommand = join(installRoot, "node_modules", ".bin", "mft-export"); + + run(command, ["--help"], { status: 0, stdout: /^Usage:\n mft-config list/m }); + run(legacyCommand, ["--help"], { status: 0, stdout: /^Usage:\n mft-config list/m, stderr: /mft-export is deprecated/ }); + run(command, ["list", "--timeout", "99"], { status: 1, stderr: /--timeout must be an integer/ }); + run(command, ["export", "--device", "-1"], { status: 1, stderr: /--device must be a non-negative integer/ }); + run(command, ["plan"], { status: 1, stderr: /plan requires --snapshot/ }); + run(command, ["apply"], { status: 1, stderr: /apply requires --plan/ }); + + const snapshotPath = join(temporaryDirectory, "snapshot.json"); + const planPath = join(temporaryDirectory, "plan.json"); + const snapshot = { + schemaVersion: "djtt.mft.config-export.v1", + capturedAt: "2026-08-03T00:00:00.000Z", + device: { + manufacturer: "DJ TechTools", + manufacturerId: [0, 1, 121], + familyId: 5, + modelId: 1, + firmware: { date: "2026-07-02", identityBytes: [32, 38, 7, 2] }, + unitId: { source: "sysex-0x05", hex: "0102030405060708" }, + midiPorts: { input: "input-a", output: "output-a" }, + }, + capabilities: { bankCount: 4, encodersPerBank: 16 }, + globals: { + midiChannel: 4, + sideButtonsBanked: true, + sideButtons: Array.from({ length: 6 }, (_, index) => ({ number: index + 1, action: { code: 0, name: "ccHold" } })), + superKnob: { start: 63, end: 127 }, + brightness: { rgb: 127, indicator: 127 }, + colorMap: { code: 1, name: "mf64" }, + animationChannels: { encoder: 6, switch: 3 }, + sleep: { timeoutIndex: 7, timeoutMinutes: 60, animation: { code: 1, name: "rainbowWave" } }, + bankAnimationsEnabled: true, + rawTags: { "0": 4, "1": 1, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0, "8": 63, "9": 127, "31": 127, "32": 127, "33": 1, "34": 6, "35": 3, "36": 7, "37": 1, "38": 1 }, + }, + banks: [{ + number: 1, + encoders: [{ + number: 1, + detent: { enabled: false, color: 63 }, + movement: { code: 0, name: "direct" }, + switch: { action: { code: 0, name: "ccHold" }, midiChannel: 2, midiNumber: 0 }, + encoder: { midiChannel: 1, shiftedMidiChannel: 5, midiNumber: 0, type: { code: 1, name: "cc" } }, + colors: { active: 25, inactive: 5 }, + indicator: { code: 2, name: "blendedBar" }, + superKnobEnabled: false, + rawTags: { "10": 0, "11": 0, "12": 0, "13": 2, "14": 0, "15": 0, "16": 1, "17": 0, "18": 1, "19": 25, "20": 5, "21": 63, "22": 2, "23": 0, "24": 5 }, + }], + }], + warnings: [], + }; + writeFileSync(snapshotPath, `${JSON.stringify(snapshot)}\n`); + run(command, ["plan", "--snapshot", snapshotPath, "--set", "bank.1.encoder.1.colors.active=red", "--out", planPath], { status: 0 }); + assert.equal(JSON.parse(readFileSync(planPath, "utf8")).changes[0].path, "bank.1.encoder.1.colors.active"); + run(command, ["apply", "--plan", planPath], { status: 1, stderr: /explicit confirmation with --yes/ }); + + process.stdout.write(`Packed and clean-installed ${packed.filename}; mft-config and mft-export smoke tests passed.\n`); +} finally { + if (tarballPath) rmSync(tarballPath, { force: true }); + rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/src/cli.ts b/src/cli.ts index 9d2701d..10ac96d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import type { ConfigExport } from "./model.js"; import { createPatchPlan, type PatchInput, type PatchPlan } from "./planner.js"; import { applyPatchPlan } from "./applier.js"; import { assertPlanNotConsumed } from "./journal.js"; +import { stateDirectory } from "./state.js"; interface Arguments { command: "list" | "export" | "plan" | "apply" | "help"; @@ -22,10 +23,10 @@ interface Arguments { function usage(): string { return `Usage: - mft-export list [--timeout ] - mft-export export [--device ] [--out ] [--timeout ] - mft-export plan --snapshot --set [--set ] [--out ] - mft-export apply --plan --yes [--device ] + mft-config list [--timeout ] + mft-config export [--device ] [--out ] [--timeout ] + mft-config plan --snapshot --set [--set ] [--out ] + mft-config apply --plan --yes [--device ] This tool only sends Universal Identity, global pull (0x02), encoder bulk-pull (0x04/0x01), and device-ID pull (0x05) messages. The plan command is offline. @@ -101,6 +102,9 @@ async function writeAtomically(path: string, data: string): Promise { } async function main(): Promise { + if (process.argv[1]?.endsWith("mft-export")) { + process.stderr.write("mft-export is deprecated; use mft-config instead.\n"); + } const args = parseArguments(process.argv.slice(2)); if (args.command === "help") { process.stdout.write(`${usage()}\n`); @@ -146,8 +150,8 @@ async function main(): Promise { if (args.command === "apply") { const plan = JSON.parse(await readFile(resolve(args.plan!), "utf8")) as PatchPlan; - const stateDirectory = resolve(".mft-state"); - const journalPath = resolve(stateDirectory, "journal.ndjson"); + const stateRoot = stateDirectory(); + const journalPath = resolve(stateRoot, "journal.ndjson"); await assertPlanNotConsumed(journalPath, plan.planId); const connection = backend.connectForApply(device); try { @@ -156,12 +160,12 @@ async function main(): Promise { timeoutMs: args.timeoutMs, saveBackup: async (snapshot) => { const stamp = new Date().toISOString().replaceAll(":", "-"); - const path = resolve(stateDirectory, "backups", `${stamp}.json`); + const path = resolve(stateRoot, "backups", `${stamp}.json`); await writeAtomically(path, `${JSON.stringify(snapshot, null, 2)}\n`); return path; }, }); - const postPath = resolve(stateDirectory, "last-verified.json"); + const postPath = resolve(stateRoot, "last-verified.json"); await writeAtomically(postPath, `${JSON.stringify(result.postSnapshot, null, 2)}\n`); process.stdout.write(`Applied and verified ${plan.changes.length} change(s).\nBackup: ${result.backupPath}\nVerified snapshot: ${postPath}\n`); } finally { @@ -186,6 +190,6 @@ async function main(): Promise { } main().catch((error: Error) => { - process.stderr.write(`mft-export: ${error.message}\n`); + process.stderr.write(`mft-config: ${error.message}\n`); process.exitCode = 1; }); diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..baa92f1 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,29 @@ +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +interface StateDirectoryOptions { + env?: NodeJS.ProcessEnv; + home?: string; + platform?: NodeJS.Platform; +} + +/** + * Keep backups and the single-use-plan journal stable regardless of the + * directory from which a globally installed command is invoked. + */ +export function stateDirectory(options: StateDirectoryOptions = {}): string { + const env = options.env ?? process.env; + const home = options.home ?? homedir(); + const platform = options.platform ?? process.platform; + const override = env.MFT_CONFIG_STATE_DIR; + if (override) { + if (!isAbsolute(override)) throw new Error("MFT_CONFIG_STATE_DIR must be an absolute path"); + return override; + } + if (env.XDG_STATE_HOME) { + if (!isAbsolute(env.XDG_STATE_HOME)) throw new Error("XDG_STATE_HOME must be an absolute path"); + return join(env.XDG_STATE_HOME, "mft-config"); + } + if (platform === "win32" && env.LOCALAPPDATA) return join(env.LOCALAPPDATA, "mft-config"); + return join(home, ".mft-config"); +} diff --git a/test/state.test.ts b/test/state.test.ts new file mode 100644 index 0000000..c0c7e4f --- /dev/null +++ b/test/state.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { stateDirectory } from "../src/state.js"; + +test("uses a stable per-user directory for journals and backups", () => { + assert.equal(stateDirectory({ env: {}, home: "/home/example", platform: "linux" }), "/home/example/.mft-config"); + assert.equal(stateDirectory({ env: { XDG_STATE_HOME: "/state" }, home: "/home/example", platform: "linux" }), "/state/mft-config"); + assert.equal(stateDirectory({ env: { LOCALAPPDATA: "C:\\State" }, home: "C:\\Users\\example", platform: "win32" }), "C:\\State/mft-config"); + assert.equal(stateDirectory({ env: { MFT_CONFIG_STATE_DIR: "/custom" }, home: "/home/example" }), "/custom"); + assert.throws(() => stateDirectory({ env: { MFT_CONFIG_STATE_DIR: "relative" }, home: "/home/example" }), /must be an absolute path/); +}); diff --git a/tsconfig.json b/tsconfig.json index f2b32c7..eadade5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,8 @@ "noUncheckedIndexedAccess": true, "outDir": "dist", "rootDir": "src", - "declaration": true, - "sourceMap": true, + "declaration": false, + "sourceMap": false, "skipLibCheck": true }, "include": ["src/**/*.ts"] From f512c2397576252a44b95fce33ea22a75c1f7078 Mon Sep 17 00:00:00 2001 From: oveddan Date: Mon, 3 Aug 2026 23:59:15 -0600 Subject: [PATCH 2/4] ci: avoid duplicate branch runs --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dd31e1..3772a47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ concurrency: on: push: + branches: + - main pull_request: permissions: From 28ed467b60ae396d46ff0ce110de88cea881e2f4 Mon Sep 17 00:00:00 2001 From: oveddan Date: Fri, 7 Aug 2026 10:32:31 -0600 Subject: [PATCH 3/4] feat: add read-only Twister configuration UI Add a local, dependency-free web UI (mft-config ui) for visually inspecting a connected MIDI Fighter Twister's complete configuration: banked 4x4 knob grid, persistent active/inactive/detent colors, rotary and push MIDI mappings, switch/movement/indicator/detent/super-knob settings, global settings, device identity and firmware details, compatibility warnings, and raw JSON export/download/offline-import. The UI reuses the existing read-only exporter and protocol decoder. The read-only boundary is enforced at the transport layer, not just in the browser: the server depends on RtMidiReadOnlyBackend, which exposes only discover/connect and cannot construct the separate write-capable connection; every outbound frame is also independently validated by assertReadOnlyRequest immediately before the native MIDI call, and the HTTP surface exposes no plan/apply/write/reset/system/bootloader route. Automated tests assert both layers, including that unknown /api/* mutation-shaped routes 404 without ever opening a MIDI connection. Co-Authored-By: Claude Sonnet 5 --- README.md | 27 ++++ docs/ui-architecture.md | 80 ++++++++++ package.json | 5 +- scripts/packed-artifact-smoke.mjs | 8 +- src/cli.ts | 15 +- src/exporter.ts | 29 ++-- src/midi.ts | 80 ++++++++-- src/ui-server.ts | 157 ++++++++++++++++++++ test/ui-server.test.ts | 120 +++++++++++++++ ui/app.js | 239 ++++++++++++++++++++++++++++++ ui/colors.js | 38 +++++ ui/index.html | 78 ++++++++++ ui/styles.css | 52 +++++++ 13 files changed, 894 insertions(+), 34 deletions(-) create mode 100644 docs/ui-architecture.md create mode 100644 src/ui-server.ts create mode 100644 test/ui-server.test.ts create mode 100644 ui/app.js create mode 100644 ui/colors.js create mode 100644 ui/index.html create mode 100644 ui/styles.css diff --git a/README.md b/README.md index 8f80573..9197120 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ MIDI channel, and MIDI number are independently configurable. It does not open MIDI ports. - `apply` writes an eligible plan, saves a backup first, and reads the entire controller back after every changed target to verify the result. +- `ui` opens a local, visual, strictly read-only inspector for a connected + controller or a previously exported JSON snapshot. Only `apply` can write configuration. System commands, reset commands, and bootloader commands are always blocked. Live writes currently require firmware @@ -69,6 +71,31 @@ Confirm the command is ready: mft-config --help ``` +## Visual read-only viewer + +Build and start the local viewer: + +```sh +mft-config ui +``` + +Open the printed `http://127.0.0.1:4783` URL. Discover a connected Twister, +select it, and read its configuration, or choose **Open JSON snapshot** to work +offline. The viewer provides: + +- a physical 4×4 knob layout for every detected bank, including eight-bank + firmware; +- persistent active, inactive, and detent colors with authoritative stored + indices; +- all decoded rotary, push, indicator, movement, detent, super-knob, global, + side-button, identity, firmware, warning, and raw-tag fields; +- exact JSON download and offline snapshot import. + +The UI has a hard read-only Node/MIDI boundary, not merely disabled controls. +Its server cannot construct an apply connection, exposes no mutation endpoint, +and the transport permits only identity and configuration-pull SysEx. See +[the UI architecture and development guide](docs/ui-architecture.md). + The former `mft-export` executable remains as a compatibility alias for now. New scripts and documentation should use `mft-config`; the alias may be removed in a future major release. diff --git a/docs/ui-architecture.md b/docs/ui-architecture.md new file mode 100644 index 0000000..449ced1 --- /dev/null +++ b/docs/ui-architecture.md @@ -0,0 +1,80 @@ +# Read-only UI architecture + +The visual configurator is a small local web application. `mft-config ui` +starts an HTTP server on `127.0.0.1:4783` and serves dependency-free HTML, CSS, +and browser JavaScript from `ui/`. The browser never opens MIDI ports. The Node +server reuses the same exporter and protocol decoder as `mft-config export`. + +## Hard read-only boundary + +The server imports `RtMidiReadOnlyBackend`, not `RtMidiBackend`. The read-only +backend has only `discover` and `connect`; it cannot create the separate +`ConfigurationWriteConnection` required by the applier. Its connection's only +send method runs every frame through `assertReadOnlyRequest` immediately before +the native MIDI output call. + +The local HTTP surface is intentionally tiny: + +- `GET /api/devices` sends Universal Identity Request and returns matching + Twister ports. +- `POST /api/export` accepts only `{ "deviceIndex": }` + and runs the complete read-only export. + +There are no plan, apply, write, reset, system, or bootloader routes. Unknown +`/api/*` paths return 404 without opening a MIDI connection. The transport guard +still blocks a mutating or unknown SysEx frame if one reaches it. Tests assert +both layers. + +The UI listens on loopback by default. `MFT_CONFIG_UI_HOST` can override the +host, but exposing a hardware-adjacent local service to a network is not +recommended. Responses use a same-origin Content Security Policy and do not +load remote assets. + +## Browser model + +`ui/app.js` is split into small state, API, validation, and rendering functions. +It validates imported snapshots before rendering. Imports stay entirely in the +browser, so offline inspection does not touch MIDI. Downloads serialize the +exact in-memory snapshot rather than reconstructing configuration fields. + +The grid always renders 16 encoders in physical left-to-right, top-to-bottom +order. Bank tabs are created from the snapshot, so both four- and eight-bank +exports use the same rendering path. Each knob persistently shows all three +stored 7-bit color indices: active, inactive, and detent. Active and inactive +use the selected Classic or MF64 RGB palette; the indicator-ring detent uses its +separate red/blue balance. CSS previews are a screen approximation; the numeric +index displayed in the knob detail is authoritative because hardware LED output +depends on brightness and diffusion. + +## Local development + +Install and run all checks: + +```sh +npm ci +npm run check +npm run test:package +``` + +Start the UI through the packaged command path: + +```sh +npm run build +node dist/cli.js ui +``` + +Or use the contributor shortcut, which builds first: + +```sh +npm run ui +``` + +Set a different loopback port when needed: + +```sh +MFT_CONFIG_UI_PORT=4784 npm run ui +``` + +Then open the printed URL. Exercise device discovery with hardware attached, +or use **Open JSON snapshot** to test every view offline. Server tests inject a +fake read-only backend and never require MIDI hardware. diff --git a/package.json b/package.json index 9979d86..a3a89aa 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "homepage": "https://github.com/oveddan/mft-api#readme", "files": [ "dist/**/*.js", + "ui/**/*", + "docs/ui-architecture.md", "docs/write-safety.md", "schema/*.json", "README.md" @@ -30,7 +32,8 @@ "check": "npm run build && npm test", "prepare": "npm run build", "prepack": "npm run clean && npm run build", - "test:package": "node scripts/packed-artifact-smoke.mjs" + "test:package": "node scripts/packed-artifact-smoke.mjs", + "ui": "npm run build && node dist/ui-server.js" }, "engines": { "node": ">=20" diff --git a/scripts/packed-artifact-smoke.mjs b/scripts/packed-artifact-smoke.mjs index 22dcecb..9ea2b09 100644 --- a/scripts/packed-artifact-smoke.mjs +++ b/scripts/packed-artifact-smoke.mjs @@ -40,14 +40,20 @@ try { "README.md", "dist/cli.js", "dist/applier.js", + "dist/ui-server.js", + "docs/ui-architecture.md", "docs/write-safety.md", "package.json", "schema/config-export.schema.json", "schema/patch-plan.schema.json", + "ui/index.html", + "ui/app.js", + "ui/colors.js", + "ui/styles.css", ]; for (const path of required) assert.ok(paths.includes(path), `packed artifact is missing ${path}`); for (const path of paths) { - assert.match(path, /^(README\.md|package\.json|(?:LICENSE|LICENCE|COPYING)(?:\..+)?|dist\/.+\.js|docs\/write-safety\.md|schema\/[^/]+\.json)$/); + assert.match(path, /^(README\.md|package\.json|(?:LICENSE|LICENCE|COPYING)(?:\..+)?|dist\/.+\.js|docs\/(?:write-safety|ui-architecture)\.md|schema\/[^/]+\.json|ui\/(?:index\.html|app\.js|colors\.js|styles\.css))$/); } assert.ok(!paths.some((path) => /(^|\/)(src|test|scripts|node_modules|\.mft-state)(\/|$)/.test(path))); diff --git a/src/cli.ts b/src/cli.ts index 10ac96d..21a5d97 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { assertPlanNotConsumed } from "./journal.js"; import { stateDirectory } from "./state.js"; interface Arguments { - command: "list" | "export" | "plan" | "apply" | "help"; + command: "list" | "export" | "plan" | "apply" | "ui" | "help"; device?: number; out?: string; timeoutMs: number; @@ -27,6 +27,7 @@ function usage(): string { mft-config export [--device ] [--out ] [--timeout ] mft-config plan --snapshot --set [--set ] [--out ] mft-config apply --plan --yes [--device ] + mft-config ui This tool only sends Universal Identity, global pull (0x02), encoder bulk-pull (0x04/0x01), and device-ID pull (0x05) messages. The plan command is offline. @@ -39,7 +40,7 @@ function parseArguments(argv: string[]): Arguments { if (command === "help" || command === "--help" || command === "-h") { return { command: "help", timeoutMs: 500, sets: [], yes: false }; } - if (command !== "list" && command !== "export" && command !== "plan" && command !== "apply") throw new Error(`Unknown command: ${command}`); + if (command !== "list" && command !== "export" && command !== "plan" && command !== "apply" && command !== "ui") throw new Error(`Unknown command: ${command}`); const result: Arguments = { command, timeoutMs: 500, sets: [], yes: false }; for (let index = 1; index < argv.length; index += 1) { @@ -124,6 +125,16 @@ async function main(): Promise { return; } + if (args.command === "ui") { + const { startUiServer } = await import("./ui-server.js"); + const host = process.env.MFT_CONFIG_UI_HOST ?? "127.0.0.1"; + const port = Number(process.env.MFT_CONFIG_UI_PORT ?? "4783"); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("MFT_CONFIG_UI_PORT must be an integer from 0 to 65535"); + const { url } = await startUiServer(host, port); + process.stdout.write(`MFT Config read-only UI: ${url}\n`); + return; + } + const { RtMidiBackend } = await import("./midi.js"); const backend = new RtMidiBackend(); const devices = await backend.discover(args.timeoutMs); diff --git a/src/exporter.ts b/src/exporter.ts index c74fbb3..003e626 100644 --- a/src/exporter.ts +++ b/src/exporter.ts @@ -29,9 +29,10 @@ function waitForMessage( ): Promise { return new Promise((resolve, reject) => { let unsubscribe = () => {}; + let parseError: Error | undefined; const timer = setTimeout(() => { unsubscribe(); - reject(new Error(`Timed out after ${timeoutMs} ms`)); + reject(parseError ?? new Error(`Timed out after ${timeoutMs} ms`)); }, timeoutMs); unsubscribe = connection.subscribe((message) => { try { @@ -40,8 +41,8 @@ function waitForMessage( clearTimeout(timer); unsubscribe(); resolve(result); - } catch { - // Ignore unrelated MIDI and malformed responses while waiting. + } catch (error) { + parseError = error as Error; } }); send(); @@ -66,13 +67,7 @@ async function pullGlobals(connection: MidiConnection, timeoutMs: number, retrie waitForMessage( connection, () => connection.send(pullGlobalsRequest()), - (message) => { - try { - return parseGlobalResponse(message); - } catch { - return undefined; - } - }, + (message) => message[4] === 0x02 ? parseGlobalResponse(message) : undefined, timeoutMs, ), retries, @@ -86,11 +81,8 @@ async function pullDeviceId(connection: MidiConnection, timeoutMs: number): Prom connection, () => connection.send(getDeviceIdRequest()), (message) => { - try { - return parseDeviceIdResponse(message); - } catch { - return undefined; - } + if (message[4] !== 0x05) return undefined; + return parseDeviceIdResponse(message); }, timeoutMs, ); @@ -110,9 +102,10 @@ async function pullEncoderData( new Promise((resolve, reject) => { const parts = new Map(); let expectedTotal: number | undefined; + let malformed: Error | undefined; const timer = setTimeout(() => { unsubscribe(); - reject(new Error(`Timed out after ${timeoutMs} ms`)); + reject(malformed ?? new Error(`Timed out after ${timeoutMs} ms`)); }, timeoutMs); const unsubscribe = connection.subscribe((message) => { try { @@ -125,8 +118,8 @@ async function pullEncoderData( clearTimeout(timer); unsubscribe(); resolve(assembleBulkParts([...parts.values()])); - } catch { - // Ignore unrelated MIDI traffic. + } catch (error) { + if (message[4] === 0x04) malformed = error as Error; } }); connection.send(pullEncoderRequest(tag)); diff --git a/src/midi.ts b/src/midi.ts index f5b979a..80c6405 100644 --- a/src/midi.ts +++ b/src/midi.ts @@ -22,7 +22,7 @@ export interface MidiBackend { connectForApply(device: DeviceDescriptor): ConfigurationWriteConnection; } -class RtMidiConnection implements MidiConnection { +class RtMidiPorts implements MidiConnection { private readonly input = new Input(); private readonly output = new Output(); private readonly handlers = new Set(); @@ -37,12 +37,6 @@ class RtMidiConnection implements MidiConnection { } send(message: ArrayLike): void { - assertReadOnlyRequest(message); - this.output.sendMessage(Array.from(message)); - } - - sendConfigurationWrite(message: ArrayLike): void { - assertConfigurationWrite(message); this.output.sendMessage(Array.from(message)); } @@ -58,6 +52,50 @@ class RtMidiConnection implements MidiConnection { } } +/** Guarded connection used by every read-only host surface. */ +export class ReadOnlyMidiConnection implements MidiConnection { + constructor(private readonly transport: MidiConnection) {} + + send(message: ArrayLike): void { + assertReadOnlyRequest(message); + this.transport.send(message); + } + + subscribe(handler: MessageHandler): () => void { + return this.transport.subscribe(handler); + } + + close(): void { + this.transport.close(); + } +} + +class RtMidiApplyConnection implements ConfigurationWriteConnection { + private readonly transport: MidiConnection; + + constructor(inputPort: number, outputPort: number) { + this.transport = new RtMidiPorts(inputPort, outputPort); + } + + send(message: ArrayLike): void { + assertReadOnlyRequest(message); + this.transport.send(message); + } + + sendConfigurationWrite(message: ArrayLike): void { + assertConfigurationWrite(message); + this.transport.send(message); + } + + subscribe(handler: MessageHandler): () => void { + return this.transport.subscribe(handler); + } + + close(): void { + this.transport.close(); + } +} + function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } @@ -66,8 +104,7 @@ function delay(milliseconds: number): Promise { * Probe every MIDI output while listening on every input. This does not rely on * port names, which vary across CoreMIDI, ALSA, and Windows MIDI services. */ -export class RtMidiBackend implements MidiBackend { - async discover(timeoutMs = 350): Promise { +async function discoverTwisters(timeoutMs = 350): Promise { const inputEnumerator = new Input(); const outputEnumerator = new Output(); const inputPorts = Array.from({ length: inputEnumerator.getPortCount() }, (_, index) => ({ @@ -120,15 +157,34 @@ export class RtMidiBackend implements MidiBackend { for (const device of discovered) { unique.set(`${device.inputPort.index}:${device.outputPort.index}`, device); } - return [...unique.values()]; + return [...unique.values()]; +} + +export class RtMidiBackend implements MidiBackend { + discover(timeoutMs = 350): Promise { + return discoverTwisters(timeoutMs); } connect(device: DeviceDescriptor): MidiConnection { - return new RtMidiConnection(device.inputPort.index, device.outputPort.index); + return new ReadOnlyMidiConnection(new RtMidiPorts(device.inputPort.index, device.outputPort.index)); } connectForApply(device: DeviceDescriptor): ConfigurationWriteConnection { - return new RtMidiConnection(device.inputPort.index, device.outputPort.index); + return new RtMidiApplyConnection(device.inputPort.index, device.outputPort.index); + } +} + +/** + * Backend exposed to read-only surfaces. Its public API cannot construct the + * separate apply connection, so a UI route cannot accidentally gain writes. + */ +export class RtMidiReadOnlyBackend implements Pick { + discover(timeoutMs: number): Promise { + return discoverTwisters(timeoutMs); + } + + connect(device: DeviceDescriptor): MidiConnection { + return new ReadOnlyMidiConnection(new RtMidiPorts(device.inputPort.index, device.outputPort.index)); } } diff --git a/src/ui-server.ts b/src/ui-server.ts new file mode 100644 index 0000000..3af410b --- /dev/null +++ b/src/ui-server.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +import { exportConfiguration } from "./exporter.js"; +import { RtMidiReadOnlyBackend, type MidiConnection } from "./midi.js"; +import type { DeviceDescriptor } from "./model.js"; + +export interface ReadOnlyBackend { + discover(timeoutMs: number): Promise; + connect(device: DeviceDescriptor): MidiConnection; +} + +export interface UiServerOptions { + backend?: ReadOnlyBackend; + timeoutMs?: number; +} + +const ASSETS = new Map([ + ["/", { file: "index.html", type: "text/html; charset=utf-8" }], + ["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }], + ["/colors.js", { file: "colors.js", type: "text/javascript; charset=utf-8" }], + ["/styles.css", { file: "styles.css", type: "text/css; charset=utf-8" }], +]); + +function json(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(JSON.stringify(body)); +} + +function classifyError(error: Error): { status: number; code: string; message: string } { + const message = error.message; + if (/permission|not permitted|access denied|operation not allowed/i.test(message)) { + return { status: 403, code: "MIDI_PERMISSION", message: `MIDI permission was denied: ${message}` }; + } + if (/no midi fighter|disconnected|does not exist|invalid port|not open/i.test(message)) { + return { status: 409, code: "DEVICE_DISCONNECTED", message: `The Twister is disconnected or unavailable: ${message}` }; + } + if (/malformed|invalid .*response|incomplete tag|expected .*response/i.test(message)) { + return { status: 502, code: "MALFORMED_RESPONSE", message: `The controller returned malformed data: ${message}` }; + } + if (/timed out|pull failed|empty configuration/i.test(message)) { + return { status: 504, code: "PARTIAL_READ", message: `The configuration read did not complete: ${message}` }; + } + return { status: 500, code: "MIDI_ERROR", message }; +} + +async function readJson(request: IncomingMessage): Promise> { + const type = request.headers["content-type"] ?? ""; + if (!type.toLowerCase().startsWith("application/json")) throw new Error("Expected application/json request body"); + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.from(chunk); + size += buffer.length; + if (size > 32 * 1024) throw new Error("Request body is too large"); + chunks.push(buffer); + } + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Expected a JSON object"); + return parsed as Record; +} + +function publicDevice(device: DeviceDescriptor, index: number): unknown { + return { + index, + inputPort: device.inputPort.name, + outputPort: device.outputPort.name, + familyId: device.identity.familyId, + modelId: device.identity.modelId, + firmwareDate: device.identity.firmwareDate, + }; +} + +async function serveAsset(pathname: string, response: ServerResponse): Promise { + const asset = ASSETS.get(pathname); + if (!asset) return false; + const path = new URL(`../ui/${asset.file}`, import.meta.url); + const data = await readFile(path); + response.writeHead(200, { + "content-type": asset.type, + "cache-control": "no-cache", + "content-security-policy": "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + "x-content-type-options": "nosniff", + }); + response.end(data); + return true; +} + +export function createUiServer(options: UiServerOptions = {}): Server { + const backend = options.backend ?? new RtMidiReadOnlyBackend(); + const timeoutMs = options.timeoutMs ?? 750; + + return createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/api/devices") { + if (request.method !== "GET") return json(response, 405, { error: { code: "METHOD_NOT_ALLOWED", message: "Only GET is allowed" } }); + const devices = await backend.discover(timeoutMs); + return json(response, 200, { devices: devices.map(publicDevice) }); + } + if (url.pathname === "/api/export") { + if (request.method !== "POST") return json(response, 405, { error: { code: "METHOD_NOT_ALLOWED", message: "Only POST is allowed" } }); + const body = await readJson(request); + const deviceIndex = body.deviceIndex; + if (!Number.isInteger(deviceIndex) || (deviceIndex as number) < 0 || Object.keys(body).some((key) => key !== "deviceIndex")) { + return json(response, 400, { error: { code: "INVALID_REQUEST", message: "deviceIndex must be the only field and a non-negative integer" } }); + } + const devices = await backend.discover(timeoutMs); + const device = devices[deviceIndex as number]; + if (!device) return json(response, 409, { error: { code: "DEVICE_DISCONNECTED", message: "The selected Twister is no longer connected" } }); + const connection = backend.connect(device); + try { + const snapshot = await exportConfiguration(connection, device, { timeoutMs }); + return json(response, 200, { snapshot }); + } finally { + connection.close(); + } + } + if (url.pathname.startsWith("/api/")) return json(response, 404, { error: { code: "NOT_FOUND", message: "No such read-only API endpoint" } }); + if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { error: { code: "METHOD_NOT_ALLOWED", message: "Only GET and HEAD are allowed" } }); + if (await serveAsset(url.pathname, response)) return; + json(response, 404, { error: { code: "NOT_FOUND", message: "Not found" } }); + } catch (caught) { + const error = caught instanceof Error ? caught : new Error(String(caught)); + if (error instanceof SyntaxError || /Expected application|Expected a JSON|too large/.test(error.message)) { + return json(response, 400, { error: { code: "INVALID_REQUEST", message: error.message } }); + } + const classified = classifyError(error); + json(response, classified.status, { error: { code: classified.code, message: classified.message } }); + } + }); +} + +export async function startUiServer(host = "127.0.0.1", port = 0): Promise<{ server: Server; url: string }> { + const server = createUiServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not determine UI server address"); + return { server, url: `http://${host}:${address.port}` }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const host = process.env.MFT_CONFIG_UI_HOST ?? "127.0.0.1"; + const port = Number(process.env.MFT_CONFIG_UI_PORT ?? "4783"); + startUiServer(host, port) + .then(({ url }) => process.stdout.write(`MFT Config read-only UI: ${url}\n`)) + .catch((error: Error) => { + process.stderr.write(`mft-config ui: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/test/ui-server.test.ts b/test/ui-server.test.ts new file mode 100644 index 0000000..974ed55 --- /dev/null +++ b/test/ui-server.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ReadOnlyMidiConnection, RtMidiReadOnlyBackend, type MessageHandler, type MidiConnection } from "../src/midi.js"; +import type { DeviceDescriptor } from "../src/model.js"; +import { assertReadOnlyRequest } from "../src/protocol.js"; +import { createUiServer, type ReadOnlyBackend } from "../src/ui-server.js"; + +const GLOBALS = [0, 4, 1, 1, 2, 0, 3, 9, 4, 0, 5, 0, 6, 8, 7, 0, 8, 63, 9, 127, 31, 127, 32, 100, 33, 1, 34, 6, 35, 3, 36, 7, 37, 1, 38, 1]; +const ENCODER = [10, 1, 11, 0, 12, 1, 13, 2, 14, 12, 15, 0, 16, 1, 17, 34, 18, 1, 19, 25, 20, 5, 21, 63, 22, 2, 23, 0, 24, 5]; + +const device: DeviceDescriptor = { + inputPort: { index: 0, name: "Twister In" }, outputPort: { index: 1, name: "Twister Out" }, + identity: { manufacturerId: [0, 1, 121], familyId: 5, modelId: 1, firmwareBytes: [32, 38, 7, 2], firmwareDate: "2026-07-02" }, +}; + +class FakeReadOnlyConnection implements MidiConnection { + readonly sent: number[][] = []; + closed = false; + private readonly handlers = new Set(); + + send(message: ArrayLike): void { + assertReadOnlyRequest(message); + const bytes = Array.from(message); this.sent.push(bytes); + if (bytes[4] === 5) this.emit([0xf0, 0, 1, 0x79, 5, 1, 1, 2, 3, 4, 5, 6, 7, 8, 0xf7]); + if (bytes[4] === 2) this.emit([0xf0, 0, 1, 0x79, 2, 1, ...GLOBALS, 0xf7]); + if (bytes[4] === 4) { + const tag = bytes[6]!; + if (tag === 65) return this.emit([0xf0, 0, 1, 0x79, 4, 0, 65, 1, 1, 0, 0xf7]); + this.emit([0xf0, 0, 1, 0x79, 4, 0, tag, 1, 2, 24, ...ENCODER.slice(0, 24), 0xf7]); + this.emit([0xf0, 0, 1, 0x79, 4, 0, tag, 2, 2, 6, ...ENCODER.slice(24), 0xf7]); + } + } + subscribe(handler: MessageHandler): () => void { this.handlers.add(handler); return () => this.handlers.delete(handler); } + close(): void { this.closed = true; } + private emit(message: number[]): void { for (const handler of [...this.handlers]) handler(message); } +} + +async function withServer(backend: ReadOnlyBackend, run: (base: string) => Promise): Promise { + const server = createUiServer({ backend, timeoutMs: 100 }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address && typeof address !== "string"); + try { await run(`http://127.0.0.1:${address.port}`); } + finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } +} + +test("read-only UI backend has no apply connection constructor", () => { + const backend = new RtMidiReadOnlyBackend(); + assert.equal("connectForApply" in backend, false); + assert.deepEqual(Object.getOwnPropertyNames(RtMidiReadOnlyBackend.prototype).sort(), ["connect", "constructor", "discover"]); +}); + +test("read-only transport blocks mutating SysEx before it reaches the MIDI output", () => { + const delivered: number[][] = []; + const transport: MidiConnection = { + send: (message) => delivered.push(Array.from(message)), + subscribe: () => () => undefined, + close: () => undefined, + }; + const connection = new ReadOnlyMidiConnection(transport); + connection.send([0xf0, 0, 1, 0x79, 2, 0, 0xf7]); + assert.equal(delivered.length, 1); + for (const mutating of [ + [0xf0, 0, 1, 0x79, 1, 0, 4, 0xf7], + [0xf0, 0, 1, 0x79, 4, 0, 1, 1, 1, 2, 19, 43, 0xf7], + [0xf0, 0, 1, 0x79, 3, 2, 0xf7], + ]) assert.throws(() => connection.send(mutating), /Blocked mutating/); + assert.equal(delivered.length, 1); +}); + +test("UI API exports a complete snapshot using only allowlisted read requests", async () => { + const connection = new FakeReadOnlyConnection(); + const backend: ReadOnlyBackend = { discover: async () => [device], connect: () => connection }; + await withServer(backend, async (base) => { + const devices = await fetch(`${base}/api/devices`).then((response) => response.json()) as { devices: unknown[] }; + assert.equal(devices.devices.length, 1); + const response = await fetch(`${base}/api/export`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceIndex: 0 }) }); + assert.equal(response.status, 200); + const body = await response.json() as { snapshot: { banks: unknown[]; globals: { rawTags: Record } } }; + assert.equal(body.snapshot.banks.length, 4); + assert.equal(body.snapshot.globals.rawTags["38"], 1); + assert(connection.sent.length > 60); + for (const frame of connection.sent) assert.doesNotThrow(() => assertReadOnlyRequest(frame)); + assert.equal(connection.closed, true); + }); +}); + +test("UI server has no mutation route and never opens a connection for one", async () => { + let connections = 0; + const backend: ReadOnlyBackend = { discover: async () => [device], connect: () => { connections += 1; return new FakeReadOnlyConnection(); } }; + await withServer(backend, async (base) => { + for (const path of ["apply", "plan", "write", "reset", "bootloader", "system"]) { + const response = await fetch(`${base}/api/${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); + assert.equal(response.status, 404); + assert.equal((await response.json() as { error: { code: string } }).error.code, "NOT_FOUND"); + } + }); + assert.equal(connections, 0); +}); + +test("UI server reports permission, malformed, partial, and disconnected reads clearly", async () => { + for (const [message, status, code] of [ + ["MIDI permission denied", 403, "MIDI_PERMISSION"], + ["Malformed response", 502, "MALFORMED_RESPONSE"], + ["Global configuration pull failed after 2 attempts", 504, "PARTIAL_READ"], + ] as const) { + const backend: ReadOnlyBackend = { discover: async () => { throw new Error(message); }, connect: () => new FakeReadOnlyConnection() }; + await withServer(backend, async (base) => { + const response = await fetch(`${base}/api/devices`); assert.equal(response.status, status); + assert.equal((await response.json() as { error: { code: string } }).error.code, code); + }); + } + const backend: ReadOnlyBackend = { discover: async () => [], connect: () => new FakeReadOnlyConnection() }; + await withServer(backend, async (base) => { + const response = await fetch(`${base}/api/export`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceIndex: 0 }) }); + assert.equal(response.status, 409); + assert.equal((await response.json() as { error: { code: string } }).error.code, "DEVICE_DISCONNECTED"); + }); +}); diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..1cd66f1 --- /dev/null +++ b/ui/app.js @@ -0,0 +1,239 @@ +import { colorCss, colorLabel, detentCss, detentLabel } from "./colors.js"; + +const byId = (id) => document.getElementById(id); +const elements = Object.fromEntries([ + "device-select", "discover-button", "read-button", "import-button", "import-input", "download-button", "status", + "empty-state", "viewer", "capture-source", "identity-fields", "warnings", "bank-tabs", "knob-grid", "encoder-detail", + "global-fields", "side-buttons", "global-raw", "raw-json", +].map((id) => [id, byId(id)])); + +const state = { devices: [], snapshot: null, bank: 1, encoder: 1, source: "" }; + +function assertSnapshot(value) { + if (!value || typeof value !== "object") throw new Error("Snapshot must be a JSON object."); + if (value.schemaVersion !== "djtt.mft.config-export.v1") throw new Error("Unsupported snapshot schema. Expected djtt.mft.config-export.v1."); + const bankCount = value.capabilities?.bankCount; + if (bankCount !== 4 && bankCount !== 8) throw new Error("Snapshot bankCount must be 4 or 8."); + if (!Array.isArray(value.banks) || value.banks.length !== bankCount) throw new Error(`Snapshot must contain exactly ${bankCount} banks.`); + const bankNumbers = new Set(); + for (const bank of value.banks) { + if (!Number.isInteger(bank.number) || bank.number < 1 || bank.number > bankCount || bankNumbers.has(bank.number)) throw new Error("Snapshot contains an invalid or duplicate bank number."); + bankNumbers.add(bank.number); + if (!Array.isArray(bank.encoders) || bank.encoders.length !== 16) throw new Error(`Bank ${bank.number ?? "?"} must contain 16 encoders.`); + const encoderNumbers = new Set(); + for (const encoder of bank.encoders) { + if (!Number.isInteger(encoder.number) || encoder.number < 1 || encoder.number > 16 || encoderNumbers.has(encoder.number)) throw new Error(`Bank ${bank.number} contains an invalid or duplicate encoder number.`); + encoderNumbers.add(encoder.number); + if (!encoder.rawTags || !encoder.colors || !encoder.detent || !encoder.switch || !encoder.encoder) throw new Error(`Bank ${bank.number}, encoder ${encoder.number} is incomplete.`); + } + } + if (!value.device?.firmware?.date || !value.globals?.rawTags || !Array.isArray(value.globals.sideButtons) || !Array.isArray(value.warnings)) throw new Error("Snapshot is missing device, global, side-button, or warning fields."); + return value; +} + +function setStatus(message, kind = "info") { + elements.status.textContent = message; + elements.status.dataset.kind = kind; +} + +async function api(path, options) { + const response = await fetch(path, options); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error?.message ?? `Request failed (${response.status})`); + return body; +} + +function field(label, value) { + const dt = document.createElement("dt"); + dt.textContent = label; + const dd = document.createElement("dd"); + dd.textContent = String(value ?? "—"); + return [dt, dd]; +} + +function renderFields(container, entries) { + container.replaceChildren(...entries.flatMap(([label, value]) => field(label, value))); +} + +function named(value) { + return value ? `${value.name} (${value.code})` : "—"; +} + +function paletteName() { + return state.snapshot.globals.colorMap.name === "mf64" ? "mf64" : "classic"; +} + +function renderIdentity() { + const snapshot = state.snapshot; + elements["capture-source"].textContent = `${state.source} · captured ${new Date(snapshot.capturedAt).toLocaleString()}`; + renderFields(elements["identity-fields"], [ + ["Manufacturer", snapshot.device.manufacturer], ["Firmware", snapshot.device.firmware.date], + ["Family / model", `${snapshot.device.familyId} / ${snapshot.device.modelId}`], ["Banks", snapshot.capabilities.bankCount], + ["Unit ID", snapshot.device.unitId ? `${snapshot.device.unitId.hex} (${snapshot.device.unitId.source})` : "Unavailable"], + ["MIDI input", snapshot.device.midiPorts.input], ["MIDI output", snapshot.device.midiPorts.output], + ["Identity bytes", snapshot.device.firmware.identityBytes.join(" ")], + ]); + elements.warnings.replaceChildren(); + if (snapshot.warnings.length) { + const box = document.createElement("div"); box.className = "warning"; + const title = document.createElement("strong"); title.textContent = `${snapshot.warnings.length} compatibility warning${snapshot.warnings.length === 1 ? "" : "s"}`; + const list = document.createElement("ul"); + for (const warning of snapshot.warnings) { const item = document.createElement("li"); item.textContent = warning; list.append(item); } + box.append(title, list); elements.warnings.append(box); + } +} + +function renderBanks() { + elements["bank-tabs"].replaceChildren(); + for (const bank of state.snapshot.banks) { + const button = document.createElement("button"); + button.textContent = `Bank ${bank.number}`; button.type = "button"; button.role = "tab"; + button.id = `bank-tab-${bank.number}`; button.setAttribute("aria-controls", "knob-grid"); + button.ariaSelected = String(bank.number === state.bank); button.dataset.active = String(bank.number === state.bank); + button.tabIndex = bank.number === state.bank ? 0 : -1; + button.addEventListener("click", () => selectBank(bank.number)); + button.addEventListener("keydown", (event) => { + const numbers = state.snapshot.banks.map((candidate) => candidate.number); + const current = numbers.indexOf(bank.number); + let next; + if (event.key === "ArrowRight") next = numbers[(current + 1) % numbers.length]; + if (event.key === "ArrowLeft") next = numbers[(current - 1 + numbers.length) % numbers.length]; + if (event.key === "Home") next = numbers[0]; + if (event.key === "End") next = numbers.at(-1); + if (next !== undefined) { event.preventDefault(); selectBank(next, true); } + }); + elements["bank-tabs"].append(button); + } + elements["knob-grid"].role = "tabpanel"; + elements["knob-grid"].setAttribute("aria-labelledby", `bank-tab-${state.bank}`); +} + +function selectBank(number, focus = false) { + state.bank = number; state.encoder = 1; renderBanks(); renderGrid(); + if (focus) byId(`bank-tab-${number}`).focus(); +} + +function currentEncoder() { + return state.snapshot.banks.find((bank) => bank.number === state.bank)?.encoders.find((encoder) => encoder.number === state.encoder); +} + +function renderGrid() { + const bank = state.snapshot.banks.find((candidate) => candidate.number === state.bank); + elements["knob-grid"].replaceChildren(); + for (const encoder of bank.encoders) { + const button = document.createElement("button"); button.type = "button"; button.className = "knob"; + button.dataset.selected = String(encoder.number === state.encoder); + const palette = paletteName(); + button.style.setProperty("--active", colorCss(encoder.colors.active, palette)); + button.style.setProperty("--inactive", colorCss(encoder.colors.inactive, palette)); + button.style.setProperty("--detent", detentCss(encoder.detent.color)); + button.setAttribute("aria-label", `Encoder ${encoder.number}. Active ${colorLabel(encoder.colors.active, palette)}, inactive ${colorLabel(encoder.colors.inactive, palette)}, detent ${detentLabel(encoder.detent.color)}`); + const ring = document.createElement("span"); ring.className = "knob-ring"; ring.ariaHidden = "true"; + const center = document.createElement("span"); center.className = "knob-center"; center.ariaHidden = "true"; + const number = document.createElement("span"); number.className = "knob-number"; number.textContent = String(encoder.number); + ring.append(center); button.append(ring, number); + button.addEventListener("click", () => { state.encoder = encoder.number; renderGrid(); }); + elements["knob-grid"].append(button); + } + renderEncoderDetail(currentEncoder()); +} + +function colorRow(label, index, detent = false) { + const row = document.createElement("div"); row.className = "color-row"; + const swatch = document.createElement("i"); swatch.style.background = detent ? detentCss(index) : colorCss(index, paletteName()); + const text = document.createElement("span"); text.textContent = `${label}: ${detent ? detentLabel(index) : colorLabel(index, paletteName())}`; + row.append(swatch, text); return row; +} + +function renderEncoderDetail(encoder) { + const detail = elements["encoder-detail"]; + detail.replaceChildren(); + const title = document.createElement("h3"); title.textContent = `Bank ${state.bank} · Encoder ${encoder.number}`; + const colors = document.createElement("div"); colors.className = "color-list"; + colors.append(colorRow("Active", encoder.colors.active), colorRow("Inactive", encoder.colors.inactive), colorRow("Detent", encoder.detent.color, true)); + const fields = document.createElement("dl"); fields.className = "detail-fields"; + renderFields(fields, [ + ["Rotary MIDI", `Ch ${encoder.encoder.midiChannel} · #${encoder.encoder.midiNumber}`], + ["Shifted channel", encoder.encoder.shiftedMidiChannel], ["Rotary type", named(encoder.encoder.type)], + ["Movement", named(encoder.movement)], ["Push MIDI", `Ch ${encoder.switch.midiChannel} · #${encoder.switch.midiNumber}`], + ["Push action", named(encoder.switch.action)], ["Indicator", named(encoder.indicator)], + ["Detent enabled", encoder.detent.enabled ? "Yes" : "No"], ["Super knob", encoder.superKnobEnabled ? "Enabled" : "Disabled"], + ]); + const raw = document.createElement("details"); const summary = document.createElement("summary"); summary.textContent = "Raw encoder tags"; + const pre = document.createElement("pre"); pre.textContent = JSON.stringify(encoder.rawTags, null, 2); raw.append(summary, pre); + detail.append(title, colors, fields, raw); +} + +function renderGlobals() { + const globals = state.snapshot.globals; + renderFields(elements["global-fields"], [ + ["MIDI channel", globals.midiChannel], ["Side buttons banked", globals.sideButtonsBanked ? "Yes" : "No"], + ["Super knob range", `${globals.superKnob.start}–${globals.superKnob.end}`], ["RGB brightness", globals.brightness.rgb], + ["Indicator brightness", globals.brightness.indicator], ["Color map", named(globals.colorMap)], + ["Encoder animation channel", globals.animationChannels.encoder], ["Switch animation channel", globals.animationChannels.switch], + ["Sleep timeout", globals.sleep.timeoutMinutes === null ? `Unknown (${globals.sleep.timeoutIndex})` : `${globals.sleep.timeoutMinutes} min (index ${globals.sleep.timeoutIndex})`], + ["Sleep animation", named(globals.sleep.animation)], ["Bank animations", globals.bankAnimationsEnabled ? "Enabled" : "Disabled"], + ]); + elements["side-buttons"].replaceChildren(...globals.sideButtons.map((button) => { + const item = document.createElement("div"); item.className = "side-button"; + const number = document.createElement("strong"); number.textContent = String(button.number); + const action = document.createElement("span"); action.textContent = named(button.action); item.append(number, action); return item; + })); + elements["global-raw"].textContent = JSON.stringify(globals.rawTags, null, 2); +} + +function renderSnapshot(snapshot, source) { + state.snapshot = assertSnapshot(snapshot); state.bank = snapshot.banks[0].number; state.encoder = 1; state.source = source; + elements["empty-state"].hidden = true; elements.viewer.hidden = false; elements["download-button"].disabled = false; + renderIdentity(); renderBanks(); renderGrid(); renderGlobals(); + elements["raw-json"].textContent = JSON.stringify(snapshot, null, 2); +} + +async function discover() { + setStatus("Discovering MIDI devices…"); elements["discover-button"].disabled = true; elements["read-button"].disabled = true; + try { + const { devices } = await api("/api/devices"); state.devices = devices; + elements["device-select"].replaceChildren(); + if (!devices.length) { + const option = new Option("No MIDI Fighter Twister found", ""); elements["device-select"].append(option); + setStatus("No Twister responded. Check USB power, cable, MIDI permissions, and that no other app owns the port.", "error"); + } else { + for (const device of devices) elements["device-select"].append(new Option(`${device.outputPort} · firmware ${device.firmwareDate}`, String(device.index))); + elements["read-button"].disabled = false; setStatus(`Found ${devices.length} Twister${devices.length === 1 ? "" : "s"}.`, "success"); + } + } catch (error) { setStatus(error.message, "error"); } + finally { elements["discover-button"].disabled = false; } +} + +async function readDevice() { + const deviceIndex = Number(elements["device-select"].value); + if (!Number.isInteger(deviceIndex)) return; + setStatus("Reading globals and every encoder. This can take a moment…"); elements["read-button"].disabled = true; + try { + const { snapshot } = await api("/api/export", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceIndex }) }); + renderSnapshot(snapshot, "Live controller"); setStatus(`Read all ${snapshot.capabilities.bankCount * 16} encoder records successfully.`, "success"); + } catch (error) { setStatus(error.message, "error"); } + finally { elements["read-button"].disabled = state.devices.length === 0; } +} + +async function importSnapshot(file) { + try { + const snapshot = JSON.parse(await file.text()); renderSnapshot(snapshot, `Offline snapshot · ${file.name}`); + setStatus(`Opened ${file.name} without accessing MIDI hardware.`, "success"); + } catch (error) { setStatus(`Could not open snapshot: ${error.message}`, "error"); } + finally { elements["import-input"].value = ""; } +} + +function downloadSnapshot() { + const blob = new Blob([`${JSON.stringify(state.snapshot, null, 2)}\n`], { type: "application/json" }); + const link = document.createElement("a"); link.href = URL.createObjectURL(blob); + const id = state.snapshot.device.unitId?.hex ?? "twister"; link.download = `mft-${id}-${state.snapshot.capturedAt.slice(0, 10)}.json`; + link.click(); setTimeout(() => URL.revokeObjectURL(link.href), 0); setStatus(`Downloaded ${link.download}.`, "success"); +} + +elements["discover-button"].addEventListener("click", discover); +elements["read-button"].addEventListener("click", readDevice); +elements["device-select"].addEventListener("change", () => { elements["read-button"].disabled = elements["device-select"].value === ""; }); +elements["import-button"].addEventListener("click", () => elements["import-input"].click()); +elements["import-input"].addEventListener("change", () => { const file = elements["import-input"].files[0]; if (file) importSnapshot(file); }); +elements["download-button"].addEventListener("click", downloadSnapshot); diff --git a/ui/colors.js b/ui/colors.js new file mode 100644 index 0000000..35aeaf0 --- /dev/null +++ b/ui/colors.js @@ -0,0 +1,38 @@ +const MF64_HUES = [0, 0, 16, 60, 140, 120, 114, 96, 78, 42, 22, 0, 334, 300, 320]; + +function clampIndex(index) { + return Number.isInteger(index) && index >= 0 && index <= 127 ? index : 0; +} + +/** CSS preview of the persistent 7-bit hardware color index. */ +export function colorCss(index, palette) { + const value = clampIndex(index); + if (value === 0) return "#090b10"; + if (palette === "mf64") { + if (value <= 3) return `hsl(0 0% ${[0, 15, 50, 100][value]}%)`; + if (value < 60) { + const group = Math.floor(value / 4); + const shade = value % 4; + return `hsl(${MF64_HUES[group] ?? 0} 100% ${[74, 50, 28, 12][shade]}%)`; + } + return `hsl(${(value * 137.508) % 360} 72% ${38 + ((value * 17) % 35)}%)`; + } + if (value === 127) return "#ffffff"; + const hue = (240 - ((value - 1) * 360) / 126 + 360) % 360; + return `hsl(${hue} 100% 50%)`; +} + +export function colorLabel(index, palette) { + return `${palette === "mf64" ? "MF64" : "Classic"} ${clampIndex(index)}`; +} + +/** The indicator-ring detent is a red/blue balance, not an RGB palette entry. */ +export function detentCss(index) { + const value = clampIndex(index); + const blue = Math.round((value / 127) * 255); + return `rgb(${255 - blue} 0 ${blue})`; +} + +export function detentLabel(index) { + return `Red/blue ${clampIndex(index)}`; +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..6d19067 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,78 @@ + + + + + + + MFT Config Viewer + + + +
+
+

MIDI Fighter Twister

+

Config viewer

+
+ Strictly read-only +
+ +
+
+
+

01

Choose a source

+

Read a connected controller or inspect an export offline.

+
+
+ + + + + + +
+

Discovery sends only the universal MIDI identity request.

+
Ready. No controller has been read.
+
+ +
+ +

Your configuration will appear here

The full 4×4 layout, every bank, raw protocol tags, and device identity stay visible without changing the controller.

+
+ + +
+
This local viewer has no plan, apply, reset, system, or bootloader route.
+ + + diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 0000000..ddec90b --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,52 @@ +:root { color-scheme: dark; --bg: #090b10; --panel: #11151d; --line: #29303c; --muted: #9da8b9; --text: #f5f7fa; --acid: #b8ff3d; --cyan: #3de6ff; --error: #ff706e; font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; background: radial-gradient(circle at 80% -10%, #192538 0, transparent 38rem), var(--bg); color: var(--text); } +button, select { font: inherit; } +button { border: 1px solid var(--acid); border-radius: .45rem; background: var(--acid); color: #10140a; font-weight: 750; padding: .72rem 1rem; cursor: pointer; } +button:hover:not(:disabled) { filter: brightness(1.12); } +button:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--cyan); outline-offset: 3px; } +button:disabled { cursor: not-allowed; opacity: .4; } +button.secondary { background: transparent; color: var(--text); border-color: #4a5668; } +.masthead { max-width: 1400px; margin: auto; padding: 2.5rem clamp(1rem, 4vw, 4rem) 1rem; display: flex; justify-content: space-between; align-items: end; gap: 1rem; } +.eyebrow, .step { margin: 0; color: var(--acid); font: 700 .72rem/1.2 ui-monospace, monospace; letter-spacing: .15em; text-transform: uppercase; } +h1 { margin: .2rem 0 0; font-size: clamp(2.2rem, 6vw, 5rem); letter-spacing: -.07em; line-height: .95; } +h2 { margin: .2rem 0 0; font-size: 1.45rem; } h3 { margin-top: 1.5rem; } +.safety { color: var(--acid); border: 1px solid #49681f; border-radius: 999px; padding: .5rem .75rem; white-space: nowrap; font-size: .82rem; } +main { max-width: 1400px; margin: auto; padding: 1rem clamp(1rem, 4vw, 4rem) 4rem; } +.panel { background: color-mix(in srgb, var(--panel) 94%, transparent); border: 1px solid var(--line); border-radius: .8rem; padding: clamp(1rem, 2.5vw, 2rem); margin-bottom: 1rem; box-shadow: 0 24px 80px #0005; } +.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 2rem; margin-bottom: 1.4rem; } +.section-heading > p { color: var(--muted); margin: 0; max-width: 35rem; text-align: right; } +.controls { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; } +label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; min-width: min(100%, 22rem); } +select { min-height: 2.85rem; background: #090c12; color: var(--text); border: 1px solid #4a5668; border-radius: .45rem; padding: .65rem .8rem; } +.hint { color: var(--muted); font-size: .78rem; margin: .7rem 0 0; } +.status { border-left: 3px solid var(--cyan); margin-top: 1rem; padding: .7rem .9rem; background: #0b1119; color: #cfd7e4; } +.status[data-kind="success"] { border-color: var(--acid); } .status[data-kind="error"] { border-color: var(--error); color: #ffc4c3; } +.empty { min-height: 18rem; display: flex; align-items: center; justify-content: center; gap: 2rem; color: var(--muted); } +.empty h2 { color: var(--text); }.empty p { max-width: 42rem; line-height: 1.6; } +.empty-mark { width: 7rem; height: 7rem; border: 1px solid #455063; border-radius: 50%; display: grid; place-items: center; color: var(--acid); font: 800 2rem ui-monospace, monospace; box-shadow: inset 0 0 0 10px #090c12, inset 0 0 0 12px #26303d; } +.fact-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); } +.fact-grid dt, .fact-grid dd { margin: 0; background: #0d1118; padding: .7rem .8rem; } +.fact-grid dt { color: var(--muted); font-size: .75rem; }.fact-grid dd { font-family: ui-monospace, monospace; overflow-wrap: anywhere; } +.warning { margin-top: 1rem; border: 1px solid #654924; background: #231a0e; padding: 1rem; color: #ffd993; }.warning ul { margin-bottom: 0; } +.bank-tabs { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; } +.bank-tabs button { background: transparent; color: var(--muted); border-color: #455063; padding: .55rem .9rem; } +.bank-tabs button[data-active="true"] { color: #0d1206; background: var(--acid); border-color: var(--acid); } +.legend { display: flex; gap: 1rem; color: var(--muted); font-size: .76rem; margin-bottom: 1rem; }.legend span { display: flex; align-items: center; gap: .35rem; }.legend i { width: .65rem; height: .65rem; border-radius: 50%; }.active-dot { background: var(--acid); }.inactive-dot { background: #738095; }.detent-dot { background: var(--cyan); } +.controller-layout { display: grid; grid-template-columns: minmax(27rem, 1.15fr) minmax(20rem, .85fr); gap: 2rem; align-items: start; } +.knob-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(.6rem, 2vw, 1.5rem); padding: clamp(.7rem, 2vw, 1.5rem); background: linear-gradient(145deg, #1c222c, #090b0f); border: 1px solid #343d49; border-radius: .75rem; } +.knob { aspect-ratio: 1; min-width: 0; background: #0a0d12; border: 1px solid #303946; border-radius: .7rem; padding: .4rem; display: grid; place-items: center; position: relative; color: var(--text); } +.knob[data-selected="true"] { border-color: var(--acid); box-shadow: 0 0 0 2px #b8ff3d55; } +.knob-ring { width: 74%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--active) 0 46%, var(--detent) 46% 52%, var(--inactive) 52% 100%); box-shadow: 0 0 18px color-mix(in srgb, var(--active) 38%, transparent); } +.knob-center { width: 64%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #4b5360, #11151b 58%, #050608); box-shadow: inset 0 1px 2px #fff4; } +.knob-number { position: absolute; bottom: .3rem; right: .45rem; color: var(--muted); font: 700 .68rem ui-monospace, monospace; } +.detail { border-left: 1px solid var(--line); padding-left: 1.5rem; min-width: 0; }.detail h3 { margin-top: 0; } +.color-list { display: grid; gap: .45rem; }.color-row { display: flex; gap: .6rem; align-items: center; color: var(--muted); font: .82rem ui-monospace, monospace; }.color-row i { width: 1.1rem; height: 1.1rem; border-radius: 50%; border: 1px solid #fff5; } +.detail-fields { display: grid; grid-template-columns: 1fr 1fr; margin: 1rem 0; border-top: 1px solid var(--line); }.detail-fields dt, .detail-fields dd { margin: 0; padding: .48rem 0; border-bottom: 1px solid var(--line); }.detail-fields dt { color: var(--muted); }.detail-fields dd { text-align: right; font-family: ui-monospace, monospace; overflow-wrap: anywhere; } +.side-buttons { display: grid; grid-template-columns: repeat(6, 1fr); gap: .6rem; }.side-button { border: 1px solid var(--line); border-radius: .4rem; padding: .8rem; display: grid; gap: .5rem; }.side-button strong { color: var(--acid); }.side-button span { color: var(--muted); font: .75rem ui-monospace, monospace; } +details { margin-top: 1rem; } summary { cursor: pointer; color: var(--cyan); font-weight: 700; } pre { max-height: 32rem; overflow: auto; background: #07090d; border: 1px solid var(--line); padding: 1rem; color: #c9d3e2; font-size: .76rem; } +footer { border-top: 1px solid var(--line); color: var(--muted); padding: 1.5rem; text-align: center; font-size: .78rem; } +[hidden] { display: none !important; } +@media (max-width: 850px) { .controller-layout { grid-template-columns: 1fr; }.detail { border-left: 0; border-top: 1px solid var(--line); padding: 1.5rem 0 0; }.fact-grid { grid-template-columns: 1fr 1fr; }.side-buttons { grid-template-columns: repeat(3, 1fr); }.section-heading { align-items: start; flex-direction: column; gap: .5rem; }.section-heading > p { text-align: left; }.masthead { align-items: start; flex-direction: column; } } +@media (max-width: 520px) { .fact-grid { grid-template-columns: 1fr; }.controller-layout { display: block; }.knob-grid { gap: .4rem; padding: .5rem; }.knob { border-radius: .4rem; padding: .2rem; }.knob-number { font-size: .55rem; right: .25rem; bottom: .2rem; }.side-buttons { grid-template-columns: 1fr 1fr; }.controls > button, label { width: 100%; }.empty { align-items: start; flex-direction: column; }.safety { white-space: normal; } } +@media (prefers-reduced-motion: no-preference) { .knob, button { transition: border-color .15s, box-shadow .15s, filter .15s; } } From c5c32d8a43772ea220b0c35557536c84c13da0ee Mon Sep 17 00:00:00 2001 From: oveddan Date: Fri, 7 Aug 2026 10:47:15 -0600 Subject: [PATCH 4/4] fix: address adversarial review findings in read-only UI (PR #10) An Opus adversarial review of the read-only Twister UI (write-safety boundary itself verified solid, untouched here) found six correctness and robustness issues, all fixed: 1. ui/app.js: assertSnapshot now validates globals.colorMap/superKnob/ brightness/animationChannels/sleep, device.midiPorts, firmware identityBytes, and capturedAt. renderSnapshot is now transactional: it only commits state.snapshot and unhides the viewer after every render function succeeds; a failure rolls back to the previous good snapshot (or the empty state) instead of leaving a spliced mix of new/old content on screen with a corrupt snapshot wired to Download. 2. src/ui-server.ts: /api/export now has a single-flight guard so a second concurrent export request gets 409 EXPORT_IN_PROGRESS instead of racing to open the same MIDI ports and having pullEncoderData match replies across requests by tag alone. 3. src/ui-server.ts rejects requests whose Host header doesn't name this server (400 INVALID_HOST), closing the cross-origin/DNS-rebind path to the local MIDI API. cli.ts now also validates and warns on a non-loopback MFT_CONFIG_UI_HOST. 4. src/midi.ts: RtMidiPorts.send is guarded again (defaults to assertReadOnlyRequest; RtMidiApplyConnection passes the broader assertApplyRequest), restoring "innermost layer is safe by default" even if a future call site bypasses the wrapper classes. 5. src/exporter.ts: pullGlobals/pullDeviceId/pullEncoderData once again check the DJTT vendor header before trusting the command byte, so unrelated SysEx sharing the bus can't be misread as a malformed Twister reply. 6. src/ui-server.ts: /api/export now also accepts the inputPort/ outputPort the browser saw at discovery time and rejects the request (409 DEVICE_LIST_CHANGED) if the freshly re-discovered device at that index no longer matches, instead of silently exporting the wrong (still read-only) device after a replug. Adds test/ui-app.test.ts (jsdom-backed) covering fix #1, and extends test/ui-server.test.ts with coverage for fixes #2, #3, and #6. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 568 +++++++++++++++++++++++++++++++++++++++++ package.json | 2 + src/cli.ts | 4 +- src/exporter.ts | 16 +- src/midi.ts | 33 ++- src/protocol.ts | 2 +- src/ui-server.ts | 93 ++++++- test/ui-app.test.ts | 156 +++++++++++ test/ui-server.test.ts | 101 ++++++++ ui/app.js | 53 +++- 10 files changed, 1007 insertions(+), 21 deletions(-) create mode 100644 test/ui-app.test.ts diff --git a/package-lock.json b/package-lock.json index a9f18e3..a120d8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "mft-export": "dist/cli.js" }, "devDependencies": { + "@types/jsdom": "^28.0.3", "@types/node": "^24.0.0", + "jsdom": "^30.0.1", "tsx": "^4.20.0", "typescript": "^5.9.0" }, @@ -24,6 +26,192 @@ "node": ">=20" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -466,6 +654,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@julusian/midi": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/@julusian/midi/-/midi-3.8.0.tgz", @@ -481,6 +687,26 @@ "node": ">=14.15" } }, + "node_modules/@types/jsdom": { + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", + "integrity": "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.29.0.tgz", + "integrity": "sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -491,6 +717,86 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -548,12 +854,103 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pkg-prebuilds": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pkg-prebuilds/-/pkg-prebuilds-1.1.0.tgz", @@ -567,6 +964,102 @@ "node": ">= 14.15.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -606,12 +1099,87 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index a3a89aa..856c052 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "@julusian/midi": "^3.0.1" }, "devDependencies": { + "@types/jsdom": "^28.0.3", "@types/node": "^24.0.0", + "jsdom": "^30.0.1", "tsx": "^4.20.0", "typescript": "^5.9.0" } diff --git a/src/cli.ts b/src/cli.ts index 21a5d97..c85a89e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -126,10 +126,12 @@ async function main(): Promise { } if (args.command === "ui") { - const { startUiServer } = await import("./ui-server.js"); + const { startUiServer, warnIfNonLocalHost } = await import("./ui-server.js"); const host = process.env.MFT_CONFIG_UI_HOST ?? "127.0.0.1"; const port = Number(process.env.MFT_CONFIG_UI_PORT ?? "4783"); if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("MFT_CONFIG_UI_PORT must be an integer from 0 to 65535"); + if (host.trim() === "") throw new Error("MFT_CONFIG_UI_HOST must not be empty"); + warnIfNonLocalHost(host); const { url } = await startUiServer(host, port); process.stdout.write(`MFT Config read-only UI: ${url}\n`); return; diff --git a/src/exporter.ts b/src/exporter.ts index 003e626..79b4be8 100644 --- a/src/exporter.ts +++ b/src/exporter.ts @@ -4,7 +4,9 @@ import { encoderTransferTag, firmwarePolicy } from "./compatibility.js"; import type { MidiConnection } from "./midi.js"; import { assembleBulkParts, + bytesEqual, bytesToHex, + DJTT_HEADER, getDeviceIdRequest, parseBulkPart, parseDeviceIdResponse, @@ -14,6 +16,13 @@ import { pullGlobalsRequest, type BulkPart, } from "./protocol.js"; + +/** True when `message` carries the DJ TechTools SysEx vendor header, i.e. it is + * plausibly a Twister reply rather than unrelated SysEx traffic from another device + * sharing the bus. */ +function isDjttMessage(message: number[]): boolean { + return message.length >= 4 && bytesEqual(message.slice(0, 4), DJTT_HEADER); +} import { findUsbSerial } from "./usb.js"; interface ExportOptions { @@ -67,7 +76,7 @@ async function pullGlobals(connection: MidiConnection, timeoutMs: number, retrie waitForMessage( connection, () => connection.send(pullGlobalsRequest()), - (message) => message[4] === 0x02 ? parseGlobalResponse(message) : undefined, + (message) => isDjttMessage(message) && message[4] === 0x02 ? parseGlobalResponse(message) : undefined, timeoutMs, ), retries, @@ -81,7 +90,7 @@ async function pullDeviceId(connection: MidiConnection, timeoutMs: number): Prom connection, () => connection.send(getDeviceIdRequest()), (message) => { - if (message[4] !== 0x05) return undefined; + if (!isDjttMessage(message) || message[4] !== 0x05) return undefined; return parseDeviceIdResponse(message); }, timeoutMs, @@ -108,6 +117,7 @@ async function pullEncoderData( reject(malformed ?? new Error(`Timed out after ${timeoutMs} ms`)); }, timeoutMs); const unsubscribe = connection.subscribe((message) => { + if (!isDjttMessage(message) || message[4] !== 0x04) return; try { const part = parseBulkPart(message); if (part.tag !== tag) return; @@ -119,7 +129,7 @@ async function pullEncoderData( unsubscribe(); resolve(assembleBulkParts([...parts.values()])); } catch (error) { - if (message[4] === 0x04) malformed = error as Error; + malformed = error as Error; } }); connection.send(pullEncoderRequest(tag)); diff --git a/src/midi.ts b/src/midi.ts index 80c6405..c0289d4 100644 --- a/src/midi.ts +++ b/src/midi.ts @@ -22,12 +22,40 @@ export interface MidiBackend { connectForApply(device: DeviceDescriptor): ConfigurationWriteConnection; } +/** + * Permits either a read-only request or a configuration write. This is the guard used + * by {@link RtMidiApplyConnection}'s transport, which itself already applies the exact + * same checks before calling down to this layer (so the check here is redundant, i.e. + * idempotent, on that path) -- it exists so that if a future edit ever constructs + * `RtMidiPorts` directly, or adds a call site that bypasses the wrapper classes, the + * innermost layer still refuses to send an arbitrary/mutating message. + */ +function assertApplyRequest(message: ArrayLike): void { + try { + assertReadOnlyRequest(message); + return; + } catch { + // fall through to the broader (write-permitting) check + } + assertConfigurationWrite(message); +} + class RtMidiPorts implements MidiConnection { private readonly input = new Input(); private readonly output = new Output(); private readonly handlers = new Set(); - constructor(inputPort: number, outputPort: number) { + /** + * `guard` runs on every outbound message before it reaches the MIDI output, so this + * class is safe by default even if constructed outside the guarded wrapper classes + * below. It defaults to read-only; {@link RtMidiApplyConnection} passes the broader + * {@link assertApplyRequest} explicitly, since it is the one surface allowed to write. + */ + constructor( + inputPort: number, + outputPort: number, + private readonly guard: (message: ArrayLike) => void = assertReadOnlyRequest, + ) { this.input.ignoreTypes(false, true, true); this.input.on("message", (_deltaTime: number, message: number[]) => { for (const handler of this.handlers) handler([...message]); @@ -37,6 +65,7 @@ class RtMidiPorts implements MidiConnection { } send(message: ArrayLike): void { + this.guard(message); this.output.sendMessage(Array.from(message)); } @@ -74,7 +103,7 @@ class RtMidiApplyConnection implements ConfigurationWriteConnection { private readonly transport: MidiConnection; constructor(inputPort: number, outputPort: number) { - this.transport = new RtMidiPorts(inputPort, outputPort); + this.transport = new RtMidiPorts(inputPort, outputPort, assertApplyRequest); } send(message: ArrayLike): void { diff --git a/src/protocol.ts b/src/protocol.ts index e7e6631..52a01b1 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -28,7 +28,7 @@ function hexByte(value: number): string { return value.toString(16).padStart(2, "0").toUpperCase(); } -function bytesEqual(actual: ArrayLike, expected: ArrayLike): boolean { +export function bytesEqual(actual: ArrayLike, expected: ArrayLike): boolean { if (actual.length !== expected.length) return false; return Array.from(actual).every((value, index) => value === expected[index]); } diff --git a/src/ui-server.ts b/src/ui-server.ts index 3af410b..ec6c3c7 100644 --- a/src/ui-server.ts +++ b/src/ui-server.ts @@ -74,6 +74,25 @@ function publicDevice(device: DeviceDescriptor, index: number): unknown { }; } +/** + * Only accept requests whose Host header names this server, so an unrelated page open + * in the user's browser can't reach the local MIDI-reading API cross-origin (directly, + * or via DNS rebinding to make the request appear same-origin). + */ +const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); + +function isAllowedHost(request: IncomingMessage): boolean { + const header = request.headers.host; + if (!header) return false; + const separatorIndex = header.lastIndexOf(":"); + const hostname = separatorIndex === -1 ? header : header.slice(0, separatorIndex); + const portPart = separatorIndex === -1 ? undefined : header.slice(separatorIndex + 1); + if (!ALLOWED_HOSTNAMES.has(hostname.toLowerCase())) return false; + const localPort = request.socket.localPort; + if (portPart === undefined || localPort === undefined) return false; + return Number(portPart) === localPort; +} + async function serveAsset(pathname: string, response: ServerResponse): Promise { const asset = ASSETS.get(pathname); if (!asset) return false; @@ -93,8 +112,17 @@ export function createUiServer(options: UiServerOptions = {}): Server { const backend = options.backend ?? new RtMidiReadOnlyBackend(); const timeoutMs = options.timeoutMs ?? 750; + // Single-flight guard: /api/export opens a fresh pair of MIDI ports and matches + // replies purely on protocol tag, so two concurrent exports could open the same + // ports twice and let one request's replies satisfy the other, silently producing + // a corrupt snapshot. Only one export runs at a time; a concurrent request gets 409. + let exportInFlight = false; + return createServer(async (request, response) => { try { + if (!isAllowedHost(request)) { + return json(response, 400, { error: { code: "INVALID_HOST", message: "Host header is not allowed" } }); + } const url = new URL(request.url ?? "/", "http://localhost"); if (url.pathname === "/api/devices") { if (request.method !== "GET") return json(response, 405, { error: { code: "METHOD_NOT_ALLOWED", message: "Only GET is allowed" } }); @@ -105,18 +133,53 @@ export function createUiServer(options: UiServerOptions = {}): Server { if (request.method !== "POST") return json(response, 405, { error: { code: "METHOD_NOT_ALLOWED", message: "Only POST is allowed" } }); const body = await readJson(request); const deviceIndex = body.deviceIndex; - if (!Number.isInteger(deviceIndex) || (deviceIndex as number) < 0 || Object.keys(body).some((key) => key !== "deviceIndex")) { - return json(response, 400, { error: { code: "INVALID_REQUEST", message: "deviceIndex must be the only field and a non-negative integer" } }); + const extraKeys = Object.keys(body).filter((key) => key !== "deviceIndex" && key !== "inputPort" && key !== "outputPort"); + const inputPort = body.inputPort; + const outputPort = body.outputPort; + if ( + !Number.isInteger(deviceIndex) || + (deviceIndex as number) < 0 || + (inputPort !== undefined && typeof inputPort !== "string") || + (outputPort !== undefined && typeof outputPort !== "string") || + extraKeys.length > 0 + ) { + return json(response, 400, { + error: { code: "INVALID_REQUEST", message: "deviceIndex must be a non-negative integer; inputPort/outputPort, if present, must be strings" }, + }); } - const devices = await backend.discover(timeoutMs); - const device = devices[deviceIndex as number]; - if (!device) return json(response, 409, { error: { code: "DEVICE_DISCONNECTED", message: "The selected Twister is no longer connected" } }); - const connection = backend.connect(device); + if (exportInFlight) { + return json(response, 409, { error: { code: "EXPORT_IN_PROGRESS", message: "Another export is already reading the device; try again shortly" } }); + } + exportInFlight = true; try { - const snapshot = await exportConfiguration(connection, device, { timeoutMs }); - return json(response, 200, { snapshot }); + const devices = await backend.discover(timeoutMs); + const device = devices[deviceIndex as number]; + if (!device) return json(response, 409, { error: { code: "DEVICE_DISCONNECTED", message: "The selected Twister is no longer connected" } }); + // Devices are re-discovered fresh on every export, and `deviceIndex` is only a + // position in that ordering. If the browser's earlier device list is stale + // (a device was unplugged/replugged in between), the array may have reordered + // and the same index would silently pick a different device. Re-checking the + // port names the browser saw at discovery time turns that into a clear error + // instead of a mislabeled (still read-only) export. This is not a perfect + // identity check across OS/driver quirks, but it is the cheap option; a fully + // robust fix would need a stable device key threaded through discovery. + if ( + (inputPort !== undefined && inputPort !== device.inputPort.name) || + (outputPort !== undefined && outputPort !== device.outputPort.name) + ) { + return json(response, 409, { + error: { code: "DEVICE_LIST_CHANGED", message: "The device list changed since it was last discovered; rescan and try again" }, + }); + } + const connection = backend.connect(device); + try { + const snapshot = await exportConfiguration(connection, device, { timeoutMs }); + return json(response, 200, { snapshot }); + } finally { + connection.close(); + } } finally { - connection.close(); + exportInFlight = false; } } if (url.pathname.startsWith("/api/")) return json(response, 404, { error: { code: "NOT_FOUND", message: "No such read-only API endpoint" } }); @@ -134,6 +197,17 @@ export function createUiServer(options: UiServerOptions = {}): Server { }); } +const LOCAL_ONLY_BIND_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]); + +/** Binding to anything other than loopback exposes the read-only MIDI API to the network; docs already say this isn't recommended, so warn loudly. */ +export function warnIfNonLocalHost(host: string): void { + if (!LOCAL_ONLY_BIND_HOSTS.has(host.toLowerCase())) { + process.stderr.write( + `mft-config ui: WARNING binding to "${host}" exposes the local MIDI read-only API to the network. This is not recommended; prefer 127.0.0.1 or localhost.\n`, + ); + } +} + export async function startUiServer(host = "127.0.0.1", port = 0): Promise<{ server: Server; url: string }> { const server = createUiServer(); await new Promise((resolve, reject) => { @@ -148,6 +222,7 @@ export async function startUiServer(host = "127.0.0.1", port = 0): Promise<{ ser if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const host = process.env.MFT_CONFIG_UI_HOST ?? "127.0.0.1"; const port = Number(process.env.MFT_CONFIG_UI_PORT ?? "4783"); + warnIfNonLocalHost(host); startUiServer(host, port) .then(({ url }) => process.stdout.write(`MFT Config read-only UI: ${url}\n`)) .catch((error: Error) => { diff --git a/test/ui-app.test.ts b/test/ui-app.test.ts new file mode 100644 index 0000000..583d300 --- /dev/null +++ b/test/ui-app.test.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; + +import { JSDOM } from "jsdom"; + +import { exportConfiguration } from "../src/exporter.js"; +import type { ConfigExport, DeviceDescriptor } from "../src/model.js"; +import type { MessageHandler, MidiConnection } from "../src/midi.js"; +import { assertReadOnlyRequest } from "../src/protocol.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const indexHtmlPath = resolve(here, "../ui/index.html"); +const appJsPath = resolve(here, "../ui/app.js"); + +const GLOBAL_PAIRS = [ + 0, 4, 1, 1, + 2, 0, 3, 9, 4, 0, 5, 0, 6, 8, 7, 0, + 8, 63, 9, 127, + 31, 127, 32, 100, 33, 1, 34, 6, 35, 3, 36, 7, 37, 1, 38, 1, +]; + +const ENCODER_PAIRS = [ + 10, 1, 11, 0, 12, 0, 13, 2, 14, 12, + 15, 0, 16, 1, 17, 34, 18, 1, 19, 25, + 20, 5, 21, 63, 22, 2, 23, 0, 24, 5, +]; + +const device: DeviceDescriptor = { + inputPort: { index: 0, name: "Midi Fighter Twister" }, + outputPort: { index: 0, name: "Midi Fighter Twister" }, + identity: { + manufacturerId: [0, 1, 0x79], + familyId: 5, + modelId: 1, + firmwareBytes: [0x20, 0x26, 7, 2], + firmwareDate: "2026-07-02", + }, +}; + +class FakeFourBankTwister implements MidiConnection { + readonly sent: number[][] = []; + private readonly handlers = new Set(); + + send(message: ArrayLike): void { + assertReadOnlyRequest(message); + const bytes = Array.from(message); + this.sent.push(bytes); + const command = bytes[4]; + if (command === 0x05) { + this.emit([0xf0, 0, 1, 0x79, 5, 1, 1, 2, 3, 4, 5, 6, 7, 8, 0xf7]); + } else if (command === 0x02) { + this.emit([0xf0, 0, 1, 0x79, 2, 1, ...GLOBAL_PAIRS, 0xf7]); + } else if (command === 0x04) { + const tag = bytes[6]!; + if (tag === 65) { + this.emit([0xf0, 0, 1, 0x79, 4, 0, 65, 1, 1, 0, 0xf7]); + } else { + this.emit([0xf0, 0, 1, 0x79, 4, 0, tag, 1, 2, 24, ...ENCODER_PAIRS.slice(0, 24), 0xf7]); + this.emit([0xf0, 0, 1, 0x79, 4, 0, tag, 2, 2, 6, ...ENCODER_PAIRS.slice(24), 0xf7]); + } + } + } + + subscribe(handler: MessageHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + close(): void {} + + private emit(message: number[]): void { + for (const handler of [...this.handlers]) handler(message); + } +} + +async function validSnapshot(): Promise { + const connection = new FakeFourBankTwister(); + return exportConfiguration(connection, device, { timeoutMs: 100, retries: 0 }); +} + +interface AppModule { + assertSnapshot(value: unknown): unknown; + renderSnapshot(snapshot: unknown, source: string): void; +} + +/** Loads ui/app.js against a fresh DOM built from the real ui/index.html, mirroring + * what a browser would do. Each call gets an isolated `document`/`window` and a + * cache-busted module import so tests don't share app.js module state. */ +async function loadApp(): Promise<{ document: Document; app: AppModule }> { + const html = readFileSync(indexHtmlPath, "utf8"); + const dom = new JSDOM(html, { url: "http://localhost/" }); + (globalThis as Record).window = dom.window; + (globalThis as Record).document = dom.window.document; + (globalThis as Record).HTMLElement = dom.window.HTMLElement; + const app = (await import(`${pathToFileURL(appJsPath).href}?cachebust=${Date.now()}-${Math.random()}`)) as unknown as AppModule; + return { document: dom.window.document, app }; +} + +test("assertSnapshot rejects a snapshot missing globals fields the renderer reads", async () => { + const { app } = await loadApp(); + const snapshot = await validSnapshot(); + const malformed = structuredClone(snapshot) as Record; + delete (malformed.globals as Record).colorMap; + assert.throws(() => app.assertSnapshot(malformed), /global fields/i); +}); + +test("a malformed offline snapshot does not corrupt the viewer: empty state stays intact", async () => { + const { document, app } = await loadApp(); + const snapshot = await validSnapshot(); + const malformed = structuredClone(snapshot) as Record; + delete (malformed.globals as Record).superKnob; + + assert.throws(() => app.renderSnapshot(malformed, "bad import")); + + // Nothing should have been committed: the empty state remains visible, the viewer + // remains hidden, and Download JSON remains disabled (it would otherwise export + // corrupt/half-applied data). + assert.equal((document.getElementById("empty-state") as HTMLElement).hidden, false); + assert.equal((document.getElementById("viewer") as HTMLElement).hidden, true); + assert.equal((document.getElementById("download-button") as HTMLButtonElement).disabled, true); + assert.equal((document.getElementById("identity-fields") as HTMLElement).children.length, 0); +}); + +test("a malformed snapshot after a good read leaves the previous good snapshot displayed, not a spliced mix", async () => { + const { document, app } = await loadApp(); + const good = await validSnapshot(); + app.renderSnapshot(good, "Live controller"); + assert.equal((document.getElementById("viewer") as HTMLElement).hidden, false); + const goodDate = (good as ConfigExport).device.firmware.date; + const identityText = (document.getElementById("identity-fields") as HTMLElement).textContent ?? ""; + assert(identityText.includes(goodDate)); + + const malformed = structuredClone(good) as Record; + delete (malformed.globals as Record).colorMap; + assert.throws(() => app.renderSnapshot(malformed, "bad import")); + + // The viewer should still show the last good snapshot's data (rolled back render), + // never a mix of the new device header and old grid/globals. + assert.equal((document.getElementById("viewer") as HTMLElement).hidden, false); + assert.equal((document.getElementById("download-button") as HTMLButtonElement).disabled, false); + const identityTextAfter = (document.getElementById("identity-fields") as HTMLElement).textContent ?? ""; + assert(identityTextAfter.includes(goodDate)); + assert.equal((document.getElementById("raw-json") as HTMLElement).textContent, JSON.stringify(good, null, 2)); +}); + +test("a valid snapshot renders successfully and unhides the viewer", async () => { + const { document, app } = await loadApp(); + const snapshot = await validSnapshot(); + app.renderSnapshot(snapshot, "Live controller"); + assert.equal((document.getElementById("empty-state") as HTMLElement).hidden, true); + assert.equal((document.getElementById("viewer") as HTMLElement).hidden, false); + assert.equal((document.getElementById("download-button") as HTMLButtonElement).disabled, false); +}); diff --git a/test/ui-server.test.ts b/test/ui-server.test.ts index 974ed55..4cafe04 100644 --- a/test/ui-server.test.ts +++ b/test/ui-server.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import http from "node:http"; import test from "node:test"; import { ReadOnlyMidiConnection, RtMidiReadOnlyBackend, type MessageHandler, type MidiConnection } from "../src/midi.js"; @@ -99,6 +100,106 @@ test("UI server has no mutation route and never opens a connection for one", asy assert.equal(connections, 0); }); +test("concurrent /api/export requests do not race: the second is rejected instead of sharing a connection", async () => { + let connectionsOpened = 0; + let inFlightSends = 0; + let maxConcurrentSends = 0; + + class SlowFakeConnection implements MidiConnection { + readonly sent: number[][] = []; + closed = false; + private readonly handlers = new Set(); + + send(message: ArrayLike): void { + assertReadOnlyRequest(message); + const bytes = Array.from(message); + this.sent.push(bytes); + inFlightSends += 1; + maxConcurrentSends = Math.max(maxConcurrentSends, inFlightSends); + // Respond asynchronously so a second concurrent request's send() calls could + // interleave with this one's if there were no single-flight guard. + setTimeout(() => { + inFlightSends -= 1; + if (bytes[4] === 5) this.emit([0xf0, 0, 1, 0x79, 5, 1, 1, 2, 3, 4, 5, 6, 7, 8, 0xf7]); + if (bytes[4] === 2) this.emit([0xf0, 0, 1, 0x79, 2, 1, ...GLOBALS, 0xf7]); + if (bytes[4] === 4) { + const tag = bytes[6]!; + if (tag === 65) return this.emit([0xf0, 0, 1, 0x79, 4, 0, 65, 1, 1, 0, 0xf7]); + this.emit([0xf0, 0, 1, 0x79, 4, 0, tag, 1, 2, 24, ...ENCODER.slice(0, 24), 0xf7]); + this.emit([0xf0, 0, 1, 0x79, 4, 0, tag, 2, 2, 6, ...ENCODER.slice(24), 0xf7]); + } + }, 1); + } + subscribe(handler: MessageHandler): () => void { this.handlers.add(handler); return () => this.handlers.delete(handler); } + close(): void { this.closed = true; } + private emit(message: number[]): void { for (const handler of [...this.handlers]) handler(message); } + } + + const backend: ReadOnlyBackend = { + discover: async () => [device], + connect: () => { connectionsOpened += 1; return new SlowFakeConnection(); }, + }; + + await withServer(backend, async (base) => { + const request = () => fetch(`${base}/api/export`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceIndex: 0 }) }); + const [first, second] = await Promise.all([request(), request()]); + const statuses = [first.status, second.status].sort(); + // Exactly one request wins and reads a complete snapshot; the other is rejected + // with 409 rather than being served from a second, concurrently-opened connection + // whose replies could interleave with the first (matched only by protocol tag). + assert.deepEqual(statuses, [200, 409]); + const rejected = first.status === 409 ? first : second; + assert.equal((await rejected.json() as { error: { code: string } }).error.code, "EXPORT_IN_PROGRESS"); + assert.equal(connectionsOpened, 1); + }); + + // A follow-up export after the first completes should succeed normally (the guard + // releases once the in-flight export finishes, it doesn't wedge the server). + const backend2: ReadOnlyBackend = { discover: async () => [device], connect: () => new SlowFakeConnection() }; + await withServer(backend2, async (base) => { + const response = await fetch(`${base}/api/export`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceIndex: 0 }) }); + assert.equal(response.status, 200); + }); +}); + +test("/api/export rejects a request whose inputPort/outputPort no longer match the freshly discovered device", async () => { + const connection = new FakeReadOnlyConnection(); + const backend: ReadOnlyBackend = { discover: async () => [device], connect: () => connection }; + await withServer(backend, async (base) => { + const response = await fetch(`${base}/api/export`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceIndex: 0, inputPort: "Some Other Device In", outputPort: "Twister Out" }), + }); + assert.equal(response.status, 409); + assert.equal((await response.json() as { error: { code: string } }).error.code, "DEVICE_LIST_CHANGED"); + }); +}); + +test("requests with a Host header that does not name this server are rejected", async () => { + // fetch() refuses to let callers override the Host header (undici derives it from the + // URL), so this exercises the real attack surface -- an arbitrary Host header sent over + // the wire, e.g. by DNS rebinding or a raw client -- with a low-level http.request. + const backend: ReadOnlyBackend = { discover: async () => [device], connect: () => new FakeReadOnlyConnection() }; + await withServer(backend, async (base) => { + const { port } = new URL(base); + const body = await new Promise<{ status: number; json: { error: { code: string } } }>((resolvePromise, reject) => { + const request = http.request( + { host: "127.0.0.1", port: Number(port), path: "/api/devices", method: "GET", headers: { host: "evil.example:80" } }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolvePromise({ status: response.statusCode!, json: JSON.parse(Buffer.concat(chunks).toString("utf8")) })); + }, + ); + request.on("error", reject); + request.end(); + }); + assert.equal(body.status, 400); + assert.equal(body.json.error.code, "INVALID_HOST"); + }); +}); + test("UI server reports permission, malformed, partial, and disconnected reads clearly", async () => { for (const [message, status, code] of [ ["MIDI permission denied", 403, "MIDI_PERMISSION"], diff --git a/ui/app.js b/ui/app.js index 1cd66f1..d4435a8 100644 --- a/ui/app.js +++ b/ui/app.js @@ -9,7 +9,7 @@ const elements = Object.fromEntries([ const state = { devices: [], snapshot: null, bank: 1, encoder: 1, source: "" }; -function assertSnapshot(value) { +export function assertSnapshot(value) { if (!value || typeof value !== "object") throw new Error("Snapshot must be a JSON object."); if (value.schemaVersion !== "djtt.mft.config-export.v1") throw new Error("Unsupported snapshot schema. Expected djtt.mft.config-export.v1."); const bankCount = value.capabilities?.bankCount; @@ -28,6 +28,23 @@ function assertSnapshot(value) { } } if (!value.device?.firmware?.date || !value.globals?.rawTags || !Array.isArray(value.globals.sideButtons) || !Array.isArray(value.warnings)) throw new Error("Snapshot is missing device, global, side-button, or warning fields."); + if (typeof value.capturedAt !== "string" || Number.isNaN(Date.parse(value.capturedAt))) throw new Error("Snapshot is missing a valid capturedAt timestamp."); + if (!value.device?.midiPorts || typeof value.device.midiPorts.input !== "string" || typeof value.device.midiPorts.output !== "string") throw new Error("Snapshot is missing device MIDI port names."); + if (!Array.isArray(value.device.firmware.identityBytes)) throw new Error("Snapshot is missing device firmware identity bytes."); + const globals = value.globals; + if ( + typeof globals.colorMap?.name !== "string" || + typeof globals.superKnob?.start !== "number" || + typeof globals.superKnob?.end !== "number" || + typeof globals.brightness?.rgb !== "number" || + typeof globals.brightness?.indicator !== "number" || + typeof globals.animationChannels?.encoder !== "number" || + typeof globals.animationChannels?.switch !== "number" || + !globals.sleep || + typeof globals.sleep.timeoutIndex !== "number" + ) { + throw new Error("Snapshot is missing required global fields (colorMap, superKnob, brightness, animationChannels, or sleep)."); + } return value; } @@ -182,13 +199,34 @@ function renderGlobals() { elements["global-raw"].textContent = JSON.stringify(globals.rawTags, null, 2); } -function renderSnapshot(snapshot, source) { - state.snapshot = assertSnapshot(snapshot); state.bank = snapshot.banks[0].number; state.encoder = 1; state.source = source; - elements["empty-state"].hidden = true; elements.viewer.hidden = false; elements["download-button"].disabled = false; +function renderAll(snapshot) { renderIdentity(); renderBanks(); renderGrid(); renderGlobals(); elements["raw-json"].textContent = JSON.stringify(snapshot, null, 2); } +// Renders defensively: a snapshot is only committed to `state` and the viewer only +// unhidden once every render function has run without throwing. If rendering the new +// snapshot fails partway through (leaving the DOM with a mix of new and old content), +// the previous good snapshot (or the empty state, if there was none) is redrawn from +// scratch so the user never sees a spliced/corrupted view. +export function renderSnapshot(snapshot, source) { + const validated = assertSnapshot(snapshot); + const previous = { snapshot: state.snapshot, bank: state.bank, encoder: state.encoder, source: state.source }; + state.snapshot = validated; state.bank = validated.banks[0].number; state.encoder = 1; state.source = source; + try { + renderAll(snapshot); + } catch (error) { + state.snapshot = previous.snapshot; state.bank = previous.bank; state.encoder = previous.encoder; state.source = previous.source; + if (previous.snapshot) { + try { renderAll(previous.snapshot); } catch { /* best-effort restore; fall through to error below */ } + } else { + elements["empty-state"].hidden = false; elements.viewer.hidden = true; elements["download-button"].disabled = true; + } + throw new Error(`Snapshot could not be rendered: ${error.message}`); + } + elements["empty-state"].hidden = true; elements.viewer.hidden = false; elements["download-button"].disabled = false; +} + async function discover() { setStatus("Discovering MIDI devices…"); elements["discover-button"].disabled = true; elements["read-button"].disabled = true; try { @@ -208,9 +246,14 @@ async function discover() { async function readDevice() { const deviceIndex = Number(elements["device-select"].value); if (!Number.isInteger(deviceIndex)) return; + const selected = state.devices[deviceIndex]; setStatus("Reading globals and every encoder. This can take a moment…"); elements["read-button"].disabled = true; try { - const { snapshot } = await api("/api/export", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceIndex }) }); + // inputPort/outputPort let the server confirm the device at this index hasn't + // changed since discovery (e.g. a controller was unplugged/replugged); if it + // has, the server rejects the request instead of silently reading the wrong one. + const body = { deviceIndex, ...(selected ? { inputPort: selected.inputPort, outputPort: selected.outputPort } : {}) }; + const { snapshot } = await api("/api/export", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); renderSnapshot(snapshot, "Live controller"); setStatus(`Read all ${snapshot.capabilities.bankCount * 16} encoder records successfully.`, "success"); } catch (error) { setStatus(error.message, "error"); } finally { elements["read-button"].disabled = state.devices.length === 0; }