Feature/update check (#1232)

Periodically fetch the latest available version, and the UI will shows a new menu for the download link. It checks both the daemon version and the UI version.
This commit is contained in:
Zoltan Papp 2023-10-30 10:32:48 +01:00 committed by GitHub
parent 52f5101715
commit 6d4240a5ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 492 additions and 18 deletions

View File

@ -17,6 +17,7 @@ on:
- 'release_files/**' - 'release_files/**'
- '**/Dockerfile' - '**/Dockerfile'
- '**/Dockerfile.*' - '**/Dockerfile.*'
- 'client/ui/**'
env: env:
SIGN_PIPE_VER: "v0.0.9" SIGN_PIPE_VER: "v0.0.9"

View File

@ -54,7 +54,7 @@ nfpms:
contents: contents:
- src: client/ui/netbird.desktop - src: client/ui/netbird.desktop
dst: /usr/share/applications/netbird.desktop dst: /usr/share/applications/netbird.desktop
- src: client/ui/disconnected.png - src: client/ui/netbird-systemtray-default.png
dst: /usr/share/pixmaps/netbird.png dst: /usr/share/pixmaps/netbird.png
dependencies: dependencies:
- netbird - netbird
@ -71,7 +71,7 @@ nfpms:
contents: contents:
- src: client/ui/netbird.desktop - src: client/ui/netbird.desktop
dst: /usr/share/applications/netbird.desktop dst: /usr/share/applications/netbird.desktop
- src: client/ui/disconnected.png - src: client/ui/netbird-systemtray-default.png
dst: /usr/share/pixmaps/netbird.png dst: /usr/share/pixmaps/netbird.png
dependencies: dependencies:
- netbird - netbird

View File

@ -15,8 +15,10 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
"unicode"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
@ -74,18 +76,30 @@ func main() {
} }
} }
//go:embed connected.ico //go:embed netbird-systemtray-connected.ico
var iconConnectedICO []byte var iconConnectedICO []byte
//go:embed connected.png //go:embed netbird-systemtray-connected.png
var iconConnectedPNG []byte var iconConnectedPNG []byte
//go:embed disconnected.ico //go:embed netbird-systemtray-default.ico
var iconDisconnectedICO []byte var iconDisconnectedICO []byte
//go:embed disconnected.png //go:embed netbird-systemtray-default.png
var iconDisconnectedPNG []byte var iconDisconnectedPNG []byte
//go:embed netbird-systemtray-update.ico
var iconUpdateICO []byte
//go:embed netbird-systemtray-update.png
var iconUpdatePNG []byte
//go:embed netbird-systemtray-update-cloud.ico
var iconUpdateCloudICO []byte
//go:embed netbird-systemtray-update-cloud.png
var iconUpdateCloudPNG []byte
type serviceClient struct { type serviceClient struct {
ctx context.Context ctx context.Context
addr string addr string
@ -93,6 +107,8 @@ type serviceClient struct {
icConnected []byte icConnected []byte
icDisconnected []byte icDisconnected []byte
icUpdate []byte
icUpdateCloud []byte
// systray menu items // systray menu items
mStatus *systray.MenuItem mStatus *systray.MenuItem
@ -100,6 +116,10 @@ type serviceClient struct {
mDown *systray.MenuItem mDown *systray.MenuItem
mAdminPanel *systray.MenuItem mAdminPanel *systray.MenuItem
mSettings *systray.MenuItem mSettings *systray.MenuItem
mAbout *systray.MenuItem
mVersionUI *systray.MenuItem
mVersionDaemon *systray.MenuItem
mUpdate *systray.MenuItem
mQuit *systray.MenuItem mQuit *systray.MenuItem
// application with main windows. // application with main windows.
@ -118,6 +138,11 @@ type serviceClient struct {
managementURL string managementURL string
preSharedKey string preSharedKey string
adminURL string adminURL string
update *version.Update
daemonVersion string
updateIndicationLock sync.Mutex
isUpdateIconActive bool
} }
// newServiceClient instance constructor // newServiceClient instance constructor
@ -130,14 +155,20 @@ func newServiceClient(addr string, a fyne.App, showSettings bool) *serviceClient
app: a, app: a,
showSettings: showSettings, showSettings: showSettings,
update: version.NewUpdate(),
} }
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
s.icConnected = iconConnectedICO s.icConnected = iconConnectedICO
s.icDisconnected = iconDisconnectedICO s.icDisconnected = iconDisconnectedICO
s.icUpdate = iconUpdateICO
s.icUpdateCloud = iconUpdateCloudICO
} else { } else {
s.icConnected = iconConnectedPNG s.icConnected = iconConnectedPNG
s.icDisconnected = iconDisconnectedPNG s.icDisconnected = iconDisconnectedPNG
s.icUpdate = iconUpdatePNG
s.icUpdateCloud = iconUpdateCloudPNG
} }
if showSettings { if showSettings {
@ -328,19 +359,53 @@ func (s *serviceClient) updateStatus() error {
return err return err
} }
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
var systrayIconState bool
if status.Status == string(internal.StatusConnected) && !s.mUp.Disabled() { if status.Status == string(internal.StatusConnected) && !s.mUp.Disabled() {
if !s.isUpdateIconActive {
systray.SetIcon(s.icConnected) systray.SetIcon(s.icConnected)
}
systray.SetTooltip("NetBird (Connected)") systray.SetTooltip("NetBird (Connected)")
s.mStatus.SetTitle("Connected") s.mStatus.SetTitle("Connected")
s.mUp.Disable() s.mUp.Disable()
s.mDown.Enable() s.mDown.Enable()
systrayIconState = true
} else if status.Status != string(internal.StatusConnected) && s.mUp.Disabled() { } else if status.Status != string(internal.StatusConnected) && s.mUp.Disabled() {
if !s.isUpdateIconActive {
systray.SetIcon(s.icDisconnected) systray.SetIcon(s.icDisconnected)
}
systray.SetTooltip("NetBird (Disconnected)") systray.SetTooltip("NetBird (Disconnected)")
s.mStatus.SetTitle("Disconnected") s.mStatus.SetTitle("Disconnected")
s.mDown.Disable() s.mDown.Disable()
s.mUp.Enable() s.mUp.Enable()
systrayIconState = false
} }
// the updater struct notify by the upgrades available only, but if meanwhile the daemon has successfully
// updated must reset the mUpdate visibility state
if s.daemonVersion != status.DaemonVersion {
s.mUpdate.Hide()
s.daemonVersion = status.DaemonVersion
s.isUpdateIconActive = s.update.SetDaemonVersion(status.DaemonVersion)
if !s.isUpdateIconActive {
if systrayIconState {
systray.SetIcon(s.icConnected)
s.mAbout.SetIcon(s.icConnected)
} else {
systray.SetIcon(s.icDisconnected)
s.mAbout.SetIcon(s.icDisconnected)
}
}
daemonVersionTitle := normalizedVersion(s.daemonVersion)
s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle))
s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle))
s.mVersionDaemon.Show()
}
return nil return nil
}, &backoff.ExponentialBackOff{ }, &backoff.ExponentialBackOff{
InitialInterval: time.Second, InitialInterval: time.Second,
@ -374,11 +439,24 @@ func (s *serviceClient) onTrayReady() {
systray.AddSeparator() systray.AddSeparator()
s.mSettings = systray.AddMenuItem("Settings", "Settings of the application") s.mSettings = systray.AddMenuItem("Settings", "Settings of the application")
systray.AddSeparator() systray.AddSeparator()
v := systray.AddMenuItem("v"+version.NetbirdVersion(), "Client Version: "+version.NetbirdVersion())
v.Disable() s.mAbout = systray.AddMenuItem("About", "About")
s.mAbout.SetIcon(s.icDisconnected)
versionString := normalizedVersion(version.NetbirdVersion())
s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString))
s.mVersionUI.Disable()
s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "")
s.mVersionDaemon.Disable()
s.mVersionDaemon.Hide()
s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", "Download latest version")
s.mUpdate.Hide()
systray.AddSeparator() systray.AddSeparator()
s.mQuit = systray.AddMenuItem("Quit", "Quit the client app") s.mQuit = systray.AddMenuItem("Quit", "Quit the client app")
s.update.SetOnUpdateListener(s.onUpdateAvailable)
go func() { go func() {
s.getSrvConfig() s.getSrvConfig()
for { for {
@ -436,6 +514,11 @@ func (s *serviceClient) onTrayReady() {
case <-s.mQuit.ClickedCh: case <-s.mQuit.ClickedCh:
systray.Quit() systray.Quit()
return return
case <-s.mUpdate.ClickedCh:
err := openURL(version.DownloadUrl())
if err != nil {
log.Errorf("%s", err)
}
} }
if err != nil { if err != nil {
log.Errorf("process connection: %v", err) log.Errorf("process connection: %v", err)
@ -444,6 +527,14 @@ func (s *serviceClient) onTrayReady() {
}() }()
} }
func normalizedVersion(version string) string {
versionString := version
if unicode.IsDigit(rune(versionString[0])) {
versionString = fmt.Sprintf("v%s", versionString)
}
return versionString
}
func (s *serviceClient) onTrayExit() {} func (s *serviceClient) onTrayExit() {}
// getSrvClient connection to the service. // getSrvClient connection to the service.
@ -504,6 +595,32 @@ func (s *serviceClient) getSrvConfig() {
} }
} }
func (s *serviceClient) onUpdateAvailable() {
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
s.mUpdate.Show()
s.mAbout.SetIcon(s.icUpdateCloud)
s.isUpdateIconActive = true
systray.SetIcon(s.icUpdate)
}
func openURL(url string) error {
var err error
switch runtime.GOOS {
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
case "linux":
err = exec.Command("xdg-open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
return err
}
// checkPIDFile exists and return error, or write new. // checkPIDFile exists and return error, or write new.
func checkPIDFile() error { func checkPIDFile() error {
pidFile := path.Join(os.TempDir(), "wiretrustee-ui.pid") pidFile := path.Join(os.TempDir(), "wiretrustee-ui.pid")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -10,6 +10,7 @@ then
wiretrustee service stop || true wiretrustee service stop || true
wiretrustee service uninstall || true wiretrustee service uninstall || true
fi fi
# check if netbird is installed # check if netbird is installed
NB_BIN=$(which netbird) NB_BIN=$(which netbird)
if [ -z "$NB_BIN" ] if [ -z "$NB_BIN" ]

View File

@ -8,6 +8,13 @@ AGENT=/usr/local/bin/netbird
mkdir -p /var/log/netbird/ mkdir -p /var/log/netbird/
{ {
# check if it was installed with brew
brew list --formula | grep netbird
if [ $? -eq 0 ]
then
echo "NetBird has been installed with Brew. Please use Brew to update the package."
exit 1
fi
osascript -e 'quit app "Netbird"' || true osascript -e 'quit app "Netbird"' || true
$AGENT service stop || true $AGENT service stop || true

184
version/update.go Normal file
View File

@ -0,0 +1,184 @@
package version
import (
"io"
"net/http"
"sync"
"time"
goversion "github.com/hashicorp/go-version"
log "github.com/sirupsen/logrus"
)
const (
fetchPeriod = 30 * time.Minute
)
var (
versionURL = "https://pkgs.netbird.io/releases/latest/version"
)
// Update fetch the version info periodically and notify the onUpdateListener in case the UI version or the
// daemon version are deprecated
type Update struct {
uiVersion *goversion.Version
daemonVersion *goversion.Version
latestAvailable *goversion.Version
versionsLock sync.Mutex
fetchTicker *time.Ticker
fetchDone chan struct{}
onUpdateListener func()
listenerLock sync.Mutex
}
// NewUpdate instantiate Update and start to fetch the new version information
func NewUpdate() *Update {
currentVersion, err := goversion.NewVersion(version)
if err != nil {
currentVersion, _ = goversion.NewVersion("0.0.0")
}
latestAvailable, _ := goversion.NewVersion("0.0.0")
u := &Update{
latestAvailable: latestAvailable,
uiVersion: currentVersion,
fetchTicker: time.NewTicker(fetchPeriod),
fetchDone: make(chan struct{}),
}
go u.startFetcher()
return u
}
// StopWatch stop the version info fetch loop
func (u *Update) StopWatch() {
u.fetchTicker.Stop()
select {
case u.fetchDone <- struct{}{}:
default:
}
}
// SetDaemonVersion update the currently running daemon version. If new version is available it will trigger
// the onUpdateListener
func (u *Update) SetDaemonVersion(newVersion string) bool {
daemonVersion, err := goversion.NewVersion(newVersion)
if err != nil {
daemonVersion, _ = goversion.NewVersion("0.0.0")
}
u.versionsLock.Lock()
if u.daemonVersion != nil && u.daemonVersion.Equal(daemonVersion) {
u.versionsLock.Unlock()
return false
}
u.daemonVersion = daemonVersion
u.versionsLock.Unlock()
return u.checkUpdate()
}
// SetOnUpdateListener set new update listener
func (u *Update) SetOnUpdateListener(updateFn func()) {
u.listenerLock.Lock()
defer u.listenerLock.Unlock()
u.onUpdateListener = updateFn
if u.isUpdateAvailable() {
u.onUpdateListener()
}
}
func (u *Update) startFetcher() {
changed := u.fetchVersion()
if changed {
u.checkUpdate()
}
select {
case <-u.fetchDone:
return
case <-u.fetchTicker.C:
changed := u.fetchVersion()
if changed {
u.checkUpdate()
}
}
}
func (u *Update) fetchVersion() bool {
resp, err := http.Get(versionURL)
if err != nil {
log.Errorf("failed to fetch version info: %s", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Errorf("invalid status code: %d", resp.StatusCode)
return false
}
if resp.ContentLength > 100 {
log.Errorf("too large response: %d", resp.ContentLength)
return false
}
content, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("failed to read content: %s", err)
return false
}
latestAvailable, err := goversion.NewVersion(string(content))
if err != nil {
log.Errorf("failed to parse the version string: %s", err)
return false
}
u.versionsLock.Lock()
defer u.versionsLock.Unlock()
if u.latestAvailable.Equal(latestAvailable) {
return false
}
u.latestAvailable = latestAvailable
return true
}
func (u *Update) checkUpdate() bool {
if !u.isUpdateAvailable() {
return false
}
u.listenerLock.Lock()
defer u.listenerLock.Unlock()
if u.onUpdateListener == nil {
return true
}
go u.onUpdateListener()
return true
}
func (u *Update) isUpdateAvailable() bool {
u.versionsLock.Lock()
defer u.versionsLock.Unlock()
if u.latestAvailable.GreaterThan(u.uiVersion) {
return true
}
if u.daemonVersion == nil {
return false
}
if u.latestAvailable.GreaterThan(u.daemonVersion) {
return true
}
return false
}

101
version/update_test.go Normal file
View File

@ -0,0 +1,101 @@
package version
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestNewUpdate(t *testing.T) {
version = "1.0.0"
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "10.0.0")
}))
defer svr.Close()
versionURL = svr.URL
wg := &sync.WaitGroup{}
wg.Add(1)
onUpdate := false
u := NewUpdate()
defer u.StopWatch()
u.SetOnUpdateListener(func() {
onUpdate = true
wg.Done()
})
waitTimeout(wg)
if onUpdate != true {
t.Errorf("update not found")
}
}
func TestDoNotUpdate(t *testing.T) {
version = "11.0.0"
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "10.0.0")
}))
defer svr.Close()
versionURL = svr.URL
wg := &sync.WaitGroup{}
wg.Add(1)
onUpdate := false
u := NewUpdate()
defer u.StopWatch()
u.SetOnUpdateListener(func() {
onUpdate = true
wg.Done()
})
waitTimeout(wg)
if onUpdate == true {
t.Errorf("invalid update")
}
}
func TestDaemonUpdate(t *testing.T) {
version = "11.0.0"
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "11.0.0")
}))
defer svr.Close()
versionURL = svr.URL
wg := &sync.WaitGroup{}
wg.Add(1)
onUpdate := false
u := NewUpdate()
defer u.StopWatch()
u.SetOnUpdateListener(func() {
onUpdate = true
wg.Done()
})
u.SetDaemonVersion("10.0.0")
waitTimeout(wg)
if onUpdate != true {
t.Errorf("invalid dameon version check")
}
}
func waitTimeout(wg *sync.WaitGroup) {
c := make(chan struct{})
go func() {
wg.Wait()
close(c)
}()
select {
case <-c:
return
case <-time.After(time.Second):
return
}
}

