diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3feefdd49c2..5833638c509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ on: - 'release_files/**' - '**/Dockerfile' - '**/Dockerfile.*' + - 'client/ui/**' env: SIGN_PIPE_VER: "v0.0.9" diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index c6f7a7c3443..66a22ee34a2 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -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 \ No newline at end of file + method: PUT diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 9c7685db03d..e66d03d951e 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -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") diff --git a/client/ui/connected.ico b/client/ui/connected.ico deleted file mode 100644 index 3dd598fa70d..00000000000 Binary files a/client/ui/connected.ico and /dev/null differ diff --git a/client/ui/connected.png b/client/ui/connected.png deleted file mode 100644 index 64b25ade12e..00000000000 Binary files a/client/ui/connected.png and /dev/null differ diff --git a/client/ui/disconnected.ico b/client/ui/disconnected.ico deleted file mode 100644 index 2bab8a503d9..00000000000 Binary files a/client/ui/disconnected.ico and /dev/null differ diff --git a/client/ui/disconnected.png b/client/ui/disconnected.png deleted file mode 100644 index c74a30b992e..00000000000 Binary files a/client/ui/disconnected.png and /dev/null differ diff --git a/client/ui/netbird-systemtray-connected.ico b/client/ui/netbird-systemtray-connected.ico new file mode 100644 index 00000000000..621afce9fb2 Binary files /dev/null and b/client/ui/netbird-systemtray-connected.ico differ diff --git a/client/ui/netbird-systemtray-connected.png b/client/ui/netbird-systemtray-connected.png new file mode 100644 index 00000000000..c5878d0187e Binary files /dev/null and b/client/ui/netbird-systemtray-connected.png differ diff --git a/client/ui/netbird-systemtray-default.ico b/client/ui/netbird-systemtray-default.ico new file mode 100644 index 00000000000..5a025267599 Binary files /dev/null and b/client/ui/netbird-systemtray-default.ico differ diff --git a/client/ui/netbird-systemtray-default.png b/client/ui/netbird-systemtray-default.png new file mode 100644 index 00000000000..12e7a2dc100 Binary files /dev/null and b/client/ui/netbird-systemtray-default.png differ diff --git a/client/ui/netbird-systemtray-update-cloud.ico b/client/ui/netbird-systemtray-update-cloud.ico new file mode 100644 index 00000000000..b87c6f4b556 Binary files /dev/null and b/client/ui/netbird-systemtray-update-cloud.ico differ diff --git a/client/ui/netbird-systemtray-update-cloud.png b/client/ui/netbird-systemtray-update-cloud.png new file mode 100644 index 00000000000..e9d0b803500 Binary files /dev/null and b/client/ui/netbird-systemtray-update-cloud.png differ diff --git a/client/ui/netbird-systemtray-update.ico b/client/ui/netbird-systemtray-update.ico new file mode 100644 index 00000000000..1a1c4086d5e Binary files /dev/null and b/client/ui/netbird-systemtray-update.ico differ diff --git a/client/ui/netbird-systemtray-update.png b/client/ui/netbird-systemtray-update.png new file mode 100644 index 00000000000..1f4651df9db Binary files /dev/null and b/client/ui/netbird-systemtray-update.png differ diff --git a/release_files/darwin-ui-installer.sh b/release_files/darwin-ui-installer.sh index 7e8115b64cf..5179f02d6ed 100644 --- a/release_files/darwin-ui-installer.sh +++ b/release_files/darwin-ui-installer.sh @@ -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 \ No newline at end of file +open /Applications/Netbird\ UI.app diff --git a/release_files/darwin_pkg/preinstall b/release_files/darwin_pkg/preinstall index cdea1465c21..5965e82ebe4 100755 --- a/release_files/darwin_pkg/preinstall +++ b/release_files/darwin_pkg/preinstall @@ -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 diff --git a/version/update.go b/version/update.go new file mode 100644 index 00000000000..1de60ea9a3c --- /dev/null +++ b/version/update.go @@ -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 +} diff --git a/version/update_test.go b/version/update_test.go new file mode 100644 index 00000000000..c2b47749522 --- /dev/null +++ b/version/update_test.go @@ -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 + } +} diff --git a/version/url.go b/version/url.go new file mode 100644 index 00000000000..ed43ab04249 --- /dev/null +++ b/version/url.go @@ -0,0 +1,5 @@ +package version + +const ( + downloadURL = "https://app.netbird.io/install" +) diff --git a/version/url_darwin.go b/version/url_darwin.go new file mode 100644 index 00000000000..cb58612f52b --- /dev/null +++ b/version/url_darwin.go @@ -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 + } +} diff --git a/version/url_linux.go b/version/url_linux.go new file mode 100644 index 00000000000..c8193e30c31 --- /dev/null +++ b/version/url_linux.go @@ -0,0 +1,6 @@ +package version + +// DownloadUrl return with the proper download link +func DownloadUrl() string { + return downloadURL +} diff --git a/version/url_windows.go b/version/url_windows.go new file mode 100644 index 00000000000..f2055b10915 --- /dev/null +++ b/version/url_windows.go @@ -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 + } +}