Exempt ReaSampler 9000's own editor from the plugin OS drag hand-off
A dragged bank card over our own instrument's editor was OLE-dropping like a third-party plugin. Classify our channel's own .vst3 module as Instrument, not Plugin, so it keeps the normal instrument-drop outcome.
This commit is contained in:
@@ -98,7 +98,9 @@ L7 sub-pass, 2026-07-27):
|
||||
hand off over any extension window docked in REAPER, so this route also REQUIRES the
|
||||
FX-surface token; within an FX window the proof is what separates the plugin's editor
|
||||
(OS hand-off — the plugin takes the files as it would from Explorer) from REAPER's
|
||||
chrome (instrument drop, unchanged).
|
||||
chrome (instrument drop, unchanged). Our own ReaSampler 9000 editor is exempted from
|
||||
this proof (`WindowCode::Instrument`) — it is FX chrome we own, so a drop on it is the
|
||||
instrument-drop outcome like any other REAPER chrome, never the OS hand-off.
|
||||
|
||||
Once handed off, the law keeps running: on Windows the OS drag loop re-asks it every
|
||||
turn (`decideOsDragTurn`) and WITHDRAWS the moment it stops answering `OsHandoff`,
|
||||
@@ -116,7 +118,7 @@ L7 sub-pass, 2026-07-27):
|
||||
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
|
||||
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
|
||||
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through the two proof members of `DropContext` — `pointerOffHost` (`drag_out_win::pointerOverHostWindow`) where REAPER's hit-test named nothing (`Other`, no track), and `pointerOverPluginEditor` (`drag_out_win::pointerOverPluginEditor`) where it named `FxSurface` — both gated ahead of the surface switch, so no evidence source can give a live drag away to OLE on its own (the invariant above states why each pairing). Also owns `decideOsDragTurn`, the OS drag loop's per-turn Continue / Drop / Cancel / Withdraw / WithdrawAtRelease verdict. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
|
||||
- `window_origin` — whose code implements a window (Host / System / Plugin / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`.
|
||||
- `window_origin` — whose code implements a window (Host / System / Plugin / Instrument / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. `Instrument` is the one deliberate exemption: our own ReaSampler 9000 module (`WindowFacts::ownModule`, a basename match against this channel's `app_version::vstOutputName() + ".vst3"` — stable and beta each recognise only their own binary) is FX chrome we own, not a hand-off-worthy plugin editor, so it is never `Plugin` and never counts as `isPluginEditorChain` proof — a drag over our own editor stays the instrument-drop outcome. Every other plugin's module is unaffected. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`.
|
||||
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
|
||||
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
|
||||
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
|
||||
|
||||
@@ -37,6 +37,7 @@ WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& syste
|
||||
if (facts.otherProcess) return WindowCode::Plugin;
|
||||
if (!facts.procResolved) return WindowCode::Unknown;
|
||||
if (facts.hostModule) return WindowCode::Host;
|
||||
if (facts.ownModule) return WindowCode::Instrument;
|
||||
if (normalizePath(systemRoot).empty()) return WindowCode::Unknown;
|
||||
return isBeneath(facts.modulePath, systemRoot) ? WindowCode::System : WindowCode::Plugin;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
namespace reasampler::ui {
|
||||
|
||||
enum class WindowCode {
|
||||
Host, // the host executable itself — REAPER's own chrome and containers
|
||||
System, // an OS module (standard controls, dialog class): says nothing about who placed it
|
||||
Plugin, // any other code — a plugin binary, or a window of another process (bridged plugin)
|
||||
Unknown, // the procedure resolved to no loaded module — no evidence either way
|
||||
Host, // the host executable itself — REAPER's own chrome and containers
|
||||
System, // an OS module (standard controls, dialog class): says nothing about who placed it
|
||||
Plugin, // any other code — a plugin binary, or a window of another process (bridged plugin)
|
||||
Instrument, // our own ReaSampler 9000 module: FX chrome, not a hand-off-worthy plugin editor
|
||||
Unknown, // the procedure resolved to no loaded module — no evidence either way
|
||||
};
|
||||
|
||||
// What the shell observed about ONE window. The CLASS procedure, not the instance one, because an
|
||||
@@ -23,6 +24,11 @@ struct WindowFacts {
|
||||
bool otherProcess = false; // the window belongs to a process other than the host's
|
||||
bool procResolved = false; // the class procedure lies inside a loaded module
|
||||
bool hostModule = false; // ...and that module is the host executable
|
||||
// ...and that module is THIS channel's ReaSampler 9000 .vst3 (basename match, set by the
|
||||
// shell against app_version::vstOutputName() + ".vst3" — stable and beta each recognise only
|
||||
// their own binary, never the other channel's). A drag over our own instrument's editor is
|
||||
// the load-bearing-principle instrument-drop outcome, not the OS hand-off other plugins get.
|
||||
bool ownModule = false;
|
||||
std::string modulePath; // that module's file path, UTF-8
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ is owned by other directories and only skinned here.
|
||||
|
||||
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins; `src/shell/bank_ops/CLAUDE.md` owns the one-op-one-Ctrl-Z rule those verbs enforce. The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
|
||||
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
|
||||
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Owns the two window tests behind `ui::DropContext`'s hand-off proofs (`core/ui/CLAUDE.md` owns the rule they feed). `pointerOverHostWindow` is process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only; its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER. `pointerOverPluginEditor` walks from the hit window to its root reading each window's owning process and the module of its CLASS procedure (`GetClassLongPtr(GCLP_WNDPROC)` → `GetModuleHandleEx(FROM_ADDRESS)`), and lets `ui::window_origin` decide; its failure modes are listed at the declaration, and it is always false under SWELL. The OLE drop source re-asks the gesture law on every `QueryContinueDrag` (a function pointer the panel supplies) and withdraws the drag per `ui::decideOsDragTurn`, reporting it as `OsDragEnd::Withdrawn*` so the panel resumes its own gesture. A drop into a plugin means the extension writes nothing to the bank and inserts no timeline item — the plugin receives a copy-only path list, exactly as from Explorer (the plugin's own reaction to that path list is outside the extension's control).
|
||||
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Owns the two window tests behind `ui::DropContext`'s hand-off proofs (`core/ui/CLAUDE.md` owns the rule they feed). `pointerOverHostWindow` is process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only; its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER. `pointerOverPluginEditor` walks from the hit window to its root reading each window's owning process and the module of its CLASS procedure (`GetClassLongPtr(GCLP_WNDPROC)` → `GetModuleHandleEx(FROM_ADDRESS)`), and lets `ui::window_origin` decide; its failure modes are listed at the declaration, and it is always false under SWELL. It also sets `WindowFacts::ownModule` — a basename match of the resolved module against this channel's own `reasampler_9000[_beta].vst3` (`app_version::vstOutputName() + ".vst3"`) — so our own instrument's editor classifies as `Instrument`, not `Plugin`, and a drag over it stays the instrument-drop outcome instead of handing off to the OS; every other plugin's editor is unaffected. The OLE drop source re-asks the gesture law on every `QueryContinueDrag` (a function pointer the panel supplies) and withdraws the drag per `ui::decideOsDragTurn`, reporting it as `OsDragEnd::Withdrawn*` so the panel resumes its own gesture. A drop into a plugin means the extension writes nothing to the bank and inserts no timeline item — the plugin receives a copy-only path list, exactly as from Explorer (the plugin's own reaction to that path list is outside the extension's control).
|
||||
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER or over a plugin's own editor (so an FX surface reaches the instrument drop only as its chrome; a plugin's editor inside it hands off to the OS) — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||
- `insert_fx_action` — the "Insert as FX" verb: a second ENTRY POINT to `instrument_drop_win`'s drop body (the bank panel's Placement button and a bindable action), loading the panel's focused capture onto the first selected track. Owns no insert logic of its own — it resolves the selection, folds `core/ui/insert_fx_enable`, and calls `performInstrumentDrop` (which keeps the one undo block). Refuses visibly on every failing precondition; the panel button additionally paints dead for the two panel-local ones.
|
||||
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#include "core/ui/drag_out.h" // decideOsDragTurn — the OS loop's per-turn verdict
|
||||
#include "core/ui/window_origin.h" // classifyWindowCode / isPluginEditorChain
|
||||
#include "core/version/app_version.h" // vstOutputName — this channel's own .vst3 basename
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -325,6 +326,30 @@ std::string moduleFileName(HMODULE mod) {
|
||||
}
|
||||
}
|
||||
|
||||
// True iff `path`'s file name (the part after the last separator) case-insensitively matches
|
||||
// `fileName` — Windows paths are case-insensitive, and GetModuleFileName's casing tracks however
|
||||
// the module was loaded (often not the on-disk casing).
|
||||
bool sameBaseName(const std::string& path, const std::string& fileName) {
|
||||
const std::size_t pos = path.find_last_of("\\/");
|
||||
const std::string base = pos == std::string::npos ? path : path.substr(pos + 1);
|
||||
if (base.size() != fileName.size()) return false;
|
||||
for (std::size_t i = 0; i < base.size(); ++i) {
|
||||
char a = base[i], b = fileName[i];
|
||||
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
|
||||
if (b >= 'A' && b <= 'Z') b = static_cast<char>(b - 'A' + 'a');
|
||||
if (a != b) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// This channel's own ReaSampler 9000 module file name (e.g. "reasampler_9000.vst3", or the
|
||||
// "_beta" variant) — the same name CMake gives the built .vst3 (OUTPUT_NAME + SUFFIX), so stable
|
||||
// and beta each recognise only their own binary, never the other channel's.
|
||||
const std::string& ownVstFileName() {
|
||||
static const std::string name = version::vstOutputName() + ".vst3";
|
||||
return name;
|
||||
}
|
||||
|
||||
// Empty on failure, which window_origin reads as "cannot tell OS code from plugin code".
|
||||
const std::string& systemRoot() {
|
||||
static const std::string root = [] {
|
||||
@@ -358,7 +383,10 @@ ui::WindowFacts windowFacts(HWND hwnd) {
|
||||
f.hostModule = mod == GetModuleHandleW(nullptr);
|
||||
// classifyWindowCode returns Host on hostModule before ever reading modulePath — skip the
|
||||
// (string-building) lookup in that case.
|
||||
if (!f.hostModule) f.modulePath = moduleFileName(mod);
|
||||
if (!f.hostModule) {
|
||||
f.modulePath = moduleFileName(mod);
|
||||
f.ownModule = sameBaseName(f.modulePath, ownVstFileName());
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ bool pointerOverHostWindow(int screenX, int screenY);
|
||||
// - an all-#32770 dialog-based editor (the generic Windows dialog class) resolves to no
|
||||
// recognizable plugin module — falls to REAPER's outcome;
|
||||
// - a .NET/WPF editor's class procedure is a CLR-thunked address with no resolvable module —
|
||||
// also falls to REAPER's outcome.
|
||||
// also falls to REAPER's outcome;
|
||||
// - our own ReaSampler 9000 module (this channel's reasampler_9000[_beta].vst3, matched by
|
||||
// basename) also falls to REAPER's outcome, deliberately — see ui::WindowFacts::ownModule.
|
||||
// The opposite direction is unguarded: a third-party hook that replaces REAPER's own chrome
|
||||
// class procedure via SetClassLongPtr(GCLP_WNDPROC) would make REAPER's own window read as
|
||||
// Plugin — toward OLE instead of away from it.
|
||||
|
||||
@@ -23,6 +23,14 @@ static WindowFacts inModule(const std::string& path, bool host = false) {
|
||||
return f;
|
||||
}
|
||||
|
||||
static WindowFacts ownModule(const std::string& path) {
|
||||
WindowFacts f;
|
||||
f.procResolved = true;
|
||||
f.modulePath = path;
|
||||
f.ownModule = true;
|
||||
return f;
|
||||
}
|
||||
|
||||
// --- classifyWindowCode --------------------------------------------------------
|
||||
|
||||
// REAPER's own chrome and containers: the class procedure lives in the executable.
|
||||
@@ -103,6 +111,17 @@ static void testMissingSystemRootNeverYieldsPlugin() {
|
||||
CHECK(classifyWindowCode(inModule("C:\\REAPER\\reaper.exe", true), "") == WindowCode::Host);
|
||||
}
|
||||
|
||||
// Our own ReaSampler 9000 module — stable or beta, whichever the shell set ownModule for — is
|
||||
// Instrument, not Plugin: FX chrome we own, so a drag over it must not hand off to the OS.
|
||||
static void testOwnModuleIsInstrument() {
|
||||
CHECK(classifyWindowCode(
|
||||
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3"), kWinDir) ==
|
||||
WindowCode::Instrument);
|
||||
CHECK(classifyWindowCode(
|
||||
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000_beta.vst3"),
|
||||
kWinDir) == WindowCode::Instrument);
|
||||
}
|
||||
|
||||
// --- isPluginEditorChain -------------------------------------------------------
|
||||
|
||||
// Pointer on REAPER's FX-window chrome: a standard control or a REAPER container, under the
|
||||
@@ -134,6 +153,19 @@ static void testBridgedEmbeddedEditorIsPlugin() {
|
||||
WindowCode::System}));
|
||||
}
|
||||
|
||||
// A pointer over our own instrument's editor — Instrument code, not Plugin — is not hand-off
|
||||
// proof: it stays REAPER's FX chrome, unlike a genuine plugin editor (testPluginWindowChainIsPlugin).
|
||||
static void testOwnInstrumentChainIsNotPlugin() {
|
||||
CHECK(!isPluginEditorChain({WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
|
||||
}
|
||||
|
||||
// Another plugin's editor sitting alongside our own instrument in the same chain is still proof —
|
||||
// only OUR module is exempted, nobody else's.
|
||||
static void testOtherPluginBesideOwnInstrumentIsStillPlugin() {
|
||||
CHECK(isPluginEditorChain(
|
||||
{WindowCode::Plugin, WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
|
||||
}
|
||||
|
||||
// Unknown anywhere, and nothing else, is not proof; an empty chain (no window at all) is not.
|
||||
static void testUnknownAndEmptyChainsAreNotPlugin() {
|
||||
CHECK(!isPluginEditorChain({WindowCode::Unknown, WindowCode::Host, WindowCode::System}));
|
||||
@@ -150,11 +182,14 @@ int main() {
|
||||
testOtherProcessIsPluginRegardlessOfModule();
|
||||
testUnresolvedProcIsUnknown();
|
||||
testMissingSystemRootNeverYieldsPlugin();
|
||||
testOwnModuleIsInstrument();
|
||||
|
||||
testChromeChainIsNotPlugin();
|
||||
testPluginWindowChainIsPlugin();
|
||||
testStandardControlInsidePluginIsPlugin();
|
||||
testBridgedEmbeddedEditorIsPlugin();
|
||||
testOwnInstrumentChainIsNotPlugin();
|
||||
testOtherPluginBesideOwnInstrumentIsStillPlugin();
|
||||
testUnknownAndEmptyChainsAreNotPlugin();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
|
||||
Reference in New Issue
Block a user