@@ -43,6 +43,15 @@ type profileManager struct {
4343 currentProfile ipn.LoginProfileView // always Valid (once [newProfileManager] returns).
4444 prefs ipn.PrefsView // always Valid (once [newProfileManager] returns).
4545
46+ // StateChangeHook is an optional hook that is called when the current profile or prefs change,
47+ // such as due to a profile switch or a change in the profile's preferences.
48+ // It is typically set by the [LocalBackend] to invert the dependency between
49+ // the [profileManager] and the [LocalBackend], so that instead of [LocalBackend]
50+ // asking [profileManager] for the state, we can have [profileManager] call
51+ // [LocalBackend] when the state changes. See also:
52+ // https://github.com/tailscale/tailscale/pull/15791#discussion_r2060838160
53+ StateChangeHook ipnext.ProfileStateChangeCallback
54+
4655 // extHost is the bridge between [profileManager] and the registered [ipnext.Extension]s.
4756 // It may be nil in tests. A nil pointer is a valid, no-op host.
4857 extHost * ExtensionHost
@@ -166,6 +175,16 @@ func (pm *profileManager) SwitchToProfile(profile ipn.LoginProfileView) (cp ipn.
166175 // But if updating the default profile fails, we should log it.
167176 pm .logf ("failed to set %s (%s) as the default profile: %v" , profile .Name (), profile .ID (), err )
168177 }
178+
179+ if f := pm .StateChangeHook ; f != nil {
180+ f (pm .currentProfile , pm .prefs , false )
181+ }
182+ // Do not call pm.extHost.NotifyProfileChange here; it is invoked in
183+ // [LocalBackend.resetForProfileChangeLockedOnEntry] after the netmap reset.
184+ // TODO(nickkhyl): Consider moving it here (or into the stateChangeCb handler
185+ // in [LocalBackend]) once the profile/node state, including the netmap,
186+ // is actually tied to the current profile.
187+
169188 return profile , true , nil
170189}
171190
@@ -344,11 +363,19 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
344363 // [LocalBackend.resetForProfileChangeLockedOnEntry] is not called and certain
345364 // node/profile-specific state may not be reset as expected.
346365 //
347- // However, LocalBackend notifies [ipnext.Extension]s about the profile change,
366+ // However, [profileManager] notifies [ipnext.Extension]s about the profile change,
348367 // so features migrated from LocalBackend to external packages should not be affected.
349368 //
350369 // See tailscale/corp#28014.
351- pm .currentProfile = cp
370+ if ! cp .Equals (pm .currentProfile ) {
371+ const sameNode = false // implicit profile switch
372+ pm .currentProfile = cp
373+ pm .prefs = prefsIn .AsStruct ().View ()
374+ if f := pm .StateChangeHook ; f != nil {
375+ f (cp , prefsIn , sameNode )
376+ }
377+ pm .extHost .NotifyProfileChange (cp , prefsIn , sameNode )
378+ }
352379 cp , err := pm .setProfilePrefs (nil , prefsIn , np )
353380 if err != nil {
354381 return err
@@ -410,7 +437,20 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
410437 // Update the current profile view to reflect the changes
411438 // if the specified profile is the current profile.
412439 if isCurrentProfile {
413- pm .currentProfile = lp .View ()
440+ // Always set pm.currentProfile to the new profile view for pointer equality.
441+ // We check it further down the call stack.
442+ lp := lp .View ()
443+ sameProfileInfo := lp .Equals (pm .currentProfile )
444+ pm .currentProfile = lp
445+ if ! sameProfileInfo {
446+ // But only invoke the callbacks if the profile info has actually changed.
447+ const sameNode = true // just an info update; still the same node
448+ pm .prefs = prefsIn .AsStruct ().View () // suppress further callbacks for this change
449+ if f := pm .StateChangeHook ; f != nil {
450+ f (lp , prefsIn , sameNode )
451+ }
452+ pm .extHost .NotifyProfileChange (lp , prefsIn , sameNode )
453+ }
414454 }
415455
416456 // An empty profile.ID indicates that the node info is not available yet,
@@ -470,7 +510,13 @@ func (pm *profileManager) setProfilePrefsNoPermCheck(profile ipn.LoginProfileVie
470510 // That said, regardless of the cleanup, we might want
471511 // to keep the profileManager responsible for invoking
472512 // profile- and prefs-related callbacks.
473- pm .extHost .NotifyProfilePrefsChanged (pm .currentProfile , oldPrefs , clonedPrefs )
513+
514+ if ! clonedPrefs .Equals (oldPrefs ) {
515+ if f := pm .StateChangeHook ; f != nil {
516+ f (pm .currentProfile , clonedPrefs , true )
517+ }
518+ pm .extHost .NotifyProfilePrefsChanged (pm .currentProfile , oldPrefs , clonedPrefs )
519+ }
474520
475521 pm .updateHealth ()
476522 }
0 commit comments