1
+package main
2
+
3
+import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "runtime"
9
+ "time"
10
+
11
+ "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
12
+ "github.com/gosuda/portal-tunnel/v2/types"
13
+)
14
+
15
+const updateCheckTTL = 24 * time.Hour
16
+
17
+type updateCache struct {
18
+ CheckedAt time.Time `json:"checked_at"`
19
+ LatestVersion string `json:"latest_version"`
20
+}
21
+
22
+// startUpdateCheck begins a background goroutine that checks for a newer
23
+// release. It returns a buffered channel that will receive the latest version
24
+// string when a newer version exists, or an empty string otherwise. The check
25
+// is skipped when a valid cache entry exists within the TTL.
26
+func startUpdateCheck() <-chan string {
27
+ ch := make(chan string, 1)
28
+
29
+ slug := runtime.GOOS + "-" + runtime.GOARCH
30
+ binURL, ok := installer.OfficialAssetURL(slug, false)
31
+ if !ok {
32
+ ch <- ""
33
+ return ch
34
+ }
35
+
36
+ go func() {
37
+ result := checkForUpdate(binURL)
38
+ ch <- result
39
+ }()
40
+
41
+ return ch
42
+}
43
+
44
+func checkForUpdate(binURL string) string {
45
+ cacheDir, err := updateCacheDir()
46
+ if err != nil {
47
+ return ""
48
+ }
49
+ cachePath := filepath.Join(cacheDir, "update_check.json")
50
+
51
+ // Try reading the cache first.
52
+ if cached, err := readUpdateCache(cachePath); err == nil {
53
+ if time.Since(cached.CheckedAt) < updateCheckTTL {
54
+ if cached.LatestVersion != types.ReleaseVersion {
55
+ return cached.LatestVersion
56
+ }
57
+ return ""
58
+ }
59
+ }
60
+
61
+ // Cache is missing or stale — check the network.
62
+ latestVersion, err := detectLatestVersion(binURL)
63
+ if err != nil {
64
+ return ""
65
+ }
66
+
67
+ // Write cache atomically.
68
+ writeUpdateCache(cachePath, updateCache{
69
+ CheckedAt: time.Now(),
70
+ LatestVersion: latestVersion,
71
+ })
72
+
73
+ if latestVersion != types.ReleaseVersion {
74
+ return latestVersion
75
+ }
76
+ return ""
77
+}
78
+
79
+// printUpdateHint collects from the channel with a short timeout and prints
80
+// a hint to stderr if a newer version is available.
81
+func printUpdateHint(ch <-chan string) {
82
+ if ch == nil {
83
+ return
84
+ }
85
+ select {
86
+ case version := <-ch:
87
+ if version != "" {
88
+ fmt.Fprintf(os.Stderr, "\nA new version is available: %s. Run 'portal update' to upgrade.\n", version)
89
+ }
90
+ case <-time.After(500 * time.Millisecond):
91
+ }
92
+}
93
+
94
+func updateCacheDir() (string, error) {
95
+ base, err := os.UserCacheDir()
96
+ if err != nil {
97
+ return "", err
98
+ }
99
+ dir := filepath.Join(base, "portal-tunnel")
100
+ if err := os.MkdirAll(dir, 0755); err != nil {
101
+ return "", err
102
+ }
103
+ return dir, nil
104
+}
105
+
106
+func readUpdateCache(path string) (updateCache, error) {
107
+ data, err := os.ReadFile(path)
108
+ if err != nil {
109
+ return updateCache{}, err
110
+ }
111
+ var c updateCache
112
+ if err := json.Unmarshal(data, &c); err != nil {
113
+ return updateCache{}, err
114
+ }
115
+ return c, nil
116
+}
117
+
118
+func writeUpdateCache(path string, c updateCache) {
119
+ data, err := json.Marshal(c)
120
+ if err != nil {
121
+ return
122
+ }
123
+
124
+ dir := filepath.Dir(path)
125
+ tmp, err := os.CreateTemp(dir, ".update_check-*.json")
126
+ if err != nil {
127
+ return
128
+ }
129
+ tmpName := tmp.Name()
130
+
131
+ if _, err := tmp.Write(data); err != nil {
132
+ tmp.Close()
133
+ os.Remove(tmpName)
134
+ return
135
+ }
136
+ tmp.Close()
137
+
138
+ os.Rename(tmpName, path)
139
+}