5
version/url.go Normal file
View File

@ -0,0 +1,5 @@
package version
const (
downloadURL = "https://app.netbird.io/install"
)

33
version/url_darwin.go Normal file
View File

@ -0,0 +1,33 @@
package version
import (
"os/exec"
"runtime"
)
const (
urlMacIntel = "https://pkgs.netbird.io/macos/amd64"
urlMacM1M2 = "https://pkgs.netbird.io/macos/arm64"
)
// DownloadUrl return with the proper download link
func DownloadUrl() string {
cmd := exec.Command("brew", "list --formula | grep -i netbird")
if err := cmd.Start(); err != nil {
goto PKGINSTALL
}
if err := cmd.Wait(); err == nil {
return downloadURL
}
PKGINSTALL:
switch runtime.GOARCH {
case "amd64":
return urlMacIntel
case "arm64":
return urlMacM1M2
default:
return downloadURL
}
}

6
version/url_linux.go Normal file
View File

@ -0,0 +1,6 @@
package version
// DownloadUrl return with the proper download link
func DownloadUrl() string {
return downloadURL
}

19
version/url_windows.go Normal file
View File

@ -0,0 +1,19 @@
package version
import "golang.org/x/sys/windows/registry"
const (
urlWinExe = "https://pkgs.netbird.io/windows/x64"
)
var regKeyAppPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\Netbird"
// DownloadUrl return with the proper download link
func DownloadUrl() string {
_, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyAppPath, registry.QUERY_VALUE)
if err == nil {
return urlWinExe
} else {
return downloadURL
}
}