iRightMenu Pro Plugin Development Guide
Everything you need to build an iRightMenu Pro plugin.
Contents
- Plugin Structure
- The manifest (plugin.json)
- The script (main.lua)
- Menu matching rules
- Plugin settings
- Localization
- Debugging
- Best practices
Plugin Structure
Every plugin contains at least two files:
- plugin.json — metadata, menus and settings (pure configuration, no code)
- main.lua — menu handlers (your logic)
Splitting into multiple files
When the logic grows, split it — the engine sets up a require search path rooted at the
plugin directory:
com.example.my-plugin/
├── plugin.json
├── main.lua
├── parser.lua -- require("parser")
└── utils/
└── init.lua -- require("utils")
local parser = require("parser")
local utils = require("utils")
The search path is the plugin directory, equivalent to:
package.path = <plugin dir>/?.lua;<plugin dir>/?/init.lua
Lua modules only.
package.cpathis cleared, so C extension modules cannot be loaded — plugins run sandboxed and this restriction is deliberate.Every file counts for checks. Calling
app.shell.executefrom a submodule still requires declaring theshellpermission inplugin.json, and you cannot see that from the main file.tools/pluginlintfollowsrequirerecursively and catches it.
The manifest (plugin.json)
Full shape
{
"manifestVersion": 1,
"id": "com.example.my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"minEngineVersion": "2.0.0",
"author": "Your Name",
"description": "What the plugin does",
"main": "main.lua",
"icon": "icon.png",
"locale": "zh-Hans",
"category": "file",
"type": "context",
"permissions": ["file", "http"],
"settings": {
"title": "Plugin Settings",
"fields": [...]
},
"menus": [...]
}
Basic fields
| Field | Type | Required | What it is |
|---|---|---|---|
manifestVersion |
Int | No | Manifest format version (currently 1) |
id |
String | Yes | Unique identifier, reverse-DNS style |
name |
String | Yes | Display name (i18n placeholders allowed) |
version |
String | Yes | Semantic version, e.g. "1.0.0" |
minEngineVersion |
String | No | Minimum engine version required |
author |
String | No | Author name |
description |
String | No | Description |
main |
String | No | Main script filename (default "main.lua") |
icon |
String | No | Icon filename, relative to the plugin directory |
locale |
String | No | Default language, e.g. "en" or "zh-Hans" |
homepage |
String | No | Homepage URL (site, repository, …) |
category |
String | No | Store category: file, image, development, system, or other (default) |
type |
String | No | "context" (context menu, default) or "resident" (always-running plugin) |
permissions |
[String] | No | Declared permissions. Omitted = everything allowed; [] = safe APIs only; ["*"] = everything |
Permissions
A plugin declares the APIs it needs in the permissions field. There are four levels:
L0 Safe — always available, no declaration needed
| Permission | API module | What it covers |
|---|---|---|
log |
app.log |
Logging |
context |
app.context |
Finder context |
dialog |
app.dialog |
Dialogs and forms |
progress |
app.progress |
Progress dialogs |
path |
app.path |
Path utilities |
i18n |
app.i18n |
Localization |
json |
app.json |
JSON |
string |
app.string |
String utilities |
date |
app.date |
Date and time |
regex |
app.regex |
Regular expressions |
notification |
app.notification |
System notifications |
settings |
app.settings |
Plugin settings |
thread |
app.thread |
Concurrency and coroutines |
uti |
app.uti |
Uniform type identifiers |
url |
app.url |
URL parsing and building |
csv |
app.csv |
CSV parsing and generation |
xml |
app.xml |
XML parsing and generation |
color |
app.color |
Color utilities |
L1 Standard — must be declared in permissions
| Permission | API module | What it covers |
|---|---|---|
file |
app.file |
File read/write |
http |
app.http |
HTTP requests |
archive |
app.archive |
Archives |
plist |
app.plist |
Plist read/write |
defaults |
app.defaults |
UserDefaults preferences |
image |
app.image |
Image processing |
crypto |
app.crypto |
Crypto and encoding |
clipboard |
app.clipboard |
Clipboard |
finder |
app.finder |
Finder operations |
system |
app.system |
System information |
keychain |
app.keychain |
Secure credential storage |
xattr |
app.xattr |
Extended attributes and tags |
trash |
app.trash |
Trash |
metadata |
app.metadata |
Spotlight metadata |
pdf |
app.pdf |
PDF operations |
network |
app.network |
Network information |
power |
app.power |
Power and battery |
screen |
app.screen |
Screen and appearance |
audio |
app.audio |
Audio control |
ocr |
app.ocr |
Text recognition (OCR) |
qrcode |
app.qrcode |
QR codes |
wakelock |
app.wakelock |
Sleep prevention |
watcher |
app.watcher |
File watching |
share |
app.share |
macOS Share |
shortcuts |
app.shortcuts |
Shortcuts |
timer |
app.timer |
Timers |
websocket |
app.websocket |
WebSocket client |
chooser |
app.chooser |
Quick chooser |
dock |
app.dock |
Dock badge and bounce |
L2 Sensitive — shows a permission warning at install time
| Permission | API module | What it covers |
|---|---|---|
shell |
app.shell |
Shell command execution |
database |
app.database |
SQLite database |
process |
app.process |
Process management |
application |
app.application |
Application control |
webview |
app.webview |
WebView windows |
statusbar |
app.statusbar |
Status bar items |
window |
app.window |
Window control (requires Accessibility permission) |
location |
app.location |
Geolocation (system prompt on first call) |
bluetooth |
app.bluetooth |
Bluetooth state and devices (system prompt on first call) |
hardware_id |
(function-level) | Machine serial, hardware UUID, disk serial — identifiers for this Mac |
login_item |
(function-level) | Launch at login (only app.system.setLoginItem needs it) |
The last two are not bound to a whole module — they gate individual functions.
app.systemitself is L1, but the functions that read hardware identifiers also requirehardware_id, and changing the launch-at-login setting requireslogin_item. Calling them without the declaration fails at runtime withpermissionDenied.
L3 Dangerous — shows a strong security warning at install time
| Permission | API module | What it covers |
|---|---|---|
applescript |
app.applescript |
AppleScript (full system access) |
keyboard |
app.keyboard |
Keyboard simulation (needs Accessibility) |
mouse |
app.mouse |
Mouse simulation (needs Accessibility) |
hotkey |
app.hotkey |
Global hotkeys (needs Accessibility) |
Examples
// Safe APIs only — nothing to declare
{"permissions": []}
// Needs files and network
{"permissions": ["file", "http"]}
// Needs everything
{"permissions": ["*"]}
// Omitting permissions entirely = everything allowed (kept for older plugins)
{}
Menu configuration
{
"menus": [
{
"id": "menu_action",
"title": "Do the thing",
"description": "What this menu item does",
"icon": "action.png",
"enabled": true,
"showInStatusBar": false,
"primaryMenu": false,
"dynamic": false,
"showOnFiles": true,
"showOnFolders": false,
"fileMatchType": "none",
"fileMatchPatterns": [],
"fileExecutableOnly": false,
"minFiles": 1,
"maxFiles": 0,
"folderMatchType": "none",
"folderMatchPatterns": [],
"minFolders": 0,
"maxFolders": 0,
"notifyType": "none",
"requireConfirm": false,
"playSound": false,
"soundPath": null
}
]
}
Menu fields
| Field | Type | Default | What it does |
|---|---|---|---|
id |
String | required | Unique id; binds the click handler and dynamic behaviour |
title |
String | required | Menu item text (i18n allowed) |
description |
String | ”” | Tooltip / description |
icon |
String | null | Menu icon filename |
enabled |
Boolean | true | Whether the item is enabled |
showInContextMenu |
Boolean | true | Show in the Finder context menu |
showInStatusBar |
Boolean | false | Show in the status bar menu |
primaryMenu |
Boolean | false | Show at the top level rather than in a submenu |
dynamic |
Boolean | false | Enable dynamic titles (see Dynamic Titles — status bar menu only) |
showInStatusBarputs the item in iRightMenu’s own status bar menu, no extra permission. To create a standalone menu bar icon of your own (to keep a value visible), useapp.statusbar— that one is L2 sensitive.
File matching
| Field | Type | Default | What it does |
|---|---|---|---|
showOnFiles |
Boolean | true | Show when files are selected |
fileMatchType |
String | “none” | How to match files |
fileMatchPatterns |
Array | [] | Extensions or regular expressions to match |
fileExecutableOnly |
Boolean | false | Match executable files only |
minFiles |
Number | 0 | Minimum files selected (0 = no limit) |
maxFiles |
Number | 0 | Maximum files selected (0 = no limit) |
Folder matching
| Field | Type | Default | What it does |
|---|---|---|---|
showOnFolders |
Boolean | true | Show when folders are selected |
folderMatchType |
String | “none” | How to match folders (only "none" and "regex") |
folderMatchPatterns |
Array | [] | Regular expressions to match |
minFolders |
Number | 0 | Minimum folders selected (0 = no limit) |
maxFolders |
Number | 0 | Maximum folders selected (0 = no limit) |
Execution options
| Field | Type | Default | What it does |
|---|---|---|---|
notifyType |
String | “none” | Notification after running: "none" or "notification". Plugins default to none and handle their own feedback through app.notification |
requireConfirm |
Boolean | false | Ask for confirmation before running |
playSound |
Boolean | false | Play a sound after running |
soundPath |
String | null | Sound filename (e.g. "Glass.aiff"); empty uses the system default |
File match types
| Type | What it matches | fileMatchPatterns |
|---|---|---|
none |
Anything — no type restriction | ignored |
extension |
By file extension | ["jpg", "png", "gif"] |
regex |
By regular expression on the filename | ["^IMG_\\d+"] |
executable |
Executable files only | ignored |
The script (main.lua)
Basic shape
-- Subclass the Plugin base class
local MyPlugin = Plugin:extend()
-- init: register your handlers
function MyPlugin:init()
-- Register the handler for a menu action
self:registerHandler("menu_id", self.handleAction)
app.log.info("Plugin initialised")
end
-- The menu action handler
-- @param context {selectedFiles, currentDirectory}
function MyPlugin:handleAction(context)
local files = context.selectedFiles
local dir = context.currentDirectory
-- Your logic here...
end
-- Dynamic title (optional; status bar menus only — see Dynamic Titles)
-- @return string the new title; nil keeps the static one from plugin.json
function MyPlugin:getMenuTitle(menuId, context)
return nil
end
-- You must return the class; the engine instantiates it
return MyPlugin
The context object
The context passed to a handler contains:
context = {
selectedFiles = {"/path/to/file1", "/path/to/file2", ...},
currentDirectory = "/path/to/current/dir"
}
The Plugin base class
Plugin is preloaded into the global environment.
Creating a plugin
-- Subclass it
local MyPlugin = Plugin:extend()
-- Implement init()
function MyPlugin:init()
-- Register handlers here
end
-- Return the class — required; the engine instantiates it
return MyPlugin
Metadata methods
| Method | Returns | What it is |
|---|---|---|
self:getId() |
String | Plugin id |
self:getName() |
String | Plugin name |
self:getVersion() |
String | Plugin version |
self:getAuthor() |
String | Plugin author |
self:getDescription() |
String | Plugin description |
self:getMetadata() |
Table | The full manifest |
self:getInstallPath() |
String | Install directory path |
Registering handlers
-- Register a menu action handler
self:registerHandler("menu_id", self.handleAction)
Menu item visibility is controlled declaratively in plugin.json (showOnFiles /
fileMatchPatterns / minFiles and friends) — no callback to register.
Important: pass a method reference (
self.methodName), not a closure. Handlers receive(self, context); a closure shifts the arguments by one.
Lifecycle methods (optional)
function MyPlugin:onLoad()
-- After the plugin environment is ready — on every launch
end
function MyPlugin:onUnload()
-- Just before the plugin environment is torn down
end
function MyPlugin:onInstall()
-- After the first install, fired after onLoad
end
function MyPlugin:onUpdate(oldVersion)
-- After an update, fired after onLoad
-- oldVersion: the version being replaced
app.log.info("Updated from " .. oldVersion)
end
function MyPlugin:onUninstall()
-- Before uninstall, fired before onUnload — a good place to clean up
end
Order of calls:
| Situation | Order |
|---|---|
| App launch | onLoad |
| Install | onLoad → onInstall |
| Update | old plugin onUnload → new plugin onLoad → onUpdate(oldVersion) |
| Uninstall | onUninstall → onUnload |
| Disable / enable | onUnload / onLoad |
onInstallandonUpdatefire only once the environment is fully ready, so every API is safe to use there.onUninstallis called synchronously before any files are removed — the place to clear Keychain credentials, temp files and the like.
Reading plugin resources
function MyPlugin:init()
-- Path to a bundled tool
local tool = self:getInstallPath() .. "/bin/mytool"
-- Build paths with app.path.join
local config = app.path.join(
self:getInstallPath(),
"config",
"default.json"
)
-- Check the resource is there
if app.path.exists(self:getInstallPath() .. "/resources/data.json") then
app.log.info("Resource found")
end
end
Menu matching rules
Basic matching
{
"menus": [{
"id": "process",
"title": "Process",
"showOnFiles": true,
"showOnFolders": false,
"minFiles": 1
}]
}
This item shows when:
- at least one file is selected
- no folders are selected
Matching by extension
{
"menus": [{
"id": "resize_image",
"title": "Resize image",
"fileMatchType": "extension",
"fileMatchPatterns": ["jpg", "jpeg", "png", "gif", "webp"],
"showOnFiles": true,
"showOnFolders": false
}]
}
This item shows for image files only.
Matching by regular expression
{
"menus": [{
"id": "process_screenshots",
"title": "Process screenshot",
"fileMatchType": "regex",
"fileMatchPatterns": ["^Screenshot.*\\.png$", "^Screen Shot.*\\.png$"],
"showOnFiles": true
}]
}
Dynamic Titles
Declare "dynamic": true in plugin.json and the menu item can regenerate its title every
time the menu opens — implement getMenuTitle(menuId, context) (a convention method, nothing
to register). Return nil to keep the static title from plugin.json.
function MyPlugin:getMenuTitle(menuId, context)
if menuId ~= "toggle_proxy" then
return nil -- other items keep their static titles
end
if self:isProxyOn() then
return app.i18n.t("menu.turn_off")
end
return app.i18n.t("menu.turn_on")
end
Use it to surface information right in the menu so the user does not even have to click — often more useful than opening a dialog:
function NetworkIP:getMenuTitle(menuId, context)
if menuId ~= "copy_local_ip" then return nil end
local ip = app.network.localIP()
if not ip then
return app.i18n.t("menu.offline")
end
-- Menu reads "Local IP: 192.168.1.100"
return app.i18n.t("menu.copy_local_ip_with_value", { ip = ip })
end
Keep it cheap.
getMenuTitleruns every time the menu opens. Never do network requests or read large files in it — the context menu will hang. In the example aboveapp.network.localIP()reads local interfaces and is safe;app.network.externalIP(), which calls out to a remote service, is not.⚠️ Where this works: the status bar menu only.
The Finder context menu is rendered by the FinderSync extension — a separate process with no Lua engine in it (it only draws the menu; clicks are handed off to the main app), so it can only show the static title from
plugin.json. The optional “override system right-click” mode is rendered by the main app and could technically run Lua, but it shows static titles too — the same gesture should not produce a different menu just because of a setting that exists for an unrelated purpose. So:
- The static
titlemust stand on its own. “Toggle Proxy” beats “Turn Proxy Off”, since the latter may contradict the real state in the context menu- Never put essential information only in the dynamic title. Something like “Local IP: 192.168.1.5” loses the IP in the context menu — such a plugin should declare
showInContextMenu: falseand live in the status bar
pluginlintenforces this: declaringdynamicwithoutshowInStatusBarmeans the dynamic title can never take effect, and is reported as an error.
registerVisibility — controlling menu item visibility from code — has been removed.
It hit the same limit but degraded badly: in the Finder context menu it was silently ignored,
so a plugin could not hide its item and the author got no feedback. Use the declarative fields
in plugin.json instead (showOnFiles / fileMatchPatterns / minFiles …) — those are
database columns and take effect on every menu surface. Older plugins calling it will not
error; they just get a warning in the log.
Plugin settings
Declaring settings
{
"settings": {
"title": "My Plugin Settings",
"fields": [
{
"key": "api_key",
"type": "text",
"label": "API key",
"default": "",
"placeholder": "Enter your API key"
},
{
"key": "quality",
"type": "number",
"label": "Quality",
"default": 80
},
{
"key": "auto_process",
"type": "checkbox",
"label": "Process automatically",
"default": true
},
{
"key": "output_format",
"type": "select",
"label": "Output format",
"default": "png",
"options": ["png", "jpg", "webp"]
},
{
"key": "output_dir",
"type": "folder",
"label": "Output folder",
"default": ""
}
]
}
}
Field types
| Type | What it is | Value type |
|---|---|---|
text |
Single-line text field | String |
number |
Number field | Number |
checkbox |
Checkbox | Boolean |
select |
Pop-up menu | String |
folder |
Folder chooser | String (path) |
file |
File chooser | String (path) |
Using settings from Lua
-- Read a value, with a fallback
local apiKey = app.settings.get("api_key", "")
local quality = app.settings.get("quality", 80)
-- Write a value
app.settings.set("last_used", os.time())
-- Read everything
local all = app.settings.getAll()
-- Remove a value
app.settings.remove("temporary_value")
Settings live in <App Group container>/PluginData/<plugin id>/settings.json, not in the plugin directory: updating a plugin replaces that directory wholesale, so anything stored there is lost on every update.
The plugin’s own writable directory
Everything else you need to persist — caches, app.database files, downloaded assets, run state — goes in
app.context.dataDirectory():
local dir = app.context.dataDirectory()
app.file.write(dir .. "/cache.json", app.json.stringify(data))
local db = app.database.open(dir .. "/history.db")
the same directory app.settings uses. It survives updates and is removed when the plugin is uninstalled.
Never write to app.context.pluginDirectory(). That is the install directory, which holds bundled content: it is replaced wholesale on update, so anything written there is lost — and modifying a bundled file breaks signature verification, which the user sees as the plugin failing to load. Use it only to read bundled resources.
Localization
Directory structure
com.example.my-plugin/
├── plugin.json
├── main.lua
└── locales/
├── en.json
└── zh-Hans.json
Placeholders in plugin.json
{
"name": "{{plugin.name}}",
"description": "{{plugin.description}}",
"menus": [
{
"id": "action",
"title": "{{menu.action}}"
}
],
"settings": {
"title": "{{settings.title}}",
"fields": [
{
"key": "option",
"type": "text",
"label": "{{settings.option}}"
}
]
}
}
Language files
locales/en.json (flat dot-notation)
{
"plugin.name": "My Plugin",
"plugin.description": "A useful plugin",
"menu.action": "Do Something",
"settings.title": "Settings",
"settings.option": "Option Label",
"messages.success": "Processed %d files successfully",
"messages.error": "An error occurred: %s"
}
locales/zh-Hans.json
{
"plugin.name": "My Plugin",
"plugin.description": "A handy plugin",
"menu.action": "Do the thing",
"settings.title": "Settings",
"settings.option": "Option label",
"messages.success": "Processed %d files",
"messages.error": "Something went wrong: %s"
}
Using it from Lua
-- Look up a translated string
local title = app.i18n.t("plugin.name")
-- With format arguments
local msg = app.i18n.t("messages.success", 5)
-- en: "Processed 5 files"
-- zh-Hans: "成功处理了 5 个文件"
-- With a fallback
local text = app.i18n.t("unknown.key", nil, "fallback text")
-- Does the key exist?
if app.i18n.has("messages.custom") then
-- ...
end
-- Current language
local locale = app.i18n.locale() -- "en" or "zh-Hans"
-- Available languages
local locales = app.i18n.locales() -- {"en", "zh-Hans"}
Debugging
Logging
-- Log levels, least to most severe
app.log.verbose("chatty detail") -- plugin log only
app.log.debug("debug detail") -- plugin log only
app.log.info("normal message") -- plugin log only
app.log.warning("warning") -- plugin + system log
app.log.error("error") -- plugin + system log
-- Path to the log file
local logPath = app.log.getPath()
-- Read the last N lines
local content = app.log.read(100)
-- Clear the log file
app.log.clear()
Where the logs are
- Plugin log:
~/Library/Group Containers/group.net.ymlab.iRightMenu.pro/Logs/Plugins/{plugin_id}.log - System log:
~/Library/Group Containers/group.net.ymlab.iRightMenu.pro/Logs/iRightMenu.log
Following the log live
# Everything
tail -f ~/Library/Group\ Containers/group.net.ymlab.iRightMenu.pro/Logs/*.log
# One plugin
tail -f ~/Library/Group\ Containers/group.net.ymlab.iRightMenu.pro/Logs/Plugins/com.example.myplugin.log
# Errors only
tail -f ~/Library/Group\ Containers/group.net.ymlab.iRightMenu.pro/Logs/*.log | grep -i error
Common problems
| Symptom | What to check |
|---|---|
| Plugin does not appear | JSON syntax in plugin.json |
| Menu item does not appear | The matching rules: showOnFiles, fileMatchType, … |
| Clicking does nothing | The id passed to registerHandler matches the menu id |
| An API returns nil | The log — it carries the reason |
| Settings do not persist | You are using the right key name |
Verbose mode
Turn on verbose logging while developing:
function MyPlugin:init()
app.log.verbose("=== init start ===")
app.log.verbose("install path: " .. self:getInstallPath())
app.log.verbose("version: " .. self:getVersion())
-- ... register handlers ...
app.log.verbose("=== init done ===")
end
Best practices
1. Use a unique plugin id
com.yourcompany.plugin-name
2. Handle errors gracefully
function MyPlugin:processFiles(context)
local success, err = pcall(function()
for _, file in ipairs(context.selectedFiles) do
self:processFile(file)
end
end)
if not success then
app.log.error("Processing failed: " .. tostring(err))
app.dialog.alert({title = "Error", message = "Could not process the file"})
end
end
3. Give the user feedback
function MyPlugin:longOperation(context)
-- Show progress for anything slow
local p = app.progress.create({
title = "Working",
message = "Preparing...",
indeterminate = false
})
local files = context.selectedFiles
for i, file in ipairs(files) do
p:update(
(i / #files) * 100,
"Processing: " .. app.path.basename(file)
)
self:processFile(file)
end
p:close()
-- Notify when it finishes
app.notification.show("Done", "Processed " .. #files .. " files")
end
4. Always supply a default
local quality = app.settings.get("quality", 80) -- default: 80
local format = app.settings.get("format", "png") -- default: "png"
5. Log the operations that matter
function MyPlugin:deleteFiles(context)
app.log.info("[MyPlugin] delete started")
app.log.info("[MyPlugin] files to delete: " .. #context.selectedFiles)
for _, file in ipairs(context.selectedFiles) do
app.log.info("[MyPlugin] deleting: " .. file)
-- ...
end
app.log.info("[MyPlugin] delete finished")
end
6. Localize from the start
Use i18n placeholders even when you ship a single language — adding a translation later then costs nothing.
7. Validate what the user types
function MyPlugin:renameFiles(context)
local __form = app.dialog.form({
title = "New name",
fields = {{type = "text", id = "value", label = "Input", default = ""}}
})
local newName = __form and __form.value
if not newName or newName == "" then
return -- cancelled, or left empty
end
-- Check the filename
if newName:match('[/\\:*?"<>|]') then
app.dialog.alert({title = "Invalid name", message = "The filename contains characters that are not allowed"})
return
end
-- Carry on renaming...
end
8. Clean up after yourself
function MyPlugin:onUnload()
-- Close database connections
if self.db then
self.db:close()
self.db = nil
end
-- Cancel anything still pending
thread.stopAll()
end
Next
- The API reference for the full surface
- The example plugins for working code