Skip to content

feat: source-code-compatible plugin system (V1)#99

Merged
hybridmachine merged 5 commits intomacos-portfrom
feature/plugin-system-v1
Apr 10, 2026
Merged

feat: source-code-compatible plugin system (V1)#99
hybridmachine merged 5 commits intomacos-portfrom
feature/plugin-system-v1

Conversation

@hybridmachine
Copy link
Copy Markdown
Owner

@hybridmachine hybridmachine commented Apr 9, 2026

Summary

  • Implement a plugin loading system that allows Notepad++ plugins to compile on macOS as .dylib files and load into MacNote++ with zero source code changes
  • V1 targets command-oriented, non-docking plugins (menu commands, Scintilla messaging, file-path APIs, SCNotification callbacks)
  • Includes HelloMacNote sample plugin that compiles identically on Windows (.dll) and macOS (.dylib)

What's included

  • Scintilla HWND registration: Both editor views registered with HandleRegistry at startup so plugins get real HWNDs for SendMessage(sciHandle, SCI_*, ...)
  • MacPluginManager: Loads .dylib plugins from ~/Library/Application Support/MacNote++/plugins/, resolves 6 required exports, Mach-O arch validation
  • 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
  • Notifications: SCNotification forwarding from both views, NPPN_READY/NPPN_SHUTDOWN lifecycle
  • Plugin SDK: Headers + shim includes + HelloMacNote sample
  • Plugins menu: Between Tools and Language

Known limitations (V2)

  • Buffer APIs (NPPM_GETCURRENTBUFFERID, NPPN_BUFFERACTIVATED) — need stable buffer IDs
  • File/language notifications (NPPN_FILEOPENED, etc.) — require BufferID in nmhdr.idFrom
  • Docking panels, lexer plugins, modeless dialog registration, Plugin Admin

Test plan

  • cmake --build . --target MacNotePlusPlus builds successfully
  • cmake --build . --target HelloMacNote builds sample plugin as .dylib
  • cmake --build . --target install_sample_plugin installs to plugin directory
  • App launches and logs "Loaded plugin: HelloMacNote"
  • Plugins menu appears with HelloMacNote submenu
  • Click "Hello World" — MessageBox appears
  • Click "Insert Timestamp" — text inserted at cursor
  • Click "Show Version" — displays version info
  • Split/unsplit works correctly with pre-created second view

Closes #49

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 9, 2026 03:33
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MacPluginManager to discover/load .dylib plugins and populate a new “Plugins” menu.
  • Implemented routing/handling for key NPPM_* and RUNCOMMAND_USER messages 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.

Comment on lines +217 to +233
// 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);
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +293 to +306
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);
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +30
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;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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();

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +10
target_include_directories(HelloMacNote PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/../../include"
)

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
target_include_directories(HelloMacNote PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/../../include"
)

Copilot uses AI. Check for mistakes.
Brian Tabone and others added 3 commits April 8, 2026 22:51
- 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +50 to +97
{
pluginManager().relayNppMessages(WM_COMMAND, cmdId, 0);
return 0;
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +239 to +240
// Create plugin submenu
pi->_pluginMenu = ::CreateMenu();
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// Create plugin submenu
pi->_pluginMenu = ::CreateMenu();
// Plugin submenu is created later during menu initialization.

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +35
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;
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
PREFIX ""
SUFFIX ".dylib"
OUTPUT_NAME "HelloMacNote"
)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
)
)
target_link_options(HelloMacNote PRIVATE
"LINKER:-undefined,dynamic_lookup"
)

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +219
// 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(&notif);
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +345
// 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(&notif);
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +78

// 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(&notif);
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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>
@hybridmachine hybridmachine merged commit f0df450 into macos-port Apr 10, 2026
26 of 31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P3-01: Plugin system (dylib-based)

2 participants