feat: initial commit
This commit is contained in:
24
pkg/wails3kit/updates/BUILD.bazel
Normal file
24
pkg/wails3kit/updates/BUILD.bazel
Normal file
@@ -0,0 +1,24 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "updates",
|
||||
srcs = [
|
||||
"controller.go",
|
||||
"downloader.go",
|
||||
"errors.go",
|
||||
"semver.go",
|
||||
"types.go",
|
||||
],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"@org_golang_x_mod//semver",
|
||||
],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "updates_test",
|
||||
srcs = ["controller_test.go"],
|
||||
embed = [":updates"],
|
||||
deps = [],
|
||||
)
|
||||
9
pkg/wails3kit/updates/bootstrap/BUILD.bazel
Normal file
9
pkg/wails3kit/updates/bootstrap/BUILD.bazel
Normal file
@@ -0,0 +1,9 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "bootstrap",
|
||||
srcs = ["bootstrap.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/bootstrap",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = ["//pkg/wails3kit/updates/platform"],
|
||||
)
|
||||
11
pkg/wails3kit/updates/bootstrap/bootstrap.go
Normal file
11
pkg/wails3kit/updates/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform"
|
||||
)
|
||||
|
||||
func MaybeRun() (bool, error) {
|
||||
return platform.MaybeRun(os.Args)
|
||||
}
|
||||
315
pkg/wails3kit/updates/controller.go
Normal file
315
pkg/wails3kit/updates/controller.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
app AppDescriptor
|
||||
provider ReleaseProvider
|
||||
downloader Downloader
|
||||
store SnapshotStore
|
||||
platform PlatformInstaller
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
snapshot Snapshot
|
||||
busy bool
|
||||
listeners map[int]chan Snapshot
|
||||
nextListener int
|
||||
}
|
||||
|
||||
func NewController(cfg Config) (*Controller, error) {
|
||||
if cfg.Provider == nil || cfg.Downloader == nil || cfg.Store == nil || cfg.Platform == nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
if _, err := NormalizeVersion(cfg.App.CurrentVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.App.WorkingDirectory == "" {
|
||||
workingDirectory, err := os.Getwd()
|
||||
if err == nil {
|
||||
cfg.App.WorkingDirectory = workingDirectory
|
||||
}
|
||||
}
|
||||
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
snapshot, err := cfg.Store.Load(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot.CurrentVersion = cfg.App.CurrentVersion
|
||||
snapshot.Channel = cfg.App.Channel
|
||||
if snapshot.State == "" {
|
||||
snapshot.State = StateIdle
|
||||
}
|
||||
|
||||
controller := &Controller{
|
||||
app: cfg.App,
|
||||
provider: cfg.Provider,
|
||||
downloader: cfg.Downloader,
|
||||
store: cfg.Store,
|
||||
platform: cfg.Platform,
|
||||
logger: logger,
|
||||
snapshot: snapshot,
|
||||
listeners: make(map[int]chan Snapshot),
|
||||
}
|
||||
|
||||
if snapshot.Staged != nil {
|
||||
comparison, err := CompareVersions(snapshot.Staged.Release.Version, cfg.App.CurrentVersion)
|
||||
if err == nil && comparison == 0 {
|
||||
snapshot.State = StateUpToDate
|
||||
snapshot.Staged = nil
|
||||
snapshot.Candidate = nil
|
||||
snapshot.LastError = nil
|
||||
controller.snapshot = snapshot
|
||||
if err := controller.store.Save(context.Background(), snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
func (controller *Controller) Snapshot() Snapshot {
|
||||
controller.mu.Lock()
|
||||
defer controller.mu.Unlock()
|
||||
return controller.snapshot
|
||||
}
|
||||
|
||||
func (controller *Controller) Subscribe(buffer int) (<-chan Snapshot, func()) {
|
||||
if buffer < 1 {
|
||||
buffer = 1
|
||||
}
|
||||
|
||||
controller.mu.Lock()
|
||||
defer controller.mu.Unlock()
|
||||
|
||||
controller.nextListener++
|
||||
id := controller.nextListener
|
||||
channel := make(chan Snapshot, buffer)
|
||||
channel <- controller.snapshot
|
||||
controller.listeners[id] = channel
|
||||
|
||||
return channel, func() {
|
||||
controller.mu.Lock()
|
||||
defer controller.mu.Unlock()
|
||||
if _, ok := controller.listeners[id]; ok {
|
||||
delete(controller.listeners, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *Controller) Check(ctx context.Context, _ CheckRequest) (Snapshot, error) {
|
||||
if !controller.beginOperation() {
|
||||
return Snapshot{}, ErrBusy
|
||||
}
|
||||
defer controller.endOperation()
|
||||
|
||||
now := time.Now().UTC()
|
||||
controller.transition(StateChecking, nil, nil)
|
||||
|
||||
release, err := controller.provider.Resolve(ctx, ResolveRequest{App: controller.app})
|
||||
if err != nil {
|
||||
return controller.fail("resolve_failed", err)
|
||||
}
|
||||
|
||||
if release == nil {
|
||||
snapshot := controller.currentSnapshot()
|
||||
snapshot.State = StateUpToDate
|
||||
snapshot.LastCheckedAt = &now
|
||||
snapshot.Candidate = nil
|
||||
snapshot.LastError = nil
|
||||
if err := controller.save(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
comparison, err := CompareVersions(release.Version, controller.app.CurrentVersion)
|
||||
if err != nil {
|
||||
return controller.fail("invalid_release_version", err)
|
||||
}
|
||||
if comparison <= 0 {
|
||||
snapshot := controller.currentSnapshot()
|
||||
snapshot.State = StateUpToDate
|
||||
snapshot.LastCheckedAt = &now
|
||||
snapshot.Candidate = nil
|
||||
snapshot.LastError = nil
|
||||
if err := controller.save(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
snapshot := controller.currentSnapshot()
|
||||
snapshot.State = StateUpdateAvailable
|
||||
snapshot.LastCheckedAt = &now
|
||||
snapshot.Candidate = release
|
||||
snapshot.Staged = nil
|
||||
snapshot.LastError = nil
|
||||
if err := controller.save(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (controller *Controller) Download(ctx context.Context) (Snapshot, error) {
|
||||
if !controller.beginOperation() {
|
||||
return Snapshot{}, ErrBusy
|
||||
}
|
||||
defer controller.endOperation()
|
||||
|
||||
snapshot := controller.currentSnapshot()
|
||||
if snapshot.Candidate == nil {
|
||||
return Snapshot{}, ErrNoUpdate
|
||||
}
|
||||
|
||||
controller.transition(StateDownloading, snapshot.Candidate, nil)
|
||||
|
||||
downloadedFile, err := controller.downloader.Download(ctx, snapshot.Candidate.Artifact)
|
||||
if err != nil {
|
||||
return controller.fail("download_failed", err)
|
||||
}
|
||||
if downloadedFile.SHA256 != "" && snapshot.Candidate.Artifact.SHA256 != "" && downloadedFile.SHA256 != snapshot.Candidate.Artifact.SHA256 {
|
||||
return controller.fail("checksum_mismatch", ErrChecksumMismatch)
|
||||
}
|
||||
|
||||
controller.transition(StateVerifying, snapshot.Candidate, nil)
|
||||
|
||||
root, err := controller.platform.DetectInstallRoot(controller.app)
|
||||
if err != nil {
|
||||
return controller.fail("detect_root_failed", err)
|
||||
}
|
||||
|
||||
stagedArtifact, err := controller.platform.Stage(ctx, root, downloadedFile, *snapshot.Candidate)
|
||||
if err != nil {
|
||||
return controller.fail("stage_failed", err)
|
||||
}
|
||||
|
||||
nextSnapshot := controller.currentSnapshot()
|
||||
nextSnapshot.State = StateReadyToApply
|
||||
nextSnapshot.Staged = &stagedArtifact
|
||||
nextSnapshot.Candidate = &stagedArtifact.Release
|
||||
nextSnapshot.LastError = nil
|
||||
if err := controller.save(nextSnapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
return nextSnapshot, nil
|
||||
}
|
||||
|
||||
func (controller *Controller) ApplyAndRestart(ctx context.Context) error {
|
||||
if !controller.beginOperation() {
|
||||
return ErrBusy
|
||||
}
|
||||
defer controller.endOperation()
|
||||
|
||||
snapshot := controller.currentSnapshot()
|
||||
if snapshot.Staged == nil {
|
||||
return ErrNoStagedUpdate
|
||||
}
|
||||
|
||||
controller.transition(StateApplying, snapshot.Candidate, snapshot.Staged)
|
||||
|
||||
if err := controller.platform.SpawnApplyAndRestart(ctx, ApplyRequest{
|
||||
App: controller.app,
|
||||
Root: snapshot.Staged.Root,
|
||||
Staged: *snapshot.Staged,
|
||||
}); err != nil {
|
||||
_, failErr := controller.fail("apply_failed", err)
|
||||
return failErr
|
||||
}
|
||||
|
||||
nextSnapshot := controller.currentSnapshot()
|
||||
nextSnapshot.State = StateRestarting
|
||||
nextSnapshot.LastError = nil
|
||||
return controller.save(nextSnapshot)
|
||||
}
|
||||
|
||||
func (controller *Controller) beginOperation() bool {
|
||||
controller.mu.Lock()
|
||||
defer controller.mu.Unlock()
|
||||
if controller.busy {
|
||||
return false
|
||||
}
|
||||
controller.busy = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (controller *Controller) endOperation() {
|
||||
controller.mu.Lock()
|
||||
defer controller.mu.Unlock()
|
||||
controller.busy = false
|
||||
}
|
||||
|
||||
func (controller *Controller) currentSnapshot() Snapshot {
|
||||
controller.mu.Lock()
|
||||
defer controller.mu.Unlock()
|
||||
return controller.snapshot
|
||||
}
|
||||
|
||||
func (controller *Controller) transition(state State, candidate *Release, staged *StagedArtifact) {
|
||||
snapshot := controller.currentSnapshot()
|
||||
snapshot.State = state
|
||||
snapshot.Candidate = candidate
|
||||
snapshot.Staged = staged
|
||||
snapshot.LastError = nil
|
||||
_ = controller.save(snapshot)
|
||||
}
|
||||
|
||||
func (controller *Controller) save(snapshot Snapshot) error {
|
||||
snapshot.CurrentVersion = controller.app.CurrentVersion
|
||||
snapshot.Channel = controller.app.Channel
|
||||
if err := controller.store.Save(context.Background(), snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
controller.mu.Lock()
|
||||
controller.snapshot = snapshot
|
||||
listeners := make([]chan Snapshot, 0, len(controller.listeners))
|
||||
for _, listener := range controller.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
controller.mu.Unlock()
|
||||
|
||||
for _, listener := range listeners {
|
||||
select {
|
||||
case listener <- snapshot:
|
||||
default:
|
||||
select {
|
||||
case <-listener:
|
||||
default:
|
||||
}
|
||||
listener <- snapshot
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (controller *Controller) fail(code string, err error) (Snapshot, error) {
|
||||
controller.logger.Error("update flow failed", "code", code, "error", err)
|
||||
|
||||
snapshot := controller.currentSnapshot()
|
||||
snapshot.State = StateFailed
|
||||
snapshot.LastError = &ErrorInfo{
|
||||
Code: code,
|
||||
Message: err.Error(),
|
||||
}
|
||||
if saveErr := controller.save(snapshot); saveErr != nil {
|
||||
return Snapshot{}, fmt.Errorf("%w: %w", err, saveErr)
|
||||
}
|
||||
return snapshot, err
|
||||
}
|
||||
242
pkg/wails3kit/updates/controller_test.go
Normal file
242
pkg/wails3kit/updates/controller_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
version, err := NormalizeVersion("1.2.3")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeVersion returned error: %v", err)
|
||||
}
|
||||
if version != "v1.2.3" {
|
||||
t.Fatalf("NormalizeVersion returned %q", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerCheckAndDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
platform := &fakePlatform{
|
||||
root: InstallRoot{Path: t.TempDir()},
|
||||
stage: StagedArtifact{
|
||||
Path: t.TempDir(),
|
||||
Root: InstallRoot{Path: t.TempDir()},
|
||||
Bundle: BundleManifest{
|
||||
SchemaVersion: 1,
|
||||
EntryPoint: "App",
|
||||
Files: []BundleFile{{Path: "App", Mode: "0755"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
release := &Release{
|
||||
ID: "1.1.0",
|
||||
Version: "1.1.0",
|
||||
Channel: ChannelStable,
|
||||
Artifact: Artifact{
|
||||
Kind: ArtifactKindBundleArchive,
|
||||
Format: ArtifactFormatZip,
|
||||
URL: "https://example.invalid/app.zip",
|
||||
SHA256: "deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
controller, err := NewController(Config{
|
||||
App: AppDescriptor{
|
||||
ProductID: "com.example.app",
|
||||
CurrentVersion: "1.0.0",
|
||||
Channel: ChannelStable,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
ExecutablePath: filepath.Join(t.TempDir(), "App"),
|
||||
},
|
||||
Provider: fakeProvider{release: release},
|
||||
Downloader: fakeDownloader{file: DownloadedFile{Path: "bundle.zip", SHA256: "deadbeef"}},
|
||||
Store: &memoryStore{},
|
||||
Platform: platform,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewController returned error: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := controller.Check(context.Background(), CheckRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("Check returned error: %v", err)
|
||||
}
|
||||
if snapshot.State != StateUpdateAvailable {
|
||||
t.Fatalf("Check state = %s", snapshot.State)
|
||||
}
|
||||
|
||||
snapshot, err = controller.Download(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
if snapshot.State != StateReadyToApply {
|
||||
t.Fatalf("Download state = %s", snapshot.State)
|
||||
}
|
||||
if snapshot.Staged == nil {
|
||||
t.Fatal("Download did not stage artifact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := make(chan struct{})
|
||||
controller, err := NewController(Config{
|
||||
App: AppDescriptor{
|
||||
ProductID: "com.example.app",
|
||||
CurrentVersion: "1.0.0",
|
||||
Channel: ChannelStable,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
ExecutablePath: filepath.Join(t.TempDir(), "App"),
|
||||
},
|
||||
Provider: fakeProvider{
|
||||
resolveFunc: func(context.Context, ResolveRequest) (*Release, error) {
|
||||
<-block
|
||||
return nil, nil
|
||||
},
|
||||
},
|
||||
Downloader: fakeDownloader{},
|
||||
Store: &memoryStore{},
|
||||
Platform: &fakePlatform{root: InstallRoot{Path: t.TempDir()}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewController returned error: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := controller.Check(context.Background(), CheckRequest{})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := controller.Check(context.Background(), CheckRequest{}); !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("expected ErrBusy, got %v", err)
|
||||
}
|
||||
close(block)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("first Check returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerCleansStagedUpdateOnMatchingVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &memoryStore{}
|
||||
err := store.Save(context.Background(), Snapshot{
|
||||
State: StateRestarting,
|
||||
Staged: &StagedArtifact{
|
||||
Release: Release{Version: "1.2.0"},
|
||||
},
|
||||
Candidate: &Release{Version: "1.2.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Save returned error: %v", err)
|
||||
}
|
||||
|
||||
controller, err := NewController(Config{
|
||||
App: AppDescriptor{
|
||||
ProductID: "com.example.app",
|
||||
CurrentVersion: "1.2.0",
|
||||
Channel: ChannelStable,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
ExecutablePath: filepath.Join(t.TempDir(), "App"),
|
||||
},
|
||||
Provider: fakeProvider{},
|
||||
Downloader: fakeDownloader{},
|
||||
Store: store,
|
||||
Platform: &fakePlatform{root: InstallRoot{Path: t.TempDir()}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewController returned error: %v", err)
|
||||
}
|
||||
|
||||
snapshot := controller.Snapshot()
|
||||
if snapshot.Staged != nil {
|
||||
t.Fatal("expected staged update to be cleared")
|
||||
}
|
||||
if snapshot.State != StateUpToDate {
|
||||
t.Fatalf("snapshot state = %s", snapshot.State)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProvider struct {
|
||||
release *Release
|
||||
resolveFunc func(context.Context, ResolveRequest) (*Release, error)
|
||||
}
|
||||
|
||||
func (provider fakeProvider) Resolve(ctx context.Context, req ResolveRequest) (*Release, error) {
|
||||
if provider.resolveFunc != nil {
|
||||
return provider.resolveFunc(ctx, req)
|
||||
}
|
||||
return provider.release, nil
|
||||
}
|
||||
|
||||
func (provider fakeProvider) OpenArtifact(context.Context, Release) (io.ReadCloser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeDownloader struct {
|
||||
file DownloadedFile
|
||||
err error
|
||||
}
|
||||
|
||||
func (downloader fakeDownloader) Download(context.Context, Artifact) (DownloadedFile, error) {
|
||||
return downloader.file, downloader.err
|
||||
}
|
||||
|
||||
type fakePlatform struct {
|
||||
root InstallRoot
|
||||
stage StagedArtifact
|
||||
err error
|
||||
}
|
||||
|
||||
func (platform *fakePlatform) DetectInstallRoot(AppDescriptor) (InstallRoot, error) {
|
||||
return platform.root, platform.err
|
||||
}
|
||||
|
||||
func (platform *fakePlatform) Stage(context.Context, InstallRoot, DownloadedFile, Release) (StagedArtifact, error) {
|
||||
return platform.stage, platform.err
|
||||
}
|
||||
|
||||
func (platform *fakePlatform) SpawnApplyAndRestart(context.Context, ApplyRequest) error {
|
||||
return platform.err
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
snapshot Snapshot
|
||||
}
|
||||
|
||||
func (store *memoryStore) Load(context.Context) (Snapshot, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return store.snapshot, nil
|
||||
}
|
||||
|
||||
func (store *memoryStore) Save(_ context.Context, snapshot Snapshot) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.snapshot = snapshot
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *memoryStore) ClearStaged(context.Context) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.snapshot.Staged = nil
|
||||
store.snapshot.Candidate = nil
|
||||
return nil
|
||||
}
|
||||
58
pkg/wails3kit/updates/downloader.go
Normal file
58
pkg/wails3kit/updates/downloader.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
type HTTPDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPDownloader(client *http.Client) *HTTPDownloader {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &HTTPDownloader{client: client}
|
||||
}
|
||||
|
||||
func (downloader *HTTPDownloader) Download(ctx context.Context, artifact Artifact) (DownloadedFile, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, artifact.URL, nil)
|
||||
if err != nil {
|
||||
return DownloadedFile{}, err
|
||||
}
|
||||
|
||||
response, err := downloader.client.Do(request)
|
||||
if err != nil {
|
||||
return DownloadedFile{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return DownloadedFile{}, fmt.Errorf("unexpected download status: %s", response.Status)
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp("", "wails3kit-download-*")
|
||||
if err != nil {
|
||||
return DownloadedFile{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(file, hash), response.Body)
|
||||
if err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return DownloadedFile{}, err
|
||||
}
|
||||
|
||||
return DownloadedFile{
|
||||
Path: file.Name(),
|
||||
Size: written,
|
||||
SHA256: hex.EncodeToString(hash.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
13
pkg/wails3kit/updates/errors.go
Normal file
13
pkg/wails3kit/updates/errors.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package updates
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrBusy = errors.New("update operation already in progress")
|
||||
ErrNoUpdate = errors.New("no update available")
|
||||
ErrNoStagedUpdate = errors.New("no staged update available")
|
||||
ErrInvalidConfig = errors.New("invalid update configuration")
|
||||
ErrInvalidVersion = errors.New("invalid semantic version")
|
||||
ErrInvalidArtifact = errors.New("invalid artifact")
|
||||
ErrChecksumMismatch = errors.New("artifact checksum mismatch")
|
||||
)
|
||||
22
pkg/wails3kit/updates/platform/BUILD.bazel
Normal file
22
pkg/wails3kit/updates/platform/BUILD.bazel
Normal file
@@ -0,0 +1,22 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "platform",
|
||||
srcs = [
|
||||
"apply.go",
|
||||
"archive.go",
|
||||
"installer.go",
|
||||
],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//pkg/wails3kit/updates",
|
||||
],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "platform_test",
|
||||
srcs = ["platform_test.go"],
|
||||
embed = [":platform"],
|
||||
deps = ["//pkg/wails3kit/updates"],
|
||||
)
|
||||
119
pkg/wails3kit/updates/platform/apply.go
Normal file
119
pkg/wails3kit/updates/platform/apply.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
type restorePoint struct {
|
||||
path string
|
||||
backup string
|
||||
hadPrior bool
|
||||
}
|
||||
|
||||
func applyBundle(request helperRequest) error {
|
||||
backupDir, err := os.MkdirTemp("", "wails3kit-backup-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
restores := make([]restorePoint, 0, len(request.Bundle.Files))
|
||||
for _, file := range request.Bundle.Files {
|
||||
source := filepath.Join(request.StagedPath, filepath.FromSlash(file.Path))
|
||||
target := filepath.Join(request.InstallRoot, filepath.FromSlash(file.Path))
|
||||
modeValue, err := strconv.ParseUint(file.Mode, 8, 32)
|
||||
if err != nil {
|
||||
rollback(restores)
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(modeValue)
|
||||
|
||||
restore, err := backupTarget(backupDir, target)
|
||||
if err != nil {
|
||||
rollback(restores)
|
||||
return err
|
||||
}
|
||||
restores = append(restores, restore)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
rollback(restores)
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
rollback(restores)
|
||||
return err
|
||||
}
|
||||
if err := copyFile(source, target, mode); err != nil {
|
||||
rollback(restores)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupTarget(backupDir string, target string) (restorePoint, error) {
|
||||
point := restorePoint{path: target}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return point, nil
|
||||
}
|
||||
return point, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return point, fmt.Errorf("%w: target %s is a directory", updates.ErrInvalidArtifact, target)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(backupDir, filepath.Base(target))
|
||||
if err := copyFile(target, backupPath, info.Mode()); err != nil {
|
||||
return point, err
|
||||
}
|
||||
point.hadPrior = true
|
||||
point.backup = backupPath
|
||||
return point, nil
|
||||
}
|
||||
|
||||
func rollback(restores []restorePoint) {
|
||||
for index := len(restores) - 1; index >= 0; index-- {
|
||||
restore := restores[index]
|
||||
if restore.hadPrior {
|
||||
info, err := os.Stat(restore.backup)
|
||||
if err == nil {
|
||||
_ = os.RemoveAll(restore.path)
|
||||
_ = os.MkdirAll(filepath.Dir(restore.path), 0o755)
|
||||
_ = copyFile(restore.backup, restore.path, info.Mode())
|
||||
}
|
||||
continue
|
||||
}
|
||||
_ = os.RemoveAll(restore.path)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(sourcePath string, destinationPath string, mode os.FileMode) error {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
return writeFile(destinationPath, source, mode)
|
||||
}
|
||||
|
||||
func writeFile(path string, source io.Reader, mode os.FileMode) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, source); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Chmod(mode)
|
||||
}
|
||||
188
pkg/wails3kit/updates/platform/archive.go
Normal file
188
pkg/wails3kit/updates/platform/archive.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
func extractBundle(_ context.Context, archivePath string, format updates.ArtifactFormat) (string, updates.BundleManifest, error) {
|
||||
stageDir, err := os.MkdirTemp("", "wails3kit-stage-*")
|
||||
if err != nil {
|
||||
return "", updates.BundleManifest{}, err
|
||||
}
|
||||
|
||||
switch format {
|
||||
case updates.ArtifactFormatZip:
|
||||
err = extractZip(archivePath, stageDir)
|
||||
case updates.ArtifactFormatTarGz:
|
||||
err = extractTarGz(archivePath, stageDir)
|
||||
default:
|
||||
err = fmt.Errorf("%w: unsupported format %s", updates.ErrInvalidArtifact, format)
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
return "", updates.BundleManifest{}, err
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(stageDir, "bundle.json")
|
||||
bytes, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
return "", updates.BundleManifest{}, err
|
||||
}
|
||||
|
||||
var manifest updates.BundleManifest
|
||||
if err := json.Unmarshal(bytes, &manifest); err != nil {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
return "", updates.BundleManifest{}, err
|
||||
}
|
||||
|
||||
if err := validateBundle(stageDir, manifest); err != nil {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
return "", updates.BundleManifest{}, err
|
||||
}
|
||||
|
||||
return stageDir, manifest, nil
|
||||
}
|
||||
|
||||
func extractZip(archivePath string, targetDir string) error {
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
path, err := safeArchivePath(targetDir, file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(path, source, file.Mode()); err != nil {
|
||||
_ = source.Close()
|
||||
return err
|
||||
}
|
||||
_ = source.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(archivePath string, targetDir string) error {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
reader := tar.NewReader(gzipReader)
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := safeArchivePath(targetDir, header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(path, reader, os.FileMode(header.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported tar entry %s", updates.ErrInvalidArtifact, header.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateBundle(stageDir string, manifest updates.BundleManifest) error {
|
||||
if manifest.SchemaVersion != 1 {
|
||||
return fmt.Errorf("%w: unsupported bundle schema %d", updates.ErrInvalidArtifact, manifest.SchemaVersion)
|
||||
}
|
||||
if !isSafeRelative(manifest.EntryPoint) {
|
||||
return fmt.Errorf("%w: invalid entrypoint", updates.ErrInvalidArtifact)
|
||||
}
|
||||
if len(manifest.Files) == 0 {
|
||||
return fmt.Errorf("%w: bundle contains no files", updates.ErrInvalidArtifact)
|
||||
}
|
||||
|
||||
for _, file := range manifest.Files {
|
||||
if !isSafeRelative(file.Path) {
|
||||
return fmt.Errorf("%w: invalid bundle path %s", updates.ErrInvalidArtifact, file.Path)
|
||||
}
|
||||
if _, err := strconv.ParseUint(file.Mode, 8, 32); err != nil {
|
||||
return fmt.Errorf("%w: invalid mode %s", updates.ErrInvalidArtifact, file.Mode)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(stageDir, filepath.FromSlash(file.Path)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%w: file entry %s is a directory", updates.ErrInvalidArtifact, file.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeArchivePath(root string, name string) (string, error) {
|
||||
if !isSafeRelative(name) && strings.TrimSpace(name) != "bundle.json" {
|
||||
return "", fmt.Errorf("%w: unsafe archive path %s", updates.ErrInvalidArtifact, name)
|
||||
}
|
||||
return filepath.Join(root, filepath.FromSlash(name)), nil
|
||||
}
|
||||
|
||||
func isSafeRelative(path string) bool {
|
||||
cleaned := filepath.Clean(filepath.FromSlash(path))
|
||||
if cleaned == "." || cleaned == "" {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(cleaned) {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(cleaned, "..")
|
||||
}
|
||||
12
pkg/wails3kit/updates/platform/darwin/BUILD.bazel
Normal file
12
pkg/wails3kit/updates/platform/darwin/BUILD.bazel
Normal file
@@ -0,0 +1,12 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "darwin",
|
||||
srcs = ["darwin.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform/darwin",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//pkg/wails3kit/updates",
|
||||
"//pkg/wails3kit/updates/platform",
|
||||
],
|
||||
)
|
||||
31
pkg/wails3kit/updates/platform/darwin/darwin.go
Normal file
31
pkg/wails3kit/updates/platform/darwin/darwin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package darwin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform"
|
||||
)
|
||||
|
||||
func New() updates.PlatformInstaller {
|
||||
return platform.New(DetectInstallRoot)
|
||||
}
|
||||
|
||||
func DetectInstallRoot(app updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
if app.ExecutablePath == "" {
|
||||
return updates.InstallRoot{}, errors.New("missing executable path")
|
||||
}
|
||||
|
||||
current := filepath.Dir(app.ExecutablePath)
|
||||
for {
|
||||
if filepath.Ext(current) == ".app" {
|
||||
return updates.InstallRoot{Path: current}, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return updates.InstallRoot{}, errors.New("unable to locate .app bundle")
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
172
pkg/wails3kit/updates/platform/installer.go
Normal file
172
pkg/wails3kit/updates/platform/installer.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
const helperFlag = "--wails3kit-update-helper"
|
||||
|
||||
type Detector func(app updates.AppDescriptor) (updates.InstallRoot, error)
|
||||
|
||||
type Installer struct {
|
||||
detector Detector
|
||||
}
|
||||
|
||||
func New(detector Detector) *Installer {
|
||||
return &Installer{detector: detector}
|
||||
}
|
||||
|
||||
func (installer *Installer) DetectInstallRoot(app updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
if installer.detector == nil {
|
||||
return updates.InstallRoot{}, errors.New("missing install root detector")
|
||||
}
|
||||
return installer.detector(app)
|
||||
}
|
||||
|
||||
func (installer *Installer) Stage(ctx context.Context, root updates.InstallRoot, file updates.DownloadedFile, release updates.Release) (updates.StagedArtifact, error) {
|
||||
stageDir, bundleManifest, err := extractBundle(ctx, file.Path, release.Artifact.Format)
|
||||
if err != nil {
|
||||
return updates.StagedArtifact{}, err
|
||||
}
|
||||
|
||||
return updates.StagedArtifact{
|
||||
Path: stageDir,
|
||||
Root: root,
|
||||
Release: release,
|
||||
Bundle: bundleManifest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (installer *Installer) SpawnApplyAndRestart(_ context.Context, req updates.ApplyRequest) error {
|
||||
helperDir, err := os.MkdirTemp("", "wails3kit-helper-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
helperBinary := filepath.Join(helperDir, filepath.Base(req.App.ExecutablePath))
|
||||
if err := copyFile(req.App.ExecutablePath, helperBinary, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := helperRequest{
|
||||
InstallRoot: req.Root.Path,
|
||||
StagedPath: req.Staged.Path,
|
||||
Bundle: req.Staged.Bundle,
|
||||
Args: append([]string(nil), req.App.Args...),
|
||||
WorkingDirectory: req.App.WorkingDirectory,
|
||||
}
|
||||
|
||||
requestPath := filepath.Join(helperDir, "request.json")
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(requestPath, encoded, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command := exec.Command(helperBinary, helperFlag, requestPath)
|
||||
command.Dir = helperDir
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
return command.Start()
|
||||
}
|
||||
|
||||
func MaybeRun(args []string) (bool, error) {
|
||||
if len(args) < 3 || args[1] != helperFlag {
|
||||
return false, nil
|
||||
}
|
||||
return true, runHelper(args[2])
|
||||
}
|
||||
|
||||
func NewCurrent() (*Installer, error) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return New(detectDarwinInstallRoot), nil
|
||||
case "windows":
|
||||
return New(detectDirectoryInstallRoot), nil
|
||||
case "linux":
|
||||
return New(detectDirectoryInstallRoot), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
type helperRequest struct {
|
||||
InstallRoot string `json:"installRoot"`
|
||||
StagedPath string `json:"stagedPath"`
|
||||
Bundle updates.BundleManifest `json:"bundle"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
||||
}
|
||||
|
||||
func runHelper(requestPath string) error {
|
||||
bytes, err := os.ReadFile(requestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var request helperRequest
|
||||
if err := json.Unmarshal(bytes, &request); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var applyErr error
|
||||
for time.Now().Before(deadline) {
|
||||
applyErr = applyBundle(request)
|
||||
if applyErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
|
||||
relaunch := filepath.Join(request.InstallRoot, filepath.FromSlash(request.Bundle.EntryPoint))
|
||||
command := exec.Command(relaunch, request.Args...)
|
||||
if request.WorkingDirectory != "" {
|
||||
command.Dir = request.WorkingDirectory
|
||||
} else {
|
||||
command.Dir = request.InstallRoot
|
||||
}
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
return command.Start()
|
||||
}
|
||||
|
||||
func detectDirectoryInstallRoot(app updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
if app.ExecutablePath == "" {
|
||||
return updates.InstallRoot{}, errors.New("missing executable path")
|
||||
}
|
||||
return updates.InstallRoot{Path: filepath.Dir(app.ExecutablePath)}, nil
|
||||
}
|
||||
|
||||
func detectDarwinInstallRoot(app updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
if app.ExecutablePath == "" {
|
||||
return updates.InstallRoot{}, errors.New("missing executable path")
|
||||
}
|
||||
|
||||
current := filepath.Dir(app.ExecutablePath)
|
||||
for {
|
||||
if filepath.Ext(current) == ".app" {
|
||||
return updates.InstallRoot{Path: current}, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return updates.InstallRoot{}, fmt.Errorf("unable to locate .app bundle from %s", app.ExecutablePath)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
12
pkg/wails3kit/updates/platform/linux/BUILD.bazel
Normal file
12
pkg/wails3kit/updates/platform/linux/BUILD.bazel
Normal file
@@ -0,0 +1,12 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "linux",
|
||||
srcs = ["linux.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform/linux",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//pkg/wails3kit/updates",
|
||||
"//pkg/wails3kit/updates/platform",
|
||||
],
|
||||
)
|
||||
20
pkg/wails3kit/updates/platform/linux/linux.go
Normal file
20
pkg/wails3kit/updates/platform/linux/linux.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package linux
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform"
|
||||
)
|
||||
|
||||
func New() updates.PlatformInstaller {
|
||||
return platform.New(DetectInstallRoot)
|
||||
}
|
||||
|
||||
func DetectInstallRoot(app updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
if app.ExecutablePath == "" {
|
||||
return updates.InstallRoot{}, errors.New("missing executable path")
|
||||
}
|
||||
return updates.InstallRoot{Path: filepath.Dir(app.ExecutablePath)}, nil
|
||||
}
|
||||
134
pkg/wails3kit/updates/platform/platform_test.go
Normal file
134
pkg/wails3kit/updates/platform/platform_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
func TestExtractBundleRejectsTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
archivePath := filepath.Join(t.TempDir(), "bundle.zip")
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
entry, err := writer.Create("../escape")
|
||||
if err != nil {
|
||||
t.Fatalf("Create entry returned error: %v", err)
|
||||
}
|
||||
if _, err := entry.Write([]byte("bad")); err != nil {
|
||||
t.Fatalf("Write returned error: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close writer returned error: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close file returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := extractBundle(t.Context(), archivePath, updates.ArtifactFormatZip); err == nil {
|
||||
t.Fatal("expected traversal archive to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBundleReplacesFilesAndPreservesArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
stage := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "resources"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "App"), []byte("old"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "resources", "config.json"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(stage, "resources"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "App"), []byte("new"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "resources", "config.json"), []byte("new"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
request := helperRequest{
|
||||
InstallRoot: root,
|
||||
StagedPath: stage,
|
||||
Bundle: updates.BundleManifest{
|
||||
SchemaVersion: 1,
|
||||
EntryPoint: "App",
|
||||
Files: []updates.BundleFile{
|
||||
{Path: "App", Mode: "0755"},
|
||||
{Path: "resources/config.json", Mode: "0644"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := applyBundle(request); err != nil {
|
||||
t.Fatalf("applyBundle returned error: %v", err)
|
||||
}
|
||||
|
||||
bytes, err := os.ReadFile(filepath.Join(root, "App"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile returned error: %v", err)
|
||||
}
|
||||
if string(bytes) != "new" {
|
||||
t.Fatalf("unexpected executable contents: %s", string(bytes))
|
||||
}
|
||||
bytes, err = os.ReadFile(filepath.Join(root, "resources", "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile returned error: %v", err)
|
||||
}
|
||||
if string(bytes) != "new" {
|
||||
t.Fatalf("unexpected resource contents: %s", string(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunIgnoresNormalArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handled, err := MaybeRun([]string{"app"})
|
||||
if err != nil {
|
||||
t.Fatalf("MaybeRun returned error: %v", err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected MaybeRun to ignore non-helper args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperRequestJSONRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := helperRequest{
|
||||
InstallRoot: "/tmp/app",
|
||||
StagedPath: "/tmp/stage",
|
||||
Bundle: updates.BundleManifest{
|
||||
SchemaVersion: 1,
|
||||
EntryPoint: "App",
|
||||
Files: []updates.BundleFile{{Path: "App", Mode: "0755"}},
|
||||
},
|
||||
Args: []string{"--flag"},
|
||||
}
|
||||
bytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
var decoded helperRequest
|
||||
if err := json.Unmarshal(bytes, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if decoded.Bundle.EntryPoint != "App" {
|
||||
t.Fatalf("unexpected entrypoint: %s", decoded.Bundle.EntryPoint)
|
||||
}
|
||||
}
|
||||
12
pkg/wails3kit/updates/platform/windows/BUILD.bazel
Normal file
12
pkg/wails3kit/updates/platform/windows/BUILD.bazel
Normal file
@@ -0,0 +1,12 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "windows",
|
||||
srcs = ["windows.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform/windows",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//pkg/wails3kit/updates",
|
||||
"//pkg/wails3kit/updates/platform",
|
||||
],
|
||||
)
|
||||
20
pkg/wails3kit/updates/platform/windows/windows.go
Normal file
20
pkg/wails3kit/updates/platform/windows/windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates/platform"
|
||||
)
|
||||
|
||||
func New() updates.PlatformInstaller {
|
||||
return platform.New(DetectInstallRoot)
|
||||
}
|
||||
|
||||
func DetectInstallRoot(app updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
if app.ExecutablePath == "" {
|
||||
return updates.InstallRoot{}, errors.New("missing executable path")
|
||||
}
|
||||
return updates.InstallRoot{Path: filepath.Dir(app.ExecutablePath)}, nil
|
||||
}
|
||||
16
pkg/wails3kit/updates/providers/githubreleases/BUILD.bazel
Normal file
16
pkg/wails3kit/updates/providers/githubreleases/BUILD.bazel
Normal file
@@ -0,0 +1,16 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "githubreleases",
|
||||
srcs = ["provider.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/providers/githubreleases",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = ["//pkg/wails3kit/updates"],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "githubreleases_test",
|
||||
srcs = ["provider_test.go"],
|
||||
embed = [":githubreleases"],
|
||||
deps = ["//pkg/wails3kit/updates"],
|
||||
)
|
||||
259
pkg/wails3kit/updates/providers/githubreleases/provider.go
Normal file
259
pkg/wails3kit/updates/providers/githubreleases/provider.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package githubreleases
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAssetTemplate = "{{ .ProductID }}_{{ .Version }}_{{ .OS }}_{{ .Arch }}.zip"
|
||||
defaultChecksumAssetTemplate = "{{ .AssetName }}.sha256"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Owner string
|
||||
Repo string
|
||||
HTTPClient *http.Client
|
||||
PrepareRequest func(*http.Request) error
|
||||
AssetNameTemplate string
|
||||
ChecksumAssetNameTemplate string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
config Config
|
||||
client *http.Client
|
||||
assetNameTemplate *template.Template
|
||||
checksumNameTemplate *template.Template
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
if cfg.Owner == "" || cfg.Repo == "" {
|
||||
return nil, updates.ErrInvalidConfig
|
||||
}
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
assetTemplate := cfg.AssetNameTemplate
|
||||
if assetTemplate == "" {
|
||||
assetTemplate = defaultAssetTemplate
|
||||
}
|
||||
checksumTemplate := cfg.ChecksumAssetNameTemplate
|
||||
if checksumTemplate == "" {
|
||||
checksumTemplate = defaultChecksumAssetTemplate
|
||||
}
|
||||
|
||||
assetNameTemplate, err := template.New("asset").Parse(assetTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checksumNameTemplate, err := template.New("checksum").Parse(checksumTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
config: cfg,
|
||||
client: client,
|
||||
assetNameTemplate: assetNameTemplate,
|
||||
checksumNameTemplate: checksumNameTemplate,
|
||||
baseURL: "https://api.github.com",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func (provider *Provider) Resolve(ctx context.Context, req updates.ResolveRequest) (*updates.Release, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.releaseURL(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if provider.config.PrepareRequest != nil {
|
||||
if err := provider.config.PrepareRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
response, err := provider.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unexpected github status: %s", response.Status)
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err := json.NewDecoder(response.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var best *updates.Release
|
||||
for _, release := range releases {
|
||||
version := strings.TrimPrefix(release.TagName, "v")
|
||||
channel := mapChannel(release)
|
||||
if channel != req.App.Channel {
|
||||
continue
|
||||
}
|
||||
comparison, err := updates.CompareVersions(version, req.App.CurrentVersion)
|
||||
if err != nil || comparison <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
assetName, err := provider.renderAssetName(req.App, version, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checksumName, err := provider.renderChecksumName(req.App, version, assetName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artifactURL := ""
|
||||
checksumURL := ""
|
||||
for _, asset := range release.Assets {
|
||||
if asset.Name == assetName {
|
||||
artifactURL = asset.BrowserDownloadURL
|
||||
}
|
||||
if asset.Name == checksumName {
|
||||
checksumURL = asset.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
if artifactURL == "" || checksumURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sha256Value, err := provider.readChecksum(ctx, checksumURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
candidate := &updates.Release{
|
||||
ID: version,
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
NotesMarkdown: release.Body,
|
||||
PublishedAt: release.PublishedAt,
|
||||
Artifact: updates.Artifact{
|
||||
Kind: updates.ArtifactKindBundleArchive,
|
||||
Format: updates.ArtifactFormatZip,
|
||||
URL: artifactURL,
|
||||
SHA256: sha256Value,
|
||||
},
|
||||
}
|
||||
if best == nil {
|
||||
best = candidate
|
||||
continue
|
||||
}
|
||||
better, err := updates.CompareVersions(candidate.Version, best.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if better > 0 {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) OpenArtifact(ctx context.Context, release updates.Release) (io.ReadCloser, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, release.Artifact.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if provider.config.PrepareRequest != nil {
|
||||
if err := provider.config.PrepareRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
response, err := provider.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
defer response.Body.Close()
|
||||
return nil, fmt.Errorf("unexpected artifact status: %s", response.Status)
|
||||
}
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) readChecksum(ctx context.Context, url string) (string, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if provider.config.PrepareRequest != nil {
|
||||
if err := provider.config.PrepareRequest(request); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
response, err := provider.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("unexpected checksum status: %s", response.Status)
|
||||
}
|
||||
bytes, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.Fields(string(bytes))[0], nil
|
||||
}
|
||||
|
||||
func (provider *Provider) releaseURL() string {
|
||||
return fmt.Sprintf("%s/repos/%s/%s/releases", provider.baseURL, provider.config.Owner, provider.config.Repo)
|
||||
}
|
||||
|
||||
func (provider *Provider) renderAssetName(app updates.AppDescriptor, version string, assetName string) (string, error) {
|
||||
return provider.executeTemplate(provider.assetNameTemplate, app, version, assetName)
|
||||
}
|
||||
|
||||
func (provider *Provider) renderChecksumName(app updates.AppDescriptor, version string, assetName string) (string, error) {
|
||||
return provider.executeTemplate(provider.checksumNameTemplate, app, version, assetName)
|
||||
}
|
||||
|
||||
func (provider *Provider) executeTemplate(templateValue *template.Template, app updates.AppDescriptor, version string, assetName string) (string, error) {
|
||||
var builder strings.Builder
|
||||
err := templateValue.Execute(&builder, map[string]string{
|
||||
"ProductID": app.ProductID,
|
||||
"Version": version,
|
||||
"OS": app.OS,
|
||||
"Arch": app.Arch,
|
||||
"AssetName": assetName,
|
||||
})
|
||||
return builder.String(), err
|
||||
}
|
||||
|
||||
func mapChannel(release githubRelease) updates.Channel {
|
||||
if !release.Prerelease {
|
||||
return updates.ChannelStable
|
||||
}
|
||||
if strings.Contains(strings.ToLower(release.TagName), "alpha") {
|
||||
return updates.ChannelAlpha
|
||||
}
|
||||
return updates.ChannelBeta
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package githubreleases
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
func TestResolveSelectsReleaseFromGitHubAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/repos/owner/repo/releases":
|
||||
_, _ = writer.Write([]byte(`[
|
||||
{
|
||||
"tag_name": "v1.2.0",
|
||||
"body": "notes",
|
||||
"prerelease": false,
|
||||
"published_at": "2026-03-01T03:10:56Z",
|
||||
"assets": [
|
||||
{"name": "com.example.app_1.2.0_linux_amd64.zip", "browser_download_url": "` + serverURL + `/artifact.zip"},
|
||||
{"name": "com.example.app_1.2.0_linux_amd64.zip.sha256", "browser_download_url": "` + serverURL + `/artifact.zip.sha256"}
|
||||
]
|
||||
}
|
||||
]`))
|
||||
case "/artifact.zip.sha256":
|
||||
_, _ = writer.Write([]byte("abcdef artifact.zip"))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
serverURL = server.URL
|
||||
|
||||
provider, err := New(Config{
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
provider.baseURL = server.URL
|
||||
|
||||
release, err := provider.Resolve(context.Background(), updates.ResolveRequest{
|
||||
App: updates.AppDescriptor{
|
||||
ProductID: "com.example.app",
|
||||
CurrentVersion: "1.0.0",
|
||||
Channel: updates.ChannelStable,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if release == nil {
|
||||
t.Fatal("Resolve returned nil release")
|
||||
}
|
||||
if release.Artifact.URL != server.URL+"/artifact.zip" {
|
||||
t.Fatalf("unexpected artifact url: %s", release.Artifact.URL)
|
||||
}
|
||||
if release.Artifact.SHA256 != "abcdef" {
|
||||
t.Fatalf("unexpected checksum: %s", release.Artifact.SHA256)
|
||||
}
|
||||
}
|
||||
19
pkg/wails3kit/updates/providers/httpmanifest/BUILD.bazel
Normal file
19
pkg/wails3kit/updates/providers/httpmanifest/BUILD.bazel
Normal file
@@ -0,0 +1,19 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "httpmanifest",
|
||||
srcs = [
|
||||
"provider.go",
|
||||
"time.go",
|
||||
],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/providers/httpmanifest",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = ["//pkg/wails3kit/updates"],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "httpmanifest_test",
|
||||
srcs = ["provider_test.go"],
|
||||
embed = [":httpmanifest"],
|
||||
deps = ["//pkg/wails3kit/updates"],
|
||||
)
|
||||
158
pkg/wails3kit/updates/providers/httpmanifest/provider.go
Normal file
158
pkg/wails3kit/updates/providers/httpmanifest/provider.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package httpmanifest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ManifestURL string
|
||||
HTTPClient *http.Client
|
||||
PrepareRequest func(*http.Request) error
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
config Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
if cfg.ManifestURL == "" {
|
||||
return nil, updates.ErrInvalidConfig
|
||||
}
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &Provider{config: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
type manifestDocument struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ProductID string `json:"productID"`
|
||||
Releases []manifestRelease `json:"releases"`
|
||||
}
|
||||
|
||||
type manifestRelease struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Channel updates.Channel `json:"channel"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
NotesMarkdown string `json:"notesMarkdown"`
|
||||
Artifacts []manifestArtifact `json:"artifacts"`
|
||||
}
|
||||
|
||||
type manifestArtifact struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Kind updates.ArtifactKind `json:"kind"`
|
||||
Format updates.ArtifactFormat `json:"format"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func (provider *Provider) Resolve(ctx context.Context, req updates.ResolveRequest) (*updates.Release, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.config.ManifestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if provider.config.PrepareRequest != nil {
|
||||
if err := provider.config.PrepareRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
response, err := provider.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unexpected manifest status: %s", response.Status)
|
||||
}
|
||||
|
||||
var document manifestDocument
|
||||
if err := json.NewDecoder(response.Body).Decode(&document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if document.ProductID != req.App.ProductID {
|
||||
return nil, fmt.Errorf("manifest product mismatch: %s", document.ProductID)
|
||||
}
|
||||
|
||||
var best *updates.Release
|
||||
for _, release := range document.Releases {
|
||||
if release.Channel != req.App.Channel {
|
||||
continue
|
||||
}
|
||||
comparison, err := updates.CompareVersions(release.Version, req.App.CurrentVersion)
|
||||
if err != nil || comparison <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, artifact := range release.Artifacts {
|
||||
if artifact.OS != req.App.OS || artifact.Arch != req.App.Arch {
|
||||
continue
|
||||
}
|
||||
parsedPublishedAt, err := parseReleaseTime(release.PublishedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidate := &updates.Release{
|
||||
ID: release.ID,
|
||||
Version: release.Version,
|
||||
Channel: release.Channel,
|
||||
NotesMarkdown: release.NotesMarkdown,
|
||||
PublishedAt: parsedPublishedAt,
|
||||
Artifact: updates.Artifact{
|
||||
Kind: artifact.Kind,
|
||||
Format: artifact.Format,
|
||||
URL: artifact.URL,
|
||||
SHA256: artifact.SHA256,
|
||||
Size: artifact.Size,
|
||||
},
|
||||
}
|
||||
if best == nil {
|
||||
best = candidate
|
||||
continue
|
||||
}
|
||||
better, err := updates.CompareVersions(candidate.Version, best.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if better > 0 {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) OpenArtifact(ctx context.Context, release updates.Release) (io.ReadCloser, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, release.Artifact.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if provider.config.PrepareRequest != nil {
|
||||
if err := provider.config.PrepareRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
response, err := provider.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
defer response.Body.Close()
|
||||
return nil, fmt.Errorf("unexpected artifact status: %s", response.Status)
|
||||
}
|
||||
return response.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package httpmanifest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
func TestResolveRequiresAuthAndSelectsNewest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "Bearer token" {
|
||||
http.Error(writer, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"schemaVersion": 1,
|
||||
"productID": "com.example.app",
|
||||
"releases": [
|
||||
{
|
||||
"id": "1.1.0",
|
||||
"version": "1.1.0",
|
||||
"channel": "stable",
|
||||
"publishedAt": "2026-03-01T03:10:56Z",
|
||||
"artifacts": [
|
||||
{"os": "darwin", "arch": "arm64", "kind": "bundle-archive", "format": "zip", "url": "https://example.invalid/1.1.0.zip", "sha256": "111"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1.2.0",
|
||||
"version": "1.2.0",
|
||||
"channel": "stable",
|
||||
"publishedAt": "2026-03-02T03:10:56Z",
|
||||
"artifacts": [
|
||||
{"os": "darwin", "arch": "arm64", "kind": "bundle-archive", "format": "zip", "url": "https://example.invalid/1.2.0.zip", "sha256": "222"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Config{
|
||||
ManifestURL: server.URL,
|
||||
PrepareRequest: func(request *http.Request) error {
|
||||
request.Header.Set("Authorization", "Bearer token")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
release, err := provider.Resolve(context.Background(), updates.ResolveRequest{
|
||||
App: updates.AppDescriptor{
|
||||
ProductID: "com.example.app",
|
||||
CurrentVersion: "1.0.0",
|
||||
Channel: updates.ChannelStable,
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if release == nil || release.Version != "1.2.0" {
|
||||
t.Fatalf("Resolve selected %+v", release)
|
||||
}
|
||||
}
|
||||
7
pkg/wails3kit/updates/providers/httpmanifest/time.go
Normal file
7
pkg/wails3kit/updates/providers/httpmanifest/time.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package httpmanifest
|
||||
|
||||
import "time"
|
||||
|
||||
func parseReleaseTime(value string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339, value)
|
||||
}
|
||||
35
pkg/wails3kit/updates/semver.go
Normal file
35
pkg/wails3kit/updates/semver.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
func NormalizeVersion(version string) (string, error) {
|
||||
trimmed := strings.TrimSpace(version)
|
||||
if trimmed == "" {
|
||||
return "", ErrInvalidVersion
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "v") {
|
||||
trimmed = "v" + trimmed
|
||||
}
|
||||
if !semver.IsValid(trimmed) {
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidVersion, version)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func CompareVersions(left string, right string) (int, error) {
|
||||
normalizedLeft, err := NormalizeVersion(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalizedRight, err := NormalizeVersion(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return semver.Compare(normalizedLeft, normalizedRight), nil
|
||||
}
|
||||
9
pkg/wails3kit/updates/storage/file/BUILD.bazel
Normal file
9
pkg/wails3kit/updates/storage/file/BUILD.bazel
Normal file
@@ -0,0 +1,9 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "file",
|
||||
srcs = ["store.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/storage/file",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = ["//pkg/wails3kit/updates"],
|
||||
)
|
||||
91
pkg/wails3kit/updates/storage/file/store.go
Normal file
91
pkg/wails3kit/updates/storage/file/store.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (store *Store) Path() string {
|
||||
return store.path
|
||||
}
|
||||
|
||||
func (store *Store) Load(_ context.Context) (updates.Snapshot, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
bytes, err := os.ReadFile(store.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return updates.Snapshot{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return updates.Snapshot{}, err
|
||||
}
|
||||
|
||||
var snapshot updates.Snapshot
|
||||
if err := json.Unmarshal(bytes, &snapshot); err != nil {
|
||||
return updates.Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (store *Store) Save(_ context.Context, snapshot updates.Snapshot) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bytes, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(filepath.Dir(store.path), "snapshot-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(tempPath)
|
||||
}()
|
||||
|
||||
if _, err := tempFile.Write(bytes); err != nil {
|
||||
_ = tempFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tempPath, store.path)
|
||||
}
|
||||
|
||||
func (store *Store) ClearStaged(ctx context.Context) error {
|
||||
snapshot, err := store.Load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot.Staged = nil
|
||||
snapshot.Candidate = nil
|
||||
snapshot.LastError = nil
|
||||
if snapshot.State == updates.StateRestarting {
|
||||
snapshot.State = updates.StateUpToDate
|
||||
}
|
||||
return store.Save(ctx, snapshot)
|
||||
}
|
||||
157
pkg/wails3kit/updates/types.go
Normal file
157
pkg/wails3kit/updates/types.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
App AppDescriptor
|
||||
Provider ReleaseProvider
|
||||
Downloader Downloader
|
||||
Store SnapshotStore
|
||||
Platform PlatformInstaller
|
||||
Logger *slog.Logger
|
||||
AutoCheckOnStartup bool
|
||||
}
|
||||
|
||||
type AppDescriptor struct {
|
||||
ProductID string `json:"productID"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
Channel Channel `json:"channel"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
ExecutablePath string `json:"executablePath"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
ChannelStable Channel = "stable"
|
||||
ChannelBeta Channel = "beta"
|
||||
ChannelAlpha Channel = "alpha"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateIdle State = "idle"
|
||||
StateChecking State = "checking"
|
||||
StateUpToDate State = "up_to_date"
|
||||
StateUpdateAvailable State = "update_available"
|
||||
StateDownloading State = "downloading"
|
||||
StateDownloaded State = "downloaded"
|
||||
StateVerifying State = "verifying"
|
||||
StateReadyToApply State = "ready_to_apply"
|
||||
StateApplying State = "applying"
|
||||
StateRestarting State = "restarting"
|
||||
StateFailed State = "failed"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
State State `json:"state"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
Channel Channel `json:"channel"`
|
||||
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
|
||||
Candidate *Release `json:"candidate,omitempty"`
|
||||
Staged *StagedArtifact `json:"staged,omitempty"`
|
||||
LastError *ErrorInfo `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Channel Channel `json:"channel"`
|
||||
NotesMarkdown string `json:"notesMarkdown,omitempty"`
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
Artifact Artifact `json:"artifact"`
|
||||
}
|
||||
|
||||
type Artifact struct {
|
||||
Kind ArtifactKind `json:"kind"`
|
||||
Format ArtifactFormat `json:"format"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
type ArtifactKind string
|
||||
|
||||
const ArtifactKindBundleArchive ArtifactKind = "bundle-archive"
|
||||
|
||||
type ArtifactFormat string
|
||||
|
||||
const (
|
||||
ArtifactFormatZip ArtifactFormat = "zip"
|
||||
ArtifactFormatTarGz ArtifactFormat = "tar.gz"
|
||||
)
|
||||
|
||||
type ErrorInfo struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ResolveRequest struct {
|
||||
App AppDescriptor
|
||||
}
|
||||
|
||||
type CheckRequest struct{}
|
||||
|
||||
type DownloadedFile struct {
|
||||
Path string
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
type InstallRoot struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type BundleManifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
EntryPoint string `json:"entrypoint"`
|
||||
Files []BundleFile `json:"files"`
|
||||
}
|
||||
|
||||
type BundleFile struct {
|
||||
Path string `json:"path"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type StagedArtifact struct {
|
||||
Path string `json:"path"`
|
||||
Root InstallRoot `json:"root"`
|
||||
Release Release `json:"release"`
|
||||
Bundle BundleManifest `json:"bundle"`
|
||||
}
|
||||
|
||||
type ApplyRequest struct {
|
||||
App AppDescriptor
|
||||
Root InstallRoot
|
||||
Staged StagedArtifact
|
||||
}
|
||||
|
||||
type ReleaseProvider interface {
|
||||
Resolve(ctx context.Context, req ResolveRequest) (*Release, error)
|
||||
OpenArtifact(ctx context.Context, rel Release) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type SnapshotStore interface {
|
||||
Load(ctx context.Context) (Snapshot, error)
|
||||
Save(ctx context.Context, snapshot Snapshot) error
|
||||
ClearStaged(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Downloader interface {
|
||||
Download(ctx context.Context, artifact Artifact) (DownloadedFile, error)
|
||||
}
|
||||
|
||||
type PlatformInstaller interface {
|
||||
DetectInstallRoot(app AppDescriptor) (InstallRoot, error)
|
||||
Stage(ctx context.Context, root InstallRoot, file DownloadedFile, release Release) (StagedArtifact, error)
|
||||
SpawnApplyAndRestart(ctx context.Context, req ApplyRequest) error
|
||||
}
|
||||
23
pkg/wails3kit/updates/wails/BUILD.bazel
Normal file
23
pkg/wails3kit/updates/wails/BUILD.bazel
Normal file
@@ -0,0 +1,23 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "wails",
|
||||
srcs = ["service.go"],
|
||||
importpath = "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/wails",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//pkg/wails3kit/updates",
|
||||
"@com_github_wailsapp_wails_v3//pkg/application",
|
||||
],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "wails_test",
|
||||
srcs = ["service_test.go"],
|
||||
embed = [":wails"],
|
||||
deps = [
|
||||
"//pkg/wails3kit/updates",
|
||||
"//pkg/wails3kit/updates/storage/file",
|
||||
"@com_github_wailsapp_wails_v3//pkg/application",
|
||||
],
|
||||
)
|
||||
110
pkg/wails3kit/updates/wails/service.go
Normal file
110
pkg/wails3kit/updates/wails/service.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package wails
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
App *application.App
|
||||
Controller *updates.Controller
|
||||
EventName string
|
||||
AutoCheckOnStartup bool
|
||||
Emitter func(string, any)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
controller *updates.Controller
|
||||
eventName string
|
||||
autoCheckOnStartup bool
|
||||
emitter func(string, any)
|
||||
stop func()
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func RegisterEvents(eventName string) {
|
||||
if eventName == "" {
|
||||
eventName = "updates:state"
|
||||
}
|
||||
application.RegisterEvent[updates.Snapshot](eventName)
|
||||
}
|
||||
|
||||
func NewService(opts Options) *Service {
|
||||
eventName := opts.EventName
|
||||
if eventName == "" {
|
||||
eventName = "updates:state"
|
||||
}
|
||||
|
||||
emitter := opts.Emitter
|
||||
if emitter == nil && opts.App != nil {
|
||||
emitter = func(name string, data any) {
|
||||
opts.App.Event.Emit(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
return &Service{
|
||||
controller: opts.Controller,
|
||||
eventName: eventName,
|
||||
autoCheckOnStartup: opts.AutoCheckOnStartup,
|
||||
emitter: emitter,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) ServiceStartup(_ context.Context, _ application.ServiceOptions) error {
|
||||
if service.controller == nil {
|
||||
return updates.ErrInvalidConfig
|
||||
}
|
||||
|
||||
channel, stop := service.controller.Subscribe(4)
|
||||
service.stop = stop
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-service.done:
|
||||
return
|
||||
case snapshot := <-channel:
|
||||
if service.emitter != nil {
|
||||
service.emitter(service.eventName, snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if service.autoCheckOnStartup {
|
||||
go func() {
|
||||
_, _ = service.Check()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) ServiceShutdown() error {
|
||||
if service.stop != nil {
|
||||
service.stop()
|
||||
}
|
||||
select {
|
||||
case <-service.done:
|
||||
default:
|
||||
close(service.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) Snapshot() updates.Snapshot {
|
||||
return service.controller.Snapshot()
|
||||
}
|
||||
|
||||
func (service *Service) Check() (updates.Snapshot, error) {
|
||||
return service.controller.Check(context.Background(), updates.CheckRequest{})
|
||||
}
|
||||
|
||||
func (service *Service) Download() (updates.Snapshot, error) {
|
||||
return service.controller.Download(context.Background())
|
||||
}
|
||||
|
||||
func (service *Service) ApplyAndRestart() error {
|
||||
return service.controller.ApplyAndRestart(context.Background())
|
||||
}
|
||||
104
pkg/wails3kit/updates/wails/service_test.go
Normal file
104
pkg/wails3kit/updates/wails/service_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package wails
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Eriyc/rules_wails/pkg/wails3kit/updates"
|
||||
filestore "github.com/Eriyc/rules_wails/pkg/wails3kit/updates/storage/file"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
func TestServiceEmitsSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := &updates.Release{
|
||||
Version: "1.1.0",
|
||||
Channel: updates.ChannelStable,
|
||||
Artifact: updates.Artifact{
|
||||
Kind: updates.ArtifactKindBundleArchive,
|
||||
Format: updates.ArtifactFormatZip,
|
||||
URL: "https://example.invalid/app.zip",
|
||||
SHA256: "abc",
|
||||
},
|
||||
}
|
||||
controller, err := updates.NewController(updates.Config{
|
||||
App: updates.AppDescriptor{
|
||||
ProductID: "com.example.app",
|
||||
CurrentVersion: "1.0.0",
|
||||
Channel: updates.ChannelStable,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
ExecutablePath: filepath.Join(t.TempDir(), "App"),
|
||||
},
|
||||
Provider: serviceFakeProvider{release: release},
|
||||
Downloader: serviceFakeDownloader{},
|
||||
Store: filestore.New(filepath.Join(t.TempDir(), "snapshot.json")),
|
||||
Platform: &serviceFakePlatform{root: updates.InstallRoot{Path: t.TempDir()}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewController returned error: %v", err)
|
||||
}
|
||||
|
||||
emitted := make(chan updates.Snapshot, 2)
|
||||
service := NewService(Options{
|
||||
Controller: controller,
|
||||
Emitter: func(_ string, data any) {
|
||||
emitted <- data.(updates.Snapshot)
|
||||
},
|
||||
})
|
||||
if err := service.ServiceStartup(context.Background(), application.ServiceOptions{}); err != nil {
|
||||
t.Fatalf("ServiceStartup returned error: %v", err)
|
||||
}
|
||||
defer service.ServiceShutdown()
|
||||
|
||||
if _, err := service.Check(); err != nil {
|
||||
t.Fatalf("Check returned error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case snapshot := <-emitted:
|
||||
if snapshot.State == "" {
|
||||
t.Fatal("expected emitted snapshot state")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for emitted snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
type serviceFakeProvider struct {
|
||||
release *updates.Release
|
||||
}
|
||||
|
||||
func (provider serviceFakeProvider) Resolve(context.Context, updates.ResolveRequest) (*updates.Release, error) {
|
||||
return provider.release, nil
|
||||
}
|
||||
|
||||
func (provider serviceFakeProvider) OpenArtifact(context.Context, updates.Release) (io.ReadCloser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type serviceFakeDownloader struct{}
|
||||
|
||||
func (serviceFakeDownloader) Download(context.Context, updates.Artifact) (updates.DownloadedFile, error) {
|
||||
return updates.DownloadedFile{}, nil
|
||||
}
|
||||
|
||||
type serviceFakePlatform struct {
|
||||
root updates.InstallRoot
|
||||
}
|
||||
|
||||
func (platform *serviceFakePlatform) DetectInstallRoot(updates.AppDescriptor) (updates.InstallRoot, error) {
|
||||
return platform.root, nil
|
||||
}
|
||||
|
||||
func (platform *serviceFakePlatform) Stage(context.Context, updates.InstallRoot, updates.DownloadedFile, updates.Release) (updates.StagedArtifact, error) {
|
||||
return updates.StagedArtifact{}, nil
|
||||
}
|
||||
|
||||
func (platform *serviceFakePlatform) SpawnApplyAndRestart(context.Context, updates.ApplyRequest) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user