From 6fed713bf27aa0ae368cb2c2ca08f9e3b31b659b Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Sat, 13 Jun 2026 09:01:32 +0200 Subject: [PATCH 1/8] Add TemplateProjectWebView template New sibling template alongside TemplateProject for plugins whose UI is authored in HTML/CSS/JS via iPlug2's WebViewEditorDelegate instead of IGraphics. Modeled on iPlug2's Examples/IPlugWebUI (current CMake UI WEBVIEW + WEB_RESOURCES + WASM webview dist): - TemplateProjectWebView.{cpp,h}: WebViewEditorDelegate plugin, single Gain param, LoadIndexHtml() editor init, OnMessage() demo resize. - CMakeLists.txt: UI WEBVIEW + WEB_RESOURCES (resources/web/*). - resources/web/: index.html + script.js bridge + knob/button web components (one Gain knob). The WebView build is wired only through CMake (iPlug2::WebView defines WEBVIEW_EDITOR_DELEGATE/NO_IGRAPHICS, links WebKit, bundles resources/web). This template is CMake-only, so the legacy build machinery is omitted vs the IGraphics template. duplicate.py: add "web" to SUBFOLDERS_TO_SEARCH so resources/web assets get project-name substituted when scaffolding from this template. --- .../.vscode/c_cpp_properties.json | 49 +++ TemplateProjectWebView/CLAUDE.md | 76 +++++ TemplateProjectWebView/CMakeLists.txt | 25 ++ TemplateProjectWebView/README.md | 3 + .../TemplateProjectWebView.RPP | 128 ++++++++ .../TemplateProjectWebView.code-workspace | 12 + .../TemplateProjectWebView.cpp | 53 ++++ .../TemplateProjectWebView.h | 30 ++ TemplateProjectWebView/config.h | 61 ++++ .../TemplateProjectWebView-installer-bg.png | Bin 0 -> 4400 bytes .../installer/TemplateProjectWebView.iss | 145 +++++++++ .../installer/changelog.txt | 4 + TemplateProjectWebView/installer/intro.md | 7 + .../installer/known-issues.txt | 4 + TemplateProjectWebView/installer/license.md | 37 +++ .../installer/readme-mac.md | 9 + .../installer/readme-win-demo.md | 9 + .../installer/readme-win.md | 9 + .../manual/TemplateProjectWebView manual.md | 5 + ...ViewController_vTemplateProjectWebView.xib | 22 ++ .../resources/Images.xcassets/Contents.json | 6 + .../Contents.json | 113 +++++++ .../icon_1024x1024@1x.png | Bin 0 -> 11787 bytes .../icon_20x20@1x.png | Bin 0 -> 385 bytes .../icon_20x20@2x.png | Bin 0 -> 819 bytes .../icon_20x20@3x.png | Bin 0 -> 1203 bytes .../icon_29x29@1x.png | Bin 0 -> 564 bytes .../icon_29x29@2x.png | Bin 0 -> 1166 bytes .../icon_29x29@3x.png | Bin 0 -> 1724 bytes .../icon_40x40@1x.png | Bin 0 -> 819 bytes .../icon_40x40@2x.png | Bin 0 -> 1582 bytes .../icon_40x40@3x.png | Bin 0 -> 2333 bytes .../icon_60x60@2x.png | Bin 0 -> 2333 bytes .../icon_60x60@3x.png | Bin 0 -> 3432 bytes .../icon_76x76@1x.png | Bin 0 -> 1513 bytes .../icon_76x76@2x.png | Bin 0 -> 2934 bytes .../icon_83.5x83.5@2x.png | Bin 0 -> 3245 bytes .../Contents.json | 68 ++++ .../icon_128x128@1x.png | Bin 0 -> 4499 bytes .../icon_128x128@2x.png | Bin 0 -> 9611 bytes .../icon_16x16@1x.png | Bin 0 -> 413 bytes .../icon_16x16@2x.png | Bin 0 -> 1011 bytes .../icon_256x256@1x.png | Bin 0 -> 9611 bytes .../icon_256x256@2x.png | Bin 0 -> 21192 bytes .../icon_32x32@1x.png | Bin 0 -> 1011 bytes .../icon_32x32@2x.png | Bin 0 -> 1921 bytes .../icon_512x512@1x.png | Bin 0 -> 21192 bytes .../icon_512x512@2x.png | Bin 0 -> 18972 bytes .../TemplateProjectWebView-AAX-Info.plist | 36 +++ .../TemplateProjectWebView-AU-Info.plist | 55 ++++ .../TemplateProjectWebView-CLAP-Info.plist | 30 ++ .../TemplateProjectWebView-Pages.xml | 291 ++++++++++++++++++ .../TemplateProjectWebView-VST2-Info.plist | 30 ++ .../TemplateProjectWebView-VST3-Info.plist | 30 ++ ...TemplateProjectWebView-iOS-AUv3-Info.plist | 62 ++++ ...rojectWebView-iOS-AUv3Framework-Info.plist | 24 ++ .../TemplateProjectWebView-iOS-Info.plist | 54 ++++ ...ProjectWebView-iOS-LaunchScreen.storyboard | 45 +++ ...rojectWebView-iOS-MainInterface.storyboard | 28 ++ .../TemplateProjectWebView-iOS.storyboard | 36 +++ ...mplateProjectWebView-macOS-AUv3-Info.plist | 61 ++++ ...jectWebView-macOS-AUv3Framework-Info.plist | 22 ++ .../TemplateProjectWebView-macOS-Info.plist | 40 +++ .../TemplateProjectWebView-macOS-MainMenu.xib | 93 ++++++ .../resources/TemplateProjectWebView.icns | Bin 0 -> 153291 bytes .../resources/TemplateProjectWebView.ico | Bin 0 -> 45322 bytes .../resources/TemplateProjectWebViewAU.h | 20 ++ .../TemplateProjectWebViewAUv3Appex.m | 6 + TemplateProjectWebView/resources/main.rc | 234 ++++++++++++++ .../resources/main.rc_mac_dlg | 73 +++++ .../resources/main.rc_mac_menu | 24 ++ TemplateProjectWebView/resources/resource.h | 44 +++ .../resources/web/button-control.js | 54 ++++ .../resources/web/index.html | 137 +++++++++ .../resources/web/knob-control.js | 245 +++++++++++++++ .../resources/web/script.js | 105 +++++++ duplicate.py | 3 +- 77 files changed, 2756 insertions(+), 1 deletion(-) create mode 100644 TemplateProjectWebView/.vscode/c_cpp_properties.json create mode 100644 TemplateProjectWebView/CLAUDE.md create mode 100644 TemplateProjectWebView/CMakeLists.txt create mode 100644 TemplateProjectWebView/README.md create mode 100644 TemplateProjectWebView/TemplateProjectWebView.RPP create mode 100644 TemplateProjectWebView/TemplateProjectWebView.code-workspace create mode 100644 TemplateProjectWebView/TemplateProjectWebView.cpp create mode 100644 TemplateProjectWebView/TemplateProjectWebView.h create mode 100644 TemplateProjectWebView/config.h create mode 100644 TemplateProjectWebView/installer/TemplateProjectWebView-installer-bg.png create mode 100644 TemplateProjectWebView/installer/TemplateProjectWebView.iss create mode 100644 TemplateProjectWebView/installer/changelog.txt create mode 100644 TemplateProjectWebView/installer/intro.md create mode 100644 TemplateProjectWebView/installer/known-issues.txt create mode 100644 TemplateProjectWebView/installer/license.md create mode 100644 TemplateProjectWebView/installer/readme-mac.md create mode 100644 TemplateProjectWebView/installer/readme-win-demo.md create mode 100644 TemplateProjectWebView/installer/readme-win.md create mode 100644 TemplateProjectWebView/manual/TemplateProjectWebView manual.md create mode 100644 TemplateProjectWebView/resources/IPlugAUViewController_vTemplateProjectWebView.xib create mode 100644 TemplateProjectWebView/resources/Images.xcassets/Contents.json create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/Contents.json create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_1024x1024@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_20x20@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_20x20@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_20x20@3x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_29x29@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_29x29@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_29x29@3x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_40x40@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_40x40@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_40x40@3x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_60x60@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_60x60@3x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_76x76@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_76x76@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-iOS.appiconset/icon_83.5x83.5@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/Contents.json create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_128x128@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_128x128@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_16x16@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_16x16@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_256x256@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_256x256@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_32x32@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_32x32@2x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_512x512@1x.png create mode 100644 TemplateProjectWebView/resources/Images.xcassets/TemplateProjectWebView-macOS.appiconset/icon_512x512@2x.png create mode 100755 TemplateProjectWebView/resources/TemplateProjectWebView-AAX-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-AU-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-CLAP-Info.plist create mode 100755 TemplateProjectWebView/resources/TemplateProjectWebView-Pages.xml create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-VST2-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-VST3-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-iOS-AUv3-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-iOS-AUv3Framework-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-iOS-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-iOS-LaunchScreen.storyboard create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-iOS-MainInterface.storyboard create mode 100755 TemplateProjectWebView/resources/TemplateProjectWebView-iOS.storyboard create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-macOS-AUv3-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-macOS-AUv3Framework-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-macOS-Info.plist create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView-macOS-MainMenu.xib create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView.icns create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebView.ico create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebViewAU.h create mode 100644 TemplateProjectWebView/resources/TemplateProjectWebViewAUv3Appex.m create mode 100644 TemplateProjectWebView/resources/main.rc create mode 100644 TemplateProjectWebView/resources/main.rc_mac_dlg create mode 100644 TemplateProjectWebView/resources/main.rc_mac_menu create mode 100644 TemplateProjectWebView/resources/resource.h create mode 100644 TemplateProjectWebView/resources/web/button-control.js create mode 100644 TemplateProjectWebView/resources/web/index.html create mode 100644 TemplateProjectWebView/resources/web/knob-control.js create mode 100644 TemplateProjectWebView/resources/web/script.js diff --git a/TemplateProjectWebView/.vscode/c_cpp_properties.json b/TemplateProjectWebView/.vscode/c_cpp_properties.json new file mode 100644 index 00000000..1d4217f2 --- /dev/null +++ b/TemplateProjectWebView/.vscode/c_cpp_properties.json @@ -0,0 +1,49 @@ +{ + "env": { + "commonIncludePaths": [ + "${workspaceFolder}/**", + "${workspaceFolder}/../../WDL/**", + "${workspaceFolder}/../../IPlug/**", + "${workspaceFolder}/../../IGraphics/**", + "${workspaceFolder}/../../Dependencies/**" + ], + "commonDefs": [ + "APP_API", + "IPLUG_DSP=1", + "IPLUG_EDITOR=1", + "IGRAPHICS_NANOVG", + "NOMINMAX" + ] + }, + "configurations": [ + { + "name": "Mac", + "includePath": [ + "${commonIncludePaths}", + "${workspaceFolder}/../../Dependencies/Build/mac/include/**" + ], + "defines": [ + "${commonDefs}", + "OS_MAC", + "IGRAPHICS_METAL" + ], + "macFrameworkPath": [ + "/System/Library/Frameworks", + "/Library/Frameworks" + ], + "cppStandard": "c++14" + }, + { + "name": "Win32", + "includePath": [ + "${commonIncludePaths}" + ], + "defines": [ + "${commonDefs}", + "OS_WIN", + "IGRAPHICS_GL2" + ] + } + ], + "version": 4 +} \ No newline at end of file diff --git a/TemplateProjectWebView/CLAUDE.md b/TemplateProjectWebView/CLAUDE.md new file mode 100644 index 00000000..7377b26f --- /dev/null +++ b/TemplateProjectWebView/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +iPlug2 plugin/app project with a **WebView UI** — the editor is authored in HTML/CSS/JS under `resources/web/`, not in IGraphics/C++. + +> This file is duplicated by `../duplicate.py` along with the rest of the project, and the project-name string-rewrite pass runs over it — so `TemplateProjectWebView.cpp` etc. resolve to the new project's filenames after duplication. + +## Distributed plugins + +iPlug2 has a mechanism to split DSP and UI builds in two, in order that the plugin can run across procees or sandboxed boundaries, e.g. AudioWorklet in the WASM target. Typically this is only used in WASM builds. Code that should be part of the UI build is guarded with IPLUG_EDITOR and code that is part of the DSP build is guarded with IPLUG_DSP. The build system includes graphics related code only in the UI build etc. + +IT IS VERY IMPORTANT TO MAKE SURE CODE AND INCLUDES ARE CORRECTLY GUARDED FOR WASM BUILDS - WHICH ARE USED BY HOT RELOADING IDEs - EVEN IF THE USER ULTIMATELY BUILDS A NATIVE PLUGIN/APP. + +## UI: WebView, no IGraphics + +The plugin derives from `iplug::Plugin` and, is built with `WEBVIEW_EDITOR_DELEGATE` defined in order to swap in the right base class. It hosts a platform webview (WKWebView on macOS, WebView2 on Windows) that renders `resources/web/index.html`. Layout is CSS, controls are HTML custom elements. The webview also functions in the browser via the WASM target. + +Key UI pieces in the C++ code (`TemplateProjectWebView.cpp` / `.h`), all gated behind `#ifdef IPLUG_EDITOR`: + +- `mEditorInitFunc` runs when the editor opens. It calls `LoadIndexHtml(__FILE__, GetBundleID())` — which loads `resources/web/index.html` **from the source tree in debug builds and from the app bundle in release builds** — then `EnableScroll(false)`. +- `SetEnableDevTools(true)` is enabled in `DEBUG` builds: right-click → Inspect (macOS) opens Web Inspector against the live UI. + +## DSP: C++ + +- `ProcessBlock` is plain DSP and is independent of the UI backend. + +## The JS ↔ C++ message bridge + +The webview and the plugin talk through a fixed set of bridge functions defined in `resources/web/script.js`. Do not rename them — iPlug2 calls them by name. + +**Delegate → UI** (native calls these JS functions; you implement the `On*` handlers in `index.html`): + +- `SPVFD(paramIdx, val)` — Set Parameter Value From Delegate → calls `OnParamChange(paramIdx, value)`. This is how host/automation parameter changes reach the UI. +- `SCVFD` / `SCMFD` — control value / control message from delegate. +- `SAMFD(msgTag, dataSize, msg)` — arbitrary message from delegate → calls `OnMessage(msgTag, dataSize, data)` (data is base64). The plugin pushes the parameter list as JSON this way once the page loads. +- `SMMFD` / `SSMFD` — MIDI / sysex from delegate. + +**UI → Delegate** (your JS calls these; they marshal to `IPlugSendMsg` and surface on the C++ side): + +- `SPVFUI(paramIdx, normValue)` — Set Parameter Value From UI. `normValue` is **normalized 0–1**. Call on every value change. +- `BPCFUI(paramIdx)` / `EPCFUI(paramIdx)` — Begin / End Parameter Change From UI. Wrap a drag gesture so the host records a single automation gesture and can undo it atomically. +- `SAMFUI(msgTag, ctrlTag = -1, data = 0)` — arbitrary message from UI → arrives at C++ `OnMessage(msgTag, ctrlTag, dataSize, pData)`. +- `SMMFUI` / `SSMFUI` — send MIDI / sysex from UI. + +On the C++ side, override `OnMessage(int msgTag, int ctrlTag, int dataSize, const void* pData)` to receive `SAMFUI` calls. The template uses it to handle the editor-size buttons: `EMsgTags` in the header (`kMsgTagButtonSmall/Medium/Large`) maps to `Resize(...)` calls. Return `true` if you handled the message. + +## Parameters + +- Parameters are declared the usual iPlug2 way: add to `EParams` in the header and `GetParam(...)->InitDouble(...)` (etc.) in the constructor. +- The UI does **not** hard-code parameter ranges. After the page loads, the plugin sends the full parameter list as JSON via `SAMFD` (msgTag `-1`); `OnMessage` in `index.html` parses it and configures each `knob-control` (`min`, `max`, `label`, `default-value`) by matching its `param-id` attribute. So adding/retitling a parameter in C++ flows to the UI automatically — you only bind a control to a `param-id`. + +## Web controls — custom elements + +Controls are HTML Custom Elements (Web Components), each in its own module: + +- `resources/web/knob-control.js` — ``. On drag it calls `BPCFUI` (start), `SPVFUI` (each move, normalized), `EPCFUI` (end). Host-driven changes arrive via `updateValueFromHost(normalizedValue)`, dispatched from `OnParamChange`. `observedAttributes` includes `param-id`, `min`, `max`, `label`, `default-value`. +- `resources/web/button-control.js` — ``, a styled shadow-DOM button. The template wires `onclick="SAMFUI(n)"` directly in `index.html` to trigger the resize messages. + +### Adding a new control — checklist + +1. **C++:** add the parameter to `EParams` and `Init*` it in the constructor (for a parameter-bound control). For a non-parameter action, define a tag in `EMsgTags` and handle it in `OnMessage`. +2. **HTML:** add the element in `index.html` with its `param-id` (for a knob) or an `onclick`/event wiring (for a button/action). +3. **Bind to the host:** a parameter control must call `BPCFUI`→`SPVFUI`(normalized)→`EPCFUI` on user input, and implement an update path from `OnParamChange` (e.g. `updateValueFromHost`) so host automation moves it. +4. **Styling:** layout via CSS in the `index.html` ` + + `; + + this.button = this.shadowRoot.querySelector('button'); + this.button.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('button-click')); + }); + } +} + +customElements.define('button-control', ButtonControl); diff --git a/TemplateProjectWebView/resources/web/index.html b/TemplateProjectWebView/resources/web/index.html new file mode 100644 index 00000000..9e36958c --- /dev/null +++ b/TemplateProjectWebView/resources/web/index.html @@ -0,0 +1,137 @@ + + + + + + + + + + + + + +

TemplateProjectWebView

+
+
+ +
+ +
+ Editor size + Small + Medium + Large +
+
+ + diff --git a/TemplateProjectWebView/resources/web/knob-control.js b/TemplateProjectWebView/resources/web/knob-control.js new file mode 100644 index 00000000..9024ba01 --- /dev/null +++ b/TemplateProjectWebView/resources/web/knob-control.js @@ -0,0 +1,245 @@ +class KnobControl extends HTMLElement { + constructor() { + super(); + + this.paramId = 0; + this.controlTag = ''; + this.defaultValue = 0.0; + this.value = 0.0; + + this.label = this.getAttribute('label') || ''; + this.minValue = parseFloat(this.getAttribute('min')) || 0; + this.maxValue = parseFloat(this.getAttribute('max')) || 100; + const units = this.getAttribute('units') || ''; + const minAngle = parseFloat(this.getAttribute('min-angle')) || -135; + const maxAngle = parseFloat(this.getAttribute('max-angle')) || 135; + const circleStrokeColor = this.getAttribute('circle-stroke-color') || '#eef1f5'; + const circleStrokeWidth = parseFloat(this.getAttribute('circle-stroke-width')) || 2; + const circleFillColor = this.getAttribute('circle-fill-color') || '#18202d'; + const pointerColor = this.getAttribute('pointer-color') || '#2563eb'; + const pointerWidth = parseFloat(this.getAttribute('pointer-width')) || 4; + const valueArcColor = this.getAttribute('value-arc-color') || '#2563eb'; + const valueArcWidth = parseFloat(this.getAttribute('value-arc-width')) || 3; + const trackBgColor = this.getAttribute('track-bg-color') || '#c9ced6'; + + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = ` + +
+
${this.label}
+ + + + + + +
0 ${units}
+
+ `; + + const pointer = this.shadowRoot.querySelector('.pointer'); + const valueElement = this.shadowRoot.querySelector('.value'); + let currentValue = this.defaultValue; + + const valueArc = this.shadowRoot.querySelector('.value-arc'); + const trackBg = this.shadowRoot.querySelector('.track-bg'); + + const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => { + const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0; + return { + x: centerX + (radius * Math.cos(angleInRadians)), + y: centerY + (radius * Math.sin(angleInRadians)) + }; + }; + + const createTrackBg = () => { + const arcRadius = 48; + const start = polarToCartesian(50, 50, arcRadius, minAngle); + const end = polarToCartesian(50, 50, arcRadius, maxAngle); + + const largeArcFlag = maxAngle - minAngle <= 180 ? 0 : 1; + + const d = [ + 'M', start.x, start.y, + 'A', arcRadius, arcRadius, 0, largeArcFlag, 1, end.x, end.y + ].join(' '); + + trackBg.setAttribute('d', d); + }; + + createTrackBg(); + + const updateValueArc = (angle) => { + const startAngle = minAngle; + const endAngle = angle; + const arcRadius = 48; + + const start = polarToCartesian(50, 50, arcRadius, startAngle); + const end = polarToCartesian(50, 50, arcRadius, endAngle); + + const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1; + + const d = [ + 'M', start.x, start.y, + 'A', arcRadius, arcRadius, 0, largeArcFlag, 1, end.x, end.y + ].join(' '); + + valueArc.setAttribute('d', d); + }; + + const updateValue = (value, normalized = false) => { + let finalValue; + if (normalized) { + // If we receive a normalized value (0-1), convert it to our range + finalValue = this.minValue + (value * (this.maxValue - this.minValue)); + } else { + finalValue = value; + } + + currentValue = finalValue; + valueElement.textContent = `${finalValue.toFixed(1)} ${units}`; + const normValue = ((finalValue - this.minValue) / (this.maxValue - this.minValue)); + const angle = minAngle + normValue * (maxAngle - minAngle); + pointer.setAttribute('transform', `rotate(${angle}, 50, 50)`); + updateValueArc(angle); + + if (typeof window['SPVFUI'] === 'function') { + window['SPVFUI'](this.paramId, normValue); + } + }; + + const startDrag = (e) => { + + if (e.button == 2) return; // right click (context menu + + if (typeof window['BPCFUI'] === 'function') { + window['BPCFUI'](this.paramId); + } + + e.preventDefault(); + const svg = e.currentTarget.parentElement; + + let initialY = ('touches' in e) ? e.touches[0].clientY : e.clientY; + let initialValue = currentValue; + + const onMove = (e) => { + const clientY = ('touches' in e) ? e.touches[0].clientY : e.clientY; + const deltaY = initialY - clientY; + const valueChange = deltaY * (this.maxValue - this.minValue) / 100; + const value = Math.min(Math.max(initialValue + valueChange, this.minValue), this.maxValue); + updateValue(value); + }; + + const onEnd = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onEnd); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + document.body.classList.remove('hidden-cursor'); + document.body.style.cursor = ''; + + if (typeof window['EPCFUI'] === 'function') { + window['EPCFUI'](this.paramId); + } + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onEnd); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + document.body.classList.add('hidden-cursor'); + document.body.style.cursor = 'none'; + }; + + const onWheel = (e) => { + e.preventDefault(); + const delta = e.deltaY < 0 ? 1 : -1; + const valueChange = delta * (this.maxValue - this.minValue) / 100; + const value = Math.min(Math.max(currentValue + valueChange, this.minValue), this.maxValue); + updateValue(value); + }; + + this.shadowRoot.querySelector('circle').addEventListener('mousedown', startDrag); + this.shadowRoot.querySelector('circle').addEventListener('touchstart', startDrag, { passive: false }); + this.shadowRoot.querySelector('circle').addEventListener('wheel', onWheel); + +// updateValue(currentValue); + + this.shadowRoot.querySelector('.container').__updateValue = updateValue; + + // Expose updateValue method with a different name to avoid conflicts + this.updateValueFromHost = (normalizedValue) => updateValue(normalizedValue, true); + } + + connectedCallback() { + // Set the parameter ID, control tag, and initial value + this.paramId = this.getAttribute('param-id') || -1; + this.controlTag = this.getAttribute('control-tag') || ''; + this.defaultValue = parseFloat(this.getAttribute('default-value')) || 0.0; + } + + attributeChangedCallback(attrName, oldVal, newVal) { + if (attrName === 'param-id') { + this.paramId = parseInt(newVal); + } else if (attrName === 'control-tag') { + this.controlTag = parseInt(newVal); + } else if (attrName === 'min') { + this.minValue = parseFloat(newVal); + } else if (attrName === 'max') { + this.maxValue = parseFloat(newVal); + } else if (attrName === 'label') { + this.label = newVal; + } else if (attrName === 'default-value') { + this.defaultValue = parseFloat(newVal); + } + } + + static get observedAttributes() { + return ['param-id', 'default-value', 'control-tag', 'label', 'min', 'max'/*, 'value'*/]; + } + + // Getter and setter for value + get value() { + return this.getAttribute('value'); + } + + set value(newValue) { + this.setAttribute('value', newValue); + } +} + +customElements.define('knob-control', KnobControl); diff --git a/TemplateProjectWebView/resources/web/script.js b/TemplateProjectWebView/resources/web/script.js new file mode 100644 index 00000000..3ea33f9b --- /dev/null +++ b/TemplateProjectWebView/resources/web/script.js @@ -0,0 +1,105 @@ +// FROM DELEGATE + +function SPVFD(paramIdx, val) { + console.log("paramIdx: " + paramIdx + " value:" + val); + OnParamChange(paramIdx, val); +} + +function SCVFD(ctrlTag, val) { + OnControlChange(ctrlTag, val); +// console.log("SCVFD ctrlTag: " + ctrlTag + " value:" + val); +} + +function SCMFD(ctrlTag, msgTag, msg) { +// var decodedData = window.atob(msg); + console.log("SCMFD ctrlTag: " + ctrlTag + " msgTag:" + msgTag + "msg:" + msg); +} + +function SAMFD(msgTag, dataSize, msg) { + OnMessage(msgTag, dataSize, msg); +} + +function SMMFD(statusByte, dataByte1, dataByte2) { + console.log("Got MIDI Message" + status + ":" + dataByte1 + ":" + dataByte2); +} + +function SSMFD(offset, size, msg) { + console.log("Got Sysex Message"); +} + +// FROM UI +// data should be a base64 encoded string +function SAMFUI(msgTag, ctrlTag = -1, data = 0) { + var message = { + "msg": "SAMFUI", + "msgTag": msgTag, + "ctrlTag": ctrlTag, + "data": data + }; + + IPlugSendMsg(message); +} + +function SMMFUI(statusByte, dataByte1, dataByte2) { + var message = { + "msg": "SMMFUI", + "statusByte": statusByte, + "dataByte1": dataByte1, + "dataByte2": dataByte2 + }; + + IPlugSendMsg(message); +} + +// data should be a base64 encoded string +function SSMFUI(data = 0) { + var message = { + "msg": "SSMFUI", + "data": data + }; + + IPlugSendMsg(message); +} + +function EPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("EPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "EPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function BPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("BPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "BPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function SPVFUI(paramIdx, value) { + if (paramIdx < 0) { + console.log("SPVFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "SPVFUI", + "paramIdx": parseInt(paramIdx), + "value": value + }; + + IPlugSendMsg(message); +} diff --git a/duplicate.py b/duplicate.py index a7e7a37a..d9bef7f2 100755 --- a/duplicate.py +++ b/duplicate.py @@ -55,7 +55,8 @@ "en-osx.lproj", "project.xcworkspace", "Images.xcassets", -"build-web-wasm" +"build-web-wasm", +"web" ] def randomFourChar(chars=string.ascii_letters + string.digits): From 17ea5638216a6f0655d50bdb15ecf37463bae23d Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Thu, 9 Jul 2026 19:27:37 +0200 Subject: [PATCH 2/8] Add TemplateProjectReaperExtensionWebView template New template alongside TemplateProject and TemplateProjectWebView, for a REAPER extension (not a plugin) whose UI is authored in HTML/CSS/JS. Ported from iPlug2's Examples/IPlugReaperExtensionWebUI on the examples/reaper-ext branch. Carries the example's C++ across whole: IMPAPI imports, registered actions, dock toggle, per-project state via SaveProjectState/LoadProjectStateLine, the OnActionRun + OnIdle refresh pair, and the offline media-item processing that writes a gain-scaled .wav through an audio accessor and inserts it. CMake only, following TemplateProjectWebView, so the example's .sln, .xcworkspace, projects/, config/ and scripts/ are omitted. CMakeLists.txt points IPLUG2_DIR at ../iPlug2 and raises the minimum to 3.25 to match the sibling templates. config.h gains PLUG_NAME/PLUG_MFR/version/copyright so duplicate.py's AcmeInc rewrite has something to act on; the rest stays minimal, since an extension declares no formats, channel I/O or parameters. Verified: builds and links to reaper_*.dylib exporting only ReaperPluginEntry and SWELL_dllMain; duplicate.py produces a project with no leftover template strings (including the uppercase .RPP state key) that builds clean. Note the release-build limitation documented in the template's CLAUDE.md: an extension is not a bundle, so LoadIndexHtml() can only find the web UI in debug builds. Tracked upstream in iPlug2/iPlug2#1149. Requires no iPlug2 change: the framework is identical between the submodule's current master pin and the reaper-ext branch, so the pointer is left as is. CLAUDE.md: list all three templates, and note that the CMake-only ones have no config/ directory and so only work with duplicate.py's three-argument form. --- CLAUDE.md | 8 +- .../.gitignore | 31 ++ .../.vscode/c_cpp_properties.json | 48 +++ .../CLAUDE.md | 175 ++++++++ .../CMakeLists.txt | 58 +++ .../README.md | 17 + .../TemplateProjectReaperExtensionWebView.RPP | 86 ++++ ...ojectReaperExtensionWebView.code-workspace | 12 + .../TemplateProjectReaperExtensionWebView.cpp | 390 ++++++++++++++++++ .../TemplateProjectReaperExtensionWebView.h | 46 +++ .../config.h | 17 + .../resources/main.rc | 86 ++++ .../resources/main.rc_mac_dlg | 24 ++ .../resources/main.rc_mac_menu | 3 + .../resources/resource.h | 16 + .../resources/web/index.html | 134 ++++++ .../resources/web/script.js | 105 +++++ 17 files changed, 1255 insertions(+), 1 deletion(-) create mode 100644 TemplateProjectReaperExtensionWebView/.gitignore create mode 100644 TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json create mode 100644 TemplateProjectReaperExtensionWebView/CLAUDE.md create mode 100644 TemplateProjectReaperExtensionWebView/CMakeLists.txt create mode 100644 TemplateProjectReaperExtensionWebView/README.md create mode 100644 TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP create mode 100644 TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace create mode 100644 TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp create mode 100644 TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h create mode 100644 TemplateProjectReaperExtensionWebView/config.h create mode 100755 TemplateProjectReaperExtensionWebView/resources/main.rc create mode 100644 TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg create mode 100644 TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu create mode 100755 TemplateProjectReaperExtensionWebView/resources/resource.h create mode 100644 TemplateProjectReaperExtensionWebView/resources/web/index.html create mode 100644 TemplateProjectReaperExtensionWebView/resources/web/script.js diff --git a/CLAUDE.md b/CLAUDE.md index 20cff88b..c7f1e5ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,9 @@ This repo keeps plugin projects separate from the iPlug2 framework. Structure: - @iPlug2 - Framework as git submodule -- TemplateProject - Base template for new plugins +- TemplateProject - Base template for new plugins (IGraphics UI) +- TemplateProjectWebView - Plugin template with a WebView UI (HTML/CSS/JS), CMake only +- TemplateProjectReaperExtensionWebView - REAPER extension (not a plugin) with a WebView UI, CMake only, macOS/Windows - duplicate.py - Script to create new projects by duplicating a template ## Workflow @@ -16,6 +18,10 @@ To create a new plugin, duplicate @TemplateProject using the `new-plugin` skill ./duplicate.py TemplateProject [PluginName] [ManufacturerName] ``` +Substitute another template for `TemplateProject` to start from a WebView UI or a REAPER +extension. Note that the CMake-only templates have no `config/` directory, so they only work +with the three-argument form of `duplicate.py` (output into the repo root). + After duplication, optionally commit: ```bash git add [PluginName]/* .vscode/* .github/* bump_version.py diff --git a/TemplateProjectReaperExtensionWebView/.gitignore b/TemplateProjectReaperExtensionWebView/.gitignore new file mode 100644 index 00000000..1f95e9dc --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/.gitignore @@ -0,0 +1,31 @@ +*.vs +*.exe +*.sdf +*.opensdf +*.zip +*.suo +*.ncb +*.vcproj.* +*.pkg +*.dmg +*.depend +*.layout +*.mode1v3 +*.db +*.LSOverride +*.xcuserdata +*.xcschememanagement.plist +build-* +ipch/* +gui/* + +Icon? +.DS_Stor* + +# The bundled .RPP has no media directory configured, so audio processed by the extension +# is written here, alongside REAPER's peak caches for it +*.wav +peaks/ + +# CMake build directory +build/ diff --git a/TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json b/TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json new file mode 100644 index 00000000..c2ac4c32 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json @@ -0,0 +1,48 @@ +{ + "env": { + "commonIncludePaths": [ + "${workspaceFolder}/**", + "${workspaceFolder}/../iPlug2/WDL/**", + "${workspaceFolder}/../iPlug2/IPlug/**", + "${workspaceFolder}/../iPlug2/Dependencies/IPlug/REAPER_SDK/**" + ], + "commonDefs": [ + "REAPER_EXT_API", + "IPLUG_DSP=1", + "IPLUG_EDITOR=1", + "WEBVIEW_EDITOR_DELEGATE", + "NO_IGRAPHICS", + "NOMINMAX" + ] + }, + "configurations": [ + { + "name": "Mac", + "includePath": [ + "${commonIncludePaths}" + ], + "defines": [ + "${commonDefs}", + "OS_MAC", + "SWELL_PROVIDED_BY_APP" + ], + "macFrameworkPath": [ + "/System/Library/Frameworks", + "/Library/Frameworks" + ], + "cppStandard": "c++17" + }, + { + "name": "Win32", + "includePath": [ + "${commonIncludePaths}" + ], + "defines": [ + "${commonDefs}", + "OS_WIN" + ], + "cppStandard": "c++17" + } + ], + "version": 4 +} diff --git a/TemplateProjectReaperExtensionWebView/CLAUDE.md b/TemplateProjectReaperExtensionWebView/CLAUDE.md new file mode 100644 index 00000000..126b2d57 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/CLAUDE.md @@ -0,0 +1,175 @@ +# CLAUDE.md + +A **REAPER extension** with a **WebView UI**. This is not an audio plugin: it loads into REAPER +itself, calls the REAPER API, and puts up a dockable window whose UI is authored in HTML/CSS/JS +under `resources/web/`. + +> This file is duplicated by `../duplicate.py` along with the rest of the project, and the +> project-name string-rewrite pass runs over it — so `TemplateProjectReaperExtensionWebView.cpp` +> etc. resolve to the new project's filenames after duplication. + +## How this differs from a plugin project + +Coming from `../TemplateProject` or `../TemplateProjectWebView`, most of the plugin scaffolding is +simply absent, and it is worth knowing what is gone before looking for it: + +- **No `iplug::Plugin`.** The class derives from `ReaperExtBase`, via + `ReaperExt_include_in_plug_hdr.h` / `ReaperExt_include_in_plug_src.h`. +- **No `ProcessBlock`, no realtime thread.** Audio work here is offline: you pull samples out of + the project with an audio accessor and write a file. Nothing you write runs on the audio thread, + so the usual realtime-safety rules don't apply. +- **No parameters.** There is no host to expose them to, so there is no `EParams`, no + `GetParam()`, and no automation. `SPVFD`/`SPVFUI` and the rest of the parameter bridge exist in + `script.js` but go unused — state lives in plain C++ members and is pushed to the UI by hand + (see *Talking to the UI* below). +- **No plugin formats, no presets, no `PLUG_CHANNEL_IO`.** `config.h` is correspondingly tiny. +- **macOS and Windows only.** No iOS, no WASM/Emscripten, so there is no `IPLUG_EDITOR` / + `IPLUG_DSP` split to maintain — `ReaperExt.cmake` defines both to `1` and the whole extension is + a single binary. The `CMakeLists.txt` bails out early on iOS and Emscripten. +- **No `iplug_add_plugin()`.** The target is a plain `add_library(... MODULE ...)` configured by + `iplug_configure_reaperext()`, which names the binary `reaper_` (REAPER only loads + extensions with that prefix), restricts the exported symbols to `ReaperPluginEntry` and + `SWELL_dllMain`, and deploys to REAPER's `UserPlugins` directory. + +On macOS the extension links only SWELL's *module stub* (`swell-modstub.mm`), because REAPER +already provides SWELL. `ReaperExt.cmake` handles this; compiling the full SWELL sources into the +extension would leave every SWELL symbol undefined and the extension would silently fail to load. + +## Calling the REAPER API: IMPAPI + +REAPER's API is not linked — it is handed to the extension as function pointers at load time. Each +function you want must be imported by name in the constructor: + +```cpp +IMPAPI(CountTracks); +IMPAPI(InsertTrackAtIndex); +``` + +`IMPAPI` declares a global of the right signature (from `reaper_plugin_functions.h`) and binds it. +**A function you forget to `IMPAPI` compiles fine and is a null pointer at runtime**, so a crash on +first use of a new REAPER call is almost always a missing `IMPAPI` line. Add the import next to the +others at the top of the constructor. + +## Registering actions + +`RegisterAction(name, lambda, addToMenu, toggleStatePtr, contextMenuId, menuLabel)` puts a lambda in +REAPER's action list. The template registers four: + +- Two demo actions (a message box, and one that inserts a track). +- Show/Hide UI and Toggle dock, which pass `GetWindowTogglePtr()` / `GetDockTogglePtr()` as the + toggle-state pointer so REAPER draws a checkmark next to the menu item. +- `Process selected item`, registered into the `"Media item context"` menu rather than the + Extensions submenu. That menu is shared with every other installed extension, so its label names + the extension explicitly. + +## The UI + +`mEditorInitFunc` runs when the window opens and calls `LoadIndexHtml(__FILE__, "")`, which loads +`resources/web/index.html` **from the source tree in debug builds**. Note the empty bundle id: +`ReaperExtBase` doesn't inherit `GetBundleID()` (that lives on `IPluginBase`), and a REAPER +extension is a bare dylib/DLL rather than a bundle. Release builds therefore have no resource +directory to load from — **if you ship this, embed the UI or point `LoadFile` at a real install +path**. `SetEnableDevTools(true)` is on in `_DEBUG`, so right-click → Inspect opens the Web +Inspector. + +### Talking to the UI + +Because there are no parameters, the two directions are asymmetric: + +- **UI → C++**: JS calls `SAMFUI(msgTag, ctrlTag, data)` from `script.js`, which arrives at the + `SendArbitraryMsgFromUI(int msgTag, int ctrlTag, int dataSize, const void* pData)` override. This + is *not* the `bool OnMessage(...)` you override on `iplug::Plugin` — different name, no return + value. `EMsgTags` in the header must stay in sync with the `kMsgTag*` constants at the top of + `index.html`. The template smuggles the gain percentage through the `ctrlTag` int rather than + packing a data payload. +- **C++ → UI**: there is no delegate push for non-parameter state, so the template calls + `EvaluateJavaScript("OnTrackCount(3)")` and friends, and `index.html` defines matching global + functions (`OnTrackCount`, `OnSetGain`, `OnDockState`). + +`OnUIOpen()` is the hook for pushing current state into a freshly-loaded page. Call it for anything +the UI needs to render correctly on open — the window is destroyed and recreated when docked, so +this runs again after `ToggleDocking()`. + +`index.html` also defines empty `OnParamChange` / `OnControlChange` / `OnMessage` stubs. Don't +delete them: `OnWebContentLoaded` sends a parameter-list message regardless, and `script.js` +dispatches into them unconditionally. + +## Reacting to project changes + +Two hooks, and the template uses both deliberately: + +- `OnActionRun(commandId, flag)` — REAPER's `hookpostcommand`, fired after any main-section action. + This catches user edits (insert track, undo) with no polling lag. +- `OnIdle()` — a safety net for changes that no action caused, such as our own *Add Track* button + calling `InsertTrackAtIndex()` directly. + +`PushTrackCount(force)` only calls into the webview when the count actually changed, so the idle +tick is cheap. + +## Per-project state + +An extension has no plugin state chunk. To persist per-project data, REAPER offers `projectconfig`, +wired up here as three overrides: + +- `SaveProjectState(ctx)` — write lines into the `.RPP`. Prefix keys with the extension name so they + can't collide with another extension's. +- `LoadProjectStateLine(line)` — REAPER offers every unrecognised line to every extension; parse the + ones that are yours and **return `true` only for those**, `false` otherwise. +- `OnBeginLoadProjectState(isUndo)` — reset to defaults before the lines arrive, since a project + without your key must not inherit the previous project's value. + +This also gives you undo for free: REAPER replays project state on undo, so the same three hooks +restore the value. + +## Offline processing a media item + +`ProcessSelectedItem(gain)` is a worked example of the read-modify-write cycle: it reads the +selected item through a take audio accessor, applies gain, writes a new `.wav` and inserts it into +the project. Most of its length is the awkward parts, which are commented in place. The ones worth +knowing before writing your own: + +- Output goes to the project's media directory (`GetProjectPathEx`), not next to the source file, + which may be on read-only or shared storage. It falls back to the source directory for an unsaved + project. +- The filename is uniquified (`-001`, `-002`, …) rather than overwritten — the existing file is + usually already open in the project. +- **Section sources** (reversed, looped or trimmed takes) report an empty filename but wrap a real + source; resolve the name through `GetMediaSourceParent()`. Takes with no media file at all + (in-project MIDI) must be rejected. +- WDL's `WaveWriter` writes mono or stereo only, so the channel count is clamped for the writer, the + accessor and the interleaved buffer alike. Feeding it the source count produces a header that + disagrees with the data. +- The accessor leaves gaps untouched, so each block must be zeroed before `GetAudioAccessorSamples`. +- `WaveWriter` finalises the header in its destructor — scope it so the file is complete before + `InsertMedia()` reads it. +- `InsertMedia()` drops the file at the edit cursor, so move the cursor to the source item's + position and restore it afterwards. + +## Adding a new UI control — checklist + +1. **C++:** add a tag to `EMsgTags` and handle it in `SendArbitraryMsgFromUI`. `IMPAPI` any new + REAPER function you call. +2. **JS:** mirror the tag as a `const kMsgTag* = n` at the top of `index.html` and call + `SAMFUI(kMsgTagYours)` from the element. +3. **State back to the UI:** if C++ owns the value, add a `Push*()` that calls + `EvaluateJavaScript("OnYourThing(...)")`, define the matching global in `index.html`, and call it + from `OnUIOpen()` so the control is correct when the window (re)opens. + +The common mistake is step 3: the button works, but the UI shows stale state after a dock toggle, +project load or undo. + +## Files of interest + +- `TemplateProjectReaperExtensionWebView.cpp` / `.h` — the extension class: `IMPAPI` imports, action + registration, message tags, the project-state hooks, and the offline process. +- `resources/web/index.html` — the UI: markup, CSS, the `kMsgTag*` constants, and the global + functions C++ calls via `EvaluateJavaScript`. +- `resources/web/script.js` — the bridge function definitions. Framework glue; extend, don't rename. +- `config.h` — just a class name, window size and FPS. No formats, no channel I/O. +- `CMakeLists.txt` — `ReaperExt.cmake` + `WebView.cmake`, a `MODULE` library, and + `iplug_configure_reaperext()`. +- `resources/main.rc`, `resource.h`, `main.rc_mac_dlg` — the empty host dialog the webview is + parented into. Windows uses `main.rc`; macOS uses the SWELL-generated `main.rc_mac_dlg`. +- `TemplateProjectReaperExtensionWebView.RPP` — a scratch REAPER project for testing. It has no + media directory configured, so processed audio and REAPER's peak caches land in this directory; + both are gitignored. diff --git a/TemplateProjectReaperExtensionWebView/CMakeLists.txt b/TemplateProjectReaperExtensionWebView/CMakeLists.txt new file mode 100644 index 00000000..b9931967 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.25) +project(TemplateProjectReaperExtensionWebView VERSION 1.0.0) + +# REAPER Extensions are macOS and Windows only (no iOS, no Emscripten) +if(IOS OR CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + message(STATUS "TemplateProjectReaperExtensionWebView is not supported on ${CMAKE_SYSTEM_NAME}. Skipping.") + return() +endif() + +# The WebView editor delegate uses std::filesystem, which requires macOS 10.15+ +# (iPlug2.cmake otherwise defaults to 10.13). Set before including iPlug2.cmake. +if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum macOS version") +endif() + +if(NOT DEFINED IPLUG2_DIR) + set(IPLUG2_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../iPlug2" CACHE PATH "iPlug2 root directory") +endif() + +# Include iPlug2 CMake configuration +include(${IPLUG2_DIR}/iPlug2.cmake) +find_package(iPlug2 REQUIRED) + +# REAPER extensions are not plugins, so they do not go through iplug_add_plugin() +include(${IPLUG2_CMAKE_DIR}/ReaperExt.cmake) + +# WebViewEditorDelegate instead of IGraphics +include(${IPLUG2_CMAKE_DIR}/WebView.cmake) + +# Standard plugin setup +set(PROJECT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +set(PLUG_RESOURCES_DIR ${PROJECT_DIR}/resources) + +set(SOURCE_FILES + TemplateProjectReaperExtensionWebView.cpp + TemplateProjectReaperExtensionWebView.h + resources/resource.h + resources/web/index.html + resources/web/script.js +) + +# Add Windows resources +if(WIN32) + list(APPEND SOURCE_FILES ${PLUG_RESOURCES_DIR}/main.rc) +endif() + +# Build as a shared library (MODULE for dynamic loading) +add_library(${PROJECT_NAME} MODULE ${SOURCE_FILES}) + +# Base configuration - use the WebView UI instead of IGraphics +iplug_add_target(${PROJECT_NAME} PUBLIC + INCLUDE ${PROJECT_DIR} ${PROJECT_DIR}/resources + LINK iPlug2::WebView +) + +# Names the binary reaper_, restricts exported symbols, and deploys to +# REAPER's UserPlugins directory when IPLUG_DEPLOY_PLUGINS is set +iplug_configure_reaperext(${PROJECT_NAME} ${PROJECT_NAME}) diff --git a/TemplateProjectReaperExtensionWebView/README.md b/TemplateProjectReaperExtensionWebView/README.md new file mode 100644 index 00000000..0b702aa5 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/README.md @@ -0,0 +1,17 @@ +# TemplateProjectReaperExtensionWebView readme + +A **REAPER extension** with a **WebView UI**, rather than an audio plugin. It loads into REAPER +itself, registers actions, and opens a dockable window whose UI is authored in HTML/CSS/JS under +`resources/web/` (entry point `index.html`). + +macOS and Windows only. Build with CMake: + +```bash +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +``` + +`IPLUG_DEPLOY_PLUGINS` defaults to `ON`, so the build copies +`reaper_TemplateProjectReaperExtensionWebView.dylib`/`.dll` into REAPER's `UserPlugins` directory. +Restart REAPER and look under Extensions. Build `Debug` while developing: that is what enables +loading the web UI from the source tree and the devtools inspector. diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP new file mode 100644 index 00000000..fe6533ec --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP @@ -0,0 +1,86 @@ + + + RENDER_FILE "" + RENDER_PATTERN "" + RENDER_FMT 0 2 0 + RENDER_1X 0 + RENDER_RANGE 1 0 0 18 1000 + RENDER_RESAMPLE 3 0 1 + RENDER_ADDTOPROJ 0 + RENDER_STEMS 0 + RENDER_DITHER 0 + TIMELOCKMODE 1 + TEMPOENVLOCKMODE 1 + ITEMMIX 0 + DEFPITCHMODE 589824 + TAKELANE 1 + SAMPLERATE 44100 0 0 + + LOCK 1 + + GLOBAL_AUTO -1 + TEMPO 120 4 4 + PLAYRATE 1 0 0.25 4 + SELECTION 0 0 + SELECTION2 0 0 + MASTERAUTOMODE 0 + MASTERTRACKHEIGHT 0 0 + MASTERPEAKCOL 16576 + MASTERMUTESOLO 0 + MASTERTRACKVIEW 0 0.6667 0.5 0.5 0 0 0 + MASTERHWOUT 0 0 1 0 0 0 0 -1 + MASTER_NCH 2 2 + MASTER_VOLUME 1 0 -1 -1 1 + MASTER_FX 1 + MASTER_SEL 0 + + + +> diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace new file mode 100644 index 00000000..a7482c32 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace @@ -0,0 +1,12 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "files.associations": { + "algorithm": "cpp" + } + } +} diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp new file mode 100644 index 00000000..5dec937b --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp @@ -0,0 +1,390 @@ +#include "TemplateProjectReaperExtensionWebView.h" +#include "ReaperExt_include_in_plug_src.h" + +#include "wavwrite.h" + +#include +#include +#include + +TemplateProjectReaperExtensionWebView::TemplateProjectReaperExtensionWebView(reaper_plugin_info_t* pRec) +: ReaperExtBase(pRec) +{ + //Use IMPAPI to register any Reaper APIs that you need to use + IMPAPI(GetNumTracks); + IMPAPI(CountTracks); + IMPAPI(InsertTrackAtIndex); + IMPAPI(ShowConsoleMsg); + IMPAPI(UpdateArrange); + + // APIs for offline processing of a selected media item + IMPAPI(CountSelectedMediaItems); + IMPAPI(GetSelectedMediaItem); + IMPAPI(GetActiveTake); + IMPAPI(GetMediaItemTake_Source); + IMPAPI(GetMediaSourceNumChannels); + IMPAPI(GetMediaSourceSampleRate); + IMPAPI(GetMediaSourceFileName); + IMPAPI(GetMediaSourceParent); + IMPAPI(CreateTakeAudioAccessor); + IMPAPI(GetAudioAccessorStartTime); + IMPAPI(GetAudioAccessorEndTime); + IMPAPI(GetAudioAccessorSamples); + IMPAPI(DestroyAudioAccessor); + IMPAPI(InsertMedia); + IMPAPI(GetMediaItemInfo_Value); + IMPAPI(GetCursorPosition); + IMPAPI(SetEditCurPos); + + // APIs for choosing where to write the processed file + IMPAPI(GetProjectPathEx); + IMPAPI(RecursiveCreateDirectory); + IMPAPI(file_exists); + +#ifdef _DEBUG + SetEnableDevTools(true); +#endif + + // The WebView loads its UI from resources/web/index.html. + // NB. ReaperExtBase does not inherit GetBundleID() (that's on IPluginBase), so we + // pass an empty bundle id - it is only used by release builds that load from a bundle. + mEditorInitFunc = [&]() { + LoadIndexHtml(__FILE__, ""); + EnableScroll(false); + }; + + //Define some lambdas that can be called from either GUI widgets or in response to commands + auto action1 = [](){ + MessageBox(gParent, "Action 1!", "Reaper extension test", MB_OK); //gParent + }; + + auto action2 = [](){ + InsertTrackAtIndex(GetNumTracks(), false); + }; + + //Register an action. args: name, lambda, add to Extensions submenu, toggle ptr, context menu id, menu label + RegisterAction("TemplateProjectReaperExtensionWebView: Action 1 - MsgBox", action1, true, nullptr, nullptr, "Action 1 - MsgBox"); + RegisterAction("TemplateProjectReaperExtensionWebView: Action 2 - AddTrack", action2); + RegisterAction("TemplateProjectReaperExtensionWebView: Action 3 - Show/Hide UI", [&]() { ShowHideMainWindow(); }, true, GetWindowTogglePtr(), nullptr, "Show/Hide UI"); + RegisterAction("TemplateProjectReaperExtensionWebView: Toggle dock UI", [&]() { ToggleDocking(); }, true, GetDockTogglePtr(), nullptr, "Toggle dock UI"); + + // Also expose the offline process on the media item right-click menu. It uses the + // last gain set from the UI (defaults to 100%). Unlike our Extensions submenu, that menu is + // shared with every other extension, so the label names us explicitly. + RegisterAction("TemplateProjectReaperExtensionWebView: Process selected item", [&]() { ProcessSelectedItem(mGain); }, false, nullptr, "Media item context", + "Process selected item with " IPLUG_STRINGIFY(PLUG_CLASS_NAME)); +} + +void TemplateProjectReaperExtensionWebView::SendArbitraryMsgFromUI(int msgTag, int ctrlTag, int dataSize, const void* pData) +{ + switch (msgTag) + { + case kMsgTagAddTrack: + InsertTrackAtIndex(GetNumTracks(), false); + break; + case kMsgTagProcessItem: + // ctrlTag carries the gain as a 0..100 percentage + mGain = static_cast(ctrlTag) / 100.0; + ProcessSelectedItem(mGain); + break; + case kMsgTagToggleDock: + ToggleDocking(); // recreates the window; OnUIOpen() re-pushes state afterwards + break; + default: + break; + } +} + +namespace +{ + +#ifdef OS_WIN +constexpr char kPathSep = '\\'; +#else +constexpr char kPathSep = '/'; +#endif + +/** Splits a full path into its directory (no trailing separator) and the file's stem (the + * filename with any extension removed). Both separators are accepted when splitting, because + * REAPER hands back backslashes on Windows. The extension is stripped from the filename only, + * so a dot in a directory name doesn't truncate the path. */ +void SplitPath(const std::string& path, std::string& dirOut, std::string& stemOut) +{ + const auto sepPos = path.find_last_of("/\\"); + const std::string fileName = (sepPos == std::string::npos) ? path : path.substr(sepPos + 1); + dirOut = (sepPos == std::string::npos) ? std::string() : path.substr(0, sepPos); + + const auto dotPos = fileName.find_last_of('.'); + stemOut = (dotPos == std::string::npos) ? fileName : fileName.substr(0, dotPos); +} + +/** Finds the media file behind a take's source. Section sources - reversed, looped or trimmed + * takes - report an empty filename but wrap a real source, reachable via GetMediaSourceParent(). + * Returns "" for sources with no file at all, e.g. in-project MIDI. + * NB. the name comes from the parent file, while the audio accessor renders the section as the + * take actually sounds (reversed, trimmed). That is what we want. */ +std::string ResolveSourceFileName(PCM_source* pSrc) +{ + char buf[2048]; + + while (pSrc) + { + buf[0] = '\0'; + GetMediaSourceFileName(pSrc, buf, sizeof(buf)); + + if (buf[0] != '\0') + return std::string(buf); + + pSrc = GetMediaSourceParent(pSrc); + } + + return std::string(); +} + +/** The project's media/recording directory, which is where REAPER's own glue and apply-FX + * actions write. Comes back empty for an unsaved project, in which case we fall back to sitting + * next to the source file. */ +std::string ChooseOutputDir(const std::string& srcDir) +{ + char projPath[2048] = {}; + GetProjectPathEx(nullptr, projPath, sizeof(projPath)); + + return (projPath[0] != '\0') ? std::string(projPath) : srcDir; +} + +/** Builds a path that doesn't already exist: /-.wav, then -001, -002... + * Returns "" if every candidate is taken, so we never overwrite an existing file - it is + * usually already open in the project. */ +std::string MakeUniqueOutputPath(const std::string& dir, const std::string& stem, const std::string& extName) +{ + const std::string base = dir + kPathSep + stem + "-" + extName; + + std::string candidate = base + ".wav"; + + if (!file_exists(candidate.c_str())) + return candidate; + + for (int i = 1; i <= 999; i++) + { + char suffix[16]; + snprintf(suffix, sizeof(suffix), "-%03d", i); + candidate = base + suffix + ".wav"; + + if (!file_exists(candidate.c_str())) + return candidate; + } + + return std::string(); +} + +} // namespace + +void TemplateProjectReaperExtensionWebView::ProcessSelectedItem(double gain) +{ + if (CountSelectedMediaItems(nullptr) < 1) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: no media item selected\n"); + return; + } + + MediaItem* item = GetSelectedMediaItem(nullptr, 0); + MediaItem_Take* take = item ? GetActiveTake(item) : nullptr; + PCM_source* src = take ? GetMediaItemTake_Source(take) : nullptr; + + if (!src) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: selected item has no audio take\n"); + return; + } + + const int nch = GetMediaSourceNumChannels(src); + const int srate = GetMediaSourceSampleRate(src); + + if (nch < 1 || srate < 1) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: unsupported source (no PCM audio?)\n"); + return; + } + + // WaveWriter only writes mono or stereo, so clamp the whole pipeline rather than let the + // header disagree with the interleaved data we write + const int outChannels = std::min(nch, 2); + + if (nch > outChannels) + { + WDL_String msg; + msg.SetFormatted(256, "TemplateProjectReaperExtensionWebView: source has %i channels, writing the first %i\n", nch, outChannels); + ShowConsoleMsg(msg.Get()); + } + + const std::string srcFileName = ResolveSourceFileName(src); + + if (srcFileName.empty()) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: selected take has no media file (in-project MIDI?)\n"); + return; + } + + std::string srcDir, srcStem; + SplitPath(srcFileName, srcDir, srcStem); + + const std::string outDir = ChooseOutputDir(srcDir); + + if (outDir.empty()) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: could not determine an output directory (save the project first)\n"); + return; + } + + // The project's recording path may name a subfolder that doesn't exist yet + RecursiveCreateDirectory(outDir.c_str(), 0); + + const std::string outPath = MakeUniqueOutputPath(outDir, srcStem, IPLUG_STRINGIFY(PLUG_CLASS_NAME)); + + if (outPath.empty()) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: could not find a free output filename\n"); + return; + } + + AudioAccessor* acc = CreateTakeAudioAccessor(take); + if (!acc) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: failed to create audio accessor\n"); + return; + } + + const double startTime = GetAudioAccessorStartTime(acc); + const double endTime = GetAudioAccessorEndTime(acc); + const int totalFrames = static_cast((endTime - startTime) * srate + 0.5); + + bool ok = false; + + { + // WaveWriter finalises the file header in its destructor, so scope it so the file + // is complete before we InsertMedia() it below. + WaveWriter writer(outPath.c_str(), 24 /* 24-bit PCM */, outChannels, srate, 0 /* no append */); + + if (writer.Status()) + { + const int kBlockFrames = 4096; + std::vector buf(static_cast(kBlockFrames) * outChannels); + + int framesDone = 0; + while (framesDone < totalFrames) + { + const int n = std::min(kBlockFrames, totalFrames - framesDone); + const double t = startTime + static_cast(framesDone) / srate; + + // Zero first: the accessor leaves gaps (silence) untouched + std::fill(buf.begin(), buf.begin() + static_cast(n) * outChannels, 0.0); + GetAudioAccessorSamples(acc, srate, outChannels, t, n, buf.data()); + + for (int i = 0; i < n * outChannels; i++) + buf[i] *= gain; + + writer.WriteDoubles(buf.data(), n * outChannels); // interleaved + framesDone += n; + } + ok = true; + } + else + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: could not open output file for writing\n"); + } + } + + DestroyAudioAccessor(acc); + + if (ok) + { + WDL_String msg; + msg.SetFormatted(2100, "TemplateProjectReaperExtensionWebView: wrote %s (gain %.0f%%), inserting...\n", outPath.c_str(), gain * 100.0); + ShowConsoleMsg(msg.Get()); + + // InsertMedia() drops the file at the edit cursor, so move the cursor to the source item's + // position first to line the two up in the arrangement, then put the cursor back where the + // user had it (InsertMedia leaves it at the end of what it inserted). + const double cursorPos = GetCursorPosition(); + SetEditCurPos(GetMediaItemInfo_Value(item, "D_POSITION"), false /* moveview */, false /* seekplay */); + + InsertMedia(outPath.c_str(), 1 /* add on a new track */); + + SetEditCurPos(cursorPos, false, false); + UpdateArrange(); + } +} + +void TemplateProjectReaperExtensionWebView::OnIdle() +{ + // Safety-net poll: catches track changes not triggered by an action (e.g. our own + // "Add Track" button, which calls the API directly). Action-driven changes are + // handled immediately in OnActionRun(). + PushTrackCount(false); +} + +void TemplateProjectReaperExtensionWebView::OnActionRun(int commandId, int flag) +{ + // A main-section action ran (insert/remove track, undo, etc.) - refresh right away + // instead of waiting for the next idle tick. + PushTrackCount(false); +} + +void TemplateProjectReaperExtensionWebView::OnUIOpen() +{ + ReaperExtBase::OnUIOpen(); // base sends current parameter values (none here) + + // Push current state to the freshly-loaded UI so the slider / count / dock button are in sync + PushGain(); + PushTrackCount(true); + PushDockState(); +} + +void TemplateProjectReaperExtensionWebView::PushTrackCount(bool force) +{ + int tracks = CountTracks(0); + + if (force || tracks != mPrevTrackCount) { + mPrevTrackCount = tracks; + WDL_String js; + js.SetFormatted(64, "OnTrackCount(%i)", tracks); + EvaluateJavaScript(js.Get()); + } +} + +void TemplateProjectReaperExtensionWebView::PushGain() +{ + WDL_String js; + js.SetFormatted(64, "OnSetGain(%i)", static_cast(mGain * 100.0 + 0.5)); + EvaluateJavaScript(js.Get()); +} + +void TemplateProjectReaperExtensionWebView::PushDockState() +{ + WDL_String js; + js.SetFormatted(64, "OnDockState(%i)", IsDocked() ? 1 : 0); + EvaluateJavaScript(js.Get()); +} + +void TemplateProjectReaperExtensionWebView::SaveProjectState(ProjectStateContext* ctx) +{ + // Persist the gain into the .RPP so it survives save/load and undo + ctx->AddLine("TEMPLATEPROJECTREAPEREXTENSIONWEBVIEW_GAIN %f", mGain); +} + +bool TemplateProjectReaperExtensionWebView::LoadProjectStateLine(const char* line) +{ + double g = 0.0; + if (sscanf(line, " TEMPLATEPROJECTREAPEREXTENSIONWEBVIEW_GAIN %lf", &g) == 1) { + mGain = g; + PushGain(); // reflect it in the UI if the window is open + return true; // this line belonged to us + } + return false; +} + +void TemplateProjectReaperExtensionWebView::OnBeginLoadProjectState(bool isUndo) +{ + // Reset to default; LoadProjectStateLine() restores the value if the project stored one + mGain = 1.0; +} diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h new file mode 100644 index 00000000..a11876d0 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h @@ -0,0 +1,46 @@ +#pragma once + +#include "ReaperExt_include_in_plug_hdr.h" + +enum EMsgTags +{ + kMsgTagAddTrack = 0, + kMsgTagProcessItem = 1, + kMsgTagToggleDock = 2, + kNumMsgTags +}; + +using namespace iplug; + +class TemplateProjectReaperExtensionWebView : public ReaperExtBase +{ +public: + TemplateProjectReaperExtensionWebView(reaper_plugin_info_t* pRec); + void OnIdle() override; + + // Messages sent from the WebView UI arrive here + void SendArbitraryMsgFromUI(int msgTag, int ctrlTag, int dataSize, const void* pData) override; + + // Called once the WebView content has loaded - push current state to the fresh UI + void OnUIOpen() override; + + // hookpostcommand: react to project edits immediately (no polling lag) + void OnActionRun(int commandId, int flag) override; + + // projectconfig: persist the gain in the .RPP + void SaveProjectState(ProjectStateContext* ctx) override; + bool LoadProjectStateLine(const char* line) override; + void OnBeginLoadProjectState(bool isUndo) override; + +private: + // Offline-process the first selected media item, applying gain (0..1), writing a + // new file next to the original and inserting it into the project. + void ProcessSelectedItem(double gain); + + void PushTrackCount(bool force); // send track count to the WebView (if changed or forced) + void PushGain(); // send the current gain to the WebView slider + void PushDockState(); // tell the WebView whether the window is docked + + int mPrevTrackCount = -1; + double mGain = 1.0; // last gain set from the UI; persisted per-project; used by the right-click action +}; diff --git a/TemplateProjectReaperExtensionWebView/config.h b/TemplateProjectReaperExtensionWebView/config.h new file mode 100644 index 00000000..6a9115f7 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/config.h @@ -0,0 +1,17 @@ +// A REAPER extension needs far fewer of these than a plugin does: there is no host to +// declare formats, channel I/O or parameters to. PLUG_CLASS_NAME names the class and the +// reaper_ binary; PLUG_WIDTH/HEIGHT size the extension window. + +#define PLUG_NAME "TemplateProjectReaperExtensionWebView" +#define PLUG_MFR "AcmeInc" +#define PLUG_VERSION_HEX 0x00000000 +#define PLUG_VERSION_STR "0.0.0" +#define PLUG_COPYRIGHT_STR "Copyright 2026 Acme Inc" +#define PLUG_CLASS_NAME TemplateProjectReaperExtensionWebView + +#define PLUG_WIDTH 300 +#define PLUG_HEIGHT 300 +#define PLUG_FPS 60 +#define PLUG_SHARED_RESOURCES 0 + +#define SHARED_RESOURCES_SUBPATH "TemplateProjectReaperExtensionWebView" diff --git a/TemplateProjectReaperExtensionWebView/resources/main.rc b/TemplateProjectReaperExtensionWebView/resources/main.rc new file mode 100755 index 00000000..f47fe56f --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/main.rc @@ -0,0 +1,86 @@ +// Microsoft Visual C++ generated resource script. +// +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US +#pragma code_page(1252) + +///////////////////////////////////////////////////////////////////////////// +// +// Dialog +// + +IDD_DIALOG_MAIN DIALOG 0, 0, 300, 300 +STYLE DS_SETFONT | DS_CENTER | WS_MINIMIZEBOX | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME +CAPTION "TemplateProjectReaperExtensionWebView Window" +FONT 8, "MS Sans Serif" +BEGIN +END + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "#include ""..\\config.h""\r\n" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// DESIGNINFO +// + +#ifdef APSTUDIO_INVOKED +GUIDELINES DESIGNINFO +BEGIN + IDD_DIALOG_MAIN, DIALOG + BEGIN + END +END +#endif // APSTUDIO_INVOKED + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// +#include "..\config.h" +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED + diff --git a/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg new file mode 100644 index 00000000..1e4c3795 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg @@ -0,0 +1,24 @@ +#ifndef SWELL_DLG_FLAGS_AUTOGEN +#define SWELL_DLG_FLAGS_AUTOGEN SWELL_DLG_WS_FLIPPED|SWELL_DLG_WS_NOAUTOSIZE +#endif + +#ifndef SET_IDD_DIALOG_MAIN_STYLE +#define SET_IDD_DIALOG_MAIN_STYLE SWELL_DLG_FLAGS_AUTOGEN|SWELL_DLG_WS_RESIZABLE|SWELL_DLG_WS_OPAQUE +#endif +#ifdef SET_IDD_DIALOG_MAIN_SCALE +SWELL_DEFINE_DIALOG_RESOURCE_BEGIN(IDD_DIALOG_MAIN,SET_IDD_DIALOG_MAIN_STYLE,"TemplateProjectReaperExtensionWebView Window",300,300,SET_IDD_DIALOG_MAIN_SCALE) +#else +SWELL_DEFINE_DIALOG_RESOURCE_BEGIN2(IDD_DIALOG_MAIN,SET_IDD_DIALOG_MAIN_STYLE,"TemplateProjectReaperExtensionWebView Window",300,300) +#endif +BEGIN +END +#ifdef SET_IDD_DIALOG_MAIN_SCALE +SWELL_DEFINE_DIALOG_RESOURCE_END(IDD_DIALOG_MAIN) +#else +SWELL_DEFINE_DIALOG_RESOURCE_END2(IDD_DIALOG_MAIN) +#endif + + + +//EOF + diff --git a/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu new file mode 100644 index 00000000..1aaf7dbc --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu @@ -0,0 +1,3 @@ + +//EOF + diff --git a/TemplateProjectReaperExtensionWebView/resources/resource.h b/TemplateProjectReaperExtensionWebView/resources/resource.h new file mode 100755 index 00000000..7d4f35ab --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by main.rc +// +#define IDD_DIALOG_MAIN 40001 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 103 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1004 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/TemplateProjectReaperExtensionWebView/resources/web/index.html b/TemplateProjectReaperExtensionWebView/resources/web/index.html new file mode 100644 index 00000000..a43adc16 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/web/index.html @@ -0,0 +1,134 @@ + + + + + + + + + + + +

