首页
/ Motrix 插件市场:Registry v2 线协议、本地化解析与安装同意流水线全解

Motrix 插件市场:Registry v2 线协议、本地化解析与安装同意流水线全解

2026-09-06 10:28:26作者:江焘钦

Motrix 的插件市场(Marketplace)依赖一份发布于 https://dl.motrix.app/registry/plugins.json 的 registry v2 线协议(wire contract)。外部 registry 仓库持有源 schema 的所有权,而 Motrix 应用内部只 vendor 了一份“宽容(tolerant)”的消费端 schema,并配套维护了字节一致的 fixture 与三通道一致性语料(conformance corpus)。读完本文,你将掌握 Motrix 如何防御性地拉取与缓存注册表、如何按 BCP 47 子集解析插件本地化文案、以及如何通过“两阶段安装 + 注册表↔清单一致性门禁 + SHA-256 校验 + 保留命名空间”这条流水线,在用户明确同意之前阻止任何一次不安全的插件安装。

一、Registry v2 线协议与“宽容消费端”设计

Motrix 消费 registry v2 的核心约束可以归纳为三条:

  1. v2 的演进是 additive-only(仅可加性)。任何字段不得被重命名、改类型或删除;schema 变更必须在 registry 发布方、Motrix 应用与官网(website)三方之间协调,包括那份字节一致的 fixture 和所有 conformance 通道。
  2. 应用侧用 vendor 的 Zod schema 解析注册表数据。未知的新增字段、新增的 category slug 都仍然是“线上合法(wire-valid)”的;只在展示边界(presentation boundary)才把它们收窄(narrow)。非法响应应当保留最近一次成功的缓存(last-good cache),而不是让市场 UI 崩溃。
  3. listing.defaultLocale 与开放的 listing.localizations 映射遵循 registry 的 BCP 47 profile,与应用自身的 SupportedLocale 相互独立;展示/搜索文本必须经由共享的字段级解析器(field-level resolver)解析,且不得臆断相邻区域(例如不能因为缺 zh-TW 就回退到 zh-CN)。

这些约定在仓库中的落点是 src/shared/schemas/registry.ts。文件头部注释直接点明了设计立场:

// Vendored tolerant wire contract for Marketplace registry v2.
//
// Source of truth: plugin-registry/schema/registry.ts. Publisher authoring is
// intentionally stricter; this consumer preserves additive wire fields.

export const REGISTRY_URL = 'https://dl.motrix.app/registry/plugins.json'

/** Last-good cache file under userData, shared by both shells. */
export const REGISTRY_CACHE_FILENAME = 'registry-cache.json'

export const REGISTRY_PLUGIN_ID_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/

三个导出值得逐一看懂:

  • REGISTRY_URL:v2 注册表的固定入口地址,是 RegistryClient 的默认拉取目标。
  • REGISTRY_CACHE_FILENAME:last-good 缓存在 userData 下的文件名,Electron 与 server 两个 shell 共用。
  • REGISTRY_PLUGIN_ID_RE:插件 ID 必须形如 <publisher>.<slug> 的反向域名式命名空间(全小写字母、数字、连字符,至少两级)。这个正则后续在 src/core/plugin/install/plugin-installer.ts 中被复用为安装目录安全校验(防止 ../../ 之类路径逃逸)。

顶层文件 schema 严格要求 version 字面量等于 2,并对重复 ID 做交叉校验:

export const RegistryFileSchema = z
  .object({
    version: z.literal(2),
    generatedAt: z.iso.datetime(),
    plugins: z.array(RegistryPluginSchema),
  })
  .passthrough()
  .superRefine(addDuplicateIdIssues)

“宽容”体现在三处:

  1. .passthrough()RegistryFileSchemaRegistryPluginSchemaRegistryListingSchemaRegistryLocalizationSchema 全部启用 passthrough,未来发布方新增的自有字段会被原样保留,老消费者解析不报错——这正是“additive-only 演进”能在不 bump 大版本的情况下成立的前提。

  2. 遗留字段黑名单:v1 曾把 name/description/features{ en, zh? } 的形式存在插件根上;v2 里这三个 key 被显式禁止(forbidden),防止 v1/v2 混合条目被误判为“向前兼容的 v2 扩展字段”:

    const LEGACY_LOCALIZED_PLUGIN_ROOT_KEYS = ['name', 'description', 'features'] as const
    
  3. 单条插件的结构commonPluginShape):id 用上述命名空间正则;origin 限定为 builtin | communitypermissions / optionalPermissions / hostPermissions 三个权限数组默认 []package 是可选块,内含 url(合法 URL)、sha256(64 位小写十六进制)、size(正整数字节数)、可选 signatureupdatedAt 是 ISO 日期,featured 是布尔。

