1 Commits

Author SHA1 Message Date
every_holiday
8bff7af6bc Add release auto-update launcher
All checks were successful
build-windows-exe / build (push) Successful in 1m47s
2026-06-24 02:08:19 +09:00
5 changed files with 283 additions and 3 deletions

View File

@@ -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:

View File

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

View File

@@ -0,0 +1,6 @@
package buildinfo
var (
Version = "dev"
BuildTime = "unknown"
)

View File

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

View File

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