# 让你的网站显示在 iPhone 灵动岛上（Pulse Relay 网站接入 v1）

给想接入 Pulse Relay 的网站开发者。接入后，你网站上的一件事（订单、上传、构建、会议……）可以显示在用户 iPhone 的灵动岛和锁屏上，并且在 App 不在前台时也会实时更新。

你不需要苹果开发者账号或 APNs 密钥。网站只需做一个「允许连接」页面和三个接口，状态变化时向 Pulse Relay 推送中转发一个 HTTPS 请求。

> 想先看效果：用 iPhone 上的 Pulse Relay App 连接本站，然后在 [/demo/](/demo/) 页面点按钮，四种模板都能直接推到你的灵动岛。演示站本身就是按本文接入的，源码思路与第五节示例相同。

## 一、整体流程

1. 用户在 Pulse Relay App 里输入你的网站地址并点「连接」。
2. App 读取 `/.well-known/pulse-relay.json`，看到你的网站支持接入。
3. App 向中转申请一把**只属于你的网站、只能推送给这台手机**的钥匙，然后在**系统的安全登录窗口**（iOS 的 ASWebAuthenticationSession，和 Safari 共用登录）里打开你的 `authorize` 页面，钥匙放在网址 `#` 后面。
4. 用户在这个窗口里登录（在 Safari 里登录过就不用再登），看到「允许 Pulse Relay 显示进展」并点「允许」。你的页面把钥匙交给自己的服务器，和当前登录用户绑定保存，然后跳回 App。
5. 之后这个用户的事情有变化时，你用这把钥匙向中转发推送，灵动岛随之更新。
6. 用户在 App 里断开，或删除 App，钥匙就失效；你调用中转会收到 401/403/410，此时删掉保存的钥匙即可。

Pulse Relay App 看不到安全登录窗口里的页面和用户的登录信息；之后用户在 App 的「网页」页里浏览你的网站时，App 也不会读取页面内容（iOS 的 App-Bound Domains 限制）。

## 二、网站要做的事

### 1. `GET /.well-known/pulse-relay.json`（公开，不需要登录）

```json
{
  "version": 1,
  "name": "Example Shop",
  "authorize": "/pulse-relay/authorize",
  "connect": "/api/pulse-relay/connect",
  "open": "/orders/{ref}"
}
```

| 字段 | 必填 | 说明 |
|---|---|---|
| `version` | 是 | 固定为 `1` |
| `name` | 是 | 显示在灵动岛和锁屏上的网站名，不超过 20 个字 |
| `authorize` | 是 | 下面 2 的「允许连接」页面，同源路径，以 `/` 开头 |
| `connect` | 是 | 下面 3、4 的接口地址，同源路径，以 `/` 开头 |
| `open` | 否 | 用户点灵动岛时，App 在网页页里打开的同源路径，`{ref}` 会替换成这件事的 `ref` |

必须是 HTTPS，`Content-Type: application/json`。

### 2. `{authorize}` 页面：请用户允许连接

App 打开的地址形如：

```
https://你的网站/pulse-relay/authorize#relay=…&deviceId=…&sourceId=…&pushKey=…&deviceName=…&state=…
```

- 连接信息在 `#` 后面，浏览器不会把它发给服务器，所以不会出现在访问日志里。页面加载后用 `history.replaceState` 把它从地址栏去掉。
- 用户未登录时，按你网站平时的方式登录，登录后**回到本页且保留 `#` 后面的内容**（例如登录前把它存进 `sessionStorage`，登录后再取出）。
- 显示网站名和「允许 / 不允许」。用户点「允许」后，用同源 `fetch` 把 `relay`、`deviceId`、`sourceId`、`pushKey`、`deviceName` 发给你的 `connect` 接口（见下面 3）。
- 完成后跳转回 App，地址固定以 `pulserelay://site-connected?` 开头（不要从参数里取跳转地址）：
  - 成功：`pulserelay://site-connected?state=<原样带回>&status=ok`，可选 `&next=<同源路径>`，App 连接后会在网页页打开这个路径。
  - 用户不允许：`…&status=denied`；出错：`…&status=error&code=<HTTP 状态码>`。
- App 只接受 `state` 与它发出的一致的结果；没收到 `status=ok` 时，App 会在中转撤销这把钥匙。

**注意**：安全登录窗口用的是 Safari 的 Cookie，和 App「网页」页的浏览器不共用。用户在两边登录的是同一个账号，一般没有问题；如果你的网站用匿名访客（像本站演示那样），可以用 `next` 带一个一次性领取码，让网页页认出同一个访客。

