Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/plugin-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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。 |
Expand All @@ -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。

未知字段只用于前向兼容,不会出现在校验后的返回对象中。消费方不得依赖当前版本未声明的字段。
Expand Down
138 changes: 138 additions & 0 deletions packages/plugin-protocol/src/__tests__/delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = {}) =>
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,
Expand Down Expand Up @@ -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<string, unknown> = {}) =>
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,
Expand Down
30 changes: 30 additions & 0 deletions packages/plugin-protocol/src/delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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。 */
Expand Down Expand Up @@ -84,6 +87,8 @@ export interface VisiblePluginSummary {
organizationId: string | null;
/** 对当前请求身份计算后的默认安装值,不表示强制安装或强制启用。 */
defaultInstall: boolean;
/** Public Plugin 的累计成功安装次数;服务端未提供或统计失败时缺失。 */
installCount?: PluginInstallCount;
/** 服务端为该客户端选择的 Release;优先 current,不兼容时回退到历史兼容版本。 */
currentRelease: PluginReleaseSummary;
}
Expand All @@ -106,6 +111,8 @@ export interface VisiblePluginDetail {
organizationId: string | null;
/** 对当前请求身份计算后的默认安装值,不表示强制安装或强制启用。 */
defaultInstall: boolean;
/** Public Plugin 的累计成功安装次数;服务端未提供或统计失败时缺失。 */
installCount?: PluginInstallCount;
/** 服务端为该客户端选择的 Release,包含完整 manifest。 */
currentRelease: PluginReleaseDetail;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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`);
}
Expand All @@ -278,6 +305,7 @@ function parseVisiblePluginBase(
scope,
organizationId,
defaultInstall: raw.defaultInstall,
...(installCount === undefined ? {} : { installCount }),
};
}

Expand All @@ -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`),
};
}
Expand Down Expand Up @@ -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,
};
}
Expand Down