Skip to content

JS 与 Python 运行时 ​

启动器支持两种插件运行时:Jint(进程内 JavaScript)和 python.exe 子进程。它们的隔离强度完全不同,但能力面和权限校验是同一套代码——这是刻意的设计,不是巧合。

JS:Jint,进程内 ​

csharp
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 引擎不可并发使用,所以所有钩子调用都串在一把锁后面:

csharp
lock (_hookGate)
{
    plugin.Runtime.InvokeHook(HookLauncherEvent, ctx);
}

事件可能从任意线程投递进来(计时器、进程退出回调),不串行化就会把引擎搞坏。

5 秒是硬上限

这个超时覆盖整次脚本执行,包括它同步占住的时间。插件别在 onLoad 里做大计算——它跑在 UI 线程上。

Python:进程外子进程 ​

Python 插件跑在独立的 python.exe 进程里,宿主通过 stdin/stdout 的行式 JSON 协议跟它说话。

csharp
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:

python
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:

python
raise PermissionError(reply.get("error") or "宿主调用失败")

两条上报路径 ​

子进程和宿主之间有两类交互,方向相反:

插件 → 宿主(声明式上报):参数增量、通知、面板、槽位、日志。这些不要求往返,插件在响应载荷里声明,宿主过滤后落地:

csharp
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,宿主在等响应的间隙就地处理:

csharp
// 插件 → 宿主:{"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 出来的)会被跳过而不是当成响应:

csharp
try { parsed = JsonDocument.Parse(line).RootElement.Clone(); }
catch (JsonException) { continue; } // 子进程往 stdout 打了非协议内容:忽略

否则插件作者一个 print() 就能把协议搞乱。

卸载 ​

csharp
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」,插件保持停用。

相关链接:权限模型 · 插件清单格式

基于 MIT 许可证(有修改)开源