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