diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index b889a8fe3..7a211f93d 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -74,8 +74,9 @@ type Direct struct { keepSharerAndUserSplit bool skipIPForwardingCheck bool pinger Pinger - popBrowser func(url string) // or nil - c2nHandler http.Handler // or nil + popBrowser func(url string) // or nil + c2nHandler http.Handler // or nil + onClientVersion func(*tailcfg.ClientVersion) // or nil dialPlan ControlDialPlanner // can be nil @@ -109,13 +110,14 @@ type Options struct { NewDecompressor func() (Decompressor, error) KeepAlive bool Logf logger.Logf - HTTPTestClient *http.Client // optional HTTP client to use (for tests only) - NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only) - DebugFlags []string // debug settings to send to control - LinkMonitor *monitor.Mon // optional link monitor - PopBrowserURL func(url string) // optional func to open browser - Dialer *tsdial.Dialer // non-nil - C2NHandler http.Handler // or nil + HTTPTestClient *http.Client // optional HTTP client to use (for tests only) + NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only) + DebugFlags []string // debug settings to send to control + LinkMonitor *monitor.Mon // optional link monitor + PopBrowserURL func(url string) // optional func to open browser + OnClientVersion func(*tailcfg.ClientVersion) // optional func to inform GUI of client version status + Dialer *tsdial.Dialer // non-nil + C2NHandler http.Handler // or nil // Status is called when there's a change in status. Status func(Status) @@ -241,6 +243,7 @@ func NewDirect(opts Options) (*Direct, error) { skipIPForwardingCheck: opts.SkipIPForwardingCheck, pinger: opts.Pinger, popBrowser: opts.PopBrowserURL, + onClientVersion: opts.OnClientVersion, c2nHandler: opts.C2NHandler, dialer: opts.Dialer, dialPlan: opts.DialPlan, @@ -1008,6 +1011,9 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool c.logf("netmap: control says to open URL %v; no popBrowser func", u) } } + if resp.ClientVersion != nil && c.onClientVersion != nil { + c.onClientVersion(resp.ClientVersion) + } if resp.ControlTime != nil && !resp.ControlTime.IsZero() { c.logf.JSON(1, "controltime", resp.ControlTime.UTC()) } diff --git a/ipn/backend.go b/ipn/backend.go index c73069879..74b8ac239 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -95,6 +95,10 @@ type Notify struct { // macOS Network Extension. LocalTCPPort *uint16 `json:",omitempty"` + // ClientVersion, if non-nil, describes whether a client version update + // is available. + ClientVersion *tailcfg.ClientVersion `json:",omitempty"` + // type is mirrored in xcode/Shared/IPN.swift } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b4c622a13..57dd3e27e 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1271,6 +1271,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error { LinkMonitor: b.e.GetLinkMonitor(), Pinger: b, PopBrowserURL: b.tellClientToBrowseToURL, + OnClientVersion: b.onClientVersion, Dialer: b.Dialer(), Status: b.setClientStatus, C2NHandler: http.HandlerFunc(b.handleC2N), @@ -1836,6 +1837,21 @@ func (b *LocalBackend) tellClientToBrowseToURL(url string) { } } +// onClientVersion is called on MapResponse updates when a MapResponse contains +// a non-nil ClientVersion message. +func (b *LocalBackend) onClientVersion(v *tailcfg.ClientVersion) { + switch runtime.GOOS { + case "darwin", "ios": + // These auto-update well enough, and we haven't converted the + // ClientVersion types to Swift yet, so don't send them in ipn.Notify + // messages. + default: + // But everything else is a Go client and can deal with this field, even + // if they ignore it. + b.send(ipn.Notify{ClientVersion: v}) + } +} + // For testing lazy machine key generation. var panicOnMachineKeyGeneration = envknob.RegisterBool("TS_DEBUG_PANIC_MACHINE_KEY") diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index d6ff5372d..60f3e4563 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1425,6 +1425,40 @@ type MapResponse struct { // server. An initial nil is equivalent to new(ControlDialPlan). // A subsequent streamed nil means no change. ControlDialPlan *ControlDialPlan `json:",omitempty"` + + // ClientVersion describes the latest client version that's available for + // download and whether the client is using it. A nil value means no change + // or nothing to report. + ClientVersion *ClientVersion `json:",omitempty"` +} + +// ClientVersion is information about the latest client version that's available +// for the client (and whether they're already running it). +// +// It does not include a URL to download the client, as that varies by platform. +type ClientVersion struct { + // RunningLatest is true if the client is running the latest build. + RunningLatest bool `json:",omitempty"` + + // LatestVersion is the latest version.Short ("1.34.2") version available + // for download for the client's platform and packaging type. + // It won't be populated if RunningLatest is true. + // The primary purpose of the LatestVersion value is to invalidate the client's + // cache update check value, if any. This primarily applies to Windows. + LatestVersion string `json:",omitempty"` + + // Notify is whether the client should do an OS-specific notification about + // a new version being available. This should not be populated if + // RunningLatest is true. The client should not notify multiple times for + // the same LatestVersion value. + Notify bool `json:",omitempty"` + + // NotifyURL is a URL to open in the browser when the user clicks on the + // notification, when Notify is true. + NotifyURL string `json:",omitempty"` + + // NotifyText is the text to show in the notification, when Notify is true. + NotifyText string `json:",omitempty"` } // ControlDialPlan is instructions from the control server to the client on how