feat(tray): Velopack auto-update + elevated service worker + Quit confirm

- update.go: poll release feed, download nupkg, hand off to Update.exe
- handle --veloapp-* lifecycle hooks and --ts-apply-service-update worker
- sync service binaries from Velopack bundle into Program Files (x86)
- Quit now requires Yes/No confirm (default No) so accidental close keeps tray
This commit is contained in:
Huan LD
2026-06-10 14:38:06 +07:00
parent 212d5520cc
commit fe73a68a8f
3 changed files with 528 additions and 0 deletions
+35
View File
@@ -46,6 +46,14 @@ type app struct {
}
func main() {
// Handle Velopack lifecycle hooks (--veloapp-*) and the elevated
// service-update worker before anything else: they are short-lived
// one-shot invocations that must run even while another tray instance is
// already active, so they precede the single-instance mutex.
if handleStartupArgs() {
return
}
// Single instance mutex. Lock the OS thread so that CreateMutex and
// the subsequent error check reference the same thread-local LastError.
// (Go may otherwise reschedule the goroutine onto a different OS thread
@@ -88,6 +96,7 @@ func main() {
a.updateState()
go a.watchIPNBus()
go a.periodicRefresh()
a.startAutoUpdater()
systray.Run(a.onReady, a.onExit)
}
@@ -412,6 +421,14 @@ func (a *app) rebuild() {
quit := systray.AddMenuItem("Quit", "Quit Tailscale-Custom Tray")
onClick(ctx, quit, func() {
log.Println("action: Quit")
// Treat the menu close as hide-only: require explicit Yes to actually
// quit, so accidental clicks keep the tray icon and session alive.
if !confirmYesNo("Tailscale-Custom",
"Exit Tailscale-Custom?\n\nThe VPN service will keep running in the background.\nClick No to keep the tray icon.") {
log.Println("action: Quit cancelled (stay in tray)")
return
}
log.Println("action: Quit confirmed")
systray.Quit()
})
@@ -927,6 +944,24 @@ func showError(msg string) {
uintptr(0x00000000|0x00000010))
}
// confirmYesNo shows a modal Yes/No prompt and returns true on Yes.
// Default button is No so pressing Enter/Esc keeps the tray alive.
func confirmYesNo(title, msg string) bool {
const (
mbYesNo = 0x00000004
mbIconQuestion = 0x00000020
mbDefButton2 = 0x00000100
idYes = 6
)
titlePtr, _ := windows.UTF16PtrFromString(title)
textPtr, _ := windows.UTF16PtrFromString(msg)
r, _, _ := pMessageBoxW.Call(0,
uintptr(unsafe.Pointer(textPtr)),
uintptr(unsafe.Pointer(titlePtr)),
uintptr(mbYesNo|mbIconQuestion|mbDefButton2))
return int(r) == idYes
}
func inputDlgProcFn(hwnd, msg, wParam, lParam uintptr) uintptr {
const (
wmInitDialog = 0x0110
+443
View File
@@ -0,0 +1,443 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Velopack-based auto-update for the Tailscale-Custom tray.
//
// Velopack manages the per-user app bundle (the tray + a copy of the service
// binaries) under %LocalAppData%\TailscaleCustom and can swap it without admin.
// tailscaled however runs as a LocalSystem Windows service out of a secure
// machine-wide directory (serviceInstallDir) — it must NOT run from the
// user-writable Velopack dir, which would be a privilege-escalation hole. So
// after Velopack updates the bundle we copy the new service binaries into
// serviceInstallDir via a short elevated (UAC) worker run of this same exe.
//
// Everything here no-ops gracefully when the tray is not running from a
// Velopack install (e.g. the MSI/dev deployment), so the same binary works in
// both layouts.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const (
// updateFeedURL is the base URL of the Velopack release feed; it must serve
// releases.<channel>.json and the *-full.nupkg files next to it.
updateFeedURL = "https://vpn.softs.business/updates/"
updateChannel = "win"
// serviceName is the Windows service that runs tailscaled.
serviceName = "Tailscale-Custom"
// serviceInstallDir is the secure machine-wide home of the service
// binaries (kept out of the per-user Velopack dir on purpose).
serviceInstallDir = `C:\Program Files (x86)\Tailscale-Custom`
updateCheckInterval = 6 * time.Hour
updateInitialDelay = 90 * time.Second
)
// serviceBins are copied from the Velopack bundle into serviceInstallDir.
// tailscaled is the service; tailscale.exe and wintun.dll must match it.
var serviceBins = []string{"tailscaled.exe", "tailscale.exe", "wintun.dll"}
// handleStartupArgs processes Velopack lifecycle hooks and our internal
// elevated worker mode. It returns true when the process handled a one-shot
// command and should exit instead of showing the tray UI.
//
// Velopack invokes the main exe during its lifecycle, e.g.
//
// tailscale-tray.exe --veloapp-install 1.0.2
//
// Our elevated service swap re-invokes this same exe as
//
// tailscale-tray.exe --ts-apply-service-update
func handleStartupArgs() (exit bool) {
if len(os.Args) < 2 {
return false
}
switch os.Args[1] {
case "--ts-apply-service-update":
// Elevated worker: replace the machine-wide service binaries.
if err := applyServiceUpdate(exeDir()); err != nil {
log.Printf("applyServiceUpdate: %v", err)
}
return true
case "--veloapp-install", "--veloapp-updated":
log.Printf("velopack hook: %s", os.Args[1])
syncServiceBinaries(true)
return true
case "--veloapp-obsolete":
return true // a previous version is being retired; nothing to do
case "--veloapp-uninstall":
log.Printf("velopack hook: uninstall")
stopService()
exec.Command("sc.exe", "delete", serviceName).Run() // best-effort
return true
}
return false
}
// exeDir is the directory of the running executable (the Velopack current\ dir
// when installed via Velopack).
func exeDir() string {
p, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(p)
}
// rootDir is the Velopack application root (parent of current\), where
// Update.exe and packages\ live.
func rootDir() string { return filepath.Dir(exeDir()) }
// ── Auto-update loop ─────────────────────────────────────────────
func (a *app) startAutoUpdater() {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in autoUpdater: %v", r)
}
}()
select {
case <-a.bgCtx.Done():
return
case <-time.After(updateInitialDelay):
}
for {
if err := checkAndApplyUpdate(); err != nil {
log.Printf("autoUpdate: %v", err)
}
select {
case <-a.bgCtx.Done():
return
case <-time.After(updateCheckInterval):
}
}
}()
}
type releaseFeed struct {
Assets []struct {
Version string `json:"Version"`
Type string `json:"Type"`
FileName string `json:"FileName"`
SHA256 string `json:"SHA256"`
Size int64 `json:"Size"`
} `json:"Assets"`
}
func checkAndApplyUpdate() error {
// Only meaningful inside a Velopack install (Update.exe present).
updateExe := filepath.Join(rootDir(), "Update.exe")
if _, err := os.Stat(updateExe); err != nil {
return nil
}
cur, err := installedVersion()
if err != nil {
return fmt.Errorf("read installed version: %w", err)
}
feed, err := fetchFeed()
if err != nil {
return err
}
var latestVer, fileName string
for _, as := range feed.Assets {
if as.Type != "Full" {
continue
}
if latestVer == "" || compareVersions(as.Version, latestVer) > 0 {
latestVer, fileName = as.Version, as.FileName
}
}
if latestVer == "" || compareVersions(latestVer, cur) <= 0 {
log.Printf("autoUpdate: up to date (installed=%s latest=%s)", cur, latestVer)
return nil
}
log.Printf("autoUpdate: update available %s -> %s", cur, latestVer)
pkgURL := strings.TrimRight(updateFeedURL, "/") + "/" + fileName
pkgPath := filepath.Join(rootDir(), "packages", fileName)
if err := downloadFile(pkgURL, pkgPath); err != nil {
return fmt.Errorf("download %s: %w", fileName, err)
}
// Update.exe waits for us to exit, swaps current\, then restarts the tray.
cmd := exec.Command(updateExe, "apply", "--silent",
"--waitPid", strconv.Itoa(os.Getpid()), "--package", pkgPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start Update.exe apply: %w", err)
}
log.Printf("autoUpdate: applying %s; exiting for Update.exe", latestVer)
os.Exit(0)
return nil
}
func fetchFeed() (*releaseFeed, error) {
url := strings.TrimRight(updateFeedURL, "/") + "/releases." + updateChannel + ".json"
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed HTTP %d", resp.StatusCode)
}
var f releaseFeed
if err := json.NewDecoder(resp.Body).Decode(&f); err != nil {
return nil, fmt.Errorf("decode feed: %w", err)
}
return &f, nil
}
// installedVersion reads the current bundle's version from sq.version.
func installedVersion() (string, error) {
data, err := os.ReadFile(filepath.Join(exeDir(), "sq.version"))
if err != nil {
return "", err
}
m := regexp.MustCompile(`<version>([^<]+)</version>`).FindSubmatch(data)
if m == nil {
return "", fmt.Errorf("no <version> in sq.version")
}
return string(m[1]), nil
}
// compareVersions compares dotted numeric versions (e.g. "1.0.10" vs "1.0.2").
// Returns -1, 0 or +1.
func compareVersions(a, b string) int {
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
var ai, bi int
if i < len(as) {
ai, _ = strconv.Atoi(strings.TrimSpace(as[i]))
}
if i < len(bs) {
bi, _ = strconv.Atoi(strings.TrimSpace(bs[i]))
}
if ai != bi {
if ai < bi {
return -1
}
return 1
}
}
return 0
}
func downloadFile(url, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
tmp := dst + ".part"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, dst)
}
// ── Service binary syncing (elevated) ────────────────────────────
// syncServiceBinaries swaps the machine-wide service binaries when the bundle
// next to this exe differs from what's installed. allowElevate gates the UAC
// prompt.
func syncServiceBinaries(allowElevate bool) {
upToDate, err := serviceBinsUpToDate(exeDir())
if err != nil {
log.Printf("syncServiceBinaries: %v", err)
return
}
if upToDate {
log.Printf("syncServiceBinaries: service already current")
return
}
if !allowElevate {
return
}
log.Printf("syncServiceBinaries: binaries differ, elevating to swap")
if err := runElevated("--ts-apply-service-update"); err != nil {
log.Printf("syncServiceBinaries: elevate: %v", err)
}
}
// serviceBinsUpToDate reports whether every serviceBin in serviceInstallDir
// matches (by SHA-256) the copy in srcDir.
func serviceBinsUpToDate(srcDir string) (bool, error) {
for _, b := range serviceBins {
src, err := fileSHA256(filepath.Join(srcDir, b))
if err != nil {
return false, fmt.Errorf("hash bundle %s: %w", b, err)
}
dst, err := fileSHA256(filepath.Join(serviceInstallDir, b))
if err != nil || src != dst {
return false, nil
}
}
return true, nil
}
// applyServiceUpdate runs elevated: stop the service, copy the bundled service
// binaries into serviceInstallDir, (re)register the service, start it.
func applyServiceUpdate(srcDir string) error {
log.Printf("applyServiceUpdate from %s", srcDir)
stopService()
time.Sleep(3 * time.Second) // let the service release file locks
if err := os.MkdirAll(serviceInstallDir, 0o755); err != nil {
return err
}
for _, b := range serviceBins {
if err := copyFile(filepath.Join(srcDir, b), filepath.Join(serviceInstallDir, b)); err != nil {
return fmt.Errorf("copy %s: %w", b, err)
}
log.Printf("applyServiceUpdate: copied %s", b)
}
ensureServiceRegistered()
startService()
return nil
}
func ensureServiceRegistered() {
if err := exec.Command("sc.exe", "query", serviceName).Run(); err == nil {
return // already exists
}
bin := filepath.Join(serviceInstallDir, "tailscaled.exe")
exec.Command("sc.exe", "create", serviceName,
"binPath=", bin, "start=", "auto", "obj=", "LocalSystem",
"DisplayName=", "Tailscale-Custom").Run()
exec.Command("sc.exe", "description", serviceName, "Tailscale Custom VPN Service").Run()
}
func stopService() { exec.Command("sc.exe", "stop", serviceName).Run() }
func startService() { exec.Command("sc.exe", "start", serviceName).Run() }
// ── Helpers ──────────────────────────────────────────────────────
func fileSHA256(path string) (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 hex.EncodeToString(h.Sum(nil)), nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".new"
out, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Rename(tmp, dst)
}
// runElevated re-launches this exe with arg via ShellExecuteEx "runas" (UAC)
// and waits for the elevated process to finish.
func runElevated(arg string) error {
self, err := os.Executable()
if err != nil {
return err
}
verb, _ := windows.UTF16PtrFromString("runas")
file, _ := windows.UTF16PtrFromString(self)
params, _ := windows.UTF16PtrFromString(arg)
info := shellExecuteInfo{
fMask: seeMaskNoCloseProcess,
lpVerb: verb,
lpFile: file,
lpParameters: params,
nShow: swHide,
}
info.cbSize = uint32(unsafe.Sizeof(info))
r, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
if r == 0 {
return fmt.Errorf("ShellExecuteEx: %v", callErr)
}
if info.hProcess != 0 {
windows.WaitForSingleObject(info.hProcess, windows.INFINITE)
windows.CloseHandle(info.hProcess)
}
return nil
}
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellExecuteExW = shell32.NewProc("ShellExecuteExW")
)
const (
seeMaskNoCloseProcess = 0x00000040
swHide = 0
)
// shellExecuteInfo mirrors SHELLEXECUTEINFOW.
type shellExecuteInfo struct {
cbSize uint32
fMask uint32
hwnd uintptr
lpVerb *uint16
lpFile *uint16
lpParameters *uint16
lpDirectory *uint16
nShow int32
hInstApp uintptr
lpIDList uintptr
lpClass *uint16
hkeyClass uintptr
dwHotKey uint32
hIconOrMonitor uintptr
hProcess windows.Handle
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import "testing"
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"1.0.2", "1.0.1", 1},
{"1.0.1", "1.0.2", -1},
{"1.0.2", "1.0.2", 0},
{"1.0.10", "1.0.2", 1}, // numeric, not lexical
{"1.1.0", "1.0.9", 1},
{"2.0.0", "1.99.99", 1},
}
for _, c := range cases {
if got := compareVersions(c.a, c.b); got != c.want {
t.Errorf("compareVersions(%q,%q)=%d want %d", c.a, c.b, got, c.want)
}
}
}
// TestFetchLiveFeed verifies the deployed update feed is reachable and parses
// into the shape the updater expects. Network test; skipped in -short mode.
func TestFetchLiveFeed(t *testing.T) {
if testing.Short() {
t.Skip("network")
}
feed, err := fetchFeed()
if err != nil {
t.Fatalf("fetchFeed: %v", err)
}
var full int
for _, a := range feed.Assets {
if a.Type == "Full" {
full++
if a.Version == "" || a.FileName == "" || a.SHA256 == "" {
t.Errorf("incomplete asset: %+v", a)
}
t.Logf("feed Full asset: version=%s file=%s size=%d", a.Version, a.FileName, a.Size)
}
}
if full == 0 {
t.Fatal("feed has no Full assets")
}
}