This commit is contained in:
Theodor S. Midtlien
2026-06-08 14:50:25 +02:00
parent df0717dc16
commit 6bdbbcad36
5 changed files with 23 additions and 47 deletions
@@ -83,9 +83,7 @@ func NewProfileManager() *ProfileManager {
} }
// GetActiveProfile returns the active profile as recorded in the local // GetActiveProfile returns the active profile as recorded in the local
// user state file. Only ID is populated; the display Name is not known // user state file. Only ID is populated.
// at this layer — callers that need it should query the daemon's
// ListProfiles RPC.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
pm.mu.Lock() pm.mu.Lock()
defer pm.mu.Unlock() defer pm.mu.Unlock()
@@ -95,9 +93,7 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
} }
// SwitchProfile records the given profile ID as active in the local user // SwitchProfile records the given profile ID as active in the local user
// state file. The CLI calls this after the daemon has resolved the user- // state file.
// supplied handle to a concrete ID, so the value written here is always a
// valid profile filename stem.
func (pm *ProfileManager) SwitchProfile(id string) error { func (pm *ProfileManager) SwitchProfile(id string) error {
if id != defaultProfileName && !IsValidProfileFilenameStem(id) { if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid profile ID: %q", id) return fmt.Errorf("invalid profile ID: %q", id)
+10 -16
View File
@@ -46,9 +46,8 @@ const (
AmbiguityKindName AmbiguityKindName
) )
// profileMeta is the minimal slice of a profile JSON we need for the list. // profileMeta is the minimal slice of a profile JSON we need, so we avoid
// Using a private struct here avoids running the full Config.apply() // reading all fields
// pipeline (which can rewrite the file) on every list operation.
type profileMeta struct { type profileMeta struct {
Name string Name string
} }
@@ -87,11 +86,11 @@ func init() {
} }
type ActiveProfileState struct { type ActiveProfileState struct {
// ID is the on-disk filename stem of the active profile. The JSON // ID is the on-disk filename stem of the active profile. The JSON tag stays
// tag stays as "name" for backwards compatibility with active state // as "name" for backwards compatibility with active state files written
// files written before the ID-keyed layout. Legacy values were // before the ID-based config files. Legacy values were profile names, which
// profile names, which were also the legacy filename stems, so they // were also the legacy filename stems, so they still resolve to the correct
// still resolve to the correct file on disk. // file on disk.
ID string `json:"name"` ID string `json:"name"`
Username string `json:"username"` Username string `json:"username"`
} }
@@ -373,8 +372,6 @@ func (s *ServiceManager) RemoveProfile(id, username string) error {
return fmt.Errorf("failed to remove profile config: %w", err) return fmt.Errorf("failed to remove profile config: %w", err)
} }
// Best-effort state file cleanup. Missing is fine — state files are
// only created on demand.
stateFile := filepath.Join(filepath.Dir(target.Path), id+".state.json") stateFile := filepath.Join(filepath.Dir(target.Path), id+".state.json")
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
log.Warnf("failed to remove profile state file %s: %v", stateFile, err) log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
@@ -436,8 +433,8 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
// by ID for a stable display order. // by ID for a stable display order.
// //
// Each Profile is fully populated: ID is the filename stem, Name comes // Each Profile is fully populated: ID is the filename stem, Name comes
// from the JSON's "name" field (falling back to the ID when absent), and // from the JSON's "name" field (falling back to the filename stem when absent)
// Path is built from a basename read off disk (path-traversal safe). // and Path is built from a basename read off disk.
func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) { func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
activeID, activeIsDefault := s.activeProfileID() activeID, activeIsDefault := s.activeProfileID()
@@ -545,15 +542,13 @@ func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, erro
return nil, err return nil, err
} }
// Exact ID match wins outright — covers the default profile and any
// legacy profile whose ID is its name.
for i := range profiles { for i := range profiles {
if profiles[i].ID == handle { if profiles[i].ID == handle {
return &profiles[i], nil return &profiles[i], nil
} }
} }
// ID prefix match. Skip the default profile so a `select d` does not // ID prefix match. Skip the default profile so `select d` does not
// accidentally pick it via prefix. // accidentally pick it via prefix.
var prefixMatches []Profile var prefixMatches []Profile
for i := range profiles { for i := range profiles {
@@ -575,7 +570,6 @@ func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, erro
} }
} }
// Exact name match.
var nameMatches []Profile var nameMatches []Profile
for i := range profiles { for i := range profiles {
if profiles[i].Name == handle { if profiles[i].Name == handle {
+9 -14
View File
@@ -4443,7 +4443,7 @@ func (x *AddProfileRequest) GetProfileName() string {
type AddProfileResponse struct { type AddProfileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// id is the generated on-disk ID of the new profile. CLI clients // id is the generated on-disk ID of the new profile. CLI clients
// display a truncated form; UI clients can ignore it. // display a truncated form, UI clients can ignore it.
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -4675,12 +4675,10 @@ func (x *ListProfilesResponse) GetProfiles() []*Profile {
} }
type Profile struct { type Profile struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"` IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"`
// id is the on-disk filename stem of the profile. Always set by the Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
// server; older clients that ignore it continue to work via name.
Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -4773,13 +4771,10 @@ func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) {
} }
type GetActiveProfileResponse struct { type GetActiveProfileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// profileName carries the display name for backwards compatibility ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"`
// with UI clients. New callers should prefer id, which is unique. Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"` Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
// id is the on-disk filename stem of the active profile.
Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
+1 -6
View File
@@ -700,7 +700,7 @@ message AddProfileRequest {
message AddProfileResponse { message AddProfileResponse {
// id is the generated on-disk ID of the new profile. CLI clients // id is the generated on-disk ID of the new profile. CLI clients
// display a truncated form; UI clients can ignore it. // display a truncated form, UI clients can ignore it.
string id = 1; string id = 1;
} }
@@ -728,19 +728,14 @@ message ListProfilesResponse {
message Profile { message Profile {
string name = 1; string name = 1;
bool is_active = 2; bool is_active = 2;
// id is the on-disk filename stem of the profile. Always set by the
// server; older clients that ignore it continue to work via name.
string id = 3; string id = 3;
} }
message GetActiveProfileRequest {} message GetActiveProfileRequest {}
message GetActiveProfileResponse { message GetActiveProfileResponse {
// profileName carries the display name for backwards compatibility
// with UI clients. New callers should prefer id, which is unique.
string profileName = 1; string profileName = 1;
string username = 2; string username = 2;
// id is the on-disk filename stem of the active profile.
string id = 3; string id = 3;
} }
+1 -5
View File
@@ -1011,8 +1011,6 @@ func (s *Server) canRemoveProfile(id string) error {
return nil return nil
} }
// validateProfileOperation runs the common preconditions for add/remove/
// logout flows. `id` is the resolved profile ID (never user input).
func (s *Server) validateProfileOperation(id string, allowActiveProfile bool) error { func (s *Server) validateProfileOperation(id string, allowActiveProfile bool) error {
if s.checkProfilesDisabled() { if s.checkProfilesDisabled() {
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
@@ -1031,8 +1029,6 @@ func (s *Server) validateProfileOperation(id string, allowActiveProfile bool) er
return nil return nil
} }
// logoutFromProfile logs out from a specific (already-resolved) profile
// by loading its config and sending a logout request.
func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error { func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
activeProf, err := s.profileManager.GetActiveProfileState() activeProf, err := s.profileManager.GetActiveProfileState()
if err == nil && activeProf.ID == profile.ID && s.connectClient != nil { if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
@@ -1639,7 +1635,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
// GetActiveProfile returns the active profile in the daemon. The // GetActiveProfile returns the active profile in the daemon. The
// ProfileName field carries the display name for backwards compatibility // ProfileName field carries the display name for backwards compatibility
// with UI clients; new callers should prefer Id. // with UI clients, new callers should prefer Id.
func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) { func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()