JS 与 Python 运行时
启动器支持两种插件运行时:Jint(进程内 JavaScript)和 python.exe 子进程。它们的隔离强度完全不同,但能力面和权限校验是同一套代码——这是刻意的设计,不是巧合。
JS:Jint,进程内
private readonly Engine _engine;
public JsPluginRuntime()
{
_engine = new Engine(o => o
.LimitMemory(48_000_000) // 内存上限 48MB
.TimeoutInterval(TimeSpan.FromSeconds(5)) // 单次脚本执行防死循环
.LimitRecursion(256)
.Strict());
}四个限制:内存 48MB(超出直接抛内存超限异常)、单次执行 5 秒(死循环被引擎掐断)、递归深度 256、Strict 模式。
关键是 Jint 没有宿主外 IO 能力。插件能触达的东西仅限注入的 ctx 对象——那上面全是白名单能力,ctx.files 只能读写插件自己的 data 目录,HTTP 走 ctx.http(而且要 launcher:network 权限、且受主机白名单约束)。
Jint 引擎不可并发使用,所以所有钩子调用都串在一把锁后面:
lock (_hookGate)
{
plugin.Runtime.InvokeHook(HookLauncherEvent, ctx);
}事件可能从任意线程投递进来(计时器、进程退出回调),不串行化就会把引擎搞坏。
5 秒是硬上限
这个超时覆盖整次脚本执行,包括它同步占住的时间。插件别在 onLoad 里做大计算——它跑在 UI 线程上。
Python:进程外子进程
Python 插件跑在独立的 python.exe 进程里,宿主通过 stdin/stdout 的行式 JSON 协议跟它说话。
var psi = new ProcessStartInfo
{
FileName = python,
Arguments = $"-X utf8 \"{_hostSdkPath}\" \"{_pluginDir}\"",
UseShellExecute = false,
WorkingDirectory = _pluginDir,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};这个隔离比 JS 强得多:子进程根本触达不到启动器的内存,没有令牌、没有账户对象、没有服务注册表,它只是一个普通 Python 解释器。
代价是插件作者要处理 IPC 和同步。宿主为此提供了 host.py SDK(Assets/pluginsdk/starcloud_host.py),插件作者只需要在目录里放个 main.py:
def onLoad(ctx):
ctx.ui.register_panel("main", "我的面板", "assets/panel.html")
def beforeGameLaunch(ctx):
ctx.args.append("-Xmx4G")
def onLauncherEvent(ctx):
if ctx.event["name"] == "game.exited":
ctx.logger.info("这一局结束了")SDK 存在的意义就是不让插件作者手写协议:它把 host_call 的一问一答包装成普通方法调用,未授权时把宿主的错误翻译成 PermissionError:
raise PermissionError(reply.get("error") or "宿主调用失败")两条上报路径
子进程和宿主之间有两类交互,方向相反:
插件 → 宿主(声明式上报):参数增量、通知、面板、槽位、日志。这些不要求往返,插件在响应载荷里声明,宿主过滤后落地:
foreach (var a in resp.TryGetStringArray("append")) args.Append(a);
foreach (var a in resp.TryGetStringArray("removeKey")) args.RemoveKey(a);
foreach (var panel in resp.TryGetPanelArray("panels")) ctx.Ui?.RegisterPanel(panel.id, panel.title, panel.entry);
foreach (var note in resp.TryGetNotifyArray("notifications")) ctx.Ui?.Notify(note.title, note.message);注意 args.Append 走的还是 GameArgsManager——身份参数黑名单在 Python 侧同样生效,不是 JS 独有的检查。
插件 → 宿主(能力回调):版本列表、实例、资源、设置、存储等走 host_call,宿主在等响应的间隙就地处理:
// 插件 → 宿主:{"op":"host_call","id":7,"api":"instances.list","args":[]}
// 宿主 → 插件:{"op":"host_reply","id":7,"ok":true,"result":…}关键是这一句:权限校验与 JS 插件完全同一套代码路径。host_call 最终落到 PluginHost.InvokeCapability,和 JS 里 ctx.instances.list() 走的是同一个方法、同一个 Require(permission) 检查。所以「Python 是弱权限的旁路」这个担心不成立——它只是换了个传参方式。
协议
三段:
宿主 → 插件 {"op":"load","permissions":[…],"launcher":{…}}
宿主 → 插件 {"op":"call","hook":"beforeGameLaunch"}
宿主 → 插件 {"op":"call","hook":"onLauncherEvent","event":{"name":…,"data":…}}
宿主 → 插件 {"op":"unload"}插件回一行 {"ok":true,…} 或 {"ok":false,"error":"…"}。
单次请求超时 15 秒。超时或进程死亡,宿主拿到 null,报「Python 插件无响应」。
Request 的读取循环里有个细节:子进程往 stdout 打的非协议内容(自己 print 出来的)会被跳过而不是当成响应:
try { parsed = JsonDocument.Parse(line).RootElement.Clone(); }
catch (JsonException) { continue; } // 子进程往 stdout 打了非协议内容:忽略否则插件作者一个 print() 就能把协议搞乱。
卸载
if (_proc is { HasExited: false } p)
{
try { p.StandardInput.WriteLine("{\"op\":\"unload\"}"); p.StandardInput.Flush(); } catch { }
if (!p.WaitForExit(3000)) p.Kill(entireProcessTree: true);
}先发 unload 给插件 3 秒自己退出;不退就 Kill(entireProcessTree: true)——连同它自己 fork 出来的子孙进程一起杀。Python 插件很可能是自己起线程池或子进程的,只杀父进程会留下孤儿。
找 Python 的顺序
Windows 上先找 PATH 里的 python.exe / python3.exe,再找 py.exe 启动器;unix 上 python3 优先于 python(个别发行版的 python 还指向 python2 残留)。都没有就报「未找到本机 Python」,插件保持停用。