Merge branch 'main' into ui-refactor

Resolve conflicts and complete the profile display-name -> ID migration
across the daemon, CLI, UI services, tray, and React frontend.

- Regenerate client/proto daemon.pb.go from the merged proto so it carries
  both branches' RPCs (ui-refactor: SubscribeStatus/RegisterUILog/ExtendAuthSession,
  main: RenameProfile + id fields); keep the v6.33.1 generator header
- server.go: combine ui-refactor's profile-list-changed events with main's
  id-bearing responses; publish profile-list-changed on rename
- Drop the deleted Fyne client_ui.go/profile.go and port main's profile-ID
  changes into the refactored services/tray
- UI services, tray and React: send the profile ID as the daemon handle and
  keep the display name for rendering only (activeProfileId vs activeProfile)
- Relax the profile-name input to match the daemon's sanitizeDisplayName
  (spaces, emoji, any valid UTF-8); cap at 128
- Expose RenameProfile via a Profiles.Rename services wrapper (+ regenerated
  bindings) for the frontend to wire up
- cmd/login.go: use the profile ID for GetProfileState
This commit is contained in:
Zoltan Papp
2026-06-18 11:40:51 +02:00
77 changed files with 2789 additions and 1647 deletions
@@ -18,14 +18,19 @@ const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
type ProfileContextValue = {
username: string;
// activeProfile is the display NAME of the active profile (for rendering
// and the "default" check). activeProfileId is its stable on-disk ID, used
// as the handle for daemon requests and for active-profile comparisons,
// since display names can collide.
activeProfile: string;
activeProfileId: string;
profiles: Profile[];
loaded: boolean;
refresh: () => Promise<void>;
switchProfile: (name: string) => Promise<void>;
addProfile: (name: string) => Promise<void>;
removeProfile: (name: string) => Promise<void>;
logoutProfile: (name: string) => Promise<void>;
switchProfile: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
logoutProfile: (id: string) => Promise<void>;
};
const ProfileContext = createContext<ProfileContextValue | null>(null);
@@ -41,6 +46,7 @@ export const useProfile = () => {
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
const [username, setUsername] = useState("");
const [activeProfile, setActiveProfile] = useState("");
const [activeProfileId, setActiveProfileId] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loaded, setLoaded] = useState(false);
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -58,6 +64,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
]);
setUsername(u);
setActiveProfile(active.profileName || "default");
setActiveProfileId(active.id || "default");
setProfiles(list);
setLoaded(true);
} catch (e) {
@@ -94,33 +101,38 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
};
}, [refresh]);
// id is a handle: the daemon resolves an exact ID, ID prefix, or unique
// display name. The UI passes the profile's ID for precision.
const switchProfile = useCallback(
async (name: string) => {
await ProfileSwitcher.SwitchActive({ profileName: name, username });
async (id: string) => {
await ProfileSwitcher.SwitchActive({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// addProfile creates a profile by display name and returns the
// daemon-generated ID, so the caller can immediately address it by ID.
const addProfile = useCallback(
async (name: string) => {
await ProfilesSvc.Add({ profileName: name, username });
const id = await ProfilesSvc.Add({ profileName: name, username });
await refresh();
return id;
},
[username, refresh],
);
const removeProfile = useCallback(
async (name: string) => {
await ProfilesSvc.Remove({ profileName: name, username });
async (id: string) => {
await ProfilesSvc.Remove({ profileName: id, username });
await refresh();
},
[username, refresh],
);
const logoutProfile = useCallback(
async (name: string) => {
await Connection.Logout({ profileName: name, username });
async (id: string) => {
await Connection.Logout({ profileName: id, username });
await refresh();
},
[username, refresh],
@@ -130,6 +142,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
() => ({
username,
activeProfile,
activeProfileId,
profiles,
loaded,
refresh,
@@ -141,6 +154,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[
username,
activeProfile,
activeProfileId,
profiles,
loaded,
refresh,
@@ -58,7 +58,7 @@ export const useAutostartSetting = () => {
type LoadedConfig = { profileName: string; data: Config };
const useSettingsState = () => {
const { username, activeProfile, loaded: profileLoaded } = useProfile();
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -69,18 +69,18 @@ const useSettingsState = () => {
}, [loaded]);
useEffect(() => {
if (!profileLoaded || !activeProfile) return;
if (!profileLoaded || !activeProfileId) return;
let cancelled = false;
const load = async (showError: boolean) => {
try {
const data = await SettingsSvc.GetConfig({
profileName: activeProfile,
profileName: activeProfileId,
username,
});
if (cancelled) return;
if (saveTimer.current) return;
setLoaded({ profileName: activeProfile, data });
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
await errorDialog({
@@ -103,7 +103,7 @@ const useSettingsState = () => {
cancelled = true;
off();
};
}, [profileLoaded, activeProfile, username]);
}, [profileLoaded, activeProfileId, username]);
useEffect(() => {
let cancelled = false;
@@ -43,7 +43,7 @@ const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
const { activeProfile, username } = useProfile();
const { activeProfileId, username } = useProfile();
const daemonState = status?.status ?? "Idle";
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
@@ -91,7 +91,7 @@ export const MainConnectionStatusSwitch = () => {
setAction("connect");
try {
await Connection.Up({
profileName: activeProfile,
profileName: activeProfileId,
username,
});
await refresh();
@@ -22,13 +22,12 @@ type Props = {
onCreate: (name: string, managementUrl: string) => void;
};
// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight
// raw name diverges from what's stored, spawning a ghost row and breaking delete.
const sanitizeProfileInput = (value: string): string =>
value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]/g, "");
// The daemon (profilemanager.sanitizeDisplayName) accepts free-form display
// names — spaces, emoji, punctuation, any valid UTF-8 — stripping only control
// characters and capping the length. Since #6367 the on-disk ID is separate
// from the display name, so the raw input no longer needs to be coerced into a
// filename-safe slug client-side; just trim and let the daemon canonicalize.
const MAX_PROFILE_NAME_LEN = 128;
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
const { t } = useTranslation();
@@ -66,7 +65,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
e.preventDefault();
if (checking) return;
const sanitized = sanitizeProfileInput(name);
const sanitized = name.trim();
if (sanitized.length === 0) {
setNameError(t("profile.dialog.required"));
nameRef.current?.focus();
@@ -106,7 +105,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
};
const handleNameChange = (value: string) => {
setName(sanitizeProfileInput(value));
setName(value);
if (nameError) setNameError(null);
};
@@ -147,7 +146,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={nameError ?? undefined}
maxLength={64}
maxLength={MAX_PROFILE_NAME_LEN}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
@@ -19,13 +19,13 @@ const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const { t } = useTranslation();
const { activeProfile, profiles, switchProfile, loaded } = useProfile();
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const sortedProfiles = [...profiles].sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
if (a.id === activeProfileId) return -1;
if (b.id === activeProfileId) return 1;
return a.name.localeCompare(b.name);
});
@@ -44,10 +44,10 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
}
};
const handleSelect = (name: string) => {
const handleSelect = (id: string) => {
setOpen(false);
if (name === activeProfile) return;
void guarded(t("profile.error.switchTitle"), () => switchProfile(name));
if (id === activeProfileId) return;
void guarded(t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleManage = () => {
@@ -57,7 +57,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
if (!loaded) return <ProfileTriggerSkeleton />;
const hasProfile = !!activeProfile;
const hasProfile = !!activeProfileId;
const displayName = hasProfile ? activeProfile : t("profile.selector.noProfile");
return (
@@ -90,9 +90,9 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
<Command.List>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.name}
key={profile.id}
profile={profile}
isActive={profile.name === activeProfile}
isActive={profile.id === activeProfileId}
onSelect={handleSelect}
/>
))}
@@ -178,15 +178,15 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onSelect: (name: string) => void;
onSelect: (id: string) => void;
};
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
const showEmail = !!profile.email;
return (
<Command.Item
value={profile.name}
onSelect={() => onSelect(profile.name)}
value={profile.id}
onSelect={() => onSelect(profile.id)}
className={cn(
"flex gap-2 px-2 py-2 pr-3 w-auto last:mb-1",
"rounded-md outline-none cursor-default text-sm",
@@ -25,7 +25,7 @@ export function ProfilesTab() {
const { t } = useTranslation();
const {
profiles,
activeProfile,
activeProfileId,
loaded,
username,
switchProfile,
@@ -45,16 +45,16 @@ export function ProfilesTab() {
const { order, items } = reconcileOrder(
orderRef.current,
profiles,
(p) => p.name,
(p) => p.id,
(a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
if (a.id === activeProfileId) return -1;
if (b.id === activeProfileId) return 1;
return a.name.localeCompare(b.name);
},
);
orderRef.current = order;
return items;
}, [profiles, activeProfile]);
}, [profiles, activeProfileId]);
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
@@ -71,27 +71,27 @@ export function ProfilesTab() {
}
};
const handleSwitch = async (name: string) => {
const handleSwitch = async (id: string, name: string) => {
const ok = await confirm({
title: t("profile.switch.title", { name }),
description: t("profile.switch.message", { name }),
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleDeregister = async (name: string) => {
const handleDeregister = async (id: string, name: string) => {
const ok = await confirm({
title: t("profile.deregister.title", { name }),
description: t("profile.deregister.message", { name }),
confirmLabel: t("profile.deregister.confirm"),
});
if (!ok) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id));
};
const handleDelete = async (name: string) => {
const handleDelete = async (id: string, name: string) => {
if (name === DEFAULT_PROFILE) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
@@ -100,20 +100,21 @@ export function ProfilesTab() {
danger: true,
});
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
};
const handleCreate = async (name: string, managementUrl: string) => {
await guarded(i18next.t("profile.error.createTitle"), async () => {
await addProfile(name);
// SetConfig is keyed by profile name, so it writes the not-yet-active
// profile. Write before switching so any reconnect targets the right deployment.
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: name, username, managementUrl }),
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfile(name);
await switchProfile(id);
});
};
@@ -131,12 +132,12 @@ export function ProfilesTab() {
<tbody>
{ordered.map((profile) => (
<ProfileRow
key={profile.name}
key={profile.id}
profile={profile}
isActive={profile.name === activeProfile}
onSwitch={() => handleSwitch(profile.name)}
onDeregister={() => handleDeregister(profile.name)}
onDelete={() => handleDelete(profile.name)}
isActive={profile.id === activeProfileId}
onSwitch={() => handleSwitch(profile.id, profile.name)}
onDeregister={() => handleDeregister(profile.id, profile.name)}
onDelete={() => handleDelete(profile.id, profile.name)}
/>
))}
</tbody>
@@ -134,7 +134,7 @@ export default function SessionExpirationDialog() {
const username = await ProfilesSvc.Username();
const active = await ProfilesSvc.GetActive();
await Connection.Logout({
profileName: active.profileName || "default",
profileName: active.id || "default",
username,
});
WindowManager.CloseSessionExpiration().catch(console.error);
@@ -19,13 +19,15 @@ const WINDOW_WIDTH = 360;
type WelcomeStep = "tray" | "management";
function shouldShowManagementStep(
activeProfile: string,
activeProfileId: string,
email: string,
managementUrl: string,
managedManagementUrl: string,
): boolean {
if (managedManagementUrl) return false;
if (activeProfile !== "default") return false;
// The default profile's ID equals the literal "default", so this check
// holds whether we pass an ID or the legacy name.
if (activeProfileId !== "default") return false;
if (email.trim() !== "") return false;
return isNetbirdCloud(managementUrl);
}
@@ -51,21 +53,21 @@ export default function WelcomeDialog() {
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
]);
const profileName = active.profileName || "default";
const profileId = active.id || "default";
const [config, list, restrictions] = await Promise.all([
SettingsSvc.GetConfig({ profileName, username }),
SettingsSvc.GetConfig({ profileName: profileId, username }),
ProfilesSvc.List(username),
SettingsSvc.GetRestrictions().catch(() => new Restrictions()),
]);
const profile = list.find((p) => p.name === profileName);
const profile = list.find((p) => p.id === profileId);
const email = profile?.email ?? "";
if (cancelled) return;
setInitial({
profileName,
profileName: profileId,
username,
managementUrl: config.managementUrl,
needsManagementStep: shouldShowManagementStep(
profileName,
profileId,
email,
config.managementUrl,
restrictions.mdm.managementURL,
+3 -1
View File
@@ -122,7 +122,9 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
username := p.Username
if profileName == "" {
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
profileName = active.GetProfileName()
// Address the active profile by ID (the daemon resolves it as a
// handle); names can collide, the ID cannot.
profileName = active.GetId()
if username == "" {
username = active.GetUsername()
}
+66 -10
View File
@@ -11,6 +11,10 @@ import (
)
type Profile struct {
// ID is the daemon-generated on-disk identity of the profile. Display
// names can collide and be renamed, so the ID is the stable handle the
// daemon resolves switch/remove/logout requests against.
ID string `json:"id"`
Name string `json:"name"`
IsActive bool `json:"isActive"`
// Email is read from the user-owned per-profile state file (CLI writes it
@@ -25,10 +29,28 @@ type ProfileRef struct {
}
type ActiveProfile struct {
// ID is the active profile's stable on-disk identity. Use it (not the
// display name) as the handle for daemon requests and active-profile
// comparisons, since names can collide.
ID string `json:"id"`
ProfileName string `json:"profileName"`
Username string `json:"username"`
}
// RenameProfileParams selects a profile by handle and carries its new display
// name.
type RenameProfileParams struct {
// Handle selects the profile to rename: an exact ID, a unique ID prefix,
// or a unique display name. The daemon resolves it server-side.
Handle string `json:"handle"`
// NewName is the new free-form display name. The daemon sanitizes it
// (strips control characters, trims, caps length) but keeps spaces, emoji,
// punctuation, and non-ASCII letters.
NewName string `json:"newName"`
Username string `json:"username"`
}
type Profiles struct {
conn DaemonConn
}
@@ -58,8 +80,8 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
pm := profilemanager.NewProfileManager()
out := make([]Profile, 0, len(resp.GetProfiles()))
for _, p := range resp.GetProfiles() {
prof := Profile{Name: p.GetName(), IsActive: p.GetIsActive()}
if state, err := pm.GetProfileState(p.GetName()); err == nil {
prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()}
if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil {
prof.Email = state.Email
}
out = append(out, prof)
@@ -77,15 +99,20 @@ func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
return ActiveProfile{}, err
}
return ActiveProfile{
ID: resp.GetId(),
ProfileName: resp.GetProfileName(),
Username: resp.GetUsername(),
}, nil
}
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) error {
// Switch sends a profile switch to the daemon and returns the resolved
// on-disk ID of the now-active profile. ProfileName is treated as a handle
// (exact ID, unique ID prefix, or unique display name); the daemon resolves
// it server-side and echoes back the canonical ID.
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return err
return "", err
}
req := &proto.SwitchProfileRequest{}
if p.ProfileName != "" {
@@ -94,20 +121,29 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) error {
if p.Username != "" {
req.Username = ptrStr(p.Username)
}
_, err = cli.SwitchProfile(ctx, req)
return err
resp, err := cli.SwitchProfile(ctx, req)
if err != nil {
return "", err
}
return resp.GetId(), nil
}
func (s *Profiles) Add(ctx context.Context, p ProfileRef) error {
// Add creates a profile with the given display name and returns its
// daemon-generated on-disk ID, so callers can address the new profile by ID
// (e.g. to write config or switch to it) without re-resolving the name.
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return err
return "", err
}
_, err = cli.AddProfile(ctx, &proto.AddProfileRequest{
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
return err
if err != nil {
return "", err
}
return resp.GetId(), nil
}
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
@@ -121,3 +157,23 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
})
return err
}
// Rename changes a profile's display name. The on-disk ID is unaffected, so
// the active profile and any ID-based references stay valid (the default
// profile can be renamed too — only its display name changes). Returns the
// profile's previous display name as confirmation.
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
Username: p.Username,
Handle: p.Handle,
NewProfileName: p.NewName,
})
if err != nil {
return "", err
}
return resp.GetOldProfileName(), nil
}
+6 -3
View File
@@ -55,15 +55,18 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
s.feed.BeginProfileSwitch()
}
if err := s.profiles.Switch(ctx, p); err != nil {
resolvedID, err := s.profiles.Switch(ctx, p)
if err != nil {
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
}
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
// reads this file and sends the name back in the Up RPC, so if it diverges
// reads this file and sends the ID back in the Up RPC, so if it diverges
// the daemon reverts the UI switch on the next CLI `up`. Best-effort — the
// daemon is authoritative; a failure only leaves the CLI's view stale.
if err := profilemanager.NewProfileManager().SwitchProfile(p.ProfileName); err != nil {
// Use the daemon-resolved ID rather than the handle we sent, since the
// on-disk state is keyed by ID, not display name.
if err := profilemanager.NewProfileManager().SwitchProfile(profilemanager.ID(resolvedID)); err != nil {
log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err)
}
+1 -1
View File
@@ -206,7 +206,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
return Restrictions{}, fmt.Errorf("get active profile: %w", err)
}
cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
ProfileName: active.GetProfileName(),
ProfileName: active.GetId(),
Username: active.GetUsername(),
})
if err != nil {
+44 -11
View File
@@ -10,9 +10,26 @@ import (
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/ui/services"
)
// formatProfileLabel returns the display label for a profile. Profiles can
// share the same Name, so when more than one profile in profiles carries this
// Name, a short form of the ID is appended to disambiguate the entries.
func formatProfileLabel(profile services.Profile, profiles []services.Profile) string {
count := 0
for _, p := range profiles {
if p.Name == profile.Name {
count++
}
}
if count <= 1 {
return profile.Name
}
return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID())
}
// loadConfig caches the active-profile identity and the notifications gate.
// Runs in a startup goroutine so a slow daemon does not block menu construction.
func (t *Tray) loadConfig() {
@@ -23,7 +40,13 @@ func (t *Tray) loadConfig() {
log.Debugf("get active profile: %v", err)
return
}
cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams(active))
// Address the active profile by ID (the daemon resolves it as a handle),
// since display names can collide. ConfigParams no longer matches
// ActiveProfile's shape for a struct conversion now that it carries an ID.
cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams{
ProfileName: active.ID,
Username: active.Username,
})
if err != nil {
log.Debugf("get config: %v", err)
return
@@ -75,7 +98,12 @@ func (t *Tray) fillProfileSubmenu() {
username := t.profilesUser
t.profilesMu.Unlock()
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
sort.Slice(profiles, func(i, j int) bool {
if profiles[i].Name != profiles[j].Name {
return profiles[i].Name < profiles[j].Name
}
return profiles[i].ID < profiles[j].ID
})
// Wails' systray does not reliably propagate a disabled parent to its
// children on every platform, so disable each row explicitly.
@@ -84,26 +112,28 @@ func (t *Tray) fillProfileSubmenu() {
t.profileSubmenu.Clear()
var activeName, activeEmail string
for _, p := range profiles {
name := p.Name
id := p.ID
// Display names can collide, so disambiguate with a short ID suffix.
display := formatProfileLabel(p, profiles)
active := p.IsActive
// Add, not AddCheckbox: Wails auto-toggles a checkbox on click before
// OnClick fires, so both old and new would briefly show checked during
// the switch. A plain item with a "✓ " prefix avoids the race.
label := name
label := display
if active {
label = "✓ " + name
label = "✓ " + display
}
item := t.profileSubmenu.Add(label)
item.OnClick(func(*application.Context) {
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
log.Infof("tray profile click: profile=%q id=%q wasActive=%v", display, id, active)
if active {
return
}
t.switchProfile(name)
t.switchProfile(id, display)
})
item.SetEnabled(!disableProfiles)
if active {
activeName = name
activeName = display
activeEmail = p.Email
}
}
@@ -130,7 +160,10 @@ func (t *Tray) fillProfileSubmenu() {
// switchProfile cancels any in-flight switch before starting a new one, so
// rapid clicks converge to the last selected profile. Optimistic paint and
// event suppression live in ProfileSwitcher, shared with the React Status page.
func (t *Tray) switchProfile(name string) {
// switchProfile sends handle (the profile's ID) to the daemon, which resolves
// it precisely even when display names collide. display is used only for the
// failure notification.
func (t *Tray) switchProfile(handle, display string) {
t.profileMu.Lock()
if t.switchCancel != nil {
t.switchCancel()
@@ -146,14 +179,14 @@ func (t *Tray) switchProfile(name string) {
return
}
if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{
ProfileName: name,
ProfileName: handle,
Username: username,
}); err != nil {
if ctx.Err() != nil {
return
}
log.Errorf("tray switchProfile: %v", err)
t.notifyError(t.loc.T("notify.error.switchProfile", "profile", name))
t.notifyError(t.loc.T("notify.error.switchProfile", "profile", display))
return
}
t.loadProfiles()