feat: initial commit

This commit is contained in:
eric
2026-03-12 22:16:34 +01:00
parent 8555b02752
commit f13f4a9a69
155 changed files with 11988 additions and 0 deletions

View 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 = [],
)

View 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"],
)

View 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)
}

View 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
}

View 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
}

View 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
}

View 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")
)

View 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"],
)

View 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)
}

View 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, "..")
}

View 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",
],
)

View 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
}
}

View 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
}
}

View 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",
],
)

View 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
}

View 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)
}
}

View 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",
],
)

View 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
}

View 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"],
)

View 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
}

View File

@@ -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)
}
}

View 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"],
)

View 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
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,7 @@
package httpmanifest
import "time"
func parseReleaseTime(value string) (time.Time, error) {
return time.Parse(time.RFC3339, value)
}

View 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
}

View 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"],
)

View 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)
}

View 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
}

View 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",
],
)

View 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())
}

View 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
}