Lua Plugin System
About 2419 wordsAbout 8 min
2026-08-01
FolkPatch provides a lightweight Lua plugin system. Plugins are an extension form sitting between APM and KPM: they do not modify system files or inject into the kernel — they only run Lua callbacks within the apd daemon lifecycle.
Plugins are built on mlua (Lua 5.4) and run with root privileges, suitable for lightweight automation scripts such as scheduled tasks, property tuning, and cache cleaning.
Security Notice
Lua plugins run with root privileges, and the full Lua standard library is available (arbitrary command execution, arbitrary file read/write). Only install plugins from trusted sources and always review the code before installing.
Plugin vs APM vs KPM
| Aspect | APM | Lua Plugin | KPM |
|---|---|---|---|
| Runtime space | Userspace | Userspace (inside apd) | Kernel space |
| Modifies system files | Yes (mount) | No | No |
| Kernel injection | No | No | Yes |
| Language | Shell / any | Lua | C |
| Typical use | System-level modification, file replacement | Scheduled tasks, property tuning, automation | Kernel hooks, low-level customization |
| Risk level | Medium | Medium | High |
Plugin Package Format
Plugins are installed as a zip package or a directory, located at /data/adb/plugins/<id>/ after installation. Zip structure:
my-plugin.zip
plugin.json# Manifest (required, declares metadata and dependencies)
main.lua# Entry script (required, or specified by manifest entry)
Directory Detection
Files inside the zip can be placed at the root or in a single top-level directory; the installer automatically detects the directory containing the entry file and extracts it.
Manifest plugin.json
plugin.json declares the plugin's metadata, dependencies, entry, and configuration fields:
{
"id": "my-plugin",
"name": "My Plugin",
"author": "your-name",
"version": "1.0.0",
"description": "What this plugin does",
"descriptions": { "zh": "插件功能描述", "ja": "プラグインの説明" },
"license": "MIT",
"min_version": 0,
"depends": ["another-plugin"],
"entry": "main.lua",
"config": [],
"quick_action": null
}idRequiredstring
Plugin identifier. Must match the directory name; only alphanumerics and -_. are allowed.
nameOptionalstring
Display name; falls back to id if omitted.
authorOptionalstring
Author.
versionOptionalstring
Version.
descriptionOptionalstring
Description.
descriptionsOptionalobject
Localized descriptions keyed by language code (e.g. zh, ja, tr).
licenseOptionalstring
License.
min_versionOptionalnumber
Minimum required APD version code (number). Validated at install time; installation is rejected if the current version is lower.
dependsOptionalstring[]
List of other plugin ids this plugin depends on. Validated at both install and runtime (missing or disabled dependencies cause errors).
entryOptionalstring
Entry Lua file; defaults to main.lua. Path separators and .. are not allowed.
configOptionalConfigField[]
Declared configuration fields shown in the manager UI. See User Configuration.
quick_actionOptionalQuickAction
Quick action: a one-tap button in the manager UI that runs a specified callback. See Action Operation.
Entry Script main.lua
main.lua must return a Lua table, optionally declaring lifecycle callbacks:
return {
post_fs_data = function()
info("plugin loaded")
end,
post_mount = function()
-- after modules are mounted
end,
service = function()
-- service phase is suitable for lightweight background initialization
end,
boot_completed = function()
-- after Android boot is complete
end,
}| Callback | Trigger |
|---|---|
post_fs_data | post-fs-data phase (blocking, before module mount) |
post_mount | after modules are mounted |
service | late_start service phase (non-blocking, recommended) |
boot_completed | after Android boot is complete |
main | Special callback, runs as a background daemon loop. See Scheduled Loop |
action | Special callback for manual triggering. See Action Operation |
The runtime environment provides the following globals:
PLUGIN_ID: current plugin identifierPLUGIN_DIR: current plugin directory (e.g./data/adb/plugins/my-plugin)
Logs from info() / warn() are appended to last_output.log in the plugin directory.
Plugin API
In addition to the Lua standard library, the plugin runtime provides the following functions:
| Function | Description |
|---|---|
getprop(name) | Read a system property, returns a string |
setprop(name, value) | Set a system property (bypasses read-only), returns boolean |
sysctl(key, value) | Write a kernel parameter (e.g. sysctl("vm.swappiness", "60")), returns boolean |
| Function | Description |
|---|---|
exec(...) | Run a command, returns a { ok, code, stdout, stderr } table. Use exec("cmd", "arg") or exec("sh", "-c", "...") |
| Function | Description |
|---|---|
write_file(path, content) | Write a text file (auto-creates parent dirs), returns boolean |
read_file(path) | Read file content, returns a string |
list_dir(path) | List directory entries, returns an array table of names |
file_exists(path) | Check whether a path exists (file or directory), returns boolean |
chmod(path, mode) | Set permissions (number or string, e.g. 0755 or "0755"), returns boolean |
mkdir(path, recursive) | Create a directory; recursive when recursive is true, returns boolean |
rm(path) | Remove a file or empty directory, returns boolean |
| Function | Description |
|---|---|
json_decode(str) | Parse a JSON string into a Lua value |
json_encode(value) | Serialize a Lua value to a JSON string |
| Function | Description |
|---|---|
get_config(key) | Read this plugin's saved user config value (string) |
set_config(key, value) | Save a user config value for this plugin, returns boolean |
info(msg) | Output an info log |
warn(msg) | Output a warning log |
| Function | Description |
|---|---|
start_daemon(function, interval_secs) | Spawn the named callback as a background daemon, looping every interval_secs seconds, returns boolean |
exec Example
local result = exec("sh", "-c", "df -h /data")
if result.ok then
info("stdout: " .. result.stdout)
else
warn("failed, code=" .. result.code .. ", stderr=" .. result.stderr)
endAction Operation
A plugin can declare an action callback, which shows a "Run" button in the manager UI for manually executing an operation (e.g. manual cache clean):
return {
action = function()
-- manually triggered operation logic
info("manual clean triggered")
end,
}Trigger from the command line:
apd plugin action <id>You can also declare the button label via quick_action in plugin.json:
{
"quick_action": {
"function": "clean_now",
"label": "Clean Now",
"labels": { "zh": "立即清理", "ja": "今すぐクリーン" }
}
}User Configuration
A plugin can declare configuration fields in plugin.json. The manager UI shows a "Config" button that opens an editor; configuration is saved to /data/adb/plugins/<id>/config.json:
{
"id": "my-plugin",
"config": [
{ "key": "clean_hour", "label": "Cleaning hour (0-23)", "labels": { "zh": "清理时间" }, "type": "number", "default": 4 },
{ "key": "clear_all", "label": "Also clear running app caches", "labels": { "zh": "同时清理运行中应用缓存" }, "type": "bool", "default": false },
{ "key": "mode", "label": "Mode", "type": "select", "options": ["auto", "manual"] }
]
}keyRequiredstring
Config key, used by get_config / set_config.
labelOptionalstring
Human-readable label (default / English).
labelsOptionalobject
Localized labels keyed by language code.
typeOptionalstring
text
Field type: text / number / bool / select.
defaultOptionalany
Default value.
optionsOptionalstring[]
Options for the select type.
Inside the plugin, use get_config / set_config to read and write config. Command line:
apd plugin config --id my-plugin list
apd plugin config --id my-plugin get clean_hour
apd plugin config --id my-plugin set clean_hour 3
apd plugin config --id my-plugin delete clean_hourScheduled Loop Events
Plugins support two scheduled loop approaches:
1. Declare a main Callback (Recommended)
If the returned table contains a main function, the system automatically spawns it as a background daemon during the service phase, looping continuously (default interval 1 second; you can sleep inside the loop to control the pace):
return {
main = function()
while true do
-- check every minute
if os.time() % 60 == 0 then
info("tick")
end
os.execute("sleep 30")
end
end,
}The main daemon runs independently of the apd boot sequence — it does not block other plugins and does not exit with a single phase invocation.
2. start_daemon
Manually spawn a background loop inside any callback:
return {
service = function()
start_daemon("main", 60) -- call this plugin's main every 60 seconds
end,
main = function()
-- scheduled task logic
end,
}Stopping the Daemon
After disabling the plugin (apd plugin disable <id>) and rebooting, the daemon will no longer start; an already-running daemon can be terminated with kill.
Manually test a scheduled loop:
apd plugin daemon <id> <function> <interval_secs>Lifecycle & State
- Create an empty
disablefile to disable a plugin; delete it to re-enable - Plugins are automatically executed by
apdin thepost-fs-data,post-mount,service, andboot-completedphases - An error in a single plugin is logged and does not affect other plugins
Management Commands
apd plugin list # List plugins (JSON, with metadata, action, config)
apd plugin install <zip> # Install from zip
apd plugin uninstall <id> # Uninstall
apd plugin enable <id> # Enable
apd plugin disable <id> # Disable
apd plugin run <id> <function> # Manually run a callback
apd plugin action <id> # Run the plugin's action callback
apd plugin daemon <id> <function> <secs> # Run a callback as a looping daemon
apd plugin config --id <id> ... # View/modify plugin config (list/get/set/delete)Plugin Management Page
The plugin management entry is located in the top bar of the Settings page. The page provides:
- Enable / Disable toggle: switch plugin state (corresponds to creating/removing the
disablefile) - Run: manually trigger the plugin's
actioncallback (or thequick_actiondeclared in the manifest) - Config: edit the plugin's declared configuration fields
- Execution Output: view the plugin's
last_output.log, with export and clear support - Uninstall: remove the plugin
- Online Plugins: browse and download plugins from the official plugin repository. See Online Plugins
Online Plugins
The plugin management page has a built-in Online Plugins feature that lets you browse and install plugins from the official FolkPatch plugin repository:
- Search plugins by name / description
- Automatic Chinese/English description switching
- Track download progress via the notification bar, then install from the plugin page after download completes
注意
Online plugins also run with root privileges. Verify the plugin's source and feature description before downloading.
Example Plugins
The FolkPatch repository ships five ready-made examples in the examples/plugins/ directory:
boot-script/: a boot script plugin that runs custom commands at a chosen boot stage, with support for waiting on a path and manual immediate executioncache-cleaner/: a scheduled cache cleaner plugin that automatically cleans app and system caches in the early hours each dayhello-plugin/: demonstrates basic APIs such asgetprop,setprop,exec, and file I/Ofile-tick-test/: a file operation and scheduled loop test pluginlog-rotator/: a log rotation plugin
Scheduled cache cleaner usage example:
apd plugin install /path/to/cache-cleaner.zip # or place it in the directory directly
apd plugin run cache-cleaner clean_now # manually clean once right away
apd plugin action cache-cleaner # or tap the "Run" button in the managerConfiguration fields are edited via the "Config" button in the manager (cleaning hour, whether to clear running app caches), or by editing config.json directly. The plugin declares action and main callbacks: action for manual triggering, main running automatically as a background daemon after boot.
Full Example: boot-script
Below is the complete source of the official boot-script example plugin, demonstrating the comprehensive use of manifest declaration, configuration fields, multi-stage lifecycle, quick_action, and API calls: it runs custom commands at the user-selected boot stage, optionally waiting for a path to become ready first.
plugin.json
main.lua
{
"id": "boot-script",
"name": "Boot Script",
"author": "FolkPatch",
"version": "1.1.0",
"description": "Run custom commands at a chosen boot stage",
"descriptions": {
"zh": "在指定的启动阶段执行自定义命令",
"ja": "指定した起動段階でカスタムコマンドを実行"
},
"license": "MIT",
"quick_action": {
"function": "run_now",
"label": "Run now",
"labels": { "zh": "立即执行", "ja": "今すぐ実行" }
},
"config": [
{
"key": "stage",
"label": "Execution stage",
"labels": { "zh": "执行时机" },
"type": "select",
"options": ["post_fs_data", "service", "boot_completed"],
"default": "boot_completed"
},
{
"key": "wait_path",
"label": "Path to wait for (empty = off)",
"labels": { "zh": "等待就绪的路径(留空=关闭)" },
"type": "text",
"default": "/sdcard"
},
{
"key": "wait_time",
"label": "Wait timeout (seconds, 0 = off)",
"labels": { "zh": "等待超时(秒,0=关闭)" },
"type": "number",
"default": 0
},
{
"key": "commands",
"label": "Commands (one per line)",
"labels": { "zh": "命令列表(每行一条)" },
"type": "text",
"default": ""
}
]
}local M = {}
-- Wait up to N seconds for a path to become ready (e.g. /sdcard after unlock)
local function wait_for_path(path, timeout_sec)
local waited = 0
while waited < timeout_sec do
local r = exec("test", "-d", path)
if r.ok then
info("Path ready after " .. waited .. "s: " .. path)
return true
end
exec("sleep", "1")
waited = waited + 1
end
warn("Timeout waiting for path (" .. timeout_sec .. "s): " .. path)
return false
end
local function run_commands()
local cmds = get_config("commands")
if cmds == "" then
info("No commands configured")
return
end
for line in cmds:gmatch("[^\n]+") do
line = line:match("^%s*(.-)%s*$")
if line ~= "" and line:sub(1, 1) ~= "#" then
info("Running: " .. line)
local r = exec("sh", "-c", line)
if r.ok then
if r.stdout ~= "" then info(" stdout: " .. r.stdout) end
else
warn(" failed (code " .. r.code .. "): " .. r.stderr)
end
end
end
info("All commands executed")
end
local function try_run(stage)
local target = get_config("stage")
if target == "" then target = "boot_completed" end
if stage == target then
info("Stage [" .. stage .. "] matched, executing...")
-- If configured, wait for a path to be ready first
local wait_path = get_config("wait_path")
local wait_time = tonumber(get_config("wait_time")) or 0
if wait_path ~= "" and wait_time > 0 then
wait_for_path(wait_path, wait_time)
end
run_commands()
end
end
function M.post_fs_data()
try_run("post_fs_data")
end
function M.service()
try_run("service")
end
function M.boot_completed()
try_run("boot_completed")
end
-- Quick action: run manually regardless of stage
function M.run_now()
run_commands()
end
return MKey Points
- The manifest declares 4 configuration fields (execution stage, wait path, wait timeout, command list), editable via the "Config" button in the manager
quick_actiondeclares a "Run now" button bound to therun_nowcallback, allowing manual triggering at any timemain.luadeclares three lifecycle callbacks (post_fs_data/service/boot_completed);try_runchecks whether the current stage matches the configured stage and only executes when it matches- Uses APIs such as
exec,get_config,info, andwarn
Copyright
Copyright Ownership:FolkPatch Team
License under:Attribution 4.0 International (CC-BY-4.0)
