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/**'
- '**/Dockerfile'
- '**/Dockerfile.*'
- 'client/ui/**'
env:
SIGN_PIPE_VER: "v0.0.9"

View File

@ -54,7 +54,7 @@ nfpms:
contents:
- src: client/ui/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
dependencies:
- netbird
@ -71,7 +71,7 @@ nfpms:
contents:
- src: client/ui/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
dependencies:
- netbird
@ -91,4 +91,4 @@ uploads:
mode: archive
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT
method: PUT

View File

@ -15,8 +15,10 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unicode"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
@ -74,18 +76,30 @@ func main() {
}
}
//go:embed connected.ico
//go:embed netbird-systemtray-connected.ico
var iconConnectedICO []byte
//go:embed connected.png
//go:embed netbird-systemtray-connected.png
var iconConnectedPNG []byte
//go:embed disconnected.ico
//go:embed netbird-systemtray-default.ico
var iconDisconnectedICO []byte
//go:embed disconnected.png
//go:embed netbird-systemtray-default.png
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 {
ctx context.Context
addr string
@ -93,14 +107,20 @@ type serviceClient struct {
icConnected []byte
icDisconnected []byte
icUpdate []byte
icUpdateCloud []byte
// systray menu items
mStatus *systray.MenuItem
mUp *systray.MenuItem
mDown *systray.MenuItem
mAdminPanel *systray.MenuItem
mSettings *systray.MenuItem
mQuit *systray.MenuItem
mStatus *systray.MenuItem
mUp *systray.MenuItem
mDown *systray.MenuItem
mAdminPanel *systray.MenuItem
mSettings *systray.MenuItem
mAbout *systray.MenuItem
mVersionUI *systray.MenuItem
mVersionDaemon *systray.MenuItem
mUpdate *systray.MenuItem
mQuit *systray.MenuItem
// application with main windows.
app fyne.App
@ -118,6 +138,11 @@ type serviceClient struct {
managementURL string
preSharedKey string
adminURL string
update *version.Update
daemonVersion string
updateIndicationLock sync.Mutex
isUpdateIconActive bool
}
// newServiceClient instance constructor
@ -130,14 +155,20 @@ func newServiceClient(addr string, a fyne.App, showSettings bool) *serviceClient
app: a,
showSettings: showSettings,
update: version.NewUpdate(),
}
if runtime.GOOS == "windows" {
s.icConnected = iconConnectedICO
s.icDisconnected = iconDisconnectedICO
s.icUpdate = iconUpdateICO
s.icUpdateCloud = iconUpdateCloudICO
} else {
s.icConnected = iconConnectedPNG
s.icDisconnected = iconDisconnectedPNG
s.icUpdate = iconUpdatePNG
s.icUpdateCloud = iconUpdateCloudPNG
}
if showSettings {
@ -328,19 +359,53 @@ func (s *serviceClient) updateStatus() error {
return err
}
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
var systrayIconState bool
if status.Status == string(internal.StatusConnected) && !s.mUp.Disabled() {
systray.SetIcon(s.icConnected)
if !s.isUpdateIconActive {
systray.SetIcon(s.icConnected)
}
systray.SetTooltip("NetBird (Connected)")
s.mStatus.SetTitle("Connected")
s.mUp.Disable()
s.mDown.Enable()
systrayIconState = true
} else if status.Status != string(internal.StatusConnected) && s.mUp.Disabled() {
systray.SetIcon(s.icDisconnected)
if !s.isUpdateIconActive {
systray.SetIcon(s.icDisconnected)
}
systray.SetTooltip("NetBird (Disconnected)")
s.mStatus.SetTitle("Disconnected")
s.mDown.Disable()
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
}, &backoff.ExponentialBackOff{
InitialInterval: time.Second,
@ -374,11 +439,24 @@ func (s *serviceClient) onTrayReady() {
systray.AddSeparator()
s.mSettings = systray.AddMenuItem("Settings", "Settings of the application")
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()
s.mQuit = systray.AddMenuItem("Quit", "Quit the client app")
s.update.SetOnUpdateListener(s.onUpdateAvailable)
go func() {
s.getSrvConfig()
for {
@ -436,6 +514,11 @@ func (s *serviceClient) onTrayReady() {
case <-s.mQuit.ClickedCh:
systray.Quit()
return
case <-s.mUpdate.ClickedCh:
err := openURL(version.DownloadUrl())
if err != nil {
log.Errorf("%s", err)
}
}
if err != nil {
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() {}
// 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.
func checkPIDFile() error {
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 uninstall || true
fi
# check if netbird is installed
NB_BIN=$(which netbird)
if [ -z "$NB_BIN" ]
@ -41,4 +42,4 @@ netbird service install 2> /dev/null || true
netbird service start || true
# start app
open /Applications/Netbird\ UI.app
open /Applications/Netbird\ UI.app

View File

@ -8,6 +8,13 @@ AGENT=/usr/local/bin/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
$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
}
}