diff --git a/cmd/scout-client/main.go b/cmd/scout-client/main.go index 8797a74..e75f27e 100644 --- a/cmd/scout-client/main.go +++ b/cmd/scout-client/main.go @@ -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": diff --git a/cmd/scout-client/update_commands.go b/cmd/scout-client/update_commands.go index 438b4a9..a1b2f56 100644 --- a/cmd/scout-client/update_commands.go +++ b/cmd/scout-client/update_commands.go @@ -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 [\u53c2\u6570]") + fmt.Println("\u7528\u6cd5\uff1ascout-client [\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") } diff --git a/examples/client-integration/main.go b/examples/client-integration/main.go new file mode 100644 index 0000000..5a427e4 --- /dev/null +++ b/examples/client-integration/main.go @@ -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) +} diff --git a/pkg/updater/adapter.go b/pkg/updater/adapter.go new file mode 100644 index 0000000..25279e0 --- /dev/null +++ b/pkg/updater/adapter.go @@ -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) +} diff --git a/pkg/updater/adapter_test.go b/pkg/updater/adapter_test.go new file mode 100644 index 0000000..43dcc03 --- /dev/null +++ b/pkg/updater/adapter_test.go @@ -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 没有调用注入的适配器") + } +} diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go index ae1385e..da2418d 100644 --- a/pkg/updater/updater.go +++ b/pkg/updater/updater.go @@ -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 { diff --git a/使用说明.md b/使用说明.md index 309271e..3764626 100644 --- a/使用说明.md +++ b/使用说明.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//`;Scout 只负责分发和校验,不代替 Android 签名、系统安装授权或应用商店审核。 ## 测试 ```powershell