From 0c13a6328e9af75813a07b653bd0b42d37650c4e Mon Sep 17 00:00:00 2001 From: nanaco666 <105962962+nanaco666@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:20:10 +0800 Subject: [PATCH] feat(plugin): add optional install count contract Signed-off-by: nanaco666 <105962962+nanaco666@users.noreply.github.com> --- docs/plugin-protocol.md | 5 + .../src/__tests__/delivery.test.ts | 138 ++++++++++++++++++ packages/plugin-protocol/src/delivery.ts | 30 ++++ 3 files changed, 173 insertions(+) diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md index f5ea195..47f705f 100644 --- a/docs/plugin-protocol.md +++ b/docs/plugin-protocol.md @@ -329,6 +329,8 @@ interface PluginIconMetadata { `icon` 为 `null` 表示 manifest 未声明图标,或服务端暂未提供图标对象。旧 v2 响应缺少该字段时解析器也规范化为 `null`,客户端应继续使用兜底图标;提供该字段时,URL 必须是短期 HTTPS 地址,MIME 必须为 `image/*`,并经过 SHA-256、大小和过期时间校验。 +Public Plugin 的列表摘要和详情根对象还可携带 `installCount`。它表示客户端确认安装成功后由服务端累计的安装次数,不是下载量,也不是当前安装用户数或设备数;打开详情、申请下载地址和下载尝试均不计入。安全非负整数可使用 JSON `number`;只含 ASCII 十进制数字的非空字符串同样合法,可无损表示任意长度计数。该字段只允许出现在 `scope: "public"` 的 Plugin 上;服务端未提供统计或统计读取失败时省略,不能发送 `null`。数值或字符串形式的 `0` 都是合法 wire 值,但客户端应与字段缺失一样隐藏安装量展示。 + 三个解析器校验失败都会抛出 `PluginProtocolError`,错误消息包含出错字段路径,调用方应把它视为服务端响应不兼容或损坏,不应继续安装或切换 Release: ```ts @@ -384,6 +386,7 @@ try { | `scope` | `public` 对任意已登录 Cindy 身份可用;`organization` 只对对应组织可用;`personal` 只对发布者本人可用。 | | `organizationId` | Organization 必须是非空组织 ID;Public 和 Personal 恒为 `null`。 | | `defaultInstall` | 对当前请求身份计算后的有效默认安装值;表示未安装时自动安装,不表示强制安装或强制启用。 | +| `installCount` | Public Plugin 的累计成功安装次数;安全非负整数可为 `number`,任意长度计数可为纯十进制字符串。缺失或值为零时客户端隐藏,不等同于下载量。 | | `minCindyVersion` | Release 的最低 Cindy 版本;必须是合法 SemVer。通常可选且缺失表示兼容所有版本;`ios-simulator` 等 Host-only slot 可要求必须声明。 | | `X-Cindy-Version` | 客户端请求列表、详情和下载时携带的 Cindy SemVer;共享常量为 `CINDY_CLIENT_VERSION_HEADER`,HTTP 头名称大小写不敏感。 | | `currentRelease` | 服务端为当前客户端选择的 Release;优先服务端 current,不兼容时回退到最新且仍有效的历史兼容 Release。列表只含摘要,详情额外包含 manifest。 | @@ -408,6 +411,8 @@ try { v2 的 `nextCursor` 语义见上文字段表;解析器接受这种非空、有界字符串,是对既有 v2 线上响应的兼容修复,不是新的 envelope 形状,也不提升 `PLUGIN_API_SCHEMA_VERSION`。 +`installCount` 同样是 v2 的 append-only 可选字段,不提升 `PLUGIN_API_SCHEMA_VERSION`。老服务端不下发时,新客户端按字段缺失隐藏安装量;新服务端下发时,老客户端把它当未知字段忽略。发布顺序为先合并本协议,再由服务端和客户端分别 bump 到已合并 commit 并接入投影或展示。 + 校验器对未知字段保持宽容,对已知字段和值严格校验。新增可选字段不要求服务端和 Desktop 同时发布;破坏性格式变化必须提升对应 schema version。 未知字段只用于前向兼容,不会出现在校验后的返回对象中。消费方不得依赖当前版本未声明的字段。 diff --git a/packages/plugin-protocol/src/__tests__/delivery.test.ts b/packages/plugin-protocol/src/__tests__/delivery.test.ts index c01a82e..0ecf734 100644 --- a/packages/plugin-protocol/src/__tests__/delivery.test.ts +++ b/packages/plugin-protocol/src/__tests__/delivery.test.ts @@ -26,6 +26,7 @@ const validIcon = { url: 'https://cdn.example.com/plugin-icon.png?signature=example', expiresAt: '2026-07-19T00:05:00.000Z', } as const; +const arbitraryLengthInstallCount = '1234567890'.repeat(1_000); const oidcManifest = { ...validManifest, tools: undefined, @@ -79,6 +80,78 @@ describe('plugin delivery contract', () => { expect(response.nextCursor).toBe(pluginId); }); + it('preserves the optional public install count and keeps legacy payloads compatible', () => { + const summary = { + id: pluginId, + ghostId: validManifest.id, + name: validManifest.name, + description: null, + author: null, + scope: 'public' as const, + organizationId: null, + defaultInstall: false, + currentRelease: { + id: 'release-install-count', + version: validManifest.version, + sha256: 'a'.repeat(64), + sizeBytes: 1024, + publishedAt: '2026-07-19T00:00:00.000Z', + }, + }; + const parseSummary = (overrides: Record = {}) => + parseListPluginsResponse({ + schemaVersion: PLUGIN_API_SCHEMA_VERSION, + plugins: [{ ...summary, ...overrides }], + nextCursor: null, + }).plugins[0]!; + + expect(parseSummary({ installCount: 0 }).installCount).toBe(0); + expect(parseSummary({ installCount: Number.MAX_SAFE_INTEGER }).installCount).toBe( + Number.MAX_SAFE_INTEGER, + ); + expect(parseSummary({ installCount: '9007199254740992' }).installCount).toBe( + '9007199254740992', + ); + expect(parseSummary({ installCount: '0' }).installCount).toBe('0'); + expect(parseSummary({ installCount: '42' }).installCount).toBe('42'); + expect(parseSummary({ installCount: '00042' }).installCount).toBe('00042'); + expect(parseSummary({ installCount: arbitraryLengthInstallCount }).installCount).toBe( + arbitraryLengthInstallCount, + ); + expect(parseSummary()).not.toHaveProperty('installCount'); + + const invalidValues: unknown[] = [ + null, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + '', + '-1', + '+1', + ' 1', + '1 ', + '1.0', + '1e3', + '123', + ]; + for (const installCount of invalidValues) { + expect(() => parseSummary({ installCount }), String(installCount)).toThrow( + /plugins\[0\]\.installCount/, + ); + } + + for (const [scope, organizationId] of [ + ['organization', 'org-example'], + ['personal', null], + ] as const) { + expect(() => parseSummary({ scope, organizationId, installCount: 1 })).toThrow( + /plugins\[0\]\.installCount/, + ); + } + }); + it('accepts legacy v2 releases without icon metadata', () => { const response = parseListPluginsResponse({ schemaVersion: PLUGIN_API_SCHEMA_VERSION, @@ -171,6 +244,71 @@ describe('plugin delivery contract', () => { expect(response.plugin.currentRelease.manifest.id).toBe(validManifest.id); }); + it('preserves a large public install count in Plugin details', () => { + const response = parseGetPluginResponse({ + schemaVersion: PLUGIN_API_SCHEMA_VERSION, + plugin: { + id: pluginId, + ghostId: validManifest.id, + name: validManifest.name, + description: null, + author: null, + scope: 'public', + organizationId: null, + defaultInstall: true, + installCount: arbitraryLengthInstallCount, + currentRelease: { + id: 'release-install-count-detail', + version: validManifest.version, + sha256: 'a'.repeat(64), + sizeBytes: 1024, + publishedAt: '2026-07-19T00:00:00.000Z', + manifest: validManifest, + }, + }, + }); + expect(response.plugin.installCount).toBe(arbitraryLengthInstallCount); + }); + + it('keeps legacy detail payloads compatible and rejects invalid install counts', () => { + const plugin = { + id: pluginId, + ghostId: validManifest.id, + name: validManifest.name, + description: null, + author: null, + scope: 'public' as const, + organizationId: null, + defaultInstall: false, + currentRelease: { + id: 'release-install-count-legacy-detail', + version: validManifest.version, + sha256: 'a'.repeat(64), + sizeBytes: 1024, + publishedAt: '2026-07-19T00:00:00.000Z', + manifest: validManifest, + }, + }; + const parseDetail = (overrides: Record = {}) => + parseGetPluginResponse({ + schemaVersion: PLUGIN_API_SCHEMA_VERSION, + plugin: { ...plugin, ...overrides }, + }).plugin; + + expect(parseDetail()).not.toHaveProperty('installCount'); + for (const [scope, organizationId] of [ + ['organization', 'org-example'], + ['personal', null], + ] as const) { + expect(() => parseDetail({ scope, organizationId, installCount: 1 })).toThrow( + /response\.plugin\.installCount/, + ); + } + for (const installCount of [null, -1, 1.5, '-1', '+1', ' 1', '1 ', '1.0']) { + expect(() => parseDetail({ installCount })).toThrow(/response\.plugin\.installCount/); + } + }); + it('preserves the version-gated ios-simulator slot in Plugin details', () => { const manifest = { ...validManifest, diff --git a/packages/plugin-protocol/src/delivery.ts b/packages/plugin-protocol/src/delivery.ts index 6a631ca..e5b21cc 100644 --- a/packages/plugin-protocol/src/delivery.ts +++ b/packages/plugin-protocol/src/delivery.ts @@ -30,6 +30,9 @@ export const PLUGIN_SCOPES = ['public', 'organization', 'personal'] as const; /** `public` 对所有已登录身份可见;其余范围只对对应组织或自然人可见。 */ export type PluginScope = (typeof PLUGIN_SCOPES)[number]; +/** Public Plugin 的累计成功安装次数;字符串表示只含十进制数字,可保留任意长度计数。 */ +export type PluginInstallCount = number | string; + /** 服务端为当前客户端选择的 Release 图标元数据;URL 为短期授权地址。 */ export interface PluginIconMetadata { /** 图标 MIME 类型,例如 image/png 或 image/svg+xml。 */ @@ -84,6 +87,8 @@ export interface VisiblePluginSummary { organizationId: string | null; /** 对当前请求身份计算后的默认安装值,不表示强制安装或强制启用。 */ defaultInstall: boolean; + /** Public Plugin 的累计成功安装次数;服务端未提供或统计失败时缺失。 */ + installCount?: PluginInstallCount; /** 服务端为该客户端选择的 Release;优先 current,不兼容时回退到历史兼容版本。 */ currentRelease: PluginReleaseSummary; } @@ -106,6 +111,8 @@ export interface VisiblePluginDetail { organizationId: string | null; /** 对当前请求身份计算后的默认安装值,不表示强制安装或强制启用。 */ defaultInstall: boolean; + /** Public Plugin 的累计成功安装次数;服务端未提供或统计失败时缺失。 */ + installCount?: PluginInstallCount; /** 服务端为该客户端选择的 Release,包含完整 manifest。 */ currentRelease: PluginReleaseDetail; } @@ -245,6 +252,24 @@ function parseScopedOrganizationId( return value as string | null; } +function parseInstallCount( + scope: PluginScope, + value: unknown, + path: string, +): PluginInstallCount | undefined { + if (value === undefined) return undefined; + if (scope !== 'public') { + throw new PluginProtocolError(`${path} 仅允许 public Plugin 携带`); + } + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { + return value; + } + if (typeof value === 'string' && /^[0-9]+$/.test(value)) { + return value; + } + throw new PluginProtocolError(`${path} 必须是安全非负整数,或只含十进制数字的字符串`); +} + function parseVisiblePluginBase( value: unknown, path: string, @@ -258,12 +283,14 @@ function parseVisiblePluginBase( scope: PluginScope; organizationId: string | null; defaultInstall: boolean; + installCount?: PluginInstallCount; } { const raw = object(value, path); if (!isValidPluginResourceId(raw.id)) throw new PluginProtocolError(`${path}.id 不合法`); if (!isValidGhostId(raw.ghostId)) throw new PluginProtocolError(`${path}.ghostId 不合法`); const scope = parseScope(raw.scope, path); const organizationId = parseScopedOrganizationId(scope, raw.organizationId, path); + const installCount = parseInstallCount(scope, raw.installCount, `${path}.installCount`); if (typeof raw.defaultInstall !== 'boolean') { throw new PluginProtocolError(`${path}.defaultInstall 必须是 boolean`); } @@ -278,6 +305,7 @@ function parseVisiblePluginBase( scope, organizationId, defaultInstall: raw.defaultInstall, + ...(installCount === undefined ? {} : { installCount }), }; } @@ -293,6 +321,7 @@ function parseVisiblePluginSummary(value: unknown, index: number): VisiblePlugin scope: parsed.scope, organizationId: parsed.organizationId, defaultInstall: parsed.defaultInstall, + ...(parsed.installCount === undefined ? {} : { installCount: parsed.installCount }), currentRelease: parseReleaseSummary(parsed.raw.currentRelease, `${path}.currentRelease`), }; } @@ -331,6 +360,7 @@ function parseVisiblePluginDetail(value: unknown, path: string): VisiblePluginDe scope: parsed.scope, organizationId: parsed.organizationId, defaultInstall: parsed.defaultInstall, + ...(parsed.installCount === undefined ? {} : { installCount: parsed.installCount }), currentRelease, }; }