TemplateProjectReaperExtensionWebView

+ + + + +
Number of tracks: –
+ +
+

Offline processing

+
+ + + 100% +
+ +
+ + diff --git a/TemplateProjectReaperExtensionWebView/resources/web/script.js b/TemplateProjectReaperExtensionWebView/resources/web/script.js new file mode 100644 index 00000000..3ea33f9b --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/web/script.js @@ -0,0 +1,105 @@ +// FROM DELEGATE + +function SPVFD(paramIdx, val) { + console.log("paramIdx: " + paramIdx + " value:" + val); + OnParamChange(paramIdx, val); +} + +function SCVFD(ctrlTag, val) { + OnControlChange(ctrlTag, val); +// console.log("SCVFD ctrlTag: " + ctrlTag + " value:" + val); +} + +function SCMFD(ctrlTag, msgTag, msg) { +// var decodedData = window.atob(msg); + console.log("SCMFD ctrlTag: " + ctrlTag + " msgTag:" + msgTag + "msg:" + msg); +} + +function SAMFD(msgTag, dataSize, msg) { + OnMessage(msgTag, dataSize, msg); +} + +function SMMFD(statusByte, dataByte1, dataByte2) { + console.log("Got MIDI Message" + status + ":" + dataByte1 + ":" + dataByte2); +} + +function SSMFD(offset, size, msg) { + console.log("Got Sysex Message"); +} + +// FROM UI +// data should be a base64 encoded string +function SAMFUI(msgTag, ctrlTag = -1, data = 0) { + var message = { + "msg": "SAMFUI", + "msgTag": msgTag, + "ctrlTag": ctrlTag, + "data": data + }; + + IPlugSendMsg(message); +} + +function SMMFUI(statusByte, dataByte1, dataByte2) { + var message = { + "msg": "SMMFUI", + "statusByte": statusByte, + "dataByte1": dataByte1, + "dataByte2": dataByte2 + }; + + IPlugSendMsg(message); +} + +// data should be a base64 encoded string +function SSMFUI(data = 0) { + var message = { + "msg": "SSMFUI", + "data": data + }; + + IPlugSendMsg(message); +} + +function EPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("EPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "EPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function BPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("BPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "BPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function SPVFUI(paramIdx, value) { + if (paramIdx < 0) { + console.log("SPVFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "SPVFUI", + "paramIdx": parseInt(paramIdx), + "value": value + }; + + IPlugSendMsg(message); +} From d2c349da7a900b73524fe2362a63f0652b84ff00 Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Thu, 9 Jul 2026 20:18:40 +0200 Subject: [PATCH 3/8] duplicate.py: make root rewriting opt-in, fix out-of-source duplication Three bugs, all reproduced in a sandbox copy of the repo root before fixing. The repo's own root files were rewritten unconditionally. That is right when the repo is becoming your plugin's repo, and wrong when you are just adding a project alongside the templates - and because the replace is a naive substring match, duplicating TemplateProject also renamed TemplateProjectWebView to WebView wherever the docs mentioned it. Root rewriting is now off by default, prompted for when running on a terminal, and forced with --rewrite-root / --no-rewrite-root. The root pass additionally skips CLAUDE.md and README.md, which describe the templates rather than referring to a project, so rewriting them is never correct. The four-argument out-of-source form crashed on the CMake-only templates, which have no config/ directory to read IPLUG2_ROOT from. find_iplug2_root() falls back to the iPlug2 submodule for those. The same form never fixed CMakeLists.txt. It rewrote IPLUG2_ROOT in the xcconfig but left IPLUG2_DIR at ${CMAKE_CURRENT_SOURCE_DIR}/../iPlug2, which is only true for a project inside this repo, so the CMake build of an out-of-source duplicate was broken - including TemplateProject's. fix_cmake_iplug2_dir() rewrites it to the right relative path. Verified that the xcconfig and CMake paths both resolve to a real iPlug2 for an out-of-source duplicate of each template, and that in-source duplicates keep ../iPlug2. Also: prompt for the template, project name and manufacturer when arguments are omitted; argparse for the usage text; skip PLUG_UNIQUE_ID randomisation when config.h has no such id, as with a REAPER extension; drop the Python 2 claim, since input() differs there and the shebang has always been python3; fix the PLUG_MFR_UID typo in the closing message. CLAUDE.md and the new-plugin skill: document the templates, the prompt, --rewrite-root, and that the CMake-only templates have no xcworkspace to open. --- .claude/skills/new-plugin/SKILL.md | 33 ++-- CLAUDE.md | 9 +- duplicate.py | 249 ++++++++++++++++++++++------- 3 files changed, 217 insertions(+), 74 deletions(-) diff --git a/.claude/skills/new-plugin/SKILL.md b/.claude/skills/new-plugin/SKILL.md index 5f5b36a7..0078000d 100644 --- a/.claude/skills/new-plugin/SKILL.md +++ b/.claude/skills/new-plugin/SKILL.md @@ -16,28 +16,40 @@ Use this skill when the user wants to create a new plugin project in this out-of 2. **Check for existing projects:** - If other project folders exist besides TemplateProject, ask if user wants to continue with existing work or create new -3. **Run duplicate script:** +3. **Choose a template and run the duplicate script:** + + | Template | Use for | + |---|---| + | `TemplateProject` | A plugin with an IGraphics (C++) UI | + | `TemplateProjectWebView` | A plugin with a WebView (HTML/CSS/JS) UI | + | `TemplateProjectReaperExtensionWebView` | A REAPER extension with a WebView UI | + ```bash - ./duplicate.py TemplateProject [PluginName] [ManufacturerName] + ./duplicate.py [Template] [PluginName] [ManufacturerName] ``` + This touches only the new project. Add `--rewrite-root` to also repoint the repo's + `bump_version.py`, `CMakeLists.txt`, `.vscode/` and `.github/` at it — appropriate when + this repo is becoming the plugin's own repo. Add a fourth positional argument to create + the project outside this repo. + 4. **Offer config.h customization:** - - `PLUG_UNIQUE_ID` - Verify the auto-generated 4-char ID + - `PLUG_UNIQUE_ID` - Verify the auto-generated 4-char ID (not present for a REAPER extension) - `PLUG_MFR_ID` - Set manufacturer's 4-char ID - Copyright, URLs - allow skipping for later 5. **Ask about committing:** ```bash git add [PluginName]/* - git add .vscode/* - git add .github/* - git add bump_version.py - git commit -m "created [PluginName] based on TemplateProject" + git commit -m "created [PluginName] based on [Template]" ``` + Also stage `.vscode/*`, `.github/*` and `bump_version.py` if `--rewrite-root` was used; + otherwise those files are unchanged. 6. **Next steps:** - - Open in Xcode: `open [PluginName]/[PluginName].xcworkspace` - - Build: Use the `build` skill from iPlug2 + - Open in Xcode: `open [PluginName]/[PluginName].xcworkspace` (IGraphics template only — + the CMake-only templates have no xcworkspace; configure with CMake instead) + - Build: Use the `build` skill, or `build-cmake` for the CMake-only templates - Setup SDKs: Use the `setup-deps` skill if needed ## Example @@ -46,4 +58,5 @@ Use this skill when the user wants to create a new plugin project in this out-of ./duplicate.py TemplateProject MySynth MyBrand ``` -Creates `MySynth/` with all project files configured and ready to build. +Creates `MySynth/` with all project files configured and ready to build. Run `./duplicate.py` +with no arguments to be prompted for the template, name and manufacturer instead. diff --git a/CLAUDE.md b/CLAUDE.md index c7f1e5ac..32f39133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,13 @@ To create a new plugin, duplicate @TemplateProject using the `new-plugin` skill ``` Substitute another template for `TemplateProject` to start from a WebView UI or a REAPER -extension. Note that the CMake-only templates have no `config/` directory, so they only work -with the three-argument form of `duplicate.py` (output into the repo root). +extension. Run `./duplicate.py` with no arguments to be prompted instead. + +Pass a fourth argument to create the project outside this repo; the iPlug2 paths in its +`CMakeLists.txt` and xcconfigs are rewritten to suit. By default nothing outside the new +project is modified — pass `--rewrite-root` to also repoint this repo's `bump_version.py`, +`CMakeLists.txt`, `.vscode/` and `.github/` at it, which is what you want when the repo is +becoming your plugin's repo rather than gaining another project alongside the templates. After duplication, optionally commit: ```bash diff --git a/duplicate.py b/duplicate.py index d9bef7f2..2efc64f5 100755 --- a/duplicate.py +++ b/duplicate.py @@ -7,7 +7,7 @@ # Author accepts no responsibilty for wiping your hd # NOTES: -# should work with Python2 or Python3 +# requires Python3 # not designed to be fool proof- think carefully about what you choose for a project name # best to stick to standard characters in your project names - avoid spaces, numbers and dots # windows users need to install python and set it up so you can run it from the command line @@ -16,6 +16,11 @@ # USAGE: # duplicate.py [inputprojectname] [outputprojectname] [manufacturername] (outputpath) +# Run with no arguments to be prompted for each of them. +# +# By default only the duplicated project is touched. Pass --rewrite-root (or answer yes to +# the prompt) to also point this repo's root files - bump_version.py, CMakeLists.txt, +# .vscode/ and .github/ - at the new project. # TODO: # - indentation of directory structure @@ -24,7 +29,7 @@ from __future__ import generators -import fileinput, glob, string, sys, os, re, uuid, pprint, random +import argparse, fileinput, glob, string, sys, os, re, uuid, pprint, random from shutil import copy, copytree, ignore_patterns, rmtree from os.path import join @@ -34,11 +39,17 @@ from parse_config import parse_config, parse_xcconfig, set_uniqueid -VERSION = "0.95" +VERSION = "1.0" # binary files that we don't want to do find and replace inside FILTERED_FILE_EXTENSIONS = [".ico",".icns", ".pdf", ".png", ".zip", ".exe", ".wav", ".aif", ".data", ".wasm", "mkcert"] FILTERED_FILE_NAMES = [".DS_Store", "duplicate.py"] +# additionally skipped when rewriting the repo root: these describe the templates, which still +# exist after duplicating. Rewriting them renames the templates the docs are talking about, and +# a substring match turns e.g. TemplateProjectWebView into MyPluginWebView. +ROOT_FILTERED_FILE_NAMES = FILTERED_FILE_NAMES + ["CLAUDE.md", "README.md"] +# the iPlug2 root as spelled in a template's CMakeLists.txt, relative to the project dir +CMAKE_IPLUG2_DIR = "${CMAKE_CURRENT_SOURCE_DIR}/../iPlug2" # files that we don't want to duplicate DONT_COPY = (".vs", "*.exe", "*.dmg", "*.pkg", "*.mpkg", "*.svn", "*.ncb", "*.suo", "*sdf", "ipch", "*.layout", "*.depend", ".DS_Store", "xcuserdata", "*.aps") @@ -86,7 +97,7 @@ def replacestrsChop(filename, s, r): line = r + "\n" sys.stdout.write(line) -def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= "", newroot=""): +def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= "", newroot="", filterednames=FILTERED_FILE_NAMES): for f in os.listdir(dir): fullpath = os.path.join(dir, f) @@ -96,21 +107,21 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= fullpath = os.path.join(dir, replaceproject + "-macOS.xcodeproj") print("recursing in macOS xcode project directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif checkdirname(f, searchproject + "-iOS.xcodeproj"): os.rename(fullpath, os.path.join(dir, replaceproject + "-iOS.xcodeproj")) fullpath = os.path.join(dir, replaceproject + "-iOS.xcodeproj") print("recursing in iOS xcode project directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif checkdirname(f, searchproject + ".xcworkspace"): os.rename(fullpath, os.path.join(dir, replaceproject + ".xcworkspace")) fullpath = os.path.join(dir, replaceproject + ".xcworkspace") print("recursing in main xcode workspace directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif checkdirname(f, searchproject + "-iOS.appiconset"): os.rename(fullpath, os.path.join(dir, replaceproject + "-iOS.appiconset")) @@ -122,11 +133,11 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= fullpath = os.path.join(dir, replaceproject + "-macOS.appiconset") print("recursing in -macOS.appiconset directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif (f in SUBFOLDERS_TO_SEARCH): print('recursing in ' + f + ' directory: ') - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x if os.path.isfile(fullpath): @@ -134,7 +145,7 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= newfilename = filename.replace(searchproject, replaceproject) base, extension = os.path.splitext(filename) - if (not(extension in FILTERED_FILE_EXTENSIONS) and not(filename in FILTERED_FILE_NAMES)): + if (not(extension in FILTERED_FILE_EXTENSIONS) and not(filename in filterednames)): print("Replacing project name strings in file " + filename) replacestrs(fullpath, searchproject, replaceproject) @@ -161,37 +172,127 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= else: yield f, fullpath +def find_projects(): + """Directories next to this script that look like an iPlug2 project. Any of them can be + duplicated, not just the templates, so projects you have already made show up too.""" + projects = [] + + for f in sorted(os.listdir(scriptpath)): + if os.path.isdir(os.path.join(scriptpath, f)) and os.path.isfile(os.path.join(scriptpath, f, "config.h")): + projects.append(f) + + return projects + +def default_project(projects): + "a template makes a better default than whatever project happens to sort first" + for p in projects: + if p.startswith("TemplateProject"): + return p + + return projects[0] if projects else "" + +def prompt(question, default=""): + suffix = " [" + default + "]: " if default else ": " + + try: + answer = input(question + suffix).strip() + except EOFError: + answer = "" + + return answer if answer else default + +def prompt_yes_no(question, default=False): + answer = prompt(question + (" [y/N]" if not default else " [Y/n]")) + + if not answer: + return default + + return answer[0].lower() == "y" + +def find_iplug2_root(inputprojectname): + """Where iPlug2 lives, as an absolute path. Prefer the template's own xcconfig, since a + template may point somewhere other than the submodule, and fall back to the submodule for + CMake-only templates, which have no config/ directory.""" + xcconfigpath = os.path.join(scriptpath, inputprojectname, "config", inputprojectname + "-mac.xcconfig") + + if os.path.isfile(xcconfigpath): + oldroot = parse_xcconfig(xcconfigpath)["IPLUG2_ROOT"] + return oldroot, os.path.abspath(os.path.join(scriptpath, inputprojectname, "config", oldroot)) + + return "", os.path.join(scriptpath, "iPlug2") + +def fix_cmake_iplug2_dir(outputpath, iplug2folder): + """Point the duplicated CMakeLists.txt at iPlug2. Its default is a sibling ../iPlug2, which is + only true for a project sitting in this repo, so an out-of-source duplicate needs rewriting. + The xcconfig equivalent is handled by the oldroot/newroot pass in dirwalk().""" + cmakelists = os.path.join(outputpath, "CMakeLists.txt") + + if not os.path.isfile(cmakelists): + return + + newdir = os.path.relpath(iplug2folder, outputpath).replace(os.sep, "/") + + print("Pointing CMakeLists.txt at iPlug2 via " + newdir) + replacestrs(cmakelists, CMAKE_IPLUG2_DIR, "${CMAKE_CURRENT_SOURCE_DIR}/" + newdir) + def main(): global VERSION print("\nIPlug Project Duplicator v" + VERSION + " by Oli Larkin ------------------------------\n") - numargs = len(sys.argv) - 1 + projects = find_projects() - if not (numargs == 3 or numargs == 4): - print("Usage: duplicate.py inputprojectname outputprojectname manufacturername (outputprojectpath)") - sys.exit(1) - else: - inputprojectname=sys.argv[1] - outputprojectname=sys.argv[2] - manufacturer=sys.argv[3] + parser = argparse.ArgumentParser(description="Duplicate an iPlug2 project. Omit any argument to be prompted for it.") + parser.add_argument("inputprojectname", nargs="?", help="project to copy, one of: " + ", ".join(projects)) + parser.add_argument("outputprojectname", nargs="?", help="name of the new project") + parser.add_argument("manufacturername", nargs="?", help="your company name, replaces AcmeInc") + parser.add_argument("outputprojectpath", nargs="?", help="where to put it (default: this repo)") - if numargs == 4: - outputbasepath=os.path.abspath(sys.argv[4]) - else: - outputbasepath=os.getcwd() + rootgroup = parser.add_mutually_exclusive_group() + rootgroup.add_argument("--rewrite-root", dest="rewriteroot", action="store_true", default=None, + help="also point this repo's bump_version.py, CMakeLists.txt, .vscode/ and .github/ at the new project") + rootgroup.add_argument("--no-rewrite-root", dest="rewriteroot", action="store_false", + help="leave this repo's own files alone (default when not a terminal)") - if not (os.path.isdir(outputbasepath)): - print("error: Output path does not exist") - sys.exit(1) + args = parser.parse_args() - outputpath = os.path.join(outputbasepath, outputprojectname) + interactive = sys.stdin.isatty() - if ' ' in inputprojectname: - print("error: input project name has spaces") + inputprojectname = args.inputprojectname + outputprojectname = args.outputprojectname + manufacturer = args.manufacturername + + if not inputprojectname: + if not interactive: + parser.error("inputprojectname is required when not running interactively") + + print("Projects you can duplicate: " + ", ".join(projects) + "\n") + inputprojectname = prompt("Project to duplicate", default_project(projects)) + + if not outputprojectname: + if not interactive: + parser.error("outputprojectname is required when not running interactively") + + outputprojectname = prompt("Name of the new project") + + if not manufacturer: + if not interactive: + parser.error("manufacturername is required when not running interactively") + + manufacturer = prompt("Manufacturer name", "AcmeInc") + + # remove a trailing slash if it exists + if inputprojectname[-1:] == "/": + inputprojectname = inputprojectname[0:-1] + + if outputprojectname[-1:] == "/": + outputprojectname = outputprojectname[0:-1] + + if not outputprojectname: + print("error: output project name is empty") sys.exit(1) - if inputprojectname not in os.listdir(os.curdir): - print("error: input project " + inputprojectname + " doesn't exist, check spelling/case?") + if ' ' in inputprojectname: + print("error: input project name has spaces") sys.exit(1) if ' ' in outputprojectname: @@ -202,53 +303,76 @@ def main(): print("error: manufacturer name has spaces") sys.exit(1) - # remove a trailing slash if it exists - if inputprojectname[-1:] == "/": - inputprojectname = inputprojectname[0:-1] + #check that the folders are OK + if not os.path.isdir(os.path.join(scriptpath, inputprojectname)): + print("error: input project " + inputprojectname + " doesn't exist, check spelling/case?") + sys.exit(1) - if outputprojectname[-1:] == "/": - outputprojectname = outputprojectname[0:-1] + outofsource = args.outputprojectpath is not None - #check that the folders are OK - if os.path.isdir(inputprojectname) == False: - print("error: input project not found") + if outofsource: + outputbasepath = os.path.abspath(args.outputprojectpath) + else: + outputbasepath = scriptpath + + if not (os.path.isdir(outputbasepath)): + print("error: Output path does not exist") sys.exit(1) + outputpath = os.path.join(outputbasepath, outputprojectname) + if os.path.isdir(outputpath): print("error: output project allready exists") sys.exit(1) # rmtree(output) - print("copying " + inputprojectname + " folder to " + outputpath) - copytree(inputprojectname, outputpath, ignore=ignore_patterns(*DONT_COPY)) + # Rewriting this repo's own files is destructive and usually unwanted, so it is opt-in. + rewriteroot = args.rewriteroot + + if rewriteroot is None: + if interactive: + print("\nThe repo's own bump_version.py, CMakeLists.txt, .vscode/ and .github/ still refer") + print("to " + inputprojectname + ". They can be repointed at " + outputprojectname + ", which is what you want if") + print("this repo is becoming your plugin's repo, and not what you want if you are just") + print("adding another project alongside the templates.") + rewriteroot = prompt_yes_no("Repoint them at " + outputprojectname + "?", False) + else: + rewriteroot = False - oldroot = "" + oldroot, iplug2folder = find_iplug2_root(inputprojectname) newroot = "" - - if numargs == 4: - configpath = os.path.join(inputprojectname, "config") - xcconfig = parse_xcconfig(configpath + "/" + inputprojectname + "-mac.xcconfig") - oldroot = xcconfig["IPLUG2_ROOT"] - iplug2folder = os.path.abspath(os.path.join(configpath, oldroot)) + + # the xcconfig spells the iPlug2 root relative to the project's config/ dir, so it only needs + # rewriting when the project moves out of this repo + if outofsource and oldroot: newroot = os.path.relpath(iplug2folder, os.path.join(outputpath, "config")) - else: - newroot = "" + + print("copying " + inputprojectname + " folder to " + outputpath) + copytree(os.path.join(scriptpath, inputprojectname), outputpath, ignore=ignore_patterns(*DONT_COPY)) #replace manufacturer name strings for dir in dirwalk(outputpath, inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): pass - #replace project name in root - for dir in dirwalk(scriptpath, inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): - pass + if outofsource: + fix_cmake_iplug2_dir(outputpath, iplug2folder) - #replace project name in github - for dir in dirwalk(scriptpath + "/.github/workflows", inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): - pass + if rewriteroot: + #replace project name in root, but not in the docs describing the templates + for dir in dirwalk(scriptpath, inputprojectname, outputprojectname, "AcmeInc", manufacturer, "", "", ROOT_FILTERED_FILE_NAMES): + pass - #replace project name in vscode - for dir in dirwalk(scriptpath + "/.vscode", inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): - pass + #replace project name in github + if os.path.isdir(scriptpath + "/.github/workflows"): + for dir in dirwalk(scriptpath + "/.github/workflows", inputprojectname, outputprojectname, "AcmeInc", manufacturer): + pass + + #replace project name in vscode + if os.path.isdir(scriptpath + "/.vscode"): + for dir in dirwalk(scriptpath + "/.vscode", inputprojectname, outputprojectname, "AcmeInc", manufacturer): + pass + else: + print("\nleaving this repo's root files alone (pass --rewrite-root to change that)") # print("\ncopying gitignore template into project folder\n") @@ -256,14 +380,15 @@ def main(): config = parse_config(outputpath) - config["PLUG_UNIQUE_ID"] = randomFourChar() - - set_uniqueid(outputpath, config["PLUG_UNIQUE_ID"]) + # a REAPER extension has no plugin id to randomise + if config["PLUG_UNIQUE_ID"]: + config["PLUG_UNIQUE_ID"] = randomFourChar() + set_uniqueid(outputpath, config["PLUG_UNIQUE_ID"]) pp = pprint.PrettyPrinter(indent=4) pp.pprint(config) - print("\ndone - don't forget to change PLUG_MFR_UID in config.h") + print("\ndone - don't forget to change PLUG_MFR_ID in config.h") if __name__ == '__main__': main() From cba70987e8f2b28345f4630b60dcd1fde4532631 Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Fri, 31 Jul 2026 22:47:37 +0200 Subject: [PATCH 4/8] CI: unpin the Visual Studio generator, cover the other templates windows-latest is now the windows-2025-vs2026 image, which ships Visual Studio 2026. The windows-vs2022 preset pinned "Visual Studio 17 2022" and configure failed with "could not find any instance of Visual Studio". Add windows / windows-arm64ec presets with no generator field so CMake picks whichever Visual Studio is installed, and point CI at them. The vs2022 presets stay for anyone who wants to pin. Add a windows-templates matrix job for TemplateProjectWebView and TemplateProjectReaperExtensionWebView, which nothing built before. They are configured directly rather than via a preset: the root CMakeLists.txt only references PROJECT_NAME, and adding the other templates to it would be rewritten by duplicate.py. Also fix the build-cmake skill, whose every example passed --preset with -S [ProjectName]. That cannot work: presets are read from the source directory and only the repo root has a CMakePresets.json. The web section also named a "wam" preset that does not exist. --- .claude/skills/build-cmake/SKILL.md | 71 ++++++++++++--------- .github/workflows/build-cmake.yml | 97 +++++++++++++++++++++++------ .gitignore | 2 + CMakePresets.json | 30 +++++++++ 4 files changed, 152 insertions(+), 48 deletions(-) diff --git a/.claude/skills/build-cmake/SKILL.md b/.claude/skills/build-cmake/SKILL.md index 1ba9bb53..029b0b71 100644 --- a/.claude/skills/build-cmake/SKILL.md +++ b/.claude/skills/build-cmake/SKILL.md @@ -18,6 +18,17 @@ Use this skill when the user wants to build their plugin project using CMake. The project uses CMake presets for standardized builds. Prefer `--preset` instead of manual `-G` flags. +`CMakePresets.json` lives at the repo root, so presets are only visible when +configuring the root — `--preset` and `-S [ProjectName]` cannot be combined. The +root `CMakeLists.txt` decides which project is built (`add_subdirectory`). To +build a project it doesn't reference, configure that directory directly with an +explicit generator, e.g.: + +```bash +cmake -S TemplateProjectWebView -B build/webview -G "Visual Studio 17 2022" -A x64 +cmake --build build/webview --config Release +``` + ### List Available Presets ```bash @@ -28,17 +39,15 @@ cmake --list-presets ```bash # Configure with Ninja (fast builds) -cmake --preset macos-ninja -S [ProjectName] - -# Build +cmake --preset macos-ninja cmake --build build/macos-ninja --target [ProjectName]-app # Or with Xcode (for debugging) -cmake --preset macos-xcode -S [ProjectName] +cmake --preset macos-xcode cmake --build build/macos-xcode --config Release --target [ProjectName]-app # Universal binary (arm64 + x86_64) -cmake --preset macos-xcode-universal -S [ProjectName] +cmake --preset macos-xcode-universal cmake --build build/macos-xcode-universal --config Release ``` @@ -46,11 +55,11 @@ cmake --build build/macos-xcode-universal --config Release ```bash # iOS Device -cmake --preset ios-xcode -S [ProjectName] +cmake --preset ios-xcode cmake --build build/ios-xcode --config Release # iOS Simulator -cmake --preset ios-sim-xcode -S [ProjectName] +cmake --preset ios-sim-xcode cmake --build build/ios-sim-xcode --config Release ``` @@ -60,40 +69,44 @@ For running in Simulator, use the `run-ios-simulator` skill. ```bash # visionOS Device -cmake --preset visionos-xcode -S [ProjectName] +cmake --preset visionos-xcode cmake --build build/visionos-xcode --config Release # visionOS Simulator -cmake --preset visionos-sim-xcode -S [ProjectName] +cmake --preset visionos-sim-xcode cmake --build build/visionos-sim-xcode --config Release ``` ### Windows Builds ```bash -# Visual Studio 2022 (x64) -cmake --preset windows-vs2022 -S [ProjectName] -cmake --build build/windows-vs2022 --config Release +# Newest installed Visual Studio (x64) +cmake --preset windows +cmake --build build/windows --config Release # ARM64EC -cmake --preset windows-vs2022-arm64ec -S [ProjectName] -cmake --build build/windows-vs2022-arm64ec --config Release +cmake --preset windows-arm64ec +cmake --build build/windows-arm64ec --config Release ``` -### Web/WAM Build (Emscripten) +`windows` / `windows-arm64ec` do not pin a generator, so they pick up whichever +Visual Studio is installed (2022, 2026, …). Use `windows-vs2022` to pin VS2022 +explicitly. + +### Web Builds (Emscripten) ```bash # Configure (requires EMSDK environment) -cmake --preset wam -S [ProjectName] +cmake --preset web -# Build -cmake --build build/wam --target [ProjectName]-wam +# WAM (AudioWorklet) distribution +cmake --build build/web --target [ProjectName]-web-dist -# Create distribution -cmake --build build/wam --target [ProjectName]-wam-dist +# WASM (ScriptProcessor) distribution +cmake --build build/web --target [ProjectName]-wasm-dist ``` -Output in `build/wam/out/[ProjectName]-wam/` +Output in `build/web/out/[ProjectName]/` ## Available Configure Presets @@ -107,10 +120,12 @@ Output in `build/wam/out/[ProjectName]-wam/` | `ios-sim-xcode` | iOS | Xcode | iOS Simulator builds | | `visionos-xcode` | visionOS | Xcode | visionOS device builds | | `visionos-sim-xcode` | visionOS | Xcode | visionOS Simulator builds | +| `windows` | Windows | newest VS | Windows x64 builds (default) | +| `windows-arm64ec` | Windows | newest VS | Windows ARM64EC builds | | `windows-ninja` | Windows | Ninja | Fast Windows command-line builds | -| `windows-vs2022` | Windows | VS2022 | Windows x64 builds | -| `windows-vs2022-arm64ec` | Windows | VS2022 | Windows ARM64EC builds | -| `wam` | Web | Ninja | Emscripten/WebAssembly builds | +| `windows-vs2022` | Windows | VS2022 | Windows x64 builds, VS2022 pinned | +| `windows-vs2022-arm64ec` | Windows | VS2022 | Windows ARM64EC builds, VS2022 pinned | +| `web` | Web | Ninja | Emscripten/WebAssembly builds | ## Available Targets @@ -130,7 +145,7 @@ Default configuration uses NanoVG with Metal (macOS/iOS) or GL2 (Windows). To use Skia backend, add cache variables: ```bash -cmake --preset macos-ninja -S [ProjectName] \ +cmake --preset macos-ninja \ -DIGRAPHICS_BACKEND=SKIA \ -DIGRAPHICS_RENDERER=METAL ``` @@ -156,20 +171,20 @@ Valid combinations: ```bash # Remove build directory and reconfigure rm -rf build/macos-ninja -cmake --preset macos-ninja -S [ProjectName] +cmake --preset macos-ninja ``` ## Example ```bash # Quick build of standalone app -cmake --preset macos-ninja -S MySynth +cmake --preset macos-ninja cmake --build build/macos-ninja --target MySynth-app # Open the built app open ~/Applications/MySynth.app # Build for iOS Simulator -cmake --preset ios-sim-xcode -S MySynth +cmake --preset ios-sim-xcode cmake --build build/ios-sim-xcode --config Release ``` diff --git a/.github/workflows/build-cmake.yml b/.github/workflows/build-cmake.yml index d533318d..61c205d3 100644 --- a/.github/workflows/build-cmake.yml +++ b/.github/workflows/build-cmake.yml @@ -189,10 +189,10 @@ jobs: if-no-files-found: warn # ========================================================================== - # Windows - Visual Studio 2022 (Default) + # Windows - Visual Studio (Default) # ========================================================================== - windows-vs2022: - name: Windows (VS2022) + windows: + name: Windows (Visual Studio) needs: parse-commands if: needs.parse-commands.outputs.should_run == 'true' runs-on: windows-latest @@ -221,28 +221,85 @@ jobs: ./download-iplug-sdks.sh - name: CMake Configure - run: cmake --preset=windows-vs2022 + run: cmake --preset=windows - name: Build - run: cmake --build --preset=windows-vs2022 + run: cmake --build --preset=windows - name: Verify Bundles shell: pwsh run: | - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } echo "All bundles exist" - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: windows-vs2022-artifacts - path: build/windows-vs2022/out/ + name: windows-artifacts + path: build/windows/out/ if-no-files-found: warn # ========================================================================== - # Windows - Visual Studio 2022 ARM64EC - Optional: /ci arm64ec + # Windows - the other templates + # + # The root CMakeLists.txt only references PROJECT_NAME, so these are + # configured directly. No --preset: CMakePresets.json lives at the repo root + # and presets are read from the source directory. Omitting -G picks the + # newest installed Visual Studio. + # ========================================================================== + windows-templates: + name: Windows (${{ matrix.template }}) + needs: parse-commands + if: needs.parse-commands.outputs.should_run == 'true' + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - template: TemplateProjectWebView + artifact: TemplateProjectWebView.exe + - template: TemplateProjectReaperExtensionWebView + artifact: reaper_TemplateProjectReaperExtensionWebView.dll + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.parse-commands.outputs.pr_sha || github.sha }} + submodules: recursive + + - name: Cache IPlug SDKs + id: cache-sdks + uses: actions/cache@v4 + with: + path: | + iPlug2/Dependencies/IPlug/VST3_SDK + iPlug2/Dependencies/IPlug/CLAP_SDK + iPlug2/Dependencies/IPlug/CLAP_HELPERS + key: iplug-sdks-${{ hashFiles('iPlug2/Dependencies/IPlug/download-iplug-sdks.sh') }} + + - name: Get SDKs + if: steps.cache-sdks.outputs.cache-hit != 'true' + shell: bash + run: | + cd iPlug2/Dependencies/IPlug + ./download-iplug-sdks.sh + + - name: CMake Configure + run: cmake -S ${{ matrix.template }} -B build/${{ matrix.template }} -A x64 + + - name: Build + run: cmake --build build/${{ matrix.template }} --config Release + + - name: Verify Output + shell: pwsh + run: | + if (!(Test-Path "build\${{ matrix.template }}\out\${{ matrix.artifact }}")) { exit 1 } + echo "${{ matrix.artifact }} exists" + + # ========================================================================== + # Windows - Visual Studio ARM64EC - Optional: /ci arm64ec # ========================================================================== windows-arm64ec: name: Windows (ARM64EC) @@ -274,24 +331,24 @@ jobs: ./download-iplug-sdks.sh - name: CMake Configure - run: cmake --preset=windows-vs2022-arm64ec + run: cmake --preset=windows-arm64ec - name: Build - run: cmake --build --preset=windows-vs2022-arm64ec + run: cmake --build --preset=windows-arm64ec - name: Verify Bundles shell: pwsh run: | - if (!(Test-Path "build\windows-vs2022-arm64ec\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } - if (!(Test-Path "build\windows-vs2022-arm64ec\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } - if (!(Test-Path "build\windows-vs2022-arm64ec\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } + if (!(Test-Path "build\windows-arm64ec\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } + if (!(Test-Path "build\windows-arm64ec\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } + if (!(Test-Path "build\windows-arm64ec\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } echo "All bundles exist" - name: Upload Artifacts uses: actions/upload-artifact@v4 with: name: windows-arm64ec-artifacts - path: build/windows-vs2022-arm64ec/out/ + path: build/windows-arm64ec/out/ if-no-files-found: warn # ========================================================================== @@ -601,17 +658,17 @@ jobs: - name: CMake Configure run: | - cmake --preset=windows-vs2022 ` + cmake --preset=windows ` -DIGRAPHICS_BACKEND=${{ matrix.backend }} ` -DIGRAPHICS_RENDERER=${{ matrix.renderer }} - name: Build - run: cmake --build build/windows-vs2022 --config Release --target ${{env.PROJECT_NAME}}-app + run: cmake --build build/windows --config Release --target ${{env.PROJECT_NAME}}-app - name: Verify App shell: pwsh run: | - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } echo "${{ matrix.backend }}/${{ matrix.renderer }} build successful" # ========================================================================== diff --git a/.gitignore b/.gitignore index 6782f5ca..86bd63b6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ xcuserdata *.RPP-bak */build-*/ +# CMake build trees (CMakePresets.json puts them under build/) +/build/ *.ipch *.db *.suo diff --git a/CMakePresets.json b/CMakePresets.json index 0436b833..6a585c5f 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -136,6 +136,26 @@ "CMAKE_OSX_SYSROOT": "xrsimulator" } }, + { + "name": "windows", + "displayName": "Windows (Visual Studio)", + "description": "Windows x64 build with the newest installed Visual Studio (NanoVG/GL2)", + "inherits": "base-windows", + "architecture": { + "value": "x64", + "strategy": "set" + } + }, + { + "name": "windows-arm64ec", + "displayName": "Windows ARM64EC (Visual Studio)", + "description": "Windows ARM64EC build with the newest installed Visual Studio (NanoVG/GL2)", + "inherits": "base-windows", + "architecture": { + "value": "ARM64EC", + "strategy": "set" + } + }, { "name": "windows-ninja", "displayName": "Windows (Ninja)", @@ -232,6 +252,16 @@ "CODE_SIGNING_ALLOWED=NO" ] }, + { + "name": "windows", + "configurePreset": "windows", + "configuration": "Release" + }, + { + "name": "windows-arm64ec", + "configurePreset": "windows-arm64ec", + "configuration": "Release" + }, { "name": "windows-ninja", "configurePreset": "windows-ninja" From 952e9c43958c7b3ba55011a2c20b9bfe7af94a18 Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Sat, 1 Aug 2026 01:56:37 +0200 Subject: [PATCH 5/8] Fix the REAPER extension template's release WebView UI The template was derived from IPlugReaperExtensionWebUI but dropped the two halves of that example's release handling: the #else branch that loads the UI from REAPER's resource path, and the build-time deploy that puts it there. It built and ran in debug, where the UI is loaded from the source tree via __FILE__, and showed "file not found" in release. Restore both, using the WEB_RESOURCES_DIR argument added in iPlug2#1388 rather than the example's hand-rolled add_custom_command, and bump the submodule to pick it up. CLAUDE.md described the release case as an inherent limitation and told the reader to solve it themselves, which was never true - the example next door already solved it. It now documents both branches and the coupling that will break silently if only one side is renamed: the deploy directory is the project name, and SHARED_RESOURCES_SUBPATH in config.h has to agree with it. --- .../CLAUDE.md | 21 ++++++++++++------- .../CMakeLists.txt | 8 +++++-- .../TemplateProjectReaperExtensionWebView.cpp | 14 +++++++++++-- iPlug2 | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/TemplateProjectReaperExtensionWebView/CLAUDE.md b/TemplateProjectReaperExtensionWebView/CLAUDE.md index 126b2d57..44faab2d 100644 --- a/TemplateProjectReaperExtensionWebView/CLAUDE.md +++ b/TemplateProjectReaperExtensionWebView/CLAUDE.md @@ -64,13 +64,20 @@ REAPER's action list. The template registers four: ## The UI -`mEditorInitFunc` runs when the window opens and calls `LoadIndexHtml(__FILE__, "")`, which loads -`resources/web/index.html` **from the source tree in debug builds**. Note the empty bundle id: -`ReaperExtBase` doesn't inherit `GetBundleID()` (that lives on `IPluginBase`), and a REAPER -extension is a bare dylib/DLL rather than a bundle. Release builds therefore have no resource -directory to load from — **if you ship this, embed the UI or point `LoadFile` at a real install -path**. `SetEnableDevTools(true)` is on in `_DEBUG`, so right-click → Inspect opens the Web -Inspector. +`mEditorInitFunc` runs when the window opens, and it loads the UI differently per configuration +because a REAPER extension is a bare dylib/DLL with no bundle to carry resources: + +- **Debug** calls `LoadIndexHtml(__FILE__, "")`, which resolves `resources/web/index.html` in the + source tree, so edits to the HTML/JS show up on reopen. The bundle id is empty because + `ReaperExtBase` doesn't inherit `GetBundleID()` (that lives on `IPluginBase`) and this branch + never consults it. +- **Release** has neither a source tree nor a bundle, so it loads from REAPER's own resource + directory: `GetResourcePath()/UserPlugins/SHARED_RESOURCES_SUBPATH/index.html`. The + `WEB_RESOURCES_DIR` argument to `iplug_configure_reaperext()` in `CMakeLists.txt` is what puts + the files there at build time. The two must agree — `SHARED_RESOURCES_SUBPATH` in `config.h` + defaults to the project name, which is what CMake uses for the directory. + +`SetEnableDevTools(true)` is on in `_DEBUG`, so right-click → Inspect opens the Web Inspector. ### Talking to the UI diff --git a/TemplateProjectReaperExtensionWebView/CMakeLists.txt b/TemplateProjectReaperExtensionWebView/CMakeLists.txt index b9931967..29d0ab96 100644 --- a/TemplateProjectReaperExtensionWebView/CMakeLists.txt +++ b/TemplateProjectReaperExtensionWebView/CMakeLists.txt @@ -54,5 +54,9 @@ iplug_add_target(${PROJECT_NAME} PUBLIC ) # Names the binary reaper_, restricts exported symbols, and deploys to -# REAPER's UserPlugins directory when IPLUG_DEPLOY_PLUGINS is set -iplug_configure_reaperext(${PROJECT_NAME} ${PROJECT_NAME}) +# REAPER's UserPlugins directory when IPLUG_DEPLOY_PLUGINS is set. +# WEB_RESOURCES_DIR puts the UI in UserPlugins/${PROJECT_NAME}/, which is where +# release builds load index.html from - see the LoadFile call in the .cpp. +iplug_configure_reaperext(${PROJECT_NAME} ${PROJECT_NAME} + WEB_RESOURCES_DIR ${PLUG_RESOURCES_DIR}/web +) diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp index 5dec937b..64a66500 100644 --- a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp @@ -46,10 +46,20 @@ TemplateProjectReaperExtensionWebView::TemplateProjectReaperExtensionWebView(rea #endif // The WebView loads its UI from resources/web/index.html. - // NB. ReaperExtBase does not inherit GetBundleID() (that's on IPluginBase), so we - // pass an empty bundle id - it is only used by release builds that load from a bundle. mEditorInitFunc = [&]() { +#ifdef _DEBUG + // Debug: load straight from the source tree so edits to the HTML/JS hot-reload on reopen. + // NB. ReaperExtBase does not inherit GetBundleID() (that's on IPluginBase), and the empty + // bundle id is harmless here because this branch never consults it. LoadIndexHtml(__FILE__, ""); +#else + // Release: there is no source tree and no bundle to fall back on. CMake deploys + // resources/web into REAPER's UserPlugins under SHARED_RESOURCES_SUBPATH + // (see WEB_RESOURCES_DIR in CMakeLists.txt); load index.html from there. + WDL_String indexPath; + indexPath.SetFormatted(2048, "%s/UserPlugins/%s/index.html", GetResourcePath(), SHARED_RESOURCES_SUBPATH); + LoadFile(indexPath.Get(), nullptr); +#endif EnableScroll(false); }; diff --git a/iPlug2 b/iPlug2 index b64192fe..5c2df9dc 160000 --- a/iPlug2 +++ b/iPlug2 @@ -1 +1 @@ -Subproject commit b64192fe18afd9bc9a1fe324db5aceb48f4a0eee +Subproject commit 5c2df9dce3f5258acfeff3846a6a9563f382212c From ad9318f31bccf8fbebd68f4dac63bf786adcc3ee Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Sat, 1 Aug 2026 02:20:02 +0200 Subject: [PATCH 6/8] CI: install the v143 ARM64EC toolset the VS2026 image lacks The projects/*.vcxproj files pin PlatformToolset v143. The windows-2025-vs2026 runner image ships v143 for x86/x64, as VC.14.44.17.14.x86.x64, but only the VS2026 toolset for ARM64EC - so the x64 pass built fine and the ARM64EC pass failed with MSB8020, which read like a total toolchain failure but was not. Add the matching v143 ARM64EC component before building. This is a patch with a shelf life: the component ID carries the toolset version, so it tracks the image. Retiring the checked-in project files is the real fix, tracked in #27. Also correct the WAM target name in the build-cmake skill, which said -web-dist. No such target exists; WAMDist.cmake creates -wam-dist. --- .claude/skills/build-cmake/SKILL.md | 2 +- .github/workflows/build-win.yml | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.claude/skills/build-cmake/SKILL.md b/.claude/skills/build-cmake/SKILL.md index 029b0b71..9de64945 100644 --- a/.claude/skills/build-cmake/SKILL.md +++ b/.claude/skills/build-cmake/SKILL.md @@ -100,7 +100,7 @@ explicitly. cmake --preset web # WAM (AudioWorklet) distribution -cmake --build build/web --target [ProjectName]-web-dist +cmake --build build/web --target [ProjectName]-wam-dist # WASM (ScriptProcessor) distribution cmake --build build/web --target [ProjectName]-wasm-dist diff --git a/.github/workflows/build-win.yml b/.github/workflows/build-win.yml index 4670b189..5bab93e1 100644 --- a/.github/workflows/build-win.yml +++ b/.github/workflows/build-win.yml @@ -102,6 +102,28 @@ jobs: - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v2 + # The projects/*.vcxproj files pin v143. The + # windows-2025-vs2026 image ships v143 for x86/x64 (as VC.14.44.17.14.x86.x64) but + # only the VS2026 toolset for ARM64EC, so the ARM64EC pass fails with MSB8020 while + # x64 succeeds. Add the matching v143 ARM64EC component. + # NB. the component ID carries the toolset version, so it tracks the runner image - + # if this starts failing, check the image readme for the current VC. IDs. + - name: Install v143 ARM64EC build tools + shell: pwsh + run: | + $installerDir = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" + $vsPath = & "$installerDir\vswhere.exe" -latest -property installationPath + Write-Host "Modifying $vsPath" + $proc = Start-Process -FilePath "$installerDir\vs_installer.exe" -Wait -PassThru -ArgumentList @( + 'modify', '--installPath', "`"$vsPath`"", + '--add', 'Microsoft.VisualStudio.Component.VC.14.44.17.14.ARM64EC', + '--quiet', '--norestart', '--nocache' + ) + # 3010 = success, reboot requested + if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) { + throw "vs_installer failed with exit code $($proc.ExitCode)" + } + - name: Build shell: pwsh run: | From 395e9eada9586ead53db16f9a79aa57a5696631f Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Sat, 1 Aug 2026 02:32:42 +0200 Subject: [PATCH 7/8] Don't pin the Visual Studio toolset in the vcxproj files The projects pinned PlatformToolset v143 in all 30 configuration blocks. When the GitHub runner image moved to VS2026 this broke: the image carries v143 for x86/x64 as a compatibility component, but only the VS2026 toolset for ARM64EC, so the x64 pass built and the ARM64EC pass failed with MSB8020. Use $(DefaultPlatformToolset) instead, so the projects build with whichever Visual Studio is installed - VS2022 gives v143, VS2026 gives its own. This is the same fix as the windows CMake preset earlier on this branch, which dropped its pinned generator for the same reason. Microsoft.Cpp.Default.props, which defines the property, is imported before the Label="Configuration" property groups that consume it, so the ordering is fine. Reverts the previous attempt at this, which tried to add the v143 ARM64EC component with vs_installer. That cannot work: VS2026 has no such component to add, and modify --quiet exits 0 without installing anything when asked for one that is not in the catalogue. --- .github/workflows/build-win.yml | 22 ------------------- .../projects/TemplateProject-aax.vcxproj | 12 +++++----- .../projects/TemplateProject-app.vcxproj | 12 +++++----- .../projects/TemplateProject-clap.vcxproj | 12 +++++----- .../projects/TemplateProject-vst2.vcxproj | 12 +++++----- .../projects/TemplateProject-vst3.vcxproj | 12 +++++----- 6 files changed, 30 insertions(+), 52 deletions(-) diff --git a/.github/workflows/build-win.yml b/.github/workflows/build-win.yml index 5bab93e1..4670b189 100644 --- a/.github/workflows/build-win.yml +++ b/.github/workflows/build-win.yml @@ -102,28 +102,6 @@ jobs: - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v2 - # The projects/*.vcxproj files pin v143. The - # windows-2025-vs2026 image ships v143 for x86/x64 (as VC.14.44.17.14.x86.x64) but - # only the VS2026 toolset for ARM64EC, so the ARM64EC pass fails with MSB8020 while - # x64 succeeds. Add the matching v143 ARM64EC component. - # NB. the component ID carries the toolset version, so it tracks the runner image - - # if this starts failing, check the image readme for the current VC. IDs. - - name: Install v143 ARM64EC build tools - shell: pwsh - run: | - $installerDir = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" - $vsPath = & "$installerDir\vswhere.exe" -latest -property installationPath - Write-Host "Modifying $vsPath" - $proc = Start-Process -FilePath "$installerDir\vs_installer.exe" -Wait -PassThru -ArgumentList @( - 'modify', '--installPath', "`"$vsPath`"", - '--add', 'Microsoft.VisualStudio.Component.VC.14.44.17.14.ARM64EC', - '--quiet', '--norestart', '--nocache' - ) - # 3010 = success, reboot requested - if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) { - throw "vs_installer failed with exit code $($proc.ExitCode)" - } - - name: Build shell: pwsh run: | diff --git a/TemplateProject/projects/TemplateProject-aax.vcxproj b/TemplateProject/projects/TemplateProject-aax.vcxproj index 709a384c..42ec5561 100644 --- a/TemplateProject/projects/TemplateProject-aax.vcxproj +++ b/TemplateProject/projects/TemplateProject-aax.vcxproj @@ -36,27 +36,27 @@ DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-app.vcxproj b/TemplateProject/projects/TemplateProject-app.vcxproj index b4ed2d47..c777894f 100644 --- a/TemplateProject/projects/TemplateProject-app.vcxproj +++ b/TemplateProject/projects/TemplateProject-app.vcxproj @@ -38,41 +38,41 @@ Application true MultiByte - v143 + $(DefaultPlatformToolset) Application true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-clap.vcxproj b/TemplateProject/projects/TemplateProject-clap.vcxproj index 048321d7..e87650f1 100644 --- a/TemplateProject/projects/TemplateProject-clap.vcxproj +++ b/TemplateProject/projects/TemplateProject-clap.vcxproj @@ -38,41 +38,41 @@ DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-vst2.vcxproj b/TemplateProject/projects/TemplateProject-vst2.vcxproj index b195eeb8..956bc3d7 100644 --- a/TemplateProject/projects/TemplateProject-vst2.vcxproj +++ b/TemplateProject/projects/TemplateProject-vst2.vcxproj @@ -38,41 +38,41 @@ DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-vst3.vcxproj b/TemplateProject/projects/TemplateProject-vst3.vcxproj index d44c0716..00f5059a 100644 --- a/TemplateProject/projects/TemplateProject-vst3.vcxproj +++ b/TemplateProject/projects/TemplateProject-vst3.vcxproj @@ -38,41 +38,41 @@ DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) From dc770462ab51f2dd29d864ed2b564b13693c4a9b Mon Sep 17 00:00:00 2001 From: Oli Larkin Date: Sat, 1 Aug 2026 02:47:59 +0200 Subject: [PATCH 8/8] Fix three broken UI paths in the WebView templates All three are things a template teaches by example, so they propagate. Editor-size buttons did nothing. index.html sent SAMFUI(0/1/2) but EMsgTags only had kMsgTagTest = 0, whose handler printed to stdout - so Small printed a line and Medium and Large fell through to default. Add kMsgTagButtonSmall / Medium / Large and resize to sizes within the PLUG_MIN_/PLUG_MAX_ bounds in config.h. Guarded by WEBVIEW_EDITOR_DELEGATE rather than IPLUG_EDITOR, since Resize() comes from the webview delegate, matching Examples/IPlugWebUI. The knob echoed host values back to the host. OnParamChange -> updateValueFromHost -> updateValue called SPVFUI unconditionally, so host automation, preset recall and initialisation were all reported back as UI edits, outside any BPCFUI/EPCFUI gesture. updateValue takes a notifyHost flag; drag and wheel keep the default of true, updateValueFromHost passes false. The REAPER extension's gain slider only updated a label. mGain changed solely when Process was clicked, but it is also read by the "Media item context" action and written by SaveProjectState() - so right-clicking an item, or just saving the project, used a stale value while the UI showed the new one. Send kMsgTagSetGain on every move. Note the direction split: OnGainInput() is the user moving the slider and notifies C++, OnSetGain() is C++ setting it and only redraws. Routing the latter through the former would reintroduce the echo bug above. Both CLAUDE.md files described kMsgTagButtonSmall/Medium/Large mapping to Resize() calls that did not exist. That is now true rather than aspirational, and the host-echo rule is written down in both, since it is invisible until something automates the parameter. --- TemplateProjectReaperExtensionWebView/CLAUDE.md | 9 +++++++++ .../TemplateProjectReaperExtensionWebView.cpp | 7 ++++++- .../TemplateProjectReaperExtensionWebView.h | 1 + .../resources/web/index.html | 15 ++++++++++++--- TemplateProjectWebView/CLAUDE.md | 15 +++++++++++++-- TemplateProjectWebView/TemplateProjectWebView.cpp | 14 +++++++------- TemplateProjectWebView/TemplateProjectWebView.h | 6 +++++- TemplateProjectWebView/resources/web/index.html | 11 ++++++++--- .../resources/web/knob-control.js | 12 ++++++++---- 9 files changed, 69 insertions(+), 21 deletions(-) diff --git a/TemplateProjectReaperExtensionWebView/CLAUDE.md b/TemplateProjectReaperExtensionWebView/CLAUDE.md index 44faab2d..31049447 100644 --- a/TemplateProjectReaperExtensionWebView/CLAUDE.md +++ b/TemplateProjectReaperExtensionWebView/CLAUDE.md @@ -93,6 +93,15 @@ Because there are no parameters, the two directions are asymmetric: `EvaluateJavaScript("OnTrackCount(3)")` and friends, and `index.html` defines matching global functions (`OnTrackCount`, `OnSetGain`, `OnDockState`). +The gain slider sends `kMsgTagSetGain` on every move, not just when *Process* is clicked. `mGain` +is read by the "Media item context" action and written to the project by `SaveProjectState()`, so +if it only updated on Process, right-clicking an item — or simply saving — would use a stale value +while the UI showed the new one. Any UI state C++ owns needs pushing at the point it changes. + +Note the direction split, which mirrors the knob in `../TemplateProjectWebView`: `OnGainInput()` +is the *user* moving the slider and calls `SAMFUI`; `OnSetGain()` is *C++* setting it and only +redraws. Routing the C++ path through `OnGainInput()` would echo the value straight back. + `OnUIOpen()` is the hook for pushing current state into a freshly-loaded page. Call it for anything the UI needs to render correctly on open — the window is destroyed and recreated when docked, so this runs again after `ToggleDocking()`. diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp index 64a66500..4fe46ede 100644 --- a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp @@ -92,8 +92,13 @@ void TemplateProjectReaperExtensionWebView::SendArbitraryMsgFromUI(int msgTag, i case kMsgTagAddTrack: InsertTrackAtIndex(GetNumTracks(), false); break; + case kMsgTagSetGain: + // ctrlTag carries the gain as a 0..100 percentage. Stored as the slider moves so + // the "Media item context" action and SaveProjectState() see the current value, + // not the one from the last Process click. + mGain = static_cast(ctrlTag) / 100.0; + break; case kMsgTagProcessItem: - // ctrlTag carries the gain as a 0..100 percentage mGain = static_cast(ctrlTag) / 100.0; ProcessSelectedItem(mGain); break; diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h index a11876d0..1f47b512 100644 --- a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h @@ -7,6 +7,7 @@ enum EMsgTags kMsgTagAddTrack = 0, kMsgTagProcessItem = 1, kMsgTagToggleDock = 2, + kMsgTagSetGain = 3, kNumMsgTags }; diff --git a/TemplateProjectReaperExtensionWebView/resources/web/index.html b/TemplateProjectReaperExtensionWebView/resources/web/index.html index a43adc16..cbcb7c87 100644 --- a/TemplateProjectReaperExtensionWebView/resources/web/index.html +++ b/TemplateProjectReaperExtensionWebView/resources/web/index.html @@ -78,6 +78,7 @@ const kMsgTagAddTrack = 0; const kMsgTagProcessItem = 1; const kMsgTagToggleDock = 2; + const kMsgTagSetGain = 3; // Stubs for the "from delegate" callbacks that script.js dispatches to. // TemplateProjectReaperExtensionWebView has no parameters, but OnWebContentLoaded still sends a params message. @@ -91,14 +92,22 @@ if (el) el.textContent = n; } - function OnGainInput(v) { + function SetGainLabel(v) { document.getElementById("gainval").textContent = v + "%"; } - // Called from C++ to set the slider (e.g. after loading a project) + // The slider moved. Push the value to C++ now rather than waiting for Process, + // so the REAPER context-menu action and the saved project state both see it. + function OnGainInput(v) { + SetGainLabel(v); + SAMFUI(kMsgTagSetGain, parseInt(v)); + } + + // Called from C++ to set the slider (e.g. after loading a project). Redraws only - + // C++ is the source of this value, so don't send it straight back. function OnSetGain(pct) { document.getElementById("gain").value = pct; - OnGainInput(pct); + SetGainLabel(pct); } // Called from C++ to reflect whether the window is docked diff --git a/TemplateProjectWebView/CLAUDE.md b/TemplateProjectWebView/CLAUDE.md index 7377b26f..9cb5221b 100644 --- a/TemplateProjectWebView/CLAUDE.md +++ b/TemplateProjectWebView/CLAUDE.md @@ -41,7 +41,9 @@ The webview and the plugin talk through a fixed set of bridge functions defined - `SAMFUI(msgTag, ctrlTag = -1, data = 0)` — arbitrary message from UI → arrives at C++ `OnMessage(msgTag, ctrlTag, dataSize, pData)`. - `SMMFUI` / `SSMFUI` — send MIDI / sysex from UI. -On the C++ side, override `OnMessage(int msgTag, int ctrlTag, int dataSize, const void* pData)` to receive `SAMFUI` calls. The template uses it to handle the editor-size buttons: `EMsgTags` in the header (`kMsgTagButtonSmall/Medium/Large`) maps to `Resize(...)` calls. Return `true` if you handled the message. +On the C++ side, override `OnMessage(int msgTag, int ctrlTag, int dataSize, const void* pData)` to receive `SAMFUI` calls. The template uses it for the editor-size buttons: `EMsgTags` in the header (`kMsgTagButtonSmall/Medium/Large`) maps to `Resize(...)` calls, with matching `kMsgTag*` constants at the top of `index.html` — the two lists must stay in sync, since the wire format is a bare int. Return `true` if you handled the message. + +`OnMessage`'s resize block is guarded by `WEBVIEW_EDITOR_DELEGATE` rather than `IPLUG_EDITOR`, because `Resize()` comes from the webview delegate. ## Parameters @@ -53,7 +55,16 @@ On the C++ side, override `OnMessage(int msgTag, int ctrlTag, int dataSize, cons Controls are HTML Custom Elements (Web Components), each in its own module: - `resources/web/knob-control.js` — ``. On drag it calls `BPCFUI` (start), `SPVFUI` (each move, normalized), `EPCFUI` (end). Host-driven changes arrive via `updateValueFromHost(normalizedValue)`, dispatched from `OnParamChange`. `observedAttributes` includes `param-id`, `min`, `max`, `label`, `default-value`. -- `resources/web/button-control.js` — ``, a styled shadow-DOM button. The template wires `onclick="SAMFUI(n)"` directly in `index.html` to trigger the resize messages. +- `resources/web/button-control.js` — ``, a styled shadow-DOM button. The template wires `onclick="SAMFUI(kMsgTagButton*)"` directly in `index.html` to trigger the resize messages. + +### Don't echo host values back to the host + +`updateValue()` in `knob-control.js` takes a `notifyHost` flag. Drag and wheel leave it at +its default of `true`; `updateValueFromHost()` passes `false`, so a host-originated change +redraws the knob without calling `SPVFUI`. Without that split, automation, preset recall and +initialisation all bounce back to the host as UI edits — and outside any `BPCFUI`/`EPCFUI` +gesture, so the host records parameter writes nobody asked for. Any new control needs the +same separation between "the user did this" and "the host told us this". ### Adding a new control — checklist diff --git a/TemplateProjectWebView/TemplateProjectWebView.cpp b/TemplateProjectWebView/TemplateProjectWebView.cpp index f2149cfb..2758026c 100644 --- a/TemplateProjectWebView/TemplateProjectWebView.cpp +++ b/TemplateProjectWebView/TemplateProjectWebView.cpp @@ -1,6 +1,5 @@ #include "TemplateProjectWebView.h" #include "IPlug_include_in_plug_src.h" -#include using namespace iplug; @@ -39,13 +38,14 @@ void TemplateProjectWebView::ProcessBlock(sample** inputs, sample** outputs, int bool TemplateProjectWebView::OnMessage(int msgTag, int ctrlTag, int dataSize, const void* pData) { -#ifdef IPLUG_EDITOR - auto DoTest = []() { - std::cout << "Test message received from web UI!" << std::endl; - }; - +// Resize() lives on the WebView editor delegate, so this is guarded more tightly than +// IPLUG_EDITOR. The sizes stay within PLUG_MIN_/PLUG_MAX_ in config.h and keep the 4:3 +// aspect of the default editor size. +#ifdef WEBVIEW_EDITOR_DELEGATE switch (msgTag) { - case kMsgTagTest: DoTest(); return true; + case kMsgTagButtonSmall: Resize(512, 384); return true; + case kMsgTagButtonMedium: Resize(1024, 768); return true; + case kMsgTagButtonLarge: Resize(1536, 1152); return true; default: break; } #endif diff --git a/TemplateProjectWebView/TemplateProjectWebView.h b/TemplateProjectWebView/TemplateProjectWebView.h index bf6a278a..276709b3 100644 --- a/TemplateProjectWebView/TemplateProjectWebView.h +++ b/TemplateProjectWebView/TemplateProjectWebView.h @@ -11,9 +11,13 @@ enum EParams }; // Tags for messages sent from the web UI to the plugin (see OnMessage). +// These must stay in sync with the kMsgTag* constants at the top of resources/web/index.html. enum EMsgTags { - kMsgTagTest = 0, + kMsgTagButtonSmall = 0, + kMsgTagButtonMedium = 1, + kMsgTagButtonLarge = 2, + kNumMsgTags }; class TemplateProjectWebView final : public iplug::Plugin diff --git a/TemplateProjectWebView/resources/web/index.html b/TemplateProjectWebView/resources/web/index.html index 9e36958c..e7b86645 100644 --- a/TemplateProjectWebView/resources/web/index.html +++ b/TemplateProjectWebView/resources/web/index.html @@ -89,6 +89,11 @@