完善跨平台客户端适配与集成流程
This commit is contained in:
parent
4c5fc7b0d4
commit
92352eca11
@ -27,6 +27,8 @@ func main() {
|
||||
watchCommand(os.Args[2:])
|
||||
case "prepare":
|
||||
prepareCommand(os.Args[2:])
|
||||
case "install":
|
||||
installCommand(os.Args[2:])
|
||||
case "status":
|
||||
statusCommand(os.Args[2:])
|
||||
case "activate":
|
||||
|
||||
@ -177,6 +177,16 @@ func prepareCommand(args []string) {
|
||||
fmt.Printf("\u66f4\u65b0\u5df2\u51c6\u5907\uff1a%s\n\u76ee\u5f55\uff1a%s\n\u5c1a\u672a\u5207\u6362\u5f53\u524d\u7a0b\u5e8f\uff0c\u53ef\u7531\u7528\u6237\u51b3\u5b9a\u4f55\u65f6\u91cd\u542f\u3002\n", prepared.Version, prepared.Path)
|
||||
}
|
||||
|
||||
func installCommand(args []string) {
|
||||
flags := flag.NewFlagSet("install", flag.ExitOnError)
|
||||
managerInput := bindManagerFlags(flags)
|
||||
flags.Parse(args)
|
||||
if err := newUpdateManager(managerInput).Install(context.Background()); err != nil {
|
||||
fatal("平台安装处理失败: %v", err)
|
||||
}
|
||||
fmt.Println("平台安装处理已完成;桌面平台可以继续激活,Android 请由宿主应用完成系统安装。")
|
||||
}
|
||||
|
||||
func statusCommand(args []string) {
|
||||
flags := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
managerInput := bindManagerFlags(flags)
|
||||
@ -297,7 +307,7 @@ func parseVersions(value string) map[string]string {
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("Scout \u8f7b\u91cf\u5316\u5305\u7ba1\u7406\u5ba2\u6237\u7aef")
|
||||
fmt.Println("\u7528\u6cd5\uff1ascout-client <check|download|upload|watch|prepare|status|activate|healthy|rollback> [\u53c2\u6570]")
|
||||
fmt.Println("\u7528\u6cd5\uff1ascout-client <check|download|upload|watch|prepare|install|status|activate|healthy|rollback> [\u53c2\u6570]")
|
||||
fmt.Println("check\u3001download\u3001watch \u7528\u4e8e\u66f4\u65b0\u67e5\u8be2\uff0cupload \u7528\u4e8e\u5f00\u53d1\u8005\u4e0a\u4f20\u3002")
|
||||
fmt.Println("prepare \u53ea\u51c6\u5907\u65b0\u7248\u672c\uff0cactivate \u7531\u7528\u6237\u51b3\u5b9a\u91cd\u542f\u65f6\u673a\uff0chealthy \u786e\u8ba4\u6210\u529f\u540e\u624d\u6e05\u7406\u65e7\u7248\u672c\u3002")
|
||||
}
|
||||
|
||||
70
examples/client-integration/main.go
Normal file
70
examples/client-integration/main.go
Normal file
@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"scout/pkg/client"
|
||||
"scout/pkg/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
server := os.Getenv("SCOUT_SERVER")
|
||||
if server == "" {
|
||||
server = "http://127.0.0.1:8080"
|
||||
}
|
||||
api, err := client.New(server, client.WithSessionToken(os.Getenv("SCOUT_SESSION")))
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
manager, err := updater.New(updater.Config{
|
||||
Client: api,
|
||||
PackageName: "demo-core",
|
||||
CurrentVersion: "1.0.0",
|
||||
CurrentPath: filepath.Join(".", "demo-core-current"),
|
||||
RootDir: filepath.Join(".", ".scout-update"),
|
||||
Entrypoint: "demo-core",
|
||||
HealthTimeout: 2 * time.Minute,
|
||||
KeepReleases: 2,
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
latest, available, err := manager.Check(ctx)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if !available {
|
||||
fmt.Println("当前已是最新版本:", latest.Version)
|
||||
return
|
||||
}
|
||||
|
||||
prepared, err := manager.Prepare(ctx, latest.Version)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
fmt.Println("更新已准备完成:", prepared.Version)
|
||||
fmt.Println("当前进程没有停止;请由业务界面询问用户何时重启。")
|
||||
|
||||
if os.Getenv("SCOUT_RESTART_NOW") != "1" {
|
||||
return
|
||||
}
|
||||
if err := manager.Install(ctx); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if _, err := manager.Activate(); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
fmt.Println("版本已切换,宿主应用现在可以退出并重新启动。")
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Println("更新示例失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
143
pkg/updater/adapter.go
Normal file
143
pkg/updater/adapter.go
Normal file
@ -0,0 +1,143 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"scout/pkg/scoutpkg"
|
||||
)
|
||||
|
||||
var ErrPlatformInstallRequired = errors.New("当前平台需要宿主应用接入系统安装流程")
|
||||
|
||||
// LaunchRequest 描述平台适配器启动已确认版本所需的信息。
|
||||
type LaunchRequest struct {
|
||||
Release ReleasePointer
|
||||
Args []string
|
||||
Env []string
|
||||
}
|
||||
|
||||
// PlatformAdapter 隔离更新器与不同操作系统的启动和安装机制。
|
||||
// Prepare、Activate 和健康确认仍由 Manager 统一负责,宿主应用只需在需要时注入适配器。
|
||||
type PlatformAdapter interface {
|
||||
Platform() string
|
||||
Validate(ReleasePointer) error
|
||||
Install(context.Context, ReleasePointer) error
|
||||
Launch(context.Context, LaunchRequest) (*exec.Cmd, error)
|
||||
}
|
||||
|
||||
type nativeAdapter struct {
|
||||
platform string
|
||||
}
|
||||
|
||||
type androidAdapter struct{}
|
||||
|
||||
// NewPlatformAdapter 返回指定平台的默认适配器。
|
||||
func NewPlatformAdapter(platform string) (PlatformAdapter, error) {
|
||||
normalized, err := scoutpkg.NormalizePlatform(platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized == "android" {
|
||||
return androidAdapter{}, nil
|
||||
}
|
||||
return nativeAdapter{platform: normalized}, nil
|
||||
}
|
||||
|
||||
// NewNativeAdapter 创建桌面平台默认适配器,便于宿主应用显式注入。
|
||||
func NewNativeAdapter(platform string) (PlatformAdapter, error) {
|
||||
normalized, err := scoutpkg.NormalizePlatform(platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized == "android" {
|
||||
return nil, errors.New("Android 必须使用宿主应用提供的安装适配器")
|
||||
}
|
||||
return nativeAdapter{platform: normalized}, nil
|
||||
}
|
||||
|
||||
func (adapter nativeAdapter) Platform() string {
|
||||
return adapter.platform
|
||||
}
|
||||
|
||||
func (adapter nativeAdapter) Validate(release ReleasePointer) error {
|
||||
if release.Platform != "" && release.Platform != adapter.platform && release.Platform != "any" {
|
||||
return fmt.Errorf("更新版本平台不匹配: %s", release.Platform)
|
||||
}
|
||||
executable, err := releaseExecutable(release)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(executable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("平台适配器检查程序入口失败: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errors.New("程序入口不能是目录")
|
||||
}
|
||||
if adapter.platform == "macos" && strings.HasSuffix(strings.ToLower(executable), ".app") {
|
||||
return errors.New("macOS 程序入口必须指向应用包内的可执行文件")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (adapter nativeAdapter) Install(_ context.Context, release ReleasePointer) error {
|
||||
return adapter.Validate(release)
|
||||
}
|
||||
|
||||
func (adapter nativeAdapter) Launch(ctx context.Context, request LaunchRequest) (*exec.Cmd, error) {
|
||||
if err := adapter.Validate(request.Release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executable, err := releaseExecutable(request.Release)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command := exec.CommandContext(ctx, executable, request.Args...)
|
||||
command.Dir = request.Release.Path
|
||||
command.Env = request.Env
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, fmt.Errorf("启动当前版本失败: %w", err)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (androidAdapter) Platform() string {
|
||||
return "android"
|
||||
}
|
||||
|
||||
func (androidAdapter) Validate(release ReleasePointer) error {
|
||||
if strings.TrimSpace(release.Path) == "" {
|
||||
return errors.New("Android 更新版本路径不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (adapter androidAdapter) Install(_ context.Context, release ReleasePointer) error {
|
||||
if err := adapter.Validate(release); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrPlatformInstallRequired
|
||||
}
|
||||
|
||||
func (androidAdapter) Launch(_ context.Context, _ LaunchRequest) (*exec.Cmd, error) {
|
||||
return nil, errors.New("Android 不能由 Go 更新器直接启动,请由宿主应用通过系统组件启动")
|
||||
}
|
||||
|
||||
func releaseExecutable(release ReleasePointer) (string, error) {
|
||||
if strings.TrimSpace(release.Path) == "" {
|
||||
return "", errors.New("版本路径不能为空")
|
||||
}
|
||||
if strings.TrimSpace(release.Entrypoint) == "" {
|
||||
return "", errors.New("程序入口不能为空")
|
||||
}
|
||||
root, err := filepath.Abs(release.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析版本路径失败: %w", err)
|
||||
}
|
||||
return safeEntrypoint(root, release.Entrypoint)
|
||||
}
|
||||
108
pkg/updater/adapter_test.go
Normal file
108
pkg/updater/adapter_test.go
Normal file
@ -0,0 +1,108 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPlatformAdapterSupportsAllDeclaredPlatforms(t *testing.T) {
|
||||
for _, platform := range []string{"linux", "windows", "macos", "android"} {
|
||||
adapter, err := NewPlatformAdapter(platform)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 %s 适配器失败: %v", platform, err)
|
||||
}
|
||||
if adapter.Platform() != platform {
|
||||
t.Fatalf("适配器平台错误: got %s want %s", adapter.Platform(), platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeAdapterValidatesEntrypointAndInstall(t *testing.T) {
|
||||
releaseDir := t.TempDir()
|
||||
entrypoint := filepath.Join(releaseDir, "demo")
|
||||
if err := os.WriteFile(entrypoint, []byte("demo"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter, err := NewNativeAdapter("linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := ReleasePointer{
|
||||
Version: "1.0.0",
|
||||
Path: releaseDir,
|
||||
Entrypoint: "demo",
|
||||
Platform: "linux",
|
||||
Architecture: "amd64",
|
||||
}
|
||||
if err := adapter.Validate(release); err != nil {
|
||||
t.Fatalf("入口校验失败: %v", err)
|
||||
}
|
||||
if err := adapter.Install(context.Background(), release); err != nil {
|
||||
t.Fatalf("桌面平台安装处理失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidAdapterRequiresHostInstallation(t *testing.T) {
|
||||
adapter, err := NewPlatformAdapter("android")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = adapter.Install(context.Background(), ReleasePointer{Path: t.TempDir()})
|
||||
if !errors.Is(err, ErrPlatformInstallRequired) {
|
||||
t.Fatalf("Android 应返回宿主安装要求: %v", err)
|
||||
}
|
||||
if _, err := adapter.Launch(context.Background(), LaunchRequest{}); err == nil {
|
||||
t.Fatal("Android 不应由默认适配器直接启动")
|
||||
}
|
||||
}
|
||||
|
||||
type recordingAdapter struct {
|
||||
installed bool
|
||||
}
|
||||
|
||||
func (adapter *recordingAdapter) Platform() string {
|
||||
return "linux"
|
||||
}
|
||||
|
||||
func (adapter *recordingAdapter) Validate(ReleasePointer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (adapter *recordingAdapter) Install(_ context.Context, _ ReleasePointer) error {
|
||||
adapter.installed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (adapter *recordingAdapter) Launch(context.Context, LaunchRequest) (*exec.Cmd, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestManagerInstallDelegatesToInjectedAdapter(t *testing.T) {
|
||||
api := newTestClient(t)
|
||||
adapter := &recordingAdapter{}
|
||||
manager, err := New(Config{
|
||||
Client: api,
|
||||
Adapter: adapter,
|
||||
PackageName: "demo-core",
|
||||
RootDir: t.TempDir(),
|
||||
Platform: "linux",
|
||||
Architecture: "amd64",
|
||||
CurrentVersion: "1.0.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(manager.filePath("pending.json"), []byte(`{"version":"1.1.0","path":"/tmp/release","entrypoint":"demo","platform":"linux"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Install(context.Background()); err != nil {
|
||||
t.Fatalf("注入适配器安装失败: %v", err)
|
||||
}
|
||||
if !adapter.installed {
|
||||
t.Fatal("Manager.Install 没有调用注入的适配器")
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ const ExitRestartNow = 42
|
||||
|
||||
type Config struct {
|
||||
Client *client.Client
|
||||
Adapter PlatformAdapter
|
||||
PackageName string
|
||||
CurrentVersion string
|
||||
CurrentPath string
|
||||
@ -109,6 +110,12 @@ func New(config Config) (*Manager, error) {
|
||||
return nil, fmt.Errorf("\u89e3\u6790\u66f4\u65b0\u6839\u76ee\u5f55\u5931\u8d25: %w", err)
|
||||
}
|
||||
config.RootDir = root
|
||||
if config.Adapter == nil {
|
||||
config.Adapter, err = NewPlatformAdapter(config.Platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"releases", "downloads"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("\u521b\u5efa\u66f4\u65b0\u76ee\u5f55\u5931\u8d25: %w", err)
|
||||
@ -138,6 +145,16 @@ func (manager *Manager) RegisterCurrent(version, releasePath, entrypoint string)
|
||||
if _, err := os.Stat(executable); err != nil {
|
||||
return ReleasePointer{}, fmt.Errorf("\u5f53\u524d\u7a0b\u5e8f\u5165\u53e3\u4e0d\u5b58\u5728: %w", err)
|
||||
}
|
||||
if err := manager.config.Adapter.Validate(ReleasePointer{
|
||||
Version: version,
|
||||
Path: releasePath,
|
||||
Entrypoint: entrypoint,
|
||||
Platform: manager.config.Platform,
|
||||
Architecture: manager.config.Architecture,
|
||||
PackageType: "full",
|
||||
}); err != nil {
|
||||
return ReleasePointer{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
pointer := ReleasePointer{
|
||||
Version: version,
|
||||
@ -247,6 +264,17 @@ func (manager *Manager) Prepare(ctx context.Context, version string) (ReleasePoi
|
||||
if _, err := os.Stat(executable); err != nil {
|
||||
return ReleasePointer{}, fmt.Errorf("\u65b0\u7248\u672c\u7a0b\u5e8f\u5165\u53e3\u4e0d\u5b58\u5728: %w", err)
|
||||
}
|
||||
if err := manager.config.Adapter.Validate(ReleasePointer{
|
||||
Version: item.Version,
|
||||
Path: stagingDir,
|
||||
Entrypoint: entrypoint,
|
||||
Platform: manager.config.Platform,
|
||||
Architecture: manager.config.Architecture,
|
||||
PackageType: item.PackageType,
|
||||
SHA256: item.SHA256,
|
||||
}); err != nil {
|
||||
return ReleasePointer{}, err
|
||||
}
|
||||
if runtime.GOOS != "windows" && manager.config.Platform != "android" {
|
||||
if info, err := os.Stat(executable); err == nil && info.Mode().Perm()&0o111 == 0 {
|
||||
_ = os.Chmod(executable, info.Mode().Perm()|0o755)
|
||||
@ -320,6 +348,19 @@ func (manager *Manager) Activate() (ReleasePointer, error) {
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// Install 将待激活版本交给平台适配器处理。
|
||||
// 桌面平台通常只需要准备文件,Android 等平台由宿主应用注入系统安装实现。
|
||||
func (manager *Manager) Install(ctx context.Context) error {
|
||||
pending, err := manager.readPointer("pending.json")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New("没有待安装版本,请先准备更新")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return manager.config.Adapter.Install(ctx, pending)
|
||||
}
|
||||
|
||||
func (manager *Manager) ConfirmHealthy(version string) error {
|
||||
current, err := manager.readPointer("current.json")
|
||||
if err != nil {
|
||||
@ -438,23 +479,21 @@ func (manager *Manager) ResolveCurrent() (string, ReleasePointer, error) {
|
||||
}
|
||||
|
||||
func (manager *Manager) Launch(ctx context.Context, args ...string) (*exec.Cmd, error) {
|
||||
executable, current, err := manager.ResolveCurrent()
|
||||
_, current, err := manager.ResolveCurrent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command := exec.CommandContext(ctx, executable, args...)
|
||||
command.Dir = current.Path
|
||||
command.Env = append(os.Environ(),
|
||||
"SCOUT_PACKAGE="+manager.config.PackageName,
|
||||
"SCOUT_VERSION="+current.Version,
|
||||
"SCOUT_UPDATE_ROOT="+manager.config.RootDir,
|
||||
"SCOUT_PLATFORM="+manager.config.Platform,
|
||||
"SCOUT_ARCHITECTURE="+manager.config.Architecture,
|
||||
)
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, fmt.Errorf("\u542f\u52a8\u5f53\u524d\u7248\u672c\u5931\u8d25: %w", err)
|
||||
}
|
||||
return command, nil
|
||||
return manager.config.Adapter.Launch(ctx, LaunchRequest{
|
||||
Release: current,
|
||||
Args: args,
|
||||
Env: append(os.Environ(),
|
||||
"SCOUT_PACKAGE="+manager.config.PackageName,
|
||||
"SCOUT_VERSION="+current.Version,
|
||||
"SCOUT_UPDATE_ROOT="+manager.config.RootDir,
|
||||
"SCOUT_PLATFORM="+manager.config.Platform,
|
||||
"SCOUT_ARCHITECTURE="+manager.config.Architecture,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) Cleanup() error {
|
||||
|
||||
101
使用说明.md
101
使用说明.md
@ -1,4 +1,4 @@
|
||||
# Scout 轻量化包管理程序
|
||||
# Scout 轻量化包管理程序
|
||||
|
||||
Scout 是一个使用 **Go + Vue** 构建的轻量化包管理程序,负责包上传、版本管理、客户端更新检查、实时更新通知和账户权限控制。
|
||||
|
||||
@ -177,6 +177,18 @@ go build -o .\scout-client.exe .\cmd\scout-client
|
||||
.\scout-client.exe watch --server http://127.0.0.1:8080 --packages demo-core
|
||||
```
|
||||
|
||||
更新生命周期命令:
|
||||
|
||||
```powershell
|
||||
# 当前程序继续运行时下载并准备新版本
|
||||
.\scout-client.exe prepare --server http://127.0.0.1:8080 --package demo-core --root .\.scout-update --current 1.0.0 --entrypoint demo-core
|
||||
# 由用户决定后执行平台安装处理和版本切换
|
||||
.\scout-client.exe install --server http://127.0.0.1:8080 --package demo-core --root .\.scout-update --current 1.0.0 --entrypoint demo-core
|
||||
.\scout-client.exe activate --server http://127.0.0.1:8080 --package demo-core --root .\.scout-update --current 1.0.0 --entrypoint demo-core
|
||||
# 新进程启动并确认健康后,Scout 才清理旧版本
|
||||
.\scout-client.exe healthy --server http://127.0.0.1:8080 --package demo-core --root .\.scout-update --entrypoint demo-core
|
||||
```
|
||||
|
||||
使用兼容令牌上传:
|
||||
|
||||
```powershell
|
||||
@ -248,6 +260,93 @@ for update := range updates {
|
||||
| `WatchUpdates` | 监听 SSE 更新通知 |
|
||||
| `Upload` | 使用兼容令牌或会话上传包 |
|
||||
|
||||
## 跨平台客户端集成
|
||||
|
||||
`pkg/client` 负责服务端协议,`pkg/updater` 负责下载、校验、暂存、激活、健康确认、回滚和旧版本清理。业务程序不需要依赖 `internal` 目录,只需注入 `client.Client` 创建 `updater.Manager`。
|
||||
|
||||
推荐由宿主程序按以下流程调用:
|
||||
|
||||
1. 启动时创建 `Manager`,调用 `RecoverStaleActivation` 检查上一次更新是否超时。
|
||||
2. 后台调用 `Check` 或 `Watch`,收到对应平台和架构的更新通知。
|
||||
3. 在业务进程仍运行时调用 `Prepare`,完成下载、SHA-256 校验和暂存。
|
||||
4. 由用户决定立即重启还是稍后重启;立即重启前可调用 `Install`,再调用 `Activate`。
|
||||
5. 宿主程序退出并重新启动后调用 `ConfirmHealthy`,成功后才会清理旧版本。
|
||||
6. 启动失败或健康确认超时,调用 `Rollback` 恢复上一版本。
|
||||
|
||||
可运行的最小集成示例位于:
|
||||
|
||||
```text
|
||||
examples/client-integration/main.go
|
||||
```
|
||||
|
||||
### 平台适配边界
|
||||
|
||||
`updater.New` 默认选择平台适配器,也可以通过 `updater.Config.Adapter` 注入宿主自己的实现:
|
||||
|
||||
- Linux、Windows、macOS:默认适配器只启动已校验的版本入口,不执行系统级安装器,不停止当前进程。
|
||||
- Android:默认适配器只负责准备文件,`Install` 会返回 `updater.ErrPlatformInstallRequired`。宿主 Android 应用必须使用系统 `PackageInstaller` 或企业设备管理能力完成安装,并由系统授权流程决定是否允许安装。
|
||||
- macOS 应将 `Entrypoint` 指向 `.app/Contents/MacOS/` 内的真实可执行文件,不能直接把 `.app` 目录当作入口。
|
||||
- Windows、Linux 和 macOS 的旧版本清理由健康确认后的 `Cleanup` 完成;清理前会保护当前版本和待激活版本。
|
||||
|
||||
Android 普通应用不能承诺无需用户确认的静默 APK 安装。Scout 不绕过系统授权,也不会通过提升权限实现静默安装;需要无人值守时必须由受控设备管理方案提供能力。
|
||||
|
||||
### 一个 Scout 包包含多个平台目标
|
||||
|
||||
`.scout` 文件本身是一个包含 `scout-package.json` 的 ZIP。开发者可以在同一个包中声明多个 `targets`,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "demo-core",
|
||||
"version": "1.2.0",
|
||||
"channel": "stable",
|
||||
"package_type": "full",
|
||||
"targets": [
|
||||
{
|
||||
"platform": "windows",
|
||||
"architecture": "amd64",
|
||||
"path": "targets/windows/amd64",
|
||||
"entrypoint": "targets/windows/amd64/demo-core.exe"
|
||||
},
|
||||
{
|
||||
"platform": "linux",
|
||||
"architecture": "amd64",
|
||||
"path": "targets/linux/amd64",
|
||||
"entrypoint": "targets/linux/amd64/demo-core"
|
||||
},
|
||||
{
|
||||
"platform": "macos",
|
||||
"architecture": "arm64",
|
||||
"path": "targets/macos/arm64",
|
||||
"entrypoint": "targets/macos/arm64/demo-core"
|
||||
},
|
||||
{
|
||||
"platform": "android",
|
||||
"architecture": "arm64",
|
||||
"path": "targets/android/arm64",
|
||||
"entrypoint": "targets/android/arm64/demo.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
服务端上传时会解析每个目标并分类保存;客户端查询时只会收到匹配自身平台、架构、通道和当前版本的目标,其他客户端不会收到该更新。
|
||||
|
||||
### 跨平台构建示例
|
||||
|
||||
Go 客户端和服务端需要分别构建对应操作系统的二进制,不能把一个 Go 可执行文件直接运行在所有系统上;可以把这些二进制放进同一个 `.scout` 包,达到“一个包,多平台目标”的效果:
|
||||
|
||||
```powershell
|
||||
$env:CGO_ENABLED = "0"
|
||||
$env:GOOS = "windows"; $env:GOARCH = "amd64"; go build -trimpath -o .\targets\windows\amd64\demo-core.exe .\cmd\scout-client
|
||||
$env:GOOS = "linux"; $env:GOARCH = "amd64"; go build -trimpath -o .\targets\linux\amd64\demo-core .\cmd\scout-client
|
||||
$env:GOOS = "darwin"; $env:GOARCH = "arm64"; go build -trimpath -o .\targets\macos\arm64\demo-core .\cmd\scout-client
|
||||
Remove-Item Env:GOOS
|
||||
Remove-Item Env:GOARCH
|
||||
Remove-Item Env:CGO_ENABLED
|
||||
```
|
||||
|
||||
Android APK 通常由 Android 工程和签名流水线生成,再放入 `targets/android/<architecture>/`;Scout 只负责分发和校验,不代替 Android 签名、系统安装授权或应用商店审核。
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
|
||||
Loading…
Reference in New Issue
Block a user