### 3. `POST {connect}`：保存推送钥匙

由上面的 `authorize` 页面用同源 `fetch` 调用（带上你网站自己的登录 Cookie），并带请求头 `X-Pulse-Relay: 1`。

```json
{
  "relay": "https://relay.crsyounen.com",
  "deviceId": "8d3f…",
  "sourceId": "s_7Kq…",
  "pushKey": "base64url 编码的 32 字节",
  "deviceName": "iPhone"
}
```

- 按你网站平时的登录态确认是哪个用户；未登录返回 `401`。
- **必须检查 `X-Pulse-Relay: 1` 请求头**（并只接受 `application/json`），防止别的网站用表单伪造请求。
- 只接受你认识的 `relay` 地址：`https://relay.crsyounen.com`，以及旧地址 `https://pulse-relay.crsyounen.workers.dev`（同一个中转，旧版 App 会交来这个；大陆无法访问 workers.dev，发推送请一律用新地址）。不要把钥匙交给别的地址。
- 一个用户可以有多台设备：以 `deviceId + sourceId` 为键保存。同一 `deviceId` 再次连接时，用新的替换旧的。
- `pushKey` 是密钥：只存在服务端，不要写进日志、不要发给浏览器。
- 成功返回 `200` 和 `{"ok":true}`。

### 4. `DELETE {connect}`：删除推送钥匙

同样带 `X-Pulse-Relay: 1`，请求体 `{"deviceId":"…","sourceId":"…"}`。只删除当前用户自己的那条，重复调用也返回 `200`。

## 三、发推送

```
POST {relay}/v1/devices/{deviceId}/push
Content-Type: application/json
X-Pulse-Source: {sourceId}
X-Pulse-Auth: v2 <unix秒> <requestId> <签名>
```

签名 = `base64url( HMAC-SHA256( base64url解码(pushKey), "POST\n" + 路径 + "\n" + unix秒 + "\n" + requestId + "\n" + sha256十六进制(请求体) ) )`。
路径是 `/v1/devices/{deviceId}/push`（不含域名）；`requestId` 每次新生成一个 UUID；服务器时间与中转相差不能超过 5 分钟。

请求体：

```json
{
  "ref": "order-1024",
  "event": "update",
  "timestamp": 1793000000,
  "contentState": { "title": "订单 #1024", "summary": "骑手已取餐", "status": "running", "revision": 1793000000123 },
  "alert": { "body": "骑手已取餐" }
}
```

| 字段 | 说明 |
|---|---|
| `ref` | 你这边这件事的标识，1–64 个字符，只能用字母、数字和 `._-`。同一 `ref` 在手机上只有一个实时活动 |
| `event` | `start`：新开一个实时活动（同一 `ref` 原来的会先被关掉）；`update`：更新；`end`：结束 |
| `timestamp` | 当前 unix 秒 |
| `contentState` | 显示内容，见第四节 |
| `alert` | 可选。带上时灵动岛会弹出并亮屏（静音时震动）。只有 `body`（不超过 60 字），标题固定为你的网站名。`start` 必须带（没带时中转用 `title` 补上） |
| `staleDate` | 可选，unix 秒。过了这个时间仍没有更新，系统会把它标成过期 |
| `dismissalDate` | 仅 `end`，可选，unix 秒。结束后在锁屏上保留到这个时间 |
| `relevanceScore` | 可选。同一手机上有多个实时活动时，分数最高的占据灵动岛。建议不传，由中转按更新时间处理 |

**`revision` 必须严格递增**（同一 `ref`），建议直接用毫秒时间戳。小于等于上一次的会被拒绝（409），用来防止旧消息晚到覆盖新状态。

返回状态：

| 状态 | 含义 | 你该做什么 |
|---|---|---|
| `200` | 已交给苹果 | — |
| `400` | 请求体不合格（字段超长、模板字段不对等），响应里有 `error` | 修正 |
| `401` / `403` | 钥匙无效或已被用户断开 | 删除保存的钥匙，不再重试 |
| `404` | 手机上没有这个 `ref` 的实时活动（用户划掉了、或从没开始过） | 需要的话改发 `start` |
| `409` | `revision` 没有递增 | 用更大的 `revision` |
| `410` | 这台手机已不可用（删除了 App 等） | 删除保存的钥匙 |
| `429` | 太频繁 | 按 `Retry-After` 等待后再发 |
| `5xx` | 中转或苹果暂时出错 | 稍后重试，带退避 |

