PKU harness

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 };
/**
 * 不透明的分享链接(`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服务商的其他错误

本页目录