Improve update safety and progress output
All checks were successful
release / create-release (push) Successful in 7s
release / build-and-upload (pia-qbt-autoport-linux-amd64, amd64, linux, pia-qbt-autoport-linux-amd64) (push) Successful in 53s
release / build-and-upload (pia-qbt-autoport-macos-amd64, amd64, darwin, pia-qbt-autoport-macos-amd64) (push) Successful in 58s
release / build-and-upload (pia-qbt-autoport-macos-arm64, arm64, darwin, pia-qbt-autoport-macos-arm64) (push) Successful in 52s
release / build-and-upload (pia-qbt-autoport-windows-amd64.exe, amd64, windows, pia-qbt-autoport-windows-amd64.exe) (push) Successful in 55s

This commit is contained in:
2026-04-18 22:01:16 -05:00
parent 4aa6bd0621
commit a88c63742f
7 changed files with 369 additions and 23 deletions

View File

@@ -96,6 +96,12 @@ jobs:
ldflags="-s -w -X main.buildVersion=${BUILD_VERSION} -X main.buildCommit=${BUILD_COMMIT} -X main.buildDate=${BUILD_DATE}"
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 go build -trimpath -ldflags="${ldflags}" -o dist/${{ matrix.output_name }} ./src
- name: Create checksum
run: |
set -eu
hash=$(sha256sum "dist/${{ matrix.output_name }}" | awk '{print $1}')
printf '%s %s\n' "$hash" "${{ matrix.asset_name }}" > "dist/${{ matrix.output_name }}.sha256"
- name: Upload binary
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -107,3 +113,15 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/${{ matrix.output_name }}" \
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${{ matrix.asset_name }}"
- name: Upload checksum
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
run: |
set -eu
curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/${{ matrix.output_name }}.sha256" \
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${{ matrix.asset_name }}.sha256"

View File

@@ -19,10 +19,15 @@ Update behavior is controlled by:
- `check_for_updates`
- `auto_update`
- `update_check_interval_hours`
Both default to `true`. Turn them off if you want to disable daily update checks or automatic self-updates.
When enabled, the app checks once at startup and then every 24 hours.
`update_check_interval_hours` defaults to `24`.
When enabled, the app checks once at startup and then on the configured interval.
Each release uploads a `.sha256` checksum asset, and the updater verifies it before replacing the binary.
qBittorrent support is configured with:

View File

