From 8bff7af6bcfc563b8a1f96360826a7059c68ef6e Mon Sep 17 00:00:00 2001 From: every_holiday Date: Wed, 24 Jun 2026 02:08:19 +0900 Subject: [PATCH] Add release auto-update launcher --- .gitea/workflows/build-windows-exe.yml | 9 +- .../VRC_OSC/cmd/vrc_osc_launcher/main.go | 40 ++++++ .../VRC_OSC/internal/buildinfo/buildinfo.go | 6 + VRWT_Tool/VRC_OSC/internal/update/manager.go | 106 +++++++++++++++ VRWT_Tool/VRC_OSC/internal/update/release.go | 125 ++++++++++++++++++ 5 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go create mode 100644 VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go create mode 100644 VRWT_Tool/VRC_OSC/internal/update/manager.go create mode 100644 VRWT_Tool/VRC_OSC/internal/update/release.go diff --git a/.gitea/workflows/build-windows-exe.yml b/.gitea/workflows/build-windows-exe.yml index 02df60c..6d8f9f6 100644 --- a/.gitea/workflows/build-windows-exe.yml +++ b/.gitea/workflows/build-windows-exe.yml @@ -33,8 +33,11 @@ jobs: run: | cd VRWT_Tool/VRC_OSC mkdir -p dist - GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/vrc_osc.exe ./cmd/vrc_osc - GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/vrwt_tool.exe ./cmd/vrwt_tool + VERSION="${GITHUB_REF_NAME}" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc + GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher + GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool - name: Package executables shell: bash @@ -43,7 +46,7 @@ jobs: cp config/config.example.toml dist/config.example.toml cp config/secrets.example.toml dist/secrets.example.toml cp config/guests.example.txt dist/guests.example.txt - (cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt) + (cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt) - name: Create Release env: diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go new file mode 100644 index 0000000..87a681f --- /dev/null +++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + + "vrc_osc_go/internal/buildinfo" + "vrc_osc_go/internal/common" + "vrc_osc_go/internal/update" +) + +func main() { + var baseURL string + var ownerRepo string + var assetName string + flag.StringVar(&baseURL, "base-url", "https://git.vrcworldtour.com", "Gitea base URL") + flag.StringVar(&ownerRepo, "repo", "every_holiday/VRCWT-OSC", "owner/repo") + flag.StringVar(&assetName, "asset", "vrc_osc-windows.zip", "release asset name") + flag.Parse() + + client := &update.Client{BaseURL: baseURL} + mgr := &update.Manager{ + Client: client, + OwnerRepo: ownerRepo, + AssetName: assetName, + CurrentLabel: buildinfo.Version, + } + if _, err := mgr.CheckAndUpdate(); err != nil { + log.Printf("auto update skipped: %v", err) + } + exe := filepath.Join(common.RootDir(), "vrc_osc.exe") + if _, err := os.Stat(exe); err != nil { + log.Fatalf("missing exe: %v", err) + } + if err := update.Launch(exe, os.Args[1:]); err != nil { + log.Fatalf("launch failed: %v", err) + } +} diff --git a/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..f63e477 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go @@ -0,0 +1,6 @@ +package buildinfo + +var ( + Version = "dev" + BuildTime = "unknown" +) diff --git a/VRWT_Tool/VRC_OSC/internal/update/manager.go b/VRWT_Tool/VRC_OSC/internal/update/manager.go new file mode 100644 index 0000000..e377fab --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/update/manager.go @@ -0,0 +1,106 @@ +package update + +import ( + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + + "vrc_osc_go/internal/buildinfo" + "vrc_osc_go/internal/common" +) + +type Manager struct { + Client *Client + OwnerRepo string + AssetName string + CurrentLabel string +} + +func (m *Manager) CheckAndUpdate() (bool, error) { + if m.Client == nil || m.OwnerRepo == "" || m.AssetName == "" { + return false, nil + } + latest, err := m.Client.LatestRelease(m.OwnerRepo) + if err != nil { + return false, err + } + if latest.TagName == "" || latest.TagName == m.CurrentLabel { + return false, nil + } + assetURL := "" + for _, a := range latest.Assets { + if a.Name == m.AssetName { + assetURL = a.URL + break + } + } + if assetURL == "" { + return false, fmt.Errorf("asset %q not found in release %s", m.AssetName, latest.TagName) + } + base := common.RootDir() + tmpDir := filepath.Join(base, "update", latest.TagName) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return false, err + } + zipPath := filepath.Join(tmpDir, m.AssetName) + log.Printf("update available: %s -> %s", m.CurrentLabel, latest.TagName) + if err := m.Client.DownloadAsset(assetURL, zipPath); err != nil { + return false, err + } + if err := ExtractZip(zipPath, tmpDir); err != nil { + return false, err + } + exePath := filepath.Join(base, CurrentExeName()) + newExe := filepath.Join(tmpDir, CurrentExeName()) + if _, err := os.Stat(newExe); err != nil { + return false, err + } + backup := exePath + ".bak" + _ = os.Remove(backup) + if err := os.Rename(exePath, backup); err != nil { + return false, err + } + if err := CopyFile(newExe, exePath); err != nil { + _ = os.Rename(backup, exePath) + return false, err + } + _ = os.Remove(backup) + return true, nil +} + +func CopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func Launch(exe string, args []string) error { + cmd := exec.Command(exe, args...) + cmd.Dir = filepath.Dir(exe) + return cmd.Start() +} + +func Version() string { return buildinfo.Version } + +func CurrentExeName() string { + if runtime.GOOS == "windows" { + return "vrc_osc.exe" + } + return "vrc_osc" +} diff --git a/VRWT_Tool/VRC_OSC/internal/update/release.go b/VRWT_Tool/VRC_OSC/internal/update/release.go new file mode 100644 index 0000000..8fff9f1 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/update/release.go @@ -0,0 +1,125 @@ +package update + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type ReleaseInfo struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` +} + +type Client struct { + BaseURL string + HTTP *http.Client +} + +func (c *Client) LatestRelease(ownerRepo string) (*ReleaseInfo, error) { + req, err := http.NewRequest(http.MethodGet, c.apiURL(ownerRepo, "/releases/latest"), nil) + if err != nil { + return nil, err + } + var info ReleaseInfo + if err := c.doJSON(req, &info); err != nil { + return nil, err + } + return &info, nil +} + +func (c *Client) DownloadAsset(url, dst string) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: %s", resp.Status) + } + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} + +func ExtractZip(zipPath, dstDir string) error { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer zr.Close() + root := filepath.Clean(dstDir) + string(os.PathSeparator) + for _, f := range zr.File { + target := filepath.Clean(filepath.Join(dstDir, f.Name)) + if target != filepath.Clean(dstDir) && !strings.HasPrefix(target, root) { + return fmt.Errorf("zip path escapes destination: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.Create(target) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, rc) + out.Close() + rc.Close() + if copyErr != nil { + return copyErr + } + } + return nil +} + +func (c *Client) apiURL(ownerRepo, suffix string) string { + base := strings.TrimRight(c.BaseURL, "/") + return base + "/api/v1/repos/" + strings.Trim(ownerRepo, "/") + suffix +} + +func (c *Client) doJSON(req *http.Request, out any) error { + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 30 * time.Second} +}