diff --git a/cmd/deskconn/main.go b/cmd/deskconn/main.go index dbfa853..c6677a5 100644 --- a/cmd/deskconn/main.go +++ b/cmd/deskconn/main.go @@ -16,6 +16,7 @@ import ( "os/signal" "path/filepath" "runtime" + "runtime/debug" "strconv" "strings" "syscall" @@ -25,6 +26,7 @@ import ( "github.com/godbus/dbus/v5" "github.com/olekukonko/tablewriter" log "github.com/sirupsen/logrus" + "golang.org/x/mod/semver" "golang.org/x/term" "gopkg.in/yaml.v3" @@ -97,7 +99,8 @@ func main() { log.Fatal(err) } - versionString := fmt.Sprintf("deskconn %s", version) + currentVersion := appVersion() + versionString := fmt.Sprintf("deskconn %s", currentVersion) app := kingpin.New("deskconn", "Deskconn control CLI") attachCmd := app.Command("attach", "Attach a device") @@ -1533,7 +1536,7 @@ func main() { fmt.Println("screenshot disabled") case selfUpdateCmd.FullCommand(): - if err := updateApp(cfgDirectory); err != nil { + if err := updateApp(cfgDirectory, currentVersion); err != nil { fmt.Fprintln(os.Stderr, err) return } @@ -1554,41 +1557,17 @@ type appUpdateResponse struct { LatestVersion string `json:"latest_version"` } -func updateApp(cfgDirectory string) error { - fmt.Printf("Checking for updates for %s on %s-%s...\n", version, runtime.GOOS, runtime.GOARCH) +func updateApp(cfgDirectory, currentVersion string) error { + fmt.Printf("Checking for updates for %s on %s-%s...\n", currentVersion, runtime.GOOS, runtime.GOARCH) - cloudSession, err := deskconn.ConnectCloudRealm(cfgDirectory) + updateResp, err := latestAppUpdate(cfgDirectory, currentVersion) if err != nil { - if strings.Contains(err.Error(), deskconn.ErrAuthenticationFailed) { - _ = deskconn.RemoveCredentialsFiles(cfgDirectory) - return fmt.Errorf("invalid credentials, please login again") - } return err } - callResp := cloudSession.Call(deskconn.ProcedureAppUpdateCheck).Args("deskconn", version, runtime.GOOS, - runtime.GOARCH).Do() - if callResp.Err != nil { - return callResp.Err - } - - if len(callResp.Args()) == 0 { - fmt.Printf("You're already on version %s of deskconn (the latest version).\n", version) - return nil - } - - var updateResp appUpdateResponse - jsonData, err := json.Marshal(callResp.Args()[0]) - if err != nil { - return fmt.Errorf("failed to marshal update response: %w", err) - } - - if err := json.Unmarshal(jsonData, &updateResp); err != nil { - return fmt.Errorf("failed to parse update response: %w", err) - } - if updateResp.DownloadURL == "" { - return fmt.Errorf("update response missing download_url") + fmt.Printf("You're already on version %s of deskconn (the latest version).\n", currentVersion) + return nil } fmt.Printf("Found update at %s.\n", updateResp.DownloadURL) @@ -1596,6 +1575,9 @@ func updateApp(cfgDirectory string) error { if err := downloadAndInstallUpdate(updateResp.DownloadURL); err != nil { return err } + if err := writeInstalledAppVersion(cfgDirectory, updateResp.LatestVersion); err != nil { + return err + } fmt.Printf("Restarting deskconnd service...\n") cmd := exec.Command("systemctl", "--user", "restart", "deskconnd") @@ -1605,10 +1587,139 @@ func updateApp(cfgDirectory string) error { return fmt.Errorf("failed to restart deskconnd: %w", err) } - fmt.Printf("Updated deskconn from version %s to %s.\n", version, updateResp.LatestVersion) + fmt.Printf("Updated deskconn from version %s to %s.\n", currentVersion, updateResp.LatestVersion) return nil } +func latestAppUpdate(cfgDirectory, currentVersion string) (appUpdateResponse, error) { + const releaseURL = "https://api.github.com/repos/xconnio/deskconn/releases/latest" + + client := &http.Client{ + Timeout: 30 * time.Second, + } + req, err := http.NewRequest(http.MethodGet, releaseURL, nil) + if err != nil { + return appUpdateResponse{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "deskconn-self-update") + + resp, err := client.Do(req) + if err != nil { + return appUpdateResponse{}, fmt.Errorf("failed to check latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return appUpdateResponse{}, fmt.Errorf("failed to check latest release: unexpected status %s", resp.Status) + } + + var release struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return appUpdateResponse{}, fmt.Errorf("failed to parse latest release: %w", err) + } + + if release.TagName == "" { + return appUpdateResponse{}, fmt.Errorf("latest release response missing tag_name") + } + installedVersion, err := readInstalledAppVersion(cfgDirectory) + if err != nil { + return appUpdateResponse{}, err + } + if currentReleaseIsAtLeast(currentVersion, release.TagName) || installedVersion == release.TagName { + return appUpdateResponse{LatestVersion: release.TagName}, nil + } + + assetName := releaseAssetName(release.TagName) + for _, asset := range release.Assets { + if asset.Name == assetName { + if asset.BrowserDownloadURL == "" { + return appUpdateResponse{}, fmt.Errorf("latest release asset %s missing browser_download_url", assetName) + } + return appUpdateResponse{ + DownloadURL: asset.BrowserDownloadURL, + LatestVersion: release.TagName, + }, nil + } + } + + return appUpdateResponse{}, fmt.Errorf("latest release %s missing asset %s", release.TagName, assetName) +} + +func releaseAssetName(releaseVersion string) string { + return fmt.Sprintf("deskconn_%s_%s_%s.tar.gz", strings.TrimPrefix(releaseVersion, "v"), runtime.GOOS, runtime.GOARCH) +} + +func appVersion() string { + if version != "v0.1.0-alpha" { + return version + } + + info, ok := debug.ReadBuildInfo() + if !ok { + return version + } + + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" && len(setting.Value) >= 7 { + return version + "." + setting.Value[:7] + } + } + return version +} + +func readInstalledAppVersion(cfgDirectory string) (string, error) { + data, err := os.ReadFile(filepath.Join(cfgDirectory, "self-update-version")) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func writeInstalledAppVersion(cfgDirectory, version string) error { + return os.WriteFile(filepath.Join(cfgDirectory, "self-update-version"), []byte(version+"\n"), 0600) +} + +func currentReleaseIsAtLeast(current, latest string) bool { + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + if current == latest { + return true + } + if hasCommitSuffix(current) || hasCommitSuffix(latest) { + return false + } + + currentSemver := "v" + current + latestSemver := "v" + latest + if semver.IsValid(currentSemver) && semver.IsValid(latestSemver) { + return semver.Compare(currentSemver, latestSemver) >= 0 + } + return false +} + +func hasCommitSuffix(v string) bool { + idx := strings.LastIndex(v, ".") + if idx == -1 || len(v)-idx-1 != 7 { + return false + } + for _, r := range v[idx+1:] { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + const pathInstallerBlock = "\n# Added by deskconn installer\nexport PATH=\"$HOME/.local/bin:$PATH\"\n" // confirmPrompt prints prompt, reads a line from stdin, and reports whether the response counts diff --git a/go.mod b/go.mod index 38cfe1a..a1fb97b 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/xconnio/xconn-webrtc-go v0.0.0-20260713105202-e23ffce34677 go.etcd.io/bbolt v1.4.3 golang.org/x/crypto v0.51.0 + golang.org/x/mod v0.35.0 golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 @@ -78,7 +79,6 @@ require ( github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/time v0.14.0 // indirect