一键导入
develop-ulanzi-plugin
Develop an Ulanzi Deck plugin (UlanziStudio) — scaffold, manifest, main service, property inspector, icons, events, localization, packaging
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Develop an Ulanzi Deck plugin (UlanziStudio) — scaffold, manifest, main service, property inspector, icons, events, localization, packaging
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | develop-ulanzi-plugin |
| description | Develop an Ulanzi Deck plugin (UlanziStudio) — scaffold, manifest, main service, property inspector, icons, events, localization, packaging |
Develop a plugin for UlanziStudio's programmable macro keypad (Ulanzi Deck) using the official Ulanzi JS Plugin Development Protocol V2.1.2.
Use this skill when the user asks to create, modify, debug, or package an Ulanzi Deck plugin (folders ending in .ulanziPlugin).
Reference plugin in this repo: me.iany.clashTraffic.ulanziPlugin/ — a complete working example (HTML main service + Canvas-rendered key icon + WebSocket data source + property inspector + i18n + per-state offline icon).
{author}.{plugin}.ulanziPlugin/
├── manifest.json # required
├── en.json / zh_CN.json # optional localization
├── README.md
├── resources/ # icons (svg/png/jpg), gifs
├── libs/ # SDK (copy from common-html or common-node)
│ ├── css/uspi.css # property inspector styles
│ └── js/{constants,eventEmitter,timers,utils,ulanziApi}.js
├── plugin/ # main service
│ ├── app.html # HTML main service entry
│ └── app.js # logic (loaded by app.html)
└── property-inspector/
└── {action}/
├── inspector.html
└── inspector.js
{namespace}.{plugin}.ulanziPlugin (e.g. me.iany.clashTraffic.ulanziPlugin){ns1}.{ns2}.{ns3}.{plugin} (e.g. me.iany.ulanzistudio.clashTraffic){pluginUUID}.{action} (e.g. me.iany.ulanzistudio.clashTraffic.traffic)ulanziApi.js:42). Get this wrong and runtime breaks silently.Required top-level fields: Author, Name, Icon, Version, CodePath, Type (always "JavaScript"), UUID, Actions.
Required action fields: Name, Icon, States (array, each {Name, Image}), UUID, Controllers.
Useful flags:
PrivateAPI: true — opt into private APIs.DisableAutomaticStates: true — prevent host from auto-toggling state on press; use when plugin owns state visualization (e.g. dynamic icons via Canvas).SupportedInMultiActions: false — exclude from multi-action composition.Devices: [] — all devices. ["D200X"] whitelist. ["~Dial"] blacklist Dial. Models: D200, D200H, Dial, D200X.Controllers: ["Keypad"] and/or ["Encoder"] (rotary dial on D200X/Dial).OS, Software.MinVersion, ApplicationsToMonitor, Profiles, InstallToDepsApp — see references/manifest.md if needed.For Encoder actions, add Encoder: { layout: "$UA1" } (icon+text) or "$UA2" (text+text), or a custom layout.json (canvas 126×140).
plugin/app.html loads the SDK in order, then your script:
<script src="../libs/js/constants.js"></script>
<script src="../libs/js/eventEmitter.js"></script>
<script src="../libs/js/timers.js"></script>
<script src="../libs/js/utils.js"></script>
<script src="../libs/js/ulanziApi.js"></script>
<script src="./app.js"></script>
plugin/app.js skeleton:
const PLUGIN_UUID = 'me.iany.ulanzistudio.myplugin';
$UD.connect(PLUGIN_UUID);
const INSTANCES = {}; // keyed by context
$UD.onConnected(() => {});
$UD.onAdd((jsn) => {
// jsn.context is unique per key instance
if (!INSTANCES[jsn.context]) INSTANCES[jsn.context] = createInstance(jsn.context);
if (jsn.param) INSTANCES[jsn.context].update(jsn.param);
});
$UD.onRun((jsn) => INSTANCES[jsn.context]?.press());
$UD.onSetActive((jsn) => INSTANCES[jsn.context]?.setActive(jsn.active));
$UD.onParamFromApp((jsn) => jsn.param && INSTANCES[jsn.context]?.update(jsn.param));
$UD.onParamFromPlugin((jsn) => jsn.param && INSTANCES[jsn.context]?.update(jsn.param));
$UD.onClear((jsn) => {
// NOTE: clear payload is array; context lives on each item
for (const item of jsn.param || []) {
INSTANCES[item.context]?.destroy();
delete INSTANCES[item.context];
}
});
import UlanziApi, { Utils, RandomPort } from './plugin-common-node/index.js';
const $UD = new UlanziApi();
new RandomPort().getPort(); // writes ws-port.js so PI can find the port
$UD.connect('me.iany.ulanzistudio.myplugin');
Same event API as HTML. Set manifest.json CodePath to plugin/app.js. For debugging add "Inspect": "--inspect=127.0.0.1:9201" (unique port per plugin) and launch host with --nodeRemoteDebug.
property-inspector/{action}/inspector.html:
<link rel="stylesheet" href="../../libs/css/uspi.css">
<div class="uspi-wrapper hidden">
<form id="property-inspector">
<div class="uspi-item">
<div class="uspi-item-label" data-localize>WebSocket URL</div>
<input type="text" class="uspi-item-value" name="wsUrl" placeholder="ws://...">
</div>
</form>
</div>
<script src="../../libs/js/constants.js"></script>
<!-- ...same SDK includes as app.html... -->
<script src="./inspector.js"></script>
inspector.js:
let form;
$UD.connect(); // PI gets uuid from query string
$UD.onConnected(() => {
form = document.querySelector('#property-inspector');
document.querySelector('.uspi-wrapper').classList.remove('hidden');
form.addEventListener('input', Utils.debounce(() => {
$UD.sendParamFromPlugin(Utils.getFormValue(form));
}));
});
$UD.onAdd((jsn) => jsn.param && Utils.setFormValue(jsn.param, form));
$UD.onParamFromApp((jsn) => jsn.param && Utils.setFormValue(jsn.param, form));
Conventions:
.uspi-wrapper (auto i18n + styling). Initially hidden, revealed onConnected to avoid FOUC.name attributes on inputs map directly to settings keys.Utils.debounce on input to avoid spamming the host.sendParamFromPlugin (not setSettings) — host persists settings only when active and propagates back through paramfromapp.The host doesn't auto-render Canvas. From the main service, push icons via $UD:
| API | Use |
|---|---|
$UD.setStateIcon(context, stateIndex, text?) | Switch to a state from manifest States |
$UD.setPathIcon(context, 'resources/x.svg', text?) | Local file (paths relative to plugin root) |
$UD.setBaseDataIcon(context, 'data:image/png;base64,...', text?) | Dynamic Canvas → canvas.toDataURL('image/png') |
$UD.setGifPathIcon(context, 'anim.gif', text?) / setGifDataIcon | Animated |
For Canvas-rendered icons use 144×144 (matches device key resolution). Render only when active; the host ignores updates for inactive keys but you'll waste CPU.
setSettings(data, context) / getSettings(context) — per-action; only saves while active.setGlobalSettings(data) / getGlobalSettings() — plugin-wide.onDidReceiveSettings / onDidReceiveGlobalSettings.sendParamFromPlugin ↔ onParamFromApp) is the host-managed persistence path and is preferred over manual setSettings from the PI.Lifecycle: onConnected, onAdd, onSetActive, onClear (param is array of {context, ...}).
Keypad: onRun (debounced single-press, primary trigger), onKeyDown, onKeyUp.
Encoder: onDialDown, onDialUp, onDialRotate (message.rotateEvent ∈ left|right|hold-left|hold-right), plus onDialRotate{Left,Right,HoldLeft,HoldRight}.
Cross-page (pass-through, not persisted by host):
$UD.sendToPropertyInspector(data, context) → PI onSendToPropertyInspector$UD.sendToPlugin(data) → Main onSendToPluginSystem: toast(msg), hotkey('Ctrl+C'), openUrl(url), openView(html, w, h), selectFileDialog(filter), selectFolderDialog(), logMessage(msg, level), showAlert(context).
context decoding: $UD.decodeContext(ctx) → { uuid, key, actionid }. Format: uuid___key___actionid.
Place {lang}.json in plugin root. Supported: en, zh_CN, zh_HK, ja_JP, de_DE, ko_KR, pt_PT, es_ES.
{
"Name": "My Plugin",
"Description": "...",
"Actions": [{ "Name": "...", "Tooltip": "..." }],
"Localization": { "WebSocket URL": "WebSocket 地址" }
}
In PI HTML use data-localize (translates textContent, placeholder, title, label). The SDK auto-runs on .uspi-wrapper/.udpi-wrapper after connect. In JS use $UD.t('key').
When opening external sockets (e.g. WebSocket data sources), implement exponential backoff and tear down on onClear. Pattern from the reference plugin:
scheduleReconnect() {
if (this.destroyed || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
this.connect();
}, this.reconnectDelay);
}
Always null out socket handlers before close() to avoid recursive reconnects.
Simulator (no host app needed):
cd UlanziDeckSimulator && npm install && npm start
# copy plugin folder into UlanziDeckSimulator/plugins/
# open http://127.0.0.1:39069 → click "Refresh Plugin List"
Limitations: openUrl/openView can't open local files; Node.js main services must be started manually (node plugin/app.js); right-click a key to manually fire events.
Desktop debug flags:
| Flag | Purpose |
|---|---|
--log + --logLevel | File logs |
--webRemoteDebug | HTML plugins debuggable at http://localhost:9292 |
--nodeRemoteDebug | Node plugins via chrome://inspect |
Windows: append flags to shortcut Target. macOS: open /Applications/Ulanzi\ Studio.app --args --webRemoteDebug (note: open may break Accessibility permissions; prefer running the binary directly if hotkeys misbehave).
Copy the *.ulanziPlugin/ folder into the host's plugins directory and restart UlanziStudio (or refresh in the simulator). No build step required for plain JS/HTML plugins.
ulanziApi.js:42).setSettings is a no-op when the action isn't active; rely on the PI ↔ host ↔ main flow.onClear payload is an array. jsn.param is [{context, ...}, ...] — iterate, don't read jsn.context.canvas.toDataURL('image/png') then setBaseDataIcon.resources/icon.svg works from anywhere.$UD.connect() — order matters.$UD.connect(uuid) in the PI — pass no argument so it picks UUID from the query string the host injects.me.iany.clashTraffic.ulanziPlugin/ demonstrates:
plugin/app.js:67).setBaseDataIcon (plugin/app.js:301).setPathIcon when the data source is offline (plugin/app.js:213).sendParamFromPlugin ↔ onParamFromApp (property-inspector/traffic/inspector.js:18, plugin/app.js:53).$UD.openUrl (plugin/app.js:186).onClear (plugin/app.js:175, plugin/app.js:195).data-localize plus en.json / zh_CN.json.When asked to add a new action or new plugin, mirror this structure unless requirements demand Node.js (filesystem, native modules, raw TCP, etc.).