### 频率和远程开始

- 同一 `ref` 每秒最多 1 次更新；每个网站每台手机每小时最多 600 次。
- `start` 会在 App 不在前台时由 iOS 新建实时活动，**iOS 对每台手机的远程开始次数有限额**，用完后一段时间内谁都开不了（包括其他网站和电脑端）。所以中转限制每个网站每台手机每小时最多 4 次 `start`。只在一件新事情开始时发 `start`，其余用 `update`。
- 带 `alert` 的 `update` / `end` 每个 `ref` 30 秒最多一次，超过时中转去掉提醒照常发送，响应里有 `"alertDropped": true`。只在用户真正需要注意时带（等待用户操作、完成、出错）。
- 同一 `ref` 的 `start` 30 秒内最多一次，超过返回 429（`start` 必须带提醒，不能去掉）。

## 四、显示内容和四种模板

所有模板都要这四个字段：

| 字段 | 说明 |
|---|---|
| `title` | 这件事的名字，不超过 60 字 |
| `summary` | 一句补充，可以为空字符串，不超过 120 字 |
| `status` | 状态，决定颜色和图标：`running`（进行中）、`waiting`（等待中）、`done`（已完成）、`error`（出错）。也接受 `thinking` |
| `revision` | 见上，严格递增 |

`template` 不写就是「状态」模板。其余字段按模板选用，不需要的不要发。

### 1. 状态 `status`

大字显示状态词，下面一行是标题和摘要。

```json
{ "title": "导出报表", "summary": "正在生成 PDF", "status": "running", "revision": 1793000000123 }
```

### 2. 进度 `progress`

大字显示百分比，下面是进度条。可以给预计完成时间，右侧会显示剩余时间，由系统自己倒数，不用每秒推送。

```json
{ "template": "progress", "progress": 0.42, "endsAt": 1793000300,
  "title": "上传 4 个文件", "summary": "已传 1.2 GB", "status": "running", "revision": 1793000000123 }
```

| 字段 | 说明 |
|---|---|
| `progress` | 0–1 |
| `endsAt` | 可选，预计完成时间（unix 秒） |

紧凑灵动岛右侧显示「42%」，最小灵动岛显示一个进度环。进度建议每变化 5% 或每 10 秒推一次，不要更频繁。

### 3. 步骤 `steps`

大字显示当前步骤名，下面是分段条（第几步亮几段），上方小字「第 3/4 步」。

```json
{ "template": "steps", "step": 3, "steps": ["已下单", "备餐中", "配送中", "已送达"],
  "title": "订单 #1024", "summary": "骑手已取餐", "status": "running", "revision": 1793000000123 }
```

| 字段 | 说明 |
|---|---|
| `step` | 当前第几步，从 1 开始 |
| `steps` | 步骤名，最多 8 个，每个不超过 16 字 |
| `stepCount` | 不想给名字时只给总数（1–8），大字显示「3 / 4」 |

紧凑灵动岛右侧显示「3/4」。

### 4. 倒计时 `countdown`

大字是由系统自己走秒的倒计时；给了开始时间就同时显示一条随时间前进的进度条。适合会议开始、限时活动、计时器。倒计时期间不需要推送。

```json
{ "template": "countdown", "startsAt": 1793000000, "endsAt": 1793000900,
  "title": "产品评审会", "summary": "3 号会议室", "status": "waiting", "revision": 1793000000123 }
```

| 字段 | 说明 |
|---|---|
| `endsAt` | 倒计时结束时间（unix 秒） |
| `startsAt` | 可选，开始时间；给了就显示进度条 |

紧凑灵动岛右侧显示走秒的时间。到点后显示 0:00，请在到点时推一次 `update`（例如改成「已开始」）或 `end`。

### 通用说明

- 模板字段不合法时（例如 `progress` 不在 0–1、`steps` 超过 8 个），中转返回 400；手机端遇到不完整的模板字段会退回「状态」模板显示。
- 中转会在 `contentState` 里填上你的网站名（`site`），并去掉只供 App 自己使用的字段；你发的 `site`、`agent`、`approvalId` 等会被忽略。
- 一台手机上同时显示的实时活动有上限（系统大约 5 个），事情结束后请发 `end`。
- 实时活动最长显示 8 小时（锁屏上结束后最多再留 4 小时），更长的事情请在中途 `end` 后重新 `start`。

## 五、示例代码

### Node.js（18 及以上）

