ui: detect outdated daemon via WailsUIReady probe

Add a no-op WailsUIReady RPC the UI probes once at startup. A reachable
daemon that returns Unimplemented predates this UI and is too old to
drive it; the probe distinguishes that from an unreachable daemon. On an
outdated daemon the UI fires a localized OS notification, since the main
window may not open to show an in-app error.

Proto regenerated with protoc 33.1 to keep the v6.33.1 header.
This commit is contained in:
Zoltán Papp
2026-06-17 13:00:13 +02:00
parent 4ee691d0d6
commit fedef7588f
17 changed files with 505 additions and 219 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -149,6 +149,11 @@ service DaemonService {
// ExposeService exposes a local port via the NetBird reverse proxy
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
// WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {}
}
@@ -785,6 +790,10 @@ message LogoutRequest {
message LogoutResponse {}
message WailsUIReadyRequest {}
message WailsUIReadyResponse {}
message GetFeaturesRequest{}
message GetFeaturesResponse{

View File

@@ -63,6 +63,7 @@ const (
DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile"
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
)
// DaemonServiceClient is the client API for DaemonService service.
@@ -164,6 +165,10 @@ type DaemonServiceClient interface {
GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error)
// WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error)
}
type daemonServiceClient struct {
@@ -650,6 +655,16 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent]
func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(WailsUIReadyResponse)
err := c.cc.Invoke(ctx, DaemonService_WailsUIReady_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// DaemonServiceServer is the server API for DaemonService service.
// All implementations must embed UnimplementedDaemonServiceServer
// for forward compatibility.
@@ -749,6 +764,10 @@ type DaemonServiceServer interface {
GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error
// WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error)
mustEmbedUnimplementedDaemonServiceServer()
}
@@ -891,6 +910,9 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins
func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error {
return status.Error(codes.Unimplemented, "method ExposeService not implemented")
}
func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented")
}
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
@@ -1676,6 +1698,24 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent]
func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(WailsUIReadyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).WailsUIReady(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_WailsUIReady_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).WailsUIReady(ctx, req.(*WailsUIReadyRequest))
}
return interceptor(ctx, in, info, handler)
}
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1843,6 +1883,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetInstallerResult",
Handler: _DaemonService_GetInstallerResult_Handler,
},
{
MethodName: "WailsUIReady",
Handler: _DaemonService_WailsUIReady_Handler,
},
},
Streams: []grpc.StreamDesc{
{

View File

@@ -2121,6 +2121,12 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
return features, nil
}
// WailsUIReady is a no-op the Wails UI probes at startup; merely answering it
// (rather than returning Unimplemented) tells the UI this daemon is new enough.
func (s *Server) WailsUIReady(context.Context, *proto.WailsUIReadyRequest) (*proto.WailsUIReadyResponse, error) {
return &proto.WailsUIReadyResponse{}, nil
}
// checkDisableAdvancedView reports the MDM-policy directive for the
// upcoming UI's advanced-view section. Tristate: returns nil when no
// MDM directive is set so the UI applies its own default; returns

View File

@@ -0,0 +1,19 @@
import { Compat } from "@bindings/services";
let cached: boolean | null = null;
/**
* isDaemonCompatible probes whether the running daemon implements the WailsUIReady
* RPC. A false result means the daemon predates this UI (Unimplemented) and is too
* old to drive it. The Go side returns an error instead when the daemon is simply
* unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown"
* and let the normal connection flow report it.
*
* The result is cached for the session: daemon identity does not change without a
* UI restart, and a freshly started daemon is reachable again under the same socket.
*/
export async function isDaemonCompatible(): Promise<boolean> {
if (cached !== null) return cached;
cached = await Compat.DaemonReady();
return cached;
}

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "NetBird beenden"
},
"notify.daemonOutdated.title": {
"message": "NetBird-Dienst ist veraltet"
},
"notify.daemonOutdated.body": {
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
},
"notify.update.title": {
"message": "NetBird-Update verfügbar"
},

View File

