iRightMenu Pro Plugin Development Guide

Everything you need to build an iRightMenu Pro plugin.

Contents


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.cpath is cleared, so C extension modules cannot be loaded — plugins run sandboxed and this restriction is deliberate.

Every file counts for checks. Calling app.shell.execute from a submodule still requires declaring the shell permission in plugin.json, and you cannot see that from the main file. tools/pluginlint follows require recursively 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.system itself is L1, but the functions that read hardware identifiers also require hardware_id, and changing the launch-at-login setting requires login_item. Calling them without the declaration fails at runtime with permissionDenied.

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)
{}
{
  "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
    }
  ]
}
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)

showInStatusBar puts 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), use app.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 onLoadonInstall
Update old plugin onUnload → new plugin onLoadonUpdate(oldVersion)
Uninstall onUninstallonUnload
Disable / enable onUnload / onLoad

onInstall and onUpdate fire only once the environment is fully ready, so every API is safe to use there. onUninstall is 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

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. getMenuTitle runs every time the menu opens. Never do network requests or read large files in it — the context menu will hang. In the example above app.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 title must 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: false and live in the status bar

pluginlint enforces this: declaring dynamic without showInStatusBar means 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

Developer Documentation
User Guide
Getting Started Menu Editor Guide Plugin Manager Settings Script Menus FAQ
Script Development
Development Guide
Plugin Development
Quick Start Development Guide Example Plugins
API Reference
Overview API Query Plugin Info Logging Finder Context Plugin Settings Internationalization
UI & Interaction
Dialog Progress Notification Chooser WebView Status Bar Dock
Files & Paths
File Operations Path Utilities Finder Actions Trash Extended Attributes Metadata File Watcher
Data Formats
JSON Plist CSV XML PDF Image
Text & Encoding
String Regex Date & Time Color Crypto
System
Shell Commands Process Application System Info AppleScript Shortcuts
System Info
Network Power/Battery Screen/Appearance Audio Bluetooth Location
Network
HTTP WebSocket URL
Input & Clipboard
Keyboard Mouse Hotkey Clipboard Window
Storage
SQLite Keychain UserDefaults
Media
OCR QR Code
Utilities
Archive UTI Share Timer Wake Lock Thread