文档
OTA 更新
OTA(over-the-air,空中下发)更新把打包应用的 TSX 作为签名包(.nshpkg)下发:已安装的应用下载、验证它,并从下次启动开始运行。所有原生平台(包括 iOS 和 Android)都适用,无需构建新应用。一次发布就是这个包加一份签名的清单,放在 GitHub Releases 或自己的服务器上。在应用中,nsh/updater 的四个函数负责检查、下载、重启进入和报告包。第一次启动就失败的包会被删除,应用回到之前运行的版本。
| 平台 | OTA 的行为 |
|---|---|
| macOS、Windows、Linux | 检查并下载;restartToPackage() 重启应用进入新包,或在下次启动时运行 |
| iOS、Android | 检查并下载;包在应用下次冷启动时运行,restartToPackage() 会拒绝 |
| Web | 无需处理:部署即更新应用,checkForPackage() 返回 null |
#OTA 还是新的应用构建
包里是 nsh publish 从应用 TSX 构建出的内容:代码、它导入的图片和字体,以及它声明的能力。其余部分都属于已安装的应用,修改它们需要新的应用构建:iOS 和 Android 上通过应用商店,桌面端通过应用更新或应用商店。
| 改动 | OTA | 新的应用构建 |
|---|---|---|
| 界面、逻辑、样式、导入的图片和字体(TSX) | 可以 | |
| TSX 需要更新版本的 NSH | 需要 | |
| 应用的 Rust 代码:其 crate 的组件、模块和原生视图 | 需要 | |
平台从已安装应用读取的内容:名称、图标、URL scheme、ios、android 和 macos 键、权限 |
需要 | |
| 超出已安装应用声明的能力 | 需要:包最多获得已安装应用的能力 | |
nsh.json 的 "updater":清单来源、信任的根密钥 |
需要 |
每个包都写明能运行它的最低 NSH 运行时(minRuntime),默认是发布它的 nsh 的版本;运行时更旧的应用不会收到它。请用构建线上应用时的 nsh 版本发布 OTA 版本;如果 TSX 只用了旧运行时已有的功能,也可以用 --min-runtime 发布(见哪些运行时能运行包)。应用需要更新的运行时时,请发布新的应用构建;下文的应用会先检查包,没有包时再检查新应用。
#一次性设置
#密钥
nsh keygen keys/root # 应用信任它;keys/root.secret 离线保管
nsh keygen keys/release # 给包签名,放在 CI 中
nsh keygen keys/update # 给清单签名,放在 CI 中
nsh certify --root keys/root.secret --key keys/release.pub --apps com.example.notes -o keys/release.cert
根密钥为应用 id 认证发布密钥;应用只信任根密钥,所以更换发布密钥或证书都不需要更新应用。证书默认有效 365 天(可用 --days 指定),到期前请重新认证发布密钥。密钥的说明,以及如何吊销发布密钥或包,见签名包。
#nsh.json
{
"id": "com.example.notes",
"version": "1.2.0",
"build": 12,
"updater": {
"github": "example/notes",
"publicKey": "<keys/update.pub 的内容>",
"packages": { "roots": ["<keys/root.pub 的内容>"] }
}
}
"packages": { "roots" } 列出根公钥,由它们认证的发布密钥可以为应用的包签名;配多个根密钥就可以轮换。配了 publicKey,应用会拒绝不是由 keys/update.secret 签名的清单;用 "url" 代替 "github" 时它是必填的(见应用更新)。
nsh package 会把 "updater" 和 build 写进应用,所以应用信任哪些根密钥、拿哪个构建号和包比较,在构建应用时就已确定。只有用这份配置构建的应用才接收 OTA 更新:请在第一次 OTA 发布前先发布这样一个应用构建。
#CI 密钥
把发布密钥、它的证书和清单密钥存为发布仓库的 secret:
gh secret set NSH_RELEASE_KEY < keys/release.secret
gh secret set NSH_RELEASE_CERT < keys/release.cert
gh secret set NSH_UPDATE_KEY < keys/update.secret
#发布版本
#构建号
nsh.json 中的 build 是应用构建和包共用的计数器:每次发布其中任何一个都要调高它。只有包的构建号高于应用构建时的构建号,应用才运行它;应用也只会收到比当前运行的代码更新的包。当用户装上构建号为 20 的新应用时,已下载的 15 号包是旧代码,会被删除,下一个 OTA 版本必须是 21 号。version 也请一起调高;runningPackage() 会报告包的版本和构建号。
#手动发布
mkdir -p dist
nsh publish --key keys/release.secret --cert keys/release.cert -o dist/com.example.notes-13.nshpkg
nsh update-manifest --package dist/com.example.notes-13.nshpkg --notes CHANGES.md --key keys/update.secret -o dist
nsh publish 以 release 模式构建应用并签名,id、version 和 build 取自 nsh.json(可用 --app-version 和 --build 覆盖)。nsh update-manifest 按应用 id 和 nsh.json 中的根密钥校验包,然后写出带 "package" 的 dist/latest.json,并签名为 dist/latest.json.sig:
{
"id": "com.example.notes",
"version": "1.2.1",
"notes": "Faster search.",
"platforms": {},
"package": {
"version": "1.2.1",
"build": 13,
"url": "com.example.notes-13.nshpkg",
"sha256": "9f2c…",
"size": 48213,
"minRuntime": "0.4.0"
}
}
包的 url 是它的文件名,与清单放在一起;--base-url 可以放到别处。只有包的版本不传压缩包也不传 --link,所以 checkForUpdate 在其中找不到新应用。一个版本也可以两者都有:nsh update-manifest *.tar.gz *.zip --package … --link ios=…(见应用更新)。
把包、latest.json 和 latest.json.sig 上传到 GitHub Release:
gh release create v1.2.1 dist/com.example.notes-13.nshpkg dist/latest.json dist/latest.json.sig --notes-file CHANGES.md
或放到自己服务器的同一目录:先传包,最后传清单和签名,这样应用不会找到一份包还没上传的清单。GitHub 上标记为 draft 或 prerelease 的版本不会被推送。
#从 GitHub Actions 发布
调高 nsh.json 中的 version 和 build,提交,再推送一个 v1.2.1 这样的 tag:
# .github/workflows/ota.yml
name: OTA release
on:
push:
tags: ["v*"]
jobs:
ota:
runs-on: macos-latest
permissions:
contents: write # 创建 release
env:
# 构建线上应用所用的 nsh,例如 v0.5.0(仓库变量);为空时用最新版
NSH_VERSION: ${{ vars.NSH_VERSION }}
steps:
- uses: actions/checkout@v4
- name: Install nsh
run: |
curl -fsSL https://nsh.dev/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Write the keys
env:
RELEASE_KEY: ${{ secrets.NSH_RELEASE_KEY }}
RELEASE_CERT: ${{ secrets.NSH_RELEASE_CERT }}
UPDATE_KEY: ${{ secrets.NSH_UPDATE_KEY }}
run: |
mkdir -p "$RUNNER_TEMP/keys"
printf '%s\n' "$RELEASE_KEY" > "$RUNNER_TEMP/keys/release.secret"
printf '%s\n' "$RELEASE_CERT" > "$RUNNER_TEMP/keys/release.cert"
printf '%s\n' "$UPDATE_KEY" > "$RUNNER_TEMP/keys/update.secret"
- name: Publish the package and the manifest
run: |
mkdir -p dist
PACKAGE="dist/$(jq -r .id nsh.json)-$(jq -r .build nsh.json).nshpkg"
nsh publish --key "$RUNNER_TEMP/keys/release.secret" --cert "$RUNNER_TEMP/keys/release.cert" -o "$PACKAGE"
nsh update-manifest --package "$PACKAGE" --notes CHANGES.md --key "$RUNNER_TEMP/keys/update.secret" -o dist
echo "PACKAGE=$PACKAGE" >> "$GITHUB_ENV"
- name: Create the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" "$PACKAGE" dist/latest.json dist/latest.json.sig --notes-file CHANGES.md
发布到自己的服务器时,把最后一步换成你的上传命令,并按上面的顺序上传。
#在应用中
和 nsh/updater 的其他函数一样,这些函数一次只运行一个:另一个检查、下载或安装进行时再调用会 reject。下面这个模块先检查包,没有包时才检查新应用:
// updates.ts
import { openUrl, signal } from "nsh";
import { isAvailable as canRestart } from "nsh/desktop";
import {
checkForPackage,
checkForUpdate,
downloadPackage,
downloadUpdate,
installAndRestart,
restartToPackage,
type AppUpdate,
} from "nsh/updater";
/** 包已下载,应用可以重启进入它(桌面端)。 */
export const packageReady = signal(false);
/** 新应用,用于包无法承载的改动。 */
export const appUpdate = signal<AppUpdate | null>(null);
export async function checkForUpdates() {
try {
if (await checkForPackage()) {
await downloadPackage();
// iOS 和 Android:无需提示,包在下次冷启动时运行。
packageReady.set(canRestart);
return;
}
appUpdate.set(await checkForUpdate());
} catch (error) {
console.warn("update check failed:", error); // 下次检查时再试
}
}
export const restart = () => restartToPackage();
export async function installApp() {
const update = appUpdate();
if (!update) return;
if (!update.canInstall) return openUrl(update.url); // 应用商店
await downloadUpdate();
await installAndRestart();
}
应用启动后调用一次 checkForUpdates();如果应用会长时间开着,隔一段时间再调用。在桌面端提示用户重启:
import { Text, View } from "nsh";
import { Button } from "nsh/base";
import { packageReady, restart } from "./updates";
export function UpdateBanner() {
return (
<If when={packageReady()}>
<View class="flex-row items-center gap-3 rounded-(--radius) bg-muted px-4 py-2">
<Text class="flex-1 text-foreground">新版本已就绪。</Text>
<Button onPress={restart} class="rounded-(--radius) bg-primary px-3 py-1.5 active:bg-primary/90">
<Text class="text-primary-foreground">重启</Text>
</Button>
</View>
</If>
);
}
restartToPackage() 像 Application.restart() 一样重启应用:本次运行像用户退出时那样结束,应用再次启动并进入新包。用户不重启的话,下次打开应用时就会运行新包。
runningPackage() 说明本次运行的是哪份代码,shellInfo() 说明由什么来运行它:已安装应用自己的版本、构建号和 NSH 运行时。两者都可用于“关于”页面或错误报告:
import { runningPackage, shellInfo } from "nsh/updater";
const running = runningPackage(); // { version, build };运行应用构建时自带的代码时为 null
const shell = shellInfo(); // { version, build, nshVersion, packages }:已安装的应用
在支持包之前安装的应用(或用不支持包的运行时构建的应用)永远收不到包:这时 packages 为 false;比 shellInfo 更早的运行时连这个函数都没有。只有重新安装完整应用才能解决。
所有函数及其错误见 nsh/updater。
#设备上发生了什么
包放在应用数据目录(process.dataDir)下的 updates 目录中:
| 文件 | 含义 |
|---|---|
next.nshpkg |
已下载并验证,尚未运行 |
trying.nshpkg |
已启动,正在试运行 |
current.nshpkg |
最近一个证明运行正常的包 |
trust/ |
之前接受过的构建号和吊销列表 |
packages/ |
正在运行的包的图片和字体 |
| 平台 | 应用数据目录 |
|---|---|
| macOS | ~/Library/Application Support/<app id> |
| Windows | %APPDATA%\<app id> |
| Linux | $XDG_DATA_HOME/<app id>(~/.local/share/<app id>) |
| iOS | 应用容器中的 Library/Application Support/nsh/<app id> |
| Android | 应用内部存储中的 files/nsh/<app id> |
#每次启动时
- 如果
trying.nshpkg还在,说明上次启动没能证明运行正常:应用在试运行期间崩溃或被强制结束。它会被删除。 next.nshpkg会再验证一次:由某个根密钥认证的发布密钥签名、未被吊销、属于本应用、适用于当前运行时、构建号高于应用自带的构建号,并且不低于之前接受过的任何构建号。通过后它变成trying.nshpkg并开始试运行;被拒绝则删除。- 否则,如果
current.nshpkg仍能通过验证且构建号高于应用自带的构建号,就运行它;否则删除它。 - 否则运行应用构建时自带的代码。
应用总能启动:包有任何问题都会记录到日志,然后按顺序运行下一个选择。
包以已安装应用的身份运行:使用它的 id(因此存储和数据不变)、URL scheme 和更新来源,能力最多为已安装应用声明的能力。
#试运行
试运行中的包从第一帧起运行 10 秒,或用户在此之前退出应用,就算运行正常。它随后成为 current.nshpkg,其构建号被记录为已接受。如果它的代码加载失败(例如模块顶层抛出异常),或它的 VM 被停止(超出内存或时间限制),就算失败:包被删除,下次启动运行之前的版本。试运行期间崩溃或被强制结束会留下 trying.nshpkg,下次启动时被删除。之后才抛出的错误(例如在事件处理函数中)照常报告,不会结束试运行。
#回滚保护
被接受的构建号会抬高下限:此后应用拒绝更旧的构建,即使它由受信任的密钥签名,因此带有已知缺陷的旧包无法再被运行。要撤回有问题的包,请发布修复后更高的构建号;如果它在任何地方都不能再运行,再吊销它。
#问题排查
#checkForPackage() 返回 null
- 应用构建时没有
"packages": { "roots" },或运行在nsh run或 Web 上。 - 清单里没有
"package":给nsh update-manifest传--package。 - 包的构建号不高于应用正在运行的代码:
runningPackage()的构建号,否则是应用自带的build。 - 它的
minRuntime比应用的运行时新:用构建应用时的nsh发布,或发布新应用。 - GitHub 上该版本是 draft 或 prerelease,或者最新的版本里没有包。
清单无法获取、没有用 publicKey 对应的密钥签名,或属于其他应用时,它会像 checkForUpdate 一样 reject。
#downloadPackage() reject
错误以 package refused: 开头,并说明原因:
| 消息 | 含义 |
|---|---|
update download: SHA-256 …, the manifest says … 或 … bytes, the manifest says … |
服务器上的文件不是清单里的那个:被重新上传过,或拿到了旧缓存 |
signed by key `…`, which is not trusted |
发布密钥的证书来自应用不信任的根密钥 |
the signature does not verify |
包在签名后被改过 |
the release key's certificate does not cover this package: … |
证书已过期或覆盖的是其他应用 id:重新认证密钥 |
revoked: … |
发布密钥或包已被吊销 |
app `…` build 13 is older than build 14, which was accepted before |
回滚保护:发布更高的构建号 |
its build 13 is not newer than the app's 13 |
调高 build |
it is com.example.other, the app is com.example.notes |
这是其他应用的包 |
the package needs runtime 0.5.0 or newer; this runtime is 0.4.0 |
为旧运行时发布,或发布新应用 |
不会留下任何文件,应用继续运行原来的代码。
#下次启动时包没有运行
启动器会在终端或平台日志中记录原因:macOS 和 iOS 上是统一日志,以应用的 id 为子系统(“控制台”应用,或
log stream --predicate 'subsystem == "com.example.notes"';参见打包应用的日志),
Android 上是 adb logcat。下面前三行是警告,每个平台都会记录;最后一行是 info 日志,在 Android 上记录:
updates: downloaded package refused: …
updates: the package started last never ran 10s without a fatal error; it is deleted and the app goes back
updates: the installed package does not run: its build 13 is not newer than the app's 20
running package com.example.notes 1.2.1 (build 13), shipped build 12
#在本机试用
-
在
nsh.json中配好"packages": { "roots" },打包并安装应用,例如nsh package --target macos。 -
改一些 TSX,调高
build,发布一个包,并像应用那样校验它:nsh publish --key keys/release.secret --cert keys/release.cert -o next.nshpkg nsh verify next.nshpkg --root keys/root.pub -
把它放到下载会放的位置,然后启动应用:
mkdir -p ~/Library/Application\ Support/com.example.notes/updates cp next.nshpkg ~/Library/Application\ Support/com.example.notes/updates/应用会试运行它,10 秒后它成为
current.nshpkg。若要试回滚,可以让包的App.tsx在顶层抛出异常:应用会报告错误,下次启动运行之前的代码。
要试完整流程,可以在本机提供 latest.json 和包("url": "http://127.0.0.1:8000/latest.json",并配 publicKey;明文 HTTP 只允许访问本机),然后调用 checkForUpdates()。
#另请参阅
- 签名包:密钥、证书、校验和吊销
- 应用更新:更新整个应用
- nsh/updater:API
- CLI:
nsh publish、nsh update-manifest、nsh verify