```js
import crypto from 'node:crypto';

// saved = 用户连接时你保存的 { relay, deviceId, sourceId, pushKey }
export async function pushActivity(saved, body) {
  const path = `/v1/devices/${saved.deviceId}/push`;
  const raw = JSON.stringify({ timestamp: Math.floor(Date.now() / 1000), ...body });
  const seconds = String(Math.floor(Date.now() / 1000));
  const requestId = crypto.randomUUID();
  const digest = crypto.createHash('sha256').update(raw).digest('hex');
  const signature = crypto.createHmac('sha256', Buffer.from(saved.pushKey, 'base64url'))
    .update(`POST\n${path}\n${seconds}\n${requestId}\n${digest}`).digest('base64url');
  const response = await fetch(saved.relay.replace(/\/$/, '') + path, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Pulse-Source': saved.sourceId,
      'X-Pulse-Auth': `v2 ${seconds} ${requestId} ${signature}`,
    },
    body: raw,
  });
  return response.status; // 401/403/410：删除 saved
}

// 例：订单进入第 3 步，并提醒用户
await pushActivity(saved, {
  ref: 'order-1024', event: 'update', alert: { body: '骑手已取餐' },
  contentState: { template: 'steps', step: 3, steps: ['已下单', '备餐中', '配送中', '已送达'],
                  title: '订单 #1024', summary: '骑手已取餐', status: 'running', revision: Date.now() },
});
```

### Python（3.9 及以上，只用标准库）

```python
import base64, hashlib, hmac, json, time, uuid, urllib.request, urllib.error

def b64url_decode(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))

def push_activity(saved, body):
    path = f"/v1/devices/{saved['deviceId']}/push"
    raw = json.dumps({"timestamp": int(time.time()), **body}, ensure_ascii=False).encode()
    seconds, request_id = str(int(time.time())), str(uuid.uuid4())
    message = f"POST\n{path}\n{seconds}\n{request_id}\n{hashlib.sha256(raw).hexdigest()}".encode()
    signature = base64.urlsafe_b64encode(
        hmac.new(b64url_decode(saved["pushKey"]), message, hashlib.sha256).digest()).rstrip(b"=").decode()
    request = urllib.request.Request(saved["relay"].rstrip("/") + path, data=raw, method="POST", headers={
        "Content-Type": "application/json",
        "X-Pulse-Source": saved["sourceId"],
        "X-Pulse-Auth": f"v2 {seconds} {request_id} {signature}",
    })
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            return response.status
    except urllib.error.HTTPError as error:
        return error.code  # 401/403/410：删除 saved

# 例：上传进度 42%，预计 5 分钟后完成
push_activity(saved, {
    "ref": "upload-77", "event": "update",
    "contentState": {"template": "progress", "progress": 0.42, "endsAt": int(time.time()) + 300,
                     "title": "上传 4 个文件", "summary": "已传 1.2 GB", "status": "running",
                     "revision": int(time.time() * 1000)},
})
```

### 命令行（测试用，需要 openssl）

```sh
RELAY=https://relay.crsyounen.com DEVICE=… SOURCE=… KEY=…
BODY='{"ref":"demo","event":"update","timestamp":'$(date +%s)',"contentState":{"title":"测试","summary":"","status":"running","revision":'$(date +%s)'000'}}'
P=/v1/devices/$DEVICE/push; T=$(date +%s); R=$(uuidgen | tr A-Z a-z)
D=$(printf %s "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
HEXKEY=$(printf %s "$KEY" | tr '_-' '/+' | base64 -d 2>/dev/null | xxd -p -c 256)
S=$(printf 'POST\n%s\n%s\n%s\n%s' "$P" "$T" "$R" "$D" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$HEXKEY -binary | base64 | tr '+/' '-_' | tr -d '=')
curl -s -w '\n%{http_code}\n' "$RELAY$P" -H 'Content-Type: application/json' -H "X-Pulse-Source: $SOURCE" -H "X-Pulse-Auth: v2 $T $R $S" -d "$BODY"
```

（`KEY` 是 base64url，长度不是 4 的倍数时 `base64 -d` 可能需要先补 `=`。）

## 六、隐私和安全

- 灵动岛和锁屏在手机锁定时也能被旁人看到：标题和摘要不要放敏感信息。
- 推送内容经过 Pulse Relay 中转和苹果服务器，中转不保存显示内容。
- 每把钥匙只能推送给一台手机、只能改你自己网站的实时活动，用户随时可以在 App 里断开。
