feat: source-code-compatible plugin system (V1)#99
Conversation
Implement a plugin loading system that allows Notepad++ plugins to be compiled on macOS as .dylib files and loaded into MacNote++ without any source code modifications to the plugin. V1 scope: command-oriented, non-docking plugins that register menu commands via FuncItem, use SendMessage(sciHandle, SCI_*), and receive SCNotification callbacks. Key changes: - Register both Scintilla views with HandleRegistry at startup so plugins get real HWNDs that route SCI_* messages through the bridge - Pre-create second Scintilla view (hidden) for stable NppData handles - New MacPluginManager: loads .dylib plugins from ~/Library/Application Support/MacNote++/plugins/, resolves 6 required exports, builds menus - Mach-O architecture validation for ARM64/x86_64 - NPPM message handler (12 NPPMSG + 8 RUNCOMMAND_USER messages) with correct offsets and calling conventions - WndProc routing: static commands (22000-22999), dynamic relay (23000-24999), catch-all messageProc relay matching upstream - SCNotification forwarding from both Scintilla views to plugins - NPPN_READY/NPPN_SHUTDOWN lifecycle notifications - Plugins menu between Tools and Language - Plugin SDK with HelloMacNote sample plugin - Host exports symbols via -export_dynamic for plugin resolution Closes #49 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a first-pass macOS plugin system intended to load Notepad++-style plugins compiled as .dylib into MacNote++ with no source changes, including message routing, command dispatch, and a sample plugin.
Changes:
- Added
MacPluginManagerto discover/load.dylibplugins and populate a new “Plugins” menu. - Implemented routing/handling for key
NPPM_*andRUNCOMMAND_USERmessages and relayed window messages to plugins’messageProc. - Updated split-view and startup initialization to provide stable Scintilla
HWNDs (including a pre-created hidden second view) and forward Scintilla notifications to plugins.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| macos/platform/plugin_manager.mm | Implements dylib loading, export resolution, command ID allocation, plugin menu population, and notification/message relays |
| macos/platform/plugin_manager.h | Declares plugin manager types and singleton accessor |
| macos/platform/nppm_handler.mm | Adds handlers for NPPM_* / RUNCOMMAND_USER messages used by plugins |
| macos/platform/nppm_handler.h | Declares handler entry points for wndproc routing |
| macos/platform/wndproc.mm | Routes plugin command IDs and relays NPPM/RUNCOMMAND/window messages to plugins |
| macos/platform/menu_builder.mm | Adds a top-level “Plugins” menu and exposes its handle |
| macos/platform/menu_builder.h | Declares getPluginsMenuHandle() |
| macos/platform/app_delegate.mm | Registers Scintilla HWNDs, pre-creates second view, initializes plugin system, and sends READY/SHUTDOWN notifications |
| macos/platform/split_view.mm | Reuses pre-created second Scintilla view; forwards notifications to plugins |
| macos/platform/app_state.h | Stores Scintilla HWNDs for both views in AppContext |
| macos/plugin-sdk/CMakeLists.txt | Adds a plugin-SDK CMake entry for building example plugins |
| macos/plugin-sdk/example/HelloMacNote/HelloMacNote.cpp | Adds a sample plugin implementing standard N++ exports and a few commands |
| macos/plugin-sdk/example/HelloMacNote/CMakeLists.txt | Builds the example plugin as a .dylib module |
| macos/CMakeLists.txt | Builds/installs the sample plugin and exports host symbols for dlopen-loaded plugins |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Allocate command IDs from the static range and register commands | ||
| pi->_cmdIdBase = static_cast<int>(_pluginsCommands.size()); | ||
| for (int i = 0; i < pi->_nbFuncItem; ++i) | ||
| { | ||
| int cmdId = _staticCmdAlloc.allocate(1); | ||
| if (cmdId < 0) | ||
| { | ||
| NSLog(@"Exhausted static plugin command IDs."); | ||
| break; | ||
| } | ||
| pi->_funcItems[i]._cmdID = cmdId; | ||
|
|
||
| PluginCommand cmd; | ||
| cmd._pluginName = pi->_displayName; | ||
| cmd._pFunc = pi->_funcItems[i]._pFunc; | ||
| _pluginsCommands.push_back(cmd); | ||
| } |
There was a problem hiding this comment.
Command ID assignment currently allocates an ID and pushes an entry into _pluginsCommands for every FuncItem, even when the FuncItem represents a separator/placeholder (i.e., _pFunc == nullptr). Upstream Notepad++ only assigns command IDs and stores commands for non-null _pFunc items, and uses ID_PLUGINS_CMD + (commands.size()-1) as the cmdID. To maximize source-level compatibility (and ensure cmdId→index mapping stays correct), skip null _pFunc items when assigning IDs / pushing commands, and derive cmdID from the command vector index (rather than allocating for every FuncItem).
| for (int i = 0; i < pi->_nbFuncItem; ++i) | ||
| { | ||
| FuncItem& fi = pi->_funcItems[i]; | ||
| if (fi._itemName[0] == L'-') | ||
| { | ||
| ::AppendMenuW(pluginSub, MF_SEPARATOR, 0, nullptr); | ||
| } | ||
| else | ||
| { | ||
| UINT flags = MF_STRING; | ||
| if (fi._init2Check) | ||
| flags |= MF_CHECKED; | ||
| ::AppendMenuW(pluginSub, flags, static_cast<UINT_PTR>(fi._cmdID), fi._itemName); | ||
| } |
There was a problem hiding this comment.
Separator handling in initMenu() is based on fi._itemName[0] == L'-', but upstream treats a FuncItem with _pFunc == nullptr as a separator. As written, plugins that follow the standard pattern (null _pFunc separators) will get clickable menu items instead of separators. Update the separator check to use fi._pFunc == nullptr (and avoid appending a command item in that case).
macos/platform/nppm_handler.mm
Outdated
| wchar_t* buf = reinterpret_cast<wchar_t*>(lParam); | ||
| size_t copyLen = src.size(); | ||
| if (maxChars > 0 && copyLen >= static_cast<size_t>(maxChars)) | ||
| copyLen = static_cast<size_t>(maxChars) - 1; |
There was a problem hiding this comment.
copyWideToBuffer() truncates the string when wParam indicates the destination buffer is too small, and still returns TRUE. In Notepad++ message handling, these APIs instead fail when the buffer is insufficient (return FALSE for most path getters, and return 0 for NPPM_GETPLUGINSCONFIGDIR) without writing a partial string. For better plugin compatibility, mirror upstream behavior: if wParam!=0 and src.length() >= wParam, return failure and do not write; only copy when the buffer is large enough (or wParam==0 compatibility mode).
| wchar_t* buf = reinterpret_cast<wchar_t*>(lParam); | |
| size_t copyLen = src.size(); | |
| if (maxChars > 0 && copyLen >= static_cast<size_t>(maxChars)) | |
| copyLen = static_cast<size_t>(maxChars) - 1; | |
| if (maxChars != 0 && src.size() >= static_cast<size_t>(maxChars)) | |
| { | |
| // Mirror Notepad++ behavior: fail without writing when the provided | |
| // buffer is too small for the full string plus terminator. | |
| return 0; | |
| } | |
| wchar_t* buf = reinterpret_cast<wchar_t*>(lParam); | |
| const size_t copyLen = src.size(); |
| target_include_directories(HelloMacNote PRIVATE | ||
| "${CMAKE_CURRENT_SOURCE_DIR}/../../include" | ||
| ) | ||
|
|
There was a problem hiding this comment.
This target adds an include path to "${CMAKE_CURRENT_SOURCE_DIR}/../../include", but macos/plugin-sdk/include doesn't exist in the tree. This extra include dir is misleading and can hide real include-path issues; consider removing it or pointing it at the actual SDK header locations (shim, PluginsManager, scintilla) like macos/plugin-sdk/CMakeLists.txt does.
| target_include_directories(HelloMacNote PRIVATE | |
| "${CMAKE_CURRENT_SOURCE_DIR}/../../include" | |
| ) |
- Skip null _pFunc items when allocating command IDs (separators don't get IDs, matching upstream behavior) - Use _pFunc == nullptr for separator detection in menu building instead of checking _itemName[0] == '-' - copyWideToBuffer() now fails without writing when buffer is too small instead of truncating (matches upstream Notepad++ behavior) - Remove non-existent include path from HelloMacNote standalone CMakeLists Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The HelloMacNote sample plugin's "Insert Timestamp" command now inserts the current date/time (e.g. 2026-04-09 14:32:05) instead of the placeholder string "[HelloMacNote timestamp]". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Copy SCNotification per plugin in notify() to prevent mutation (high) - NPPM_GETCURRENTWORD returns selected text when selection exists (medium) - NPPM_GETNBOPENFILES deduplicates split-view mirrors (medium) - Emit NPPN_FILEOPENED, NPPN_FILESAVED, NPPN_FILEBEFORECLOSE, NPPN_FILECLOSED, and NPPN_LANGCHANGED lifecycle notifications (medium) - Normalize allocation failure to 0 instead of -1 (low) - Align INDICATOR_PLUGINS to 8 to match documented range Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
macos/platform/wndproc.mm
Outdated
| { | ||
| pluginManager().relayNppMessages(WM_COMMAND, cmdId, 0); | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
The relayNppMessages “catch-all” at the end of MainWndProc is bypassed by the many early return 0; paths in WM_COMMAND handling (including the plugin static command path). Upstream relays messages to plugins after processing regardless of whether the message was handled, so messageProc will miss many messages here. Consider refactoring to compute a result/handled flag and always call pluginManager().relayNppMessages(msg, wParam, lParam) just before returning.
macos/platform/plugin_manager.mm
Outdated
| // Create plugin submenu | ||
| pi->_pluginMenu = ::CreateMenu(); |
There was a problem hiding this comment.
loadPluginFromPath creates a menu with CreateMenu() and assigns it to pi->_pluginMenu, but initMenu() later overwrites _pluginMenu with a new CreatePopupMenu() handle. This leaks the original menu handle. Remove the unused CreateMenu() call (or destroy the previous handle before overwriting).
| // Create plugin submenu | |
| pi->_pluginMenu = ::CreateMenu(); | |
| // Plugin submenu is created later during menu initialization. |
| static LRESULT copyWideToBuffer(const std::wstring& src, WPARAM maxChars, LPARAM lParam) | ||
| { | ||
| if (!lParam) | ||
| { | ||
| // First call: return number of wchar_t needed (not including null) | ||
| return static_cast<LRESULT>(src.size()); | ||
| } | ||
|
|
||
| // Fail without writing when the buffer is too small (matches upstream behavior) | ||
| if (maxChars != 0 && src.size() >= static_cast<size_t>(maxChars)) | ||
| return 0; | ||
|
|
||
| wchar_t* buf = reinterpret_cast<wchar_t*>(lParam); | ||
| const size_t copyLen = src.size(); | ||
| wcsncpy(buf, src.c_str(), copyLen); | ||
| buf[copyLen] = L'\0'; | ||
| return TRUE; |
There was a problem hiding this comment.
copyWideToBuffer skips the size check when maxChars == 0, and will write into lParam even though a 0-sized buffer is invalid in the Notepad++ two-call contract (upstream treats length >= wParam as failure). This can lead to buffer overflow if a plugin passes wParam=0 with a non-null buffer. Consider always enforcing src.size() < maxChars when lParam != 0.
| PREFIX "" | ||
| SUFFIX ".dylib" | ||
| OUTPUT_NAME "HelloMacNote" | ||
| ) |
There was a problem hiding this comment.
The standalone SDK example target doesn’t set -undefined dynamic_lookup (or equivalent) for the plugin dylib. Since the plugin intentionally references host-provided symbols (e.g., SendMessage, MessageBox) without linking against a shim library, this will typically fail to link when building via macos/plugin-sdk as its own CMake project.
| ) | |
| ) | |
| target_link_options(HelloMacNote PRIVATE | |
| "LINKER:-undefined,dynamic_lookup" | |
| ) |
| // Notify plugins that a file was opened | ||
| { | ||
| SCNotification notif{}; | ||
| notif.nmhdr.hwndFrom = ctx().mainHwnd; | ||
| notif.nmhdr.code = NPPN_FILEOPENED; | ||
| notif.nmhdr.idFrom = 0; // V1: no stable buffer ID yet | ||
| pluginManager().notify(¬if); | ||
| } |
There was a problem hiding this comment.
This adds NPPN_FILEOPENED notifications but always sets nmhdr.idFrom = 0. The PR description lists file notifications as a V2 limitation specifically because they require stable buffer IDs; sending these events in V1 (without IDs) can mislead plugins that treat the notification as fully supported. Either defer emitting these notifications until buffer IDs exist, or update the PR description/limitations to clarify the partial semantics.
| // Notify plugins that the file was saved | ||
| { | ||
| SCNotification notif{}; | ||
| notif.nmhdr.hwndFrom = ctx().mainHwnd; | ||
| notif.nmhdr.code = NPPN_FILESAVED; | ||
| notif.nmhdr.idFrom = 0; // V1: no stable buffer ID yet | ||
| pluginManager().notify(¬if); | ||
| } |
There was a problem hiding this comment.
Same as the file-open case: NPPN_FILESAVED is emitted with nmhdr.idFrom = 0, despite the PR description calling file notifications a V2 item due to needing stable buffer IDs. Consider deferring or clearly documenting that these are emitted with an invalid/placeholder BufferID in V1.
|
|
||
| // Notify plugins that the language changed | ||
| SCNotification notif{}; | ||
| notif.nmhdr.hwndFrom = ctx().mainHwnd; | ||
| notif.nmhdr.code = NPPN_LANGCHANGED; | ||
| notif.nmhdr.idFrom = 0; // V1: no stable buffer ID yet | ||
| pluginManager().notify(¬if); |
There was a problem hiding this comment.
NPPN_LANGCHANGED is emitted here with nmhdr.idFrom = 0. The PR description lists file/language notifications as a V2 item due to needing stable buffer IDs; emitting the notification in V1 with a placeholder BufferID can lead plugins to assume full support. Consider deferring it until BufferIDs exist, or explicitly documenting the placeholder semantics.
- Fix menu handle leak: remove unused CreateMenu() in loadPluginFromPath since initMenu() creates the real submenu later - Fix copyWideToBuffer buffer overflow when wParam=0 with non-null buffer - Fix relayNppMessages bypass: relay WM_COMMAND to plugins at top of handler since individual command paths return early - Add -undefined dynamic_lookup linker flag to SDK example so plugin dylibs can reference host-provided symbols Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
.dylibfiles and load into MacNote++ with zero source code changesWhat's included
SendMessage(sciHandle, SCI_*, ...).dylibplugins from~/Library/Application Support/MacNote++/plugins/, resolves 6 required exports, Mach-O arch validationmessageProcrelay matching upstreamNPPN_READY/NPPN_SHUTDOWNlifecycleKnown limitations (V2)
NPPM_GETCURRENTBUFFERID,NPPN_BUFFERACTIVATED) — need stable buffer IDsNPPN_FILEOPENED, etc.) — require BufferID innmhdr.idFromTest plan
cmake --build . --target MacNotePlusPlusbuilds successfullycmake --build . --target HelloMacNotebuilds sample plugin as.dylibcmake --build . --target install_sample_plugininstalls to plugin directoryCloses #49
🤖 Generated with Claude Code