真实条目长什么样,可以直接看共享 fixture src/shared/schemas/registry.fixture.json

{
  "id": "example.archive-unpacker",
  "listing": {
    "defaultLocale": "en-US",
    "localizations": {
      "en-US": { "name": "Example Archive Unpacker", "description": "Unpacks finished archive downloads...", "features": ["..."], "keywords": ["archive", "extract"] },
      "zh-CN": { "name": "示例压缩包解压器", "description": "下载完成后将压缩包解压到同级目录。", "features": ["支持 zip、tar、7z 压缩包"], "keywords": ["压缩包", "解压"] },
      "ja-JP": { "name": "サンプル・アーカイブ展開", "features": [] }
    }
  },
  "version": "2.1.0",
  "origin": "community",
  "engines": { "motrix": ">=2.1.0 <3.0.0" },
  "permissions": ["fs.task.write"],
  "optionalPermissions": ["notifications"],
  "package": { "url": "https://...", "sha256": "4f2d...5432", "size": 48213 },
  "updatedAt": "2026-07-08",
  "featured": true
}

fixture 文件第一行注释写得很直白:"Shared registry v2 wire fixture. Copy byte-for-byte into motrix-turbo and motrix-website." —— 这就是规则文档所说的“byte-identical fixture”:同一份 JSON 被逐字节复制进 Motrix 主仓、发布器仓(motrix-turbo)与官网仓,三个通道共享同一份数据做一致性验证。配套的三通道语料是 src/shared/schemas/registry.conformance.json,其注释说明 wireExpected 跑在所有宽容线解析器中、authoringExpected 只跑在 plugin-registry 的严格源校验中、resolverExpected 跑在消费端;src/shared/schemas/registry.test.ts 遍历 conformance.cases 对这些行为做回归断言。

二、本地化:BCP 47 子集 profile 与字段级解析器

registry 本地化不依赖 ICU 的完整 BCP 47 知识,而是实现了一个刻意收窄的“安全 profile”。src/shared/schemas/registry.ts 中的 isMotrixLocaleTag 只接受:2–8 位小写字母语言码、可选的大写 Script(Hant 这类 4+1 形式)、可选 Region(两位大写字母或三位数字),之后是若干个不重复的 variant;extension 与 private-use 子标签被明确排除。任何候选标签再经 MotrixLocaleTagSchema(Zod refine)校验后才能作为 defaultLocalelocalizations 的 key。

这条规则的动机在注释中写得很清楚:“ICU-independent consumer profile… Alias and registry knowledge are intentionally not required here.” 即消费端不维护别名表(如 in -> id),也绝不猜测“可能的同族区域”。

展示文本的最终取值由共享的字段级解析器 resolveRegistryListing(listing, requestedLocale) 完成,其行为是:

  1. Intl.Locale 归一化请求语言,生成候选序列:baseName → 逐级截断的父级 → maximize() 得到的 language-Script 组合 → 纯 languagedefaultLocale
  2. **逐字段(name / description / features / keywords 独立)**按候选顺序查找:哪个本地化记录“自有该字段”就用哪个,全部落空才回退到 defaultLocale 记录的对应字段;
  3. ja-JP 只给了 features: [] 而没给 keywords 时,keywords 会继续向前查找,不会连带 ja-JP 的其他字段整体接管。

这就是“不要臆断 zh-TW -> zh-CN”的具体含义:zh-TW 的候选序列是 zh-TW → zh → defaultLocalezh-CN 永远不会出现在候选里。RegistryListingSchema 还有两条结构约束:defaultLocale 必须是 localizations 的自有 key,且默认本地化必须同时含 namedescription;非默认本地化不能是空对象(至少含一个自有字段)。

三、防御性读侧客户端:RegistryClient

规则文档要求“非法响应保留 last-good cache 而不是让市场 UI 崩溃”。实现落在 src/core/plugin/registry/registry-client.ts,类注释原话是“Public methods never throw: failures retain a validated last-good snapshot, or return null when none exists.”