@@ -9,7 +9,6 @@ import (
)
const syncInterval = 30 * time.Second
const updateCheckInterval = 24 * time.Hour
type App struct {
cfg Config
@@ -112,7 +111,7 @@ func (a *App) qBittorrentClient() (*qBittorrentClient, error) {
return client, nil
}
func (a *App) StartUpdateChecks(ctx context.Context, exePath string, stop func()) bool {
func (a *App) StartUpdateChecks(ctx context.Context, exePath string, stop func(), initialFailures int) bool {
if !a.cfg.CheckForUpdates && !a.cfg.AutoUpdate {
return false
}
@@ -121,15 +120,29 @@ func (a *App) StartUpdateChecks(ctx context.Context, exePath string, stop func()
go func() {
defer a.updateWG.Done()
ticker := time.NewTicker(updateCheckInterval)
defer ticker.Stop()
interval := a.cfg.UpdateCheckInterval()
failures := initialFailures
timer := time.NewTimer(updateDelay(interval, failures))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.checkForUpdates(exePath, stop)
case <-timer.C:
switch a.checkForUpdates(exePath, stop) {
case updateCheckFailed:
failures++
default:
failures = 0
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(updateDelay(interval, failures))
}
}
}()

View File

@@ -9,6 +9,7 @@ import (
"log"
"net"
"os"
"time"
)
type Config struct {
@@ -20,6 +21,7 @@ type Config struct {
RunAtStartup bool `json:"run_at_startup"`
CheckForUpdates bool `json:"check_for_updates"`
AutoUpdate bool `json:"auto_update"`
UpdateCheckIntervalHours int `json:"update_check_interval_hours"`
QBittorrentHost string `json:"qbittorrent_host"`
QBittorrentUsername string `json:"qbittorrent_username"`
QBittorrentPassword string `json:"qbittorrent_password"`
@@ -76,6 +78,9 @@ func (c *Config) normalize() error {
if c.PIACTLPath == "" {
c.PIACTLPath = `C:\Program Files\Private Internet Access\piactl.exe`
}
if c.UpdateCheckIntervalHours <= 0 {
c.UpdateCheckIntervalHours = 24
}
return nil
}
@@ -121,6 +126,7 @@ func createDefaultConfig(path string) (Config, bool, error) {
RunAtStartup: true,
CheckForUpdates: true,
AutoUpdate: true,
UpdateCheckIntervalHours: 24,
AutoSetPort: false,
AutoSetHost: false,
}
@@ -176,3 +182,11 @@ func normalizeListenIP(ip string) string {
}
return ip
}
func (c Config) UpdateCheckInterval() time.Duration {
hours := c.UpdateCheckIntervalHours
if hours <= 0 {
hours = 24
}
return time.Duration(hours) * time.Hour
}

View File

@@ -58,14 +58,20 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
app := NewApp(cfg, build)
if app.StartUpdateChecks(ctx, exePath, stop) {
if app.checkForUpdates(exePath, stop) {
startupFailureCount := 0
if cfg.CheckForUpdates || cfg.AutoUpdate {
switch app.checkForUpdates(exePath, stop) {
case updateCheckUpdated:
return
case updateCheckFailed:
startupFailureCount = 1
}
} else {
log.Printf("build version: %s", build.Version)
log.Printf("update checks disabled")
}
app.StartUpdateChecks(ctx, exePath, stop, startupFailureCount)
app.StartBackgroundSync(ctx)
logListenTargets(cfg)

View File

@@ -1,18 +1,33 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
var updateRand = rand.New(rand.NewSource(time.Now().UnixNano()))
type updateCheckResult int
const (
updateCheckSkipped updateCheckResult = iota
updateCheckFailed
updateCheckNoUpdate
updateCheckUpdated
)
func maybeAutoUpdate(exePath string, release ReleaseInfo) (bool, error) {
if buildVersion == "" || buildVersion == "dev" {
return false, nil
@@ -25,6 +40,10 @@ func maybeAutoUpdate(exePath string, release ReleaseInfo) (bool, error) {
if err != nil {
return false, err
}
expectedHash, err := release.checksumInfo()
if err != nil {
return false, err
}
tempFile, err := os.CreateTemp(filepath.Dir(exePath), ".pia-qbt-autoport-update-*")
if err != nil {
@@ -38,6 +57,11 @@ func maybeAutoUpdate(exePath string, release ReleaseInfo) (bool, error) {
return false, err
}
if err := verifyFileSHA256(tempPath, expectedHash); err != nil {
_ = os.Remove(tempPath)
return false, err
}
if runtime.GOOS != "windows" {
if err := os.Chmod(tempPath, 0755); err != nil {
_ = os.Remove(tempPath)
@@ -54,14 +78,14 @@ func maybeAutoUpdate(exePath string, release ReleaseInfo) (bool, error) {
return true, nil
}
func (a *App) checkForUpdates(exePath string, stop func()) bool {
func (a *App) checkForUpdates(exePath string, stop func()) updateCheckResult {
if !a.cfg.CheckForUpdates && !a.cfg.AutoUpdate {
return false
return updateCheckSkipped
}
if buildVersion == "" || buildVersion == "dev" {
log.Printf("build version: %s", buildVersion)
log.Printf("dev build: update check skipped")
return false
return updateCheckSkipped
}
release, err := fetchLatestReleaseInfo()
@@ -71,32 +95,66 @@ func (a *App) checkForUpdates(exePath string, stop func()) bool {
log.Printf("build version: %s", build.Version)
if build.UpdateError != "" {
log.Printf("update check unavailable: %s", build.UpdateError)
return false
return updateCheckFailed
}
if build.UpdateAvailable {
log.Printf("out of date: latest version is %s", build.LatestVersion)
if !a.cfg.AutoUpdate {
return false
return updateCheckNoUpdate
}
updated, err := maybeAutoUpdate(exePath, release)
if err != nil {
log.Printf("auto update failed: %v", err)
return false
return updateCheckFailed
}
if updated && stop != nil {
stop()
return true
return updateCheckUpdated
}
return false
return updateCheckNoUpdate
}
if a.cfg.CheckForUpdates {
log.Printf("up to date: %s", build.Version)
}
return false
return updateCheckNoUpdate
}
func updateDelay(interval time.Duration, failures int) time.Duration {
if interval <= 0 {
interval = 24 * time.Hour
}
if failures <= 0 {
return interval
}
delay := 15 * time.Minute
for i := 1; i < failures; i++ {
delay *= 2
if delay >= interval {
return interval
}
}
if delay > interval {
delay = interval
}
jitter := delay / 10
if jitter <= 0 {
return delay
}
spread := int64(jitter*2 + 1)
offset := time.Duration(updateRand.Int63n(spread)) - jitter
result := delay + offset
if result < time.Minute {
return time.Minute
}
return result
}
func downloadFile(rawURL, path string) error {
@@ -122,11 +180,140 @@ func downloadFile(rawURL, path string) error {
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
total := resp.ContentLength
start := time.Now()
buf := make([]byte, 32*1024)
var downloaded int64
lastPaint := time.Time{}
paint := func(final bool) {
now := time.Now()
if !final && !lastPaint.IsZero() && now.Sub(lastPaint) < 200*time.Millisecond {
return
}
lastPaint = now
percent := 0
if total > 0 {
percent = int(float64(downloaded) * 100 / float64(total))
if percent > 100 {
percent = 100
}
} else if final && downloaded > 0 {
percent = 100
}
elapsed := now.Sub(start)
if elapsed <= 0 {
elapsed = time.Millisecond
}
speed := float64(downloaded) / elapsed.Seconds()
bar := progressBar(percent, 24)
sizeText := humanBytes(downloaded)
totalText := "?"
if total > 0 {
totalText = humanBytes(total)
}
line := fmt.Sprintf("\r%3d%% [%s] %s/s, %s/%s", percent, bar, humanBytesPerSecond(speed), sizeText, totalText)
fmt.Fprint(os.Stderr, line)
if final {
fmt.Fprintln(os.Stderr)
}
}
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, err := out.Write(buf[:n]); err != nil {
fmt.Fprintln(os.Stderr)
return err
}
downloaded += int64(n)
paint(false)
}
if readErr != nil {
if readErr != io.EOF {
fmt.Fprintln(os.Stderr)
return readErr
}
break
}
}
paint(true)
return out.Sync()
}
func progressBar(percent, width int) string {
if width <= 0 {
width = 24
}
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := percent * width / 100
if filled > width {
filled = width
}
return strings.Repeat("=", filled) + strings.Repeat("-", width-filled)
}
func humanBytes(n int64) string {
if n < 0 {
n = 0
}
units := []string{"B", "KB", "MB", "GB", "TB"}
value := float64(n)
unit := 0
for value >= 1024 && unit < len(units)-1 {
value /= 1024
unit++
}
if unit == 0 {
return fmt.Sprintf("%d %s", n, units[unit])
}
return fmt.Sprintf("%.1f %s", value, units[unit])
}
func humanBytesPerSecond(n float64) string {
if n < 0 {
n = 0
}
units := []string{"B", "KB", "MB", "GB", "TB"}
unit := 0
for n >= 1024 && unit < len(units)-1 {
n /= 1024
unit++
}
if unit == 0 {
return fmt.Sprintf("%.0f %s", n, units[unit])
}
return fmt.Sprintf("%.1f %s", n, units[unit])
}
func verifyFileSHA256(path string, expected string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
return out.Sync()
actual := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(actual, expected) {
return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", filepath.Base(path), expected, actual)
}
return nil
}
func stageUpdateRestart(exePath, tempPath string) error {
@@ -139,7 +326,7 @@ func stageUpdateRestart(exePath, tempPath string) error {
}
func stageUnixUpdateRestart(exePath, tempPath string) error {
script := `target="$3"; while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; mv "$2" "$target"; exec "$target"`
script := `target="$3"; backup="${target}.bak.$$"; cp "$target" "$backup"; trap 'rm -f "$backup"' EXIT; while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; mv "$2" "$target"; if "$target"; then rm -f "$backup"; else status=$?; mv "$backup" "$target"; "$target"; exit $status; fi`
cmd := exec.Command("sh", "-c", script, "pia-qbt-autoport-updater", strconv.Itoa(os.Getpid()), tempPath, exePath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -148,10 +335,45 @@ func stageUnixUpdateRestart(exePath, tempPath string) error {
}
func stageWindowsUpdateRestart(exePath, tempPath string) error {
script := `$target=$args[2]; Wait-Process -Id $args[0] -ErrorAction SilentlyContinue; for ($i=0; $i -lt 50; $i++) { try { Move-Item -Force $args[1] $target; break } catch { Start-Sleep -Milliseconds 200 } }; Start-Process -FilePath $target -WorkingDirectory (Split-Path $target)`
cmd := exec.Command("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script, strconv.Itoa(os.Getpid()), tempPath, exePath)
script := fmt.Sprintf(`
$parentPid = %d
$tempPath = %s
$target = %s
$backup = "$target.bak.$PID"
Copy-Item -Force $target $backup
try {
Wait-Process -Id $parentPid -ErrorAction SilentlyContinue
for ($i = 0; $i -lt 50; $i++) {
try {
Move-Item -Force $tempPath $target
break
} catch {
Start-Sleep -Milliseconds 200
}
}
$p = Start-Process -FilePath $target -PassThru -Wait -WorkingDirectory (Split-Path $target)
if ($p.ExitCode -ne 0) {
Move-Item -Force $backup $target
$p2 = Start-Process -FilePath $target -PassThru -Wait -WorkingDirectory (Split-Path $target)
if ($p2.ExitCode -eq 0) {
Remove-Item -Force $backup
}
} else {
Remove-Item -Force $backup
}
} finally {
if (Test-Path $backup) {
Remove-Item -Force $backup
}
}
`, os.Getpid(), psSingleQuote(tempPath), psSingleQuote(exePath))
cmd := exec.Command("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Start()
}
func psSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

View File

@@ -3,8 +3,10 @@ package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"time"
)
@@ -108,7 +110,10 @@ func (r ReleaseInfo) assetURL() (string, error) {
if err != nil {
return "", err
}
return r.assetURLByName(name)
}
func (r ReleaseInfo) assetURLByName(name string) (string, error) {
for _, asset := range r.Assets {
if asset.Name == name {
if asset.BrowserDownloadURL == "" {
@@ -120,3 +125,66 @@ func (r ReleaseInfo) assetURL() (string, error) {
return "", fmt.Errorf("release asset %s not found", name)
}
func (r ReleaseInfo) checksumInfo() (string, error) {
name, err := r.assetName()
if err != nil {
return "", err
}
checksumName := name + ".sha256"
checksumURL, err := r.assetURLByName(checksumName)
if err != nil {
return "", err
}
expectedHash, err := fetchChecksumHash(checksumURL, name)
if err != nil {
return "", err
}
return expectedHash, nil
}
func fetchChecksumHash(rawURL, assetName string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum request failed: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return parseChecksumPayload(string(data), assetName)
}
func parseChecksumPayload(payload, assetName string) (string, error) {
lines := strings.Split(payload, "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
hash := fields[0]
name := fields[len(fields)-1]
name = strings.TrimPrefix(name, "*")
name = strings.TrimPrefix(name, "./")
if name == assetName || name == assetName+".exe" {
return hash, nil
}
}
return "", fmt.Errorf("checksum for %s not found", assetName)
}