@@ -143,6 +143,14 @@
"message": "Quit NetBird",
"description": "Tray menu item that fully exits the app and removes the tray icon. Keep short."
},
"notify.daemonOutdated.title": {
"message": "NetBird service is outdated",
"description": "Title of the OS desktop notification shown when the running daemon is too old for this UI."
},
"notify.daemonOutdated.body": {
"message": "Update the NetBird service to use this app.",
"description": "Body of the desktop notification telling the user to upgrade the daemon."
},
"notify.update.title": {
"message": "NetBird update available",
"description": "Title of the OS desktop notification shown when an app update is available."

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "Salir de NetBird"
},
"notify.daemonOutdated.title": {
"message": "El servicio de NetBird está desactualizado"
},
"notify.daemonOutdated.body": {
"message": "Actualice el servicio de NetBird para usar esta aplicación."
},
"notify.update.title": {
"message": "Actualización de NetBird disponible"
},

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "Quitter NetBird"
},
"notify.daemonOutdated.title": {
"message": "Le service NetBird est obsolète"
},
"notify.daemonOutdated.body": {
"message": "Mettez à jour le service NetBird pour utiliser cette application."
},
"notify.update.title": {
"message": "Mise à jour de NetBird disponible"
},

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "NetBird bezárása"
},
"notify.daemonOutdated.title": {
"message": "A NetBird szolgáltatás elavult"
},
"notify.daemonOutdated.body": {
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
},
"notify.update.title": {
"message": "NetBird frissítés elérhető"
},

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "Esci da NetBird"
},
"notify.daemonOutdated.title": {
"message": "Il servizio NetBird è obsoleto"
},
"notify.daemonOutdated.body": {
"message": "Aggiorna il servizio NetBird per usare questa app."
},
"notify.update.title": {
"message": "Aggiornamento NetBird disponibile"
},

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "Sair do NetBird"
},
"notify.daemonOutdated.title": {
"message": "O serviço NetBird está desatualizado"
},
"notify.daemonOutdated.body": {
"message": "Atualize o serviço NetBird para usar este aplicativo."
},
"notify.update.title": {
"message": "Atualização do NetBird disponível"
},

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "Выйти из NetBird"
},
"notify.daemonOutdated.title": {
"message": "Служба NetBird устарела"
},
"notify.daemonOutdated.body": {
"message": "Обновите службу NetBird, чтобы использовать это приложение."
},
"notify.update.title": {
"message": "Доступно обновление NetBird"
},

View File

@@ -107,6 +107,12 @@
"tray.menu.quit": {
"message": "退出 NetBird"
},
"notify.daemonOutdated.title": {
"message": "NetBird 服务版本过旧"
},
"notify.daemonOutdated.body": {
"message": "请更新 NetBird 服务以使用此应用。"
},
"notify.update.title": {
"message": "NetBird 有可用更新"
},

View File

@@ -64,6 +64,7 @@ type registeredServices struct {
update *services.Update
daemonFeed *services.DaemonFeed
notifier *notifications.NotificationService
compat *services.Compat
profileSwitcher *services.ProfileSwitcher
bundle *i18n.Bundle
prefStore *preferences.Store
@@ -103,6 +104,7 @@ func main() {
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := notifications.New()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
// ApplicationStarted so the notifier's Startup has initialised the
// notification-center delegate. No-op on Linux/Windows (stubs report
@@ -132,6 +134,7 @@ func main() {
update: update,
daemonFeed: daemonFeed,
notifier: notifier,
compat: compat,
profileSwitcher: profileSwitcher,
bundle: bundle,
prefStore: prefStore,
@@ -190,6 +193,10 @@ func main() {
// Mint). ApplicationStarted fires after the startup loop, so the bus is up.
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
daemonFeed.Watch(context.Background())
// Probe daemon compatibility once the notifier bus is up; an outdated
// daemon may keep the main window from showing, so the OS toast is the
// only reliable signal the user gets.
go notifyIfDaemonOutdated(compat, notifier, localizer)
})
if err := app.Run(); err != nil {
@@ -322,6 +329,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(services.NewAutostart(app.Autostart)))
app.RegisterService(application.NewService(services.NewVersion()))
app.RegisterService(application.NewService(services.NewUILog()))
app.RegisterService(application.NewService(s.compat))
}
// newMainWindow creates the hidden main window, sized to the user's last view

View File

@@ -0,0 +1,40 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
)
// Compat answers whether the running daemon is new enough to drive this UI.
type Compat struct {
conn DaemonConn
}
func NewCompat(conn DaemonConn) *Compat {
return &Compat{conn: conn}
}
// DaemonReady probes the WailsUIReady RPC once. A true result means the daemon
// implements it and is compatible. An Unimplemented response means the daemon
// predates this UI and is too old; the caller should surface an upgrade prompt.
// Any other error (daemon not running, transport failure) is returned so the
// frontend can tell "outdated" apart from "not reachable".
func (c *Compat) DaemonReady(ctx context.Context) (bool, error) {
client, err := c.conn.Client()
if err != nil {
return false, err
}
if _, err := client.WailsUIReady(ctx, &proto.WailsUIReadyRequest{}); err != nil {
if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -3,10 +3,16 @@
package main
import (
"context"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/services"
)
const notifyIDDaemonOutdated = "netbird-daemon-outdated"
// sendFn fits both NotificationService.SendNotification and SendNotificationWithActions.
type sendFn func(notifications.NotificationOptions) error
@@ -31,3 +37,22 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
}
return nil
}
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
// is reachable but too old for this UI. A probe error means the daemon isn't
// reachable (not outdated), so it is left to the normal connection flow.
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
ready, err := compat.DaemonReady(context.Background())
if err != nil {
log.Debugf("daemon compatibility probe: %v", err)
return
}
if ready {
return
}
_ = safeSendNotification(notifier.SendNotification, "daemon-outdated", notifications.NotificationOptions{
ID: notifyIDDaemonOutdated,
Title: loc.T("notify.daemonOutdated.title"),
Body: loc.T("notify.daemonOutdated.body"),
})
}