关键参数与行为(均可在源码中逐项核对):

常量 / 行为 位置
默认 TTL 6 小时(DEFAULT_TTL_MS registry-client.ts
拉取超时 15 秒(FETCH_TIMEOUT_MS registry-client.ts
响应体上限 4 MiB(MAX_REGISTRY_BYTES),磁盘缓存上限 +64 KiB registry-client.ts
缓存文件 userData/registry-cache.jsoncacheFormat: 2 信封(含 etag / fetchedAt / raw) REGISTRY_CACHE_FILENAME

值得展开的防御细节:

  • 条件请求与 304 短路:刷新时携带 if-none-match: <etag>;收到 304 时取消响应体、只更新 fetchedAt 并持久化,直接复用内存中已校验的文件对象。
  • 有界读取readBoundedResponse 先检查 content-length 声明,再对 body 做流式累积,任一时刻超过 4 MiB 立即 abort 并 reader.cancel()TextDecoderfatal: true 解码,非法 UTF-8 也会失败。超时竞赛(Promise.race)若先输,迟到的响应体会被显式 cancel,避免连接悬挂。
  • 磁盘缓存同样有界loadDiskstat 再读文件,尺寸超限、JSON 损坏、信封 schema 不符、raw 再解析失败,一律把缓存置空并退化为无条件请求——绝不把坏缓存投喂给 UI。
  • 原子落盘persist()write-file-atomic 写缓存,避免进程在写一半时留下截断文件。
  • 兼容性标注list(hostVersion) 为每条插件追加 compatible: semverSatisfies(hostVersion, entry.engines.motrix),得到 RegistryPluginDTO;结果按“解析文件身份 + host 版本”记忆化,304 不改变文件身份就不会重算。
  • 并发去重refresh()inflight 单例合并重复刷新;loadDiskOnce() 保证磁盘读取只发生一次。

同一目录下的 registry-fetcher.tsupdate-scan.ts 分别承担抓取与更新扫描职责(均有对应单测 registry-client.test.ts 等覆盖)。

四、包下载:有界重定向、流式落盘与 SHA-256 前置校验

规则文档对安装边界的要求是:只允许初始 HTTPS 包 URL 走 allowlist 语义,重定向必须有界、流式,且必须在解析清单之前验证最终大小与 registry 声明的 SHA-256。

URL 下载器 src/core/plugin/install/url-fetcher.ts 的实现与之逐条对应:

const MAX_REDIRECTS = 5
// ...
const dispatcher = agent.compose(
  interceptors.redirect({ maxRedirections: MAX_REDIRECTS })
)
const response = await request(url, { dispatcher })
// ...
await pipeline(response.body, createWriteStream(destFile, { mode: 0o600 }))

要点:undici Agent 每次下载独立创建并在 finallydestroy(),确保“释放每一个拥有的 socket”;重定向上限 5 跳;响应体通过 pipeline 流式写入目标文件(权限 0o600),失败时取消 body 并清理部分文件,非 200 抛出带状态码的 AppError

SHA-256 前置校验发生在安装流水线的 stage 阶段,见 src/core/plugin/install/plugin-installer.ts

const pinnedSha256 =
  sourceInput.type === 'local'
    ? sourceInput.fileHash
    : (options?.expectedSha256 ?? options?.expect?.packageSha256)
if (pinnedSha256 && loadedMoext.archiveSha256 !== pinnedSha256) {
  throw new AppError(ErrorCode.PluginManifestInvalid, /* sha256_mismatch */)
}

即:从注册表安装时,钉住的摘要来自 registry 条目的 package.sha256(经由 RegistryExpectation.packageSha256 传递);本地安装则用本地文件自身哈希。只有在摘要一致后才解包、才解析 manifest.json——“先验包,后解清单”。

五、两阶段安装与注册表↔清单一致性门禁

Motrix 的安装是两阶段的(plugin-installer.ts 头部注释):

  1. stage(moextPath, sourceInput):解压到 staging 目录、解析清单、构建同意载荷(ConsentPayload)与相对旧安装的信任面 diff(diffTrustSurface),返回 { stagingId, consent };渲染层此时弹出同意对话框,且可以无限期保持打开。
  2. commit(stagingId, grants):用户确认后落盘——升级时先 onDeactivate、备份旧目录、换入新目录、写 _install.json、刷新注册表并删备份;换入中途失败则回滚备份。

其中与 registry 契约直接相关的是 §6.3 一致性门禁,由 src/core/plugin/install/registry-expectation.ts 实现:

export function buildRegistryExpectation(entry: RegistryPlugin): RegistryExpectation { /* 从 registry 条目构建期望 */ }

export function assertMatchesRegistryExpectation(
  manifest: PluginManifest,
  expected: RegistryExpectation
): void {
  if (manifest.id !== expected.id) mismatch('id')
  if (manifest.version !== expected.version) mismatch('version')
  if (manifest.engines.motrix !== expected.enginesMotrix) mismatch('engines.motrix')
  if (!isSubset(manifest.permissions, expected.permissions)) mismatch('permissions')
  if (!isSubset(manifest.optionalPermissions ?? [], expected.optionalPermissions)) mismatch('optionalPermissions')
  if (!isSubset(manifest.hostPermissions ?? [], expected.hostPermissions)) mismatch('hostPermissions')
}

规则与规则文档完全一致,且语义刻意区分:

  • idversionengines.motrix 必须逐字相等——任何漂移都意味着“你看到的目录页”和“你拿到的包”不是同一个东西;
  • 三类权限(required / optional / host)只允许是 registry 预览的“子集”:打包清单可以申请更少权限,但绝不能申请比用户在目录页预览过的更多权限;
  • 任一字段不匹配即抛出 PluginManifestInvalid(错误键 plugin.install.registry_manifest_mismatch,并携带出错的字段名);stage() 捕获该异常后会删除整个 staging 目录再向上抛,保证没有“半安装”残留。

门禁在 stage 中的调用位置也在源码中可查:解析清单成功后立刻执行

if (options?.expect) {
  assertMatchesRegistryExpectation(parsedManifest, options.expect)
}

关于“grants 永远派生自解析后的清单”:commit 落盘的授权快照 ConsentSnapshotparsedManifest 直接构造(permissionsoptionalPermissionsinvokesCommandshostPermissionsenginesMotrix 等字段都取自包内清单),registry 条目只参与“能不能装”的判定,不参与“装完后给什么权限”。同意对话框渲染的数据则由 consent-payload.ts 统一构建:权限描述走 permission.* i18n 键(未知权限回退到 permission.<name>.description 保持前向兼容),host 权限模式若是 <all_urls>https://*/* 等宽泛模式会被标记 broad: true 用于高亮告警。

server 运行时额外注入一道 serverAck 钩子(plugin-installer.tsisServerAckSatisfied 的注入点),在构建同意载荷之前即可拒绝安装,对应 src/server/plugin/install-service.tsserver-ack.ts 的无签名来源管控。

六、保留发布方命名空间与兼容性门

规则文档还要求两点:保留发布方命名空间对社区清单不可用不兼容的条目保持可见但不可安装

前者在清单解析层强制执行。src/core/plugin/manifest/parse.ts 的 Step 3:

// Community plugins must not claim a reserved publisher
// (motrix.*, verified.*, official.*, system.*).
// Built-ins shipped inside the app bundle are exempt ...
const origin = opts.origin ?? 'community'
if (origin !== 'builtin' && isReservedPublisher(result.data.id)) {
  throw new PluginManifestInvalid('plugin.manifest.id_reserved_publisher', ...)
}

motrix.*verified.*official.*system.* 四个发布方前缀只允许 origin: 'builtin'(随应用内置、位于 <resourcesDir>/builtin-plugins/)的插件使用。src/core/plugin/plugin-registry.test.ts 中有一对镜像用例:从 builtinDir 装载 motrix.* 通过,从社区 pluginsDir 装载同名则被拒;parse.test.ts 也专门有 reserved publisher (origin gate) 的测试组。注意该约束是在 parseManifest 而非 schema 层做的(schema.test.ts 明确“schema 层接受保留发布方,由 origin 判定”),这让同一份 schema 可以服务内置与社区两条装载路径。

后者落在查询与渲染层。RegistryClient.list() 返回的每条 RegistryPluginDTO 都带 compatible 布尔(见 engines.motrix 与宿主版本的 semver 匹配);渲染端 registry-detail-panel.tsxcompatible: false 的条目渲染“可见但禁用”的 Install 按钮并给出提示,测试 registry-detail-panel.test.tsx 固定了“compatible 条目在 Electron 上启用 Install、incompatible 条目禁用”的行为。

七、motrix://plugins/<id> 深链:仅导航,永不触发安装

规则文档明确:motrix://plugins/<id> 是 navigation-only,不得编码或触发安装;每一次安装都必须走应用内同意流程。

Electron 侧的处理在 src/main/platform/protocol-manager.ts。依赖接口注释直接引用了这条规则:

// motrix://plugins/<id> — navigate the main window to a plugin's
// marketplace detail route. Navigation-only by contract: the deeplink must
// never carry or trigger an install (.claude/rules/plugin-registry.md).
onOpenPluginDetail: (pluginId: string) => void

协议管理器把深链解析成 onOpenPluginDetail(pluginId) 回调,主窗口随后导航到市场详情页——用户仍需在详情页点击 Install 并完整走 stage → 同意对话框 → commit。src/main/index.ts 中也有对应注释 // motrix://plugins/<id> — navigation-only by contract;冷启动深链(应用未运行时点开链接)由 src/preload/preload.ts 在启动后补发 NavigateTo。测试 protocol-manager.test.ts 覆盖了合法路由以及 no-namespaceUpper.Case../etc 等非法/可疑 ID 的拒绝路径——与 REGISTRY_PLUGIN_ID_RE 的严格小写命名空间约束互为呼应。

八、查询契约:Electron 与 server 共享同一语义

规则文档的 Query Contract 一节要求:Electron 与 server 两侧处理器都暴露 Queries.ListRegistryPlugins / Queries.GetRegistryPlugin,委托给同一个 RegistryClient 语义,并返回兼容性信息;共享查询载荷必须保持 host-independent,禁止为渲染层或某个 shell 造“镜像”或裸 channel 名。

仓库现状与之精确对应:

  • channel 常量只在共享层定义一次,见 src/shared/protocol/queries.ts

    ListRegistryPlugins: 'query:listRegistryPlugins',
    GetRegistryPlugin: 'query:getRegistryPlugin',
    
  • Electron 处理器 src/main/ipc/queries.ts

    [Queries.ListRegistryPlugins]: async () => registryClient.list(hostVersion),
    [Queries.GetRegistryPlugin]: async (id: string) =>
      registryClient.get(id, hostVersion),
    
  • server 处理器 src/server/ipc/queries.ts 使用完全相同的写法;渲染端统一通过 transport.invoke(Queries.ListRegistryPlugins) 调用(见 use-registry.ts),返回的 DTO 自带 compatible 字段,即“return compatibility information”的落地形态。

由于两侧共享同一 RegistryClient 实例语义(TTL、etag、4 MiB 上限、last-good 回退全部一致),市场页在 Electron 桌面与 server 部署形态下看到的条目、缓存与兼容性判定不会出现分叉。

九、给插件作者与消费者的要点小结

  1. 消费端视角:Motrix 对 registry 的读取是“永不抛错”的——超时(15s)、超限(4 MiB)、解析失败、坏缓存都会静默回退到 last-good;6 小时 TTL 内的请求直接命中内存。理解 registry-client.test.ts 中的用例可以完整看到每种退化路径的预期行为。
  2. 发布端视角:条目字段只能加不能改;本地化必须走 listing.localizations(BCP 47 安全 profile key,defaultLocale 必须自有且含 name/description);package.sha256size 是安装边界的硬校验,包内清单的 id/version/engines.motrix 必须与条目逐字一致,权限只减不增。
  3. 安全边界:下载有 5 跳重定向上限与有界流式落盘;motrix.* 等保留命名空间被 parseManifest 按 origin 门禁;不兼容条目可见不可装;motrix:// 深链只能导航。每一层都有对应的测试文件(registry-expectation.test.tsurl-fetcher.test.tsregistry.test.ts)回归锁定。

整套机制的核心思想只有一句话:用户在目录页“看见并同意”的信任面(registry 条目),必须与“实际落盘”的插件包(解析后的清单)严格一致;registry 负责预览与摘要钉住,清单负责最终授权,深链与任何自动化路径都不得绕过这道人工同意的闸门。

登录后查看全文
热门项目推荐
相关项目推荐