cloud
插件使用 PKU harness Cloud 时的接口:CloudManifest、PluginCloud(store、channels、push、functions)、Channel 及其事件、SecretLink、CloudStatus、CloudError 和 defineCloudFunction。
PKU harness Cloud 是一组可选的云服务,插件可以用它在服务器上保存少量数据,或者通过中继与打开分享链接的人实时共享内容。这个模块定义了插件这一侧的接口。插件先在清单里声明要用哪些服务,再通过宿主提供的客户端调用它们。
源码:packages/sdk/src/cloud.ts。使用指南见云服务 → 插件指南。线上协议见
@pku-harness/cloud-protocol 和 API 参考。
宿主替插件保管什么
插件在 PluginDef.cloud 里声明要用的服务,然后拿到一个只属于自己的客户端(ctx.cloud、props.cloud)。设备身份、token 和密钥都由宿主保管。宿主负责检查用户是否已经授权、调用是否符合插件清单,并和用户选定的服务商通信。服务商可以是官方服务器、自建服务器或任意 URL。
插件看不到 token、密钥和服务商凭据,助手也看不到分享链接。
宿主支持情况
应用已经实现了这个客户端。为了兼容不支持云服务的宿主,ctx.cloud 的类型仍是可选的(PluginCloud | undefined),使用前先检查 ctx.cloud?.available。
push 暂未提供,调用会以 not_configured 失败。官方云服务实例目前还没有部署。它上线后只运行中继和一个小型加密存储,不开启服务端函数。另外,服务端函数要以明文读取输入,所以声明了端到端加密(e2e)的插件调用 functions.invoke 时,宿主会拒绝。能在手机上完成的工作就放在手机上;确实需要服务器时,请使用你自己的后端。
CloudManifest
CloudManifest 是插件在 PluginDef.cloud 中声明的清单,写明插件要用哪些服务、哪些数据会离开设备,以及数据是否端到端加密。
type CloudService = 'store' | 'channels' | 'push' | 'files' | 'functions';
interface CloudManifest {
protocol: 1;
uses: CloudService[];
/** 授权弹窗中显示的说明,用平实的语言逐条写明哪些数据会离开设备。 */
disclosure: { zh: string[]; en?: string[] };
/** 所有 store 和 channel 数据都在客户端加密;为 true 时,宿主拒绝明文写入。 */
e2e: boolean;
/** 没有云服务时插件仍可使用,相关功能先检查 ctx.cloud.available。否则插件依赖 'cloud'。 */
optional?: boolean;
/** 服务端函数:运行在用户的服务商上(代码位于 plugins/<id>/server),或运行在外部 URL。 */
functions?: Record<string, CloudFunctionDecl>;
backend?: { kind: 'provider' } | { kind: 'external'; url: string };
/** plugins/<id>/web 中的静态页面,发布在 /w/<plugin>/<page>(供没有安装应用的人查看)。 */
pages?: string[];
}
interface CloudFunctionDecl<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> {
title: string;
input: I;
output: O;
version: number;
timeoutMs?: number;
}
function defineCloudFunction<I extends z.ZodType, O extends z.ZodType>(d: CloudFunctionDecl<I, O>): CloudFunctionDecl<I, O>;PluginCloud
PluginCloud 是插件拿到的客户端,CloudStatus 描述它当前的状态。
interface PluginCloud {
/** 已配置服务商、用户已授权,并且服务商可以连通(允许离线的读取除外)。 */
readonly available: boolean;
status(): CloudStatus;
/**
* 请用户允许这个插件使用云服务(弹出宿主的授权弹窗;已经允许时立即返回)。用户拒绝时返回 false。
* 界面在打开通道前调用它;助手调用工具时从不弹窗,直接得到 `CloudError('consent_required')`。
*/
requestConsent(): Promise<boolean>;
/** 键值存储,服务商按(设备,插件)划分命名空间。启用 `e2e` 时值会加密。 */
store: {
get<T>(key: string): Promise<{ value: T; version: number } | undefined>;
put<T>(key: string, value: T, opts?: { ifVersion?: number; ttlS?: number }): Promise<{ version: number }>;
delete(key: string): Promise<void>;
list(prefix?: string): Promise<{ key: string; version: number; updatedAt: number }[]>;
};
/** 端到端加密的中继通道(与打开链接的人实时共享)。 */
channels: {
/** `meta` 用链接密钥加密;查看者在加入前读取它。 */
open(opts: { ttlS: number; maxPeers?: number; page?: string; meta?: unknown }): Promise<Channel>;
/** 应用重启后仍然可用(密钥保存在安全存储中)。 */
get(id: string): Channel | undefined;
list(): Channel[];
};
/** 第 6 阶段。 */
push: {
schedule(at: number, payload: unknown): Promise<string>;
cancel(id: string): Promise<void>;
};
/** `CloudManifest.functions` 中声明的服务端函数(第 6 阶段)。 */
functions: {
invoke<N extends string>(name: N, input: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
};
}
interface CloudStatus {
provider: 'official' | 'custom' | 'off';
baseURL?: string;
/** 用户已允许这个插件使用云服务。 */
consented: boolean;
online: boolean;
}Channel
通道(channel)用于实时共享:插件打开一个通道,把分享链接交给别人,打开链接的人就成为通道的对端(peer)。
interface Channel {
id: string;
expiresAt: number;
link: SecretLink;
peers(): { id: string; joinedAt: number }[];
/** 订阅事件;返回取消订阅的函数。 */
on(ev: ChannelEventType, fn: (e: ChannelEvent) => void): () => void;
/** 按每个对端分别加密;'*' 表示所有已连接的对端。 */
send(peerId: string | '*', msg: unknown): Promise<void>;
/** 加密的数据块,所有者离线时对端也能读取(批量模式)。 */
mailbox: {
put(slot: string, msg: unknown, ttlS: number): Promise<void>;
clear(): Promise<void>;
};
/** 移除一个对端并空出它的位置(新的查看者可以用同一个链接加入)。 */
kick(peerId: string): Promise<void>;
extend(ttlS: number): Promise<void>;
close(): Promise<void>;
}
type ChannelEventType = 'join' | 'leave' | 'message' | 'closed';
type ChannelCloseReason = 'owner' | 'expired' | 'revoked' | 'locked' | 'server';
type ChannelEvent =
| { type: 'join'; peerId: string; at: number }
| { type: 'leave'; peerId: string; at: number }
/** 对端发来的消息,已经解密(使用前请用 zod 校验)。 */
| { type: 'message'; peerId: string; msg: unknown }
| { type: 'closed'; reason: ChannelCloseReason };SecretLink
/**
* 不透明的分享链接(`https://<provider>/s/<id>#k=…`)。插件读不到其中的 URL,只能用
* `host.shareLink(link)` 或 `<QRCode link={link} />` 显示它。`JSON.stringify(link)` 得到 "[secret link]"。
*/
interface SecretLink {
readonly kind: 'secret-link';
readonly channelId: string;
toJSON(): string;
}宿主实现的 toString 同样输出 "[secret link]",所以链接不会混进工具结果或日志。
错误
type CloudErrorCode = 'not_configured' | 'consent_required' | 'offline' | 'rate_limited' | 'quota' | 'gone' | 'protocol' | 'server';
const CLOUD_ERROR_CODES: readonly CloudErrorCode[];
class CloudError extends Error {
readonly code: CloudErrorCode;
/** 错误码为 'rate_limited' 时才有(取自 Retry-After)。 */
readonly retryAfterS?: number;
constructor(code: CloudErrorCode, message: string, retryAfterS?: number);
}
function isCloudError(e: unknown, code?: CloudErrorCode): e is CloudError;各错误码的含义如下:
| 错误码 | 含义 |
|---|---|
not_configured | 没有配置服务商,或该服务不可用(比如 push) |
consent_required | 用户还没允许这个插件使用云服务 |
offline | 没有网络。写入和函数调用会立即失败,不会悄悄排队重发 |
rate_limited | 服务商返回 429,retryAfterS 有值 |
quota | 超出存储或通道配额 |
gone | 通道已关闭或已过期 |
protocol | 服务商的响应不符合协议 v1,或调用违反了清单(使用未声明的服务、e2e 插件发送明文) |
server | 服务商的其他错误 |