package platform import ( "context" "encoding/json" "log/slog" "net/http" "strings" "time" ) // HealIdentity refreshes a persisted session's display identity (name + avatar) from the // authoritative /api/me and re-persists it, BEFORE the session is injected into the WebView. // // Why: a session minted by an older build derived identity from the broker's nameless machine // token, baking the subject UUID (and no avatar) into the on-disk session. The newer login path // records the real name/picture, but an already-logged-in user keeps the stale session. Correcting // it only in the WebView (per launch, via /api/me) is fragile — when the boot access token has // expired and the refresh is momentarily flaky, the correction silently fails and the UUID shows // again (the reported intermittent regression). Healing the persisted session here fixes it at the // source: once a single launch succeeds, the on-disk identity is correct and every later boot is // correct without any /api/me dependency. // // Synchronous and bounded so it can run before wails.Run; best-effort — on any network failure it // returns the session unchanged (offline simply keeps whatever was last persisted, which a prior // successful heal already corrected). If the access token has expired it refreshes once (and the // rotated refresh token is persisted so it is not lost). func HealIdentity(s *LoginResult, apiBase string) *LoginResult { if s == nil || s.AccessToken == "" { return s } // Once healed, the persisted name is a real display name (not the subject UUID); skip the // /api/me round-trip then so it costs nothing on every later launch. A stale session (name // still equal to the UUID) falls through and heals — fetching the avatar in the same pass. if s.User.Name != "" && s.User.Name != s.User.ID { return s } ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) defer cancel() info, status := fetchMe(ctx, apiBase, s.AccessToken) tokenRotated := false if status == http.StatusUnauthorized && s.RefreshToken != "" { if cfg, err := FetchOAuthConfig(ctx, apiBase); err == nil { if tok, rerr := NewFlow(cfg, func(string) {}).Refresh(ctx, s.RefreshToken); rerr == nil && tok.AccessToken != "" { s.AccessToken = tok.AccessToken if tok.RefreshToken != "" { s.RefreshToken = tok.RefreshToken // broker rotates refresh; the old one is now dead } s.ExpiresIn = tok.ExpiresIn tokenRotated = true info, status = fetchMe(ctx, apiBase, s.AccessToken) } } } changed := tokenRotated // a rotated refresh token must be persisted, regardless of the heal outcome if status == http.StatusOK && info.ID != "" { // Replace a stale name only with a real display name (not the subject UUID). if info.Name != "" && info.Name != info.ID && info.Name != s.User.Name { s.User.Name = info.Name changed = true } if info.Avatar != "" && info.Avatar != s.User.Avatar { s.User.Avatar = info.Avatar changed = true } } if changed { if err := SaveSession(*s); err != nil { slog.Warn("cdrop: heal identity save failed", "err", err) } } return s } // fetchMe GETs /api/me with the access token, returning the identity and HTTP status (0 on a // transport error). It hits the public origin directly (no WebView proxy needed here). func fetchMe(ctx context.Context, apiBase, accessToken string) (UserInfo, int) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(apiBase, "/")+"/api/me", nil) if err != nil { return UserInfo{}, 0 } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) if err != nil { return UserInfo{}, 0 } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return UserInfo{}, resp.StatusCode } var m struct { UserID string `json:"user_id"` Name string `json:"name"` Avatar string `json:"avatar"` } if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { return UserInfo{}, 0 } return UserInfo{ID: m.UserID, Name: m.Name, Avatar: m.Avatar}, http.StatusOK }