docs(spec): add desktop ota unification design

This commit is contained in:
DIYgod 2026-04-11 19:45:00 +08:00
parent 6059e654d8
commit af7f2e755a
1 changed files with 578 additions and 0 deletions

View File

@ -0,0 +1,578 @@
# Desktop OTA Unification Design
## Summary
Extend `apps/ota` from a mobile-only OTA service into the single update source of truth for:
- mobile OTA and binary update policy
- new desktop OTA and direct binary update policy
The existing desktop update routes in `follow-server` remain unchanged and continue serving old desktop clients. This design only migrates the new desktop client onto `apps/ota`.
The desktop design must stay close to the mobile model:
- file-driven release intent with `release-plan.json` and `release.json`
- `manifest` for downloadable update payloads
- `policy` for binary upgrade decisions
- explicit `runtimeVersion` compatibility instead of `mainHash`
Desktop OTA mode must publish both:
- a renderer OTA payload for installed users
- the latest direct-download installer assets for new users and direct-channel full upgrades
## Goals
- Keep `apps/ota` as the only update truth source for mobile and the new desktop client
- Keep `follow-server` untouched so old desktop clients remain fully compatible
- Remove `mainHash` from desktop OTA compatibility decisions
- Align desktop release orchestration with the new mobile `release-plan.json` and `release.json` workflow
- Support direct, Mac App Store, and Microsoft Store desktop distributions with different binary-policy timing
- Allow a desktop OTA release to publish both renderer OTA data and direct installer data
- Preserve a simple, auditable, repo-native release workflow driven by checked-in JSON config
## Non-Goals
- Migrating old desktop clients away from `follow-server`
- Replacing existing `follow-server` desktop YAML routes
- Auto-detecting App Store or Microsoft Store review completion from external APIs
- Shipping native code through OTA payloads
- Introducing staged rollout percentages in the first version
## Constraints
- `follow-server` must not be modified
- The new desktop client should use only `X-App-*` headers when talking to `apps/ota`
- Desktop distribution must be inferred from the existing `X-App-Platform` values in `packages/internal/utils/src/headers.ts`
- Desktop OTA compatibility must use explicit `runtimeVersion`
- If the desktop client omits `X-App-Runtime-Version`, the server must treat `X-App-Version` as the runtime version
## Key Decisions
### 1. Service Ownership
`apps/ota` owns update truth for:
- mobile
- the new desktop client
`follow-server` remains a legacy compatibility service for old desktop clients only.
### 2. Desktop Compatibility Model
Desktop stops using `mainHash` as the OTA compatibility key.
Desktop uses:
- `installedBinaryVersion`: the currently installed desktop app version
- `runtimeVersion`: the compatibility line for renderer OTA
- `rendererVersion`: the currently installed renderer version
Default rule:
- `runtimeVersion = installedBinaryVersion`
This matches the mobile mental model much better and removes the hidden compatibility semantics of `mainHash`.
### 3. File-Driven Release Intent
Desktop adopts the same workflow shape as mobile:
- `apps/desktop/release-plan.json` expresses the next intended release action
- `apps/desktop/release.json` records the resolved config for the actual tagged release
Release automation must read `release.json`, not ad-hoc workflow inputs, when deciding what to publish.
### 4. Desktop Release Modes
Desktop supports three modes:
- `build`
- `ota`
- `binary-policy`
Definitions:
- `build`: publish direct installer assets only
- `ota`: publish renderer OTA assets and direct installer assets together
- `binary-policy`: publish binary upgrade policy metadata only, without rebuilding or re-uploading installers
### 5. Distribution Policy Granularity
Desktop binary policy must support per-distribution timing because MAS and MSS approvals are delayed and can complete at different times.
Supported desktop distributions:
- `direct`
- `mas`
- `mss`
Policy lookups must prefer distribution-specific policy and fall back to product-level policy if no distribution-specific record exists.
### 6. Release Kind Naming
The OTA metadata model should move from:
- `ota`
- `store`
to:
- `ota`
- `binary`
Compatibility rule:
- the Worker continues accepting legacy `store` metadata as an alias of `binary`
This keeps old mobile metadata working while giving desktop a more accurate name than `store`.
## Release Config Design
### Desktop `release-plan.json`
Suggested shape:
```json
{
"mode": "build",
"runtimeVersion": null,
"channel": null,
"distributions": [],
"required": false,
"message": null
}
```
Rules:
- `mode` is one of `build`, `ota`, `binary-policy`
- `runtimeVersion` is required for `ota`
- `runtimeVersion` must be `null` for `build` and `binary-policy`
- `channel` is required for `ota` and `binary-policy`
- `distributions` is required for `binary-policy`
- `required` and `message` only affect binary policy publication
Allowed desktop channels:
- `stable`
- `beta`
- `alpha`
`development` is not a release channel.
### Desktop `release.json`
Suggested shape:
```json
{
"version": "1.6.1",
"mode": "ota",
"runtimeVersion": "1.6.0",
"channel": "stable",
"distributions": ["direct"],
"required": false,
"message": null
}
```
`release.json` is the source of truth consumed by CI for a tagged release.
### Workflow Resolution
Desktop should mirror the mobile workflow pattern:
- a desktop resolver script reads `apps/desktop/release.json`
- the resolver decides which workflow actions to trigger
- `tag.yml` dispatches build and OTA publication based on resolved outputs
This removes hand-entered release parameters from normal release execution.
## OTA Metadata Model
Desktop release publication must produce a single machine-readable metadata file for `apps/ota`.
The file should stay named `ota-release.json` so the Worker sync path remains uniform across products.
Desktop `ota-release.json` must be able to describe:
- renderer OTA payloads
- direct binary installer payloads
- binary-policy-only publications
Suggested shape:
```json
{
"schemaVersion": 2,
"product": "desktop",
"channel": "stable",
"releaseVersion": "1.6.1",
"releaseKind": "ota",
"runtimeVersion": "1.6.0",
"publishedAt": "2026-04-11T10:00:00Z",
"git": {
"tag": "desktop/v1.6.1",
"commit": "abcdef123456"
},
"policy": {
"required": false,
"minSupportedBinaryVersion": "1.6.0",
"message": null,
"distributions": {
"direct": {
"downloadUrl": "https://example.com/Folo-1.6.1.dmg"
}
}
},
"desktop": {
"renderer": {
"version": "1.6.1",
"commit": "abcdef123456",
"launchAsset": {
"path": "renderer/render-asset.tar.gz",
"sha256": "0123...",
"contentType": "application/gzip"
},
"assets": []
},
"app": {
"platforms": {
"macos": {
"platform": "macos-x64",
"releaseDate": "2026-04-11T10:00:00Z",
"manifest": {
"name": "latest-mac.yml",
"downloadUrl": "https://example.com/latest-mac.yml"
},
"files": [
{
"filename": "Folo-1.6.1-macos-x64.zip",
"sha512": "base64sha512",
"size": 123456789,
"downloadUrl": "https://example.com/Folo-1.6.1-macos-x64.zip"
}
]
}
}
}
}
}
```
Notes:
- `schemaVersion: 2` avoids ambiguity with the existing mobile-only shape
- mobile can stay on the current shape or also migrate later if desired
- `releaseKind: "ota"` means renderer OTA data is eligible for `/manifest`
- `releaseKind: "binary"` means no OTA payload is served from `/manifest`, but the metadata can still update `/policy`
- `runtimeVersion` is required only for `releaseKind: "ota"` and should be `null` or omitted for `releaseKind: "binary"`
- direct binary payload data can be present in both `build` and `ota` modes
## Request Contract
### Shared Desktop Headers
The new desktop client uses only `X-App-*` headers when requesting `apps/ota`.
Required:
- `X-App-Platform`
- `X-App-Version`
- `X-App-Channel`
Optional:
- `X-App-Runtime-Version`
- `X-App-Renderer-Version`
### `X-App-Platform` Mapping
Map the existing platform header values to OTA routing dimensions:
- `desktop/macos/dmg` -> `platform=macos`, `distribution=direct`
- `desktop/macos/mas` -> `platform=macos`, `distribution=mas`
- `desktop/windows/exe` -> `platform=windows`, `distribution=direct`
- `desktop/windows/ms` -> `platform=windows`, `distribution=mss`
- `desktop/linux` -> `platform=linux`, `distribution=direct`
- `desktop/web` -> not eligible for desktop OTA or desktop binary policy
### Desktop Version Semantics
- `X-App-Version` is always the installed binary version
- `X-App-Runtime-Version` is the OTA compatibility key
- if `X-App-Runtime-Version` is absent, use `X-App-Version`
- `X-App-Renderer-Version` is used only to decide whether a renderer payload is newer than the installed renderer
## `GET /manifest`
### Responsibility
`/manifest` answers only:
- which compatible update payloads are currently available for this client
Desktop `manifest` can include:
- a renderer OTA payload
- a direct-channel full app payload
It must not make a final UX decision about store upgrade blocking. That remains the job of `/policy`.
### Desktop Response Shape
Suggested response:
```json
{
"id": "uuid",
"createdAt": "2026-04-11T10:00:00.000Z",
"product": "desktop",
"channel": "stable",
"runtimeVersion": "1.6.0",
"renderer": {
"releaseVersion": "1.6.1",
"version": "1.6.1",
"commit": "abcdef1234",
"launchAsset": {
"key": "render-asset",
"hash": "sha256-base64url",
"fileExtension": ".tar.gz",
"contentType": "application/gzip",
"url": "https://ota.folo.is/assets/desktop/stable/1.6.0/1.6.1/windows/render-asset.tar.gz"
},
"assets": []
},
"app": {
"releaseVersion": "1.6.1",
"version": "1.6.1",
"platform": "windows-x64",
"releaseDate": "2026-04-11T10:00:00.000Z",
"manifest": {
"name": "latest.yml",
"downloadUrl": "https://example.com/latest.yml"
},
"files": [
{
"filename": "Folo-1.6.1-windows-x64.exe",
"sha512": "base64sha512",
"size": 123456789,
"downloadUrl": "https://example.com/Folo-1.6.1-windows-x64.exe"
}
]
}
}
```
### Desktop Manifest Rules
- `renderer` is returned only when:
- a compatible desktop OTA release exists for `product + channel + runtimeVersion + platform`
- the renderer payload version is newer than `X-App-Renderer-Version`
- `app` is returned only when:
- the client distribution is `direct`
- a newer compatible direct installer exists for the requested platform
- `mas` and `mss` must never receive a direct binary payload from `/manifest`
- if both `renderer` and `app` are absent, return `204`
### Client Priority
Desktop client priority should be:
1. apply `renderer` when available
2. otherwise offer `app` when available
3. use `/policy` separately for binary upgrade guidance and enforcement
## `GET /policy`
### Responsibility
`/policy` answers only:
- should this installed binary remain usable
- should the user be prompted to upgrade
- where should the user go to get the correct binary
### Desktop Response Shape
Suggested response:
```json
{
"action": "none",
"targetVersion": null,
"message": null,
"distribution": "direct",
"downloadUrl": null,
"storeUrl": null,
"publishedAt": null
}
```
Example for an available direct binary upgrade:
```json
{
"action": "prompt",
"targetVersion": "1.6.1",
"message": "A newer desktop version is available.",
"distribution": "direct",
"downloadUrl": "https://example.com/Folo-1.6.1.dmg",
"storeUrl": null,
"publishedAt": "2026-04-11T10:00:00.000Z"
}
```
### Policy Selection Rules
For desktop:
1. infer `distribution` from `X-App-Platform`
2. look up policy for `product + channel + distribution`
3. if none exists, fall back to `product + channel`
4. if no policy exists, return `none`
Action semantics:
- `none`: do not prompt or block
- `prompt`: recommend a binary upgrade but allow continued usage
- `block`: require a binary upgrade before continued usage
URL semantics:
- `direct` returns `downloadUrl`
- `mas` and `mss` return `storeUrl`
### Why Policy Is Explicitly Published
The service must not infer policy timing from GitHub release publication time.
Reason:
- MAS and MSS approval timing is asynchronous and unknown
- GitHub release publication can happen before a store binary is actually installable
Therefore binary policy becomes active only after an explicit `binary-policy` publication for the relevant distribution.
## Storage Model
### Release Records
Store parsed desktop and mobile release metadata under release-versioned keys.
Example:
- `release:desktop:1.6.1`
- `release:mobile:0.4.3`
### Latest OTA Pointers
Desktop OTA latest pointers should continue using product, channel, runtimeVersion, and platform.
Example:
- `latest:desktop:stable:1.6.0:windows`
### Binary Policy Keys
Add distribution-aware keys:
- `policy:<product>:<channel>`
- `policy:<product>:<channel>:<distribution>`
Desktop examples:
- `policy:desktop:stable:direct`
- `policy:desktop:stable:mas`
- `policy:desktop:stable:mss`
Mobile may continue using the product-level key initially and can adopt distribution-aware keys later.
## Publishing Flow
### Desktop `build`
- build and upload direct installer assets
- publish desktop binary metadata
- do not publish renderer OTA payload
### Desktop `ota`
- build and upload renderer OTA payload
- build and upload direct installer assets
- publish one metadata file that includes both renderer OTA and direct binary payload data
This keeps:
- existing users eligible for renderer OTA
- new users able to download the latest direct installer immediately
### Desktop `binary-policy`
- publish metadata only
- no installer rebuild
- no renderer OTA payload upload
- target one or more distributions explicitly
Examples:
- publish MAS policy after App Store approval
- publish MSS policy later when Microsoft Store approval completes
## Migration Plan
1. Introduce desktop release config files and resolver logic
2. Teach desktop publication flows to emit the new desktop `ota-release.json`
3. Extend `apps/ota` sync, storage, and selection logic to understand desktop metadata
4. Add desktop-aware `manifest` and `policy` routes to `apps/ota`
5. Update the new desktop client to use `apps/ota` `manifest + policy`
6. Keep old desktop clients on `follow-server` unchanged
## Validation Plan
### Unit Tests
- desktop release config validation and resolver behavior
- desktop metadata parsing
- runtimeVersion fallback from `X-App-Version`
- platform and distribution mapping from `X-App-Platform`
- policy selection and fallback
- renderer payload selection
- direct binary payload selection
### Worker Route Tests
- desktop direct request returns both `renderer` and `app` when both are available
- desktop direct request returns only `renderer` when no newer direct binary exists
- MAS and MSS requests never receive `app` payloads from `/manifest`
- desktop `policy` prefers distribution-specific records over generic records
- desktop `policy` returns `downloadUrl` for `direct`
- desktop `policy` returns `storeUrl` for `mas` and `mss`
- desktop `manifest` returns `204` when no compatible renderer or direct app payload exists
### Client Verification
- new desktop client applies renderer OTA when available
- new desktop client falls back to direct full app update when renderer OTA is unavailable
- MAS and MSS clients respect `prompt`
- MAS and MSS clients enforce `block`
## Open Risks
- Keeping the desktop metadata file backward-compatible enough for future mobile convergence requires careful schema versioning
- The desktop client must not assume that `app` payloads exist for store distributions
- Release automation must avoid publishing `binary-policy` too early for store channels
## Final Recommendation
Adopt `apps/ota` as the unified update truth source for mobile and the new desktop client, keep `follow-server` untouched for legacy desktop clients, remove `mainHash` from the compatibility model, and align desktop release orchestration with the new mobile file-driven release flow.
This provides one understandable model:
- `release-plan.json` defines intent
- `release.json` locks the tagged release config
- `/manifest` exposes compatible payload facts
- `/policy` exposes binary upgrade policy
It also preserves the direct desktop requirement that an OTA release should still publish the latest full installer.