feat(esi): improve ESI SSO login flow and system name resolution

This commit introduces several improvements to the ESI (EVE Server Interface) Single Sign-On (SSO) flow and system name resolution:

**ESI SSO Login Flow:**
- **Asynchronous Callback Server:** The `StartCallbackServer` function is now deprecated in favor of `StartCallbackServerAsync`. This allows the callback server to run in the background without blocking the main application thread, improving responsiveness.
- **Improved Login Status Polling:** After initiating the ESI login, the frontend now polls the `ESILoggedIn` status for a short period. This ensures that the UI reflects the login status more accurately and promptly after the user completes the authentication flow in their browser.
- **Error Handling:** Added more specific error messages for failed token exchanges and invalid SSO responses.

**System Name Resolution:**
- **Multi-stage Resolution:** The `ResolveSystemIDByName` function now employs a more robust, multi-stage approach to find system IDs:
 1. It first attempts to use the `universe/ids` endpoint for direct name-to-ID mapping, which is generally more accurate.
 2. If that fails, it falls back to a `strict` search via the `search` endpoint.
 3. As a final fallback, it performs a non-strict search and then resolves the names of the returned IDs to find an exact case-insensitive match. If no exact match is found, it returns the first result.
- **Logging:** Added more detailed logging for each stage of the system name resolution process, aiding in debugging.
- **ESI API Headers:** Ensured that necessary headers like `Accept` and `X-User-Agent` are correctly set for ESI API requests.

**Frontend Changes:**
- **Import `ESILoggedIn`:** The `ESILoggedIn` function is now imported into the `Header.tsx` component.
- **Updated Toast Message:** The toast message for setting a destination now includes the system name for better context in case of errors.
This commit is contained in:
2025-08-09 19:08:05 +02:00
parent 33fcaaaf52
commit ca610000db
6 changed files with 202 additions and 26 deletions

12
app.go
View File

@@ -51,7 +51,9 @@ func (a *App) StartESILogin() (string, error) {
if err != nil {
return "", err
}
go func() { _ = a.ssi.StartCallbackServer() }()
if err := a.ssi.StartCallbackServerAsync(); err != nil {
return "", err
}
runtime.BrowserOpenURL(a.ctx, url)
return url, nil
}
@@ -68,6 +70,14 @@ func (a *App) ESILoginStatus() string {
return "not logged in"
}
// ESILoggedIn returns true if a valid access token is present
func (a *App) ESILoggedIn() bool {
if a.ssi == nil {
return false
}
return a.ssi.Status().LoggedIn
}
// SetDestination posts a waypoint to ESI to set destination
func (a *App) SetDestination(destinationID int64, clearOthers bool, addToBeginning bool) error {
if a.ssi == nil {

View File

@@ -84,8 +84,62 @@ func (s *ESISSO) BuildAuthorizeURL() (string, error) {
return issuerAuthorizeURL + "?" + q.Encode(), nil
}
// StartCallbackServer starts a temporary local HTTP server to receive the SSO callback
func (s *ESISSO) StartCallbackServer() error {
// StartCallbackServerAsync starts the callback server in the background and returns immediately
func (s *ESISSO) StartCallbackServerAsync() error {
u, err := url.Parse(s.redirectURI)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("redirect URI must be http(s)")
}
hostPort := u.Host
if !strings.Contains(hostPort, ":") {
if u.Scheme == "https" {
hostPort += ":443"
} else {
hostPort += ":80"
}
}
mux := http.NewServeMux()
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
code := q.Get("code")
st := q.Get("state")
if code == "" || st == "" || st != s.state {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Invalid SSO response"))
return
}
if err := s.exchangeToken(r.Context(), code); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
return
}
_, _ = io.WriteString(w, "Login successful. You can close this window.")
go func() {
time.Sleep(200 * time.Millisecond)
_ = s.server.Shutdown(context.Background())
}()
})
ln, err := net.Listen("tcp", hostPort)
if err != nil {
return err
}
s.server = &http.Server{Handler: mux}
go func() { _ = s.server.Serve(ln) }()
return nil
}
// Deprecated: blocking start; prefer StartCallbackServerAsync
func (s *ESISSO) StartCallbackServer() error {
u, err := url.Parse(s.redirectURI)
if err != nil {
return err
@@ -93,10 +147,8 @@ func (s *ESISSO) StartCallbackServer() error {
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("redirect URI must be http(s)")
}
// Bind exact host:port
hostPort := u.Host
if !strings.Contains(hostPort, ":") {
// default ports
if u.Scheme == "https" {
hostPort += ":443"
} else {
@@ -106,7 +158,6 @@ func (s *ESISSO) StartCallbackServer() error {
mux := http.NewServeMux()
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
// Receive code
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
@@ -119,7 +170,6 @@ func (s *ESISSO) StartCallbackServer() error {
_, _ = w.Write([]byte("Invalid SSO response"))
return
}
// Exchange token
if err := s.exchangeToken(r.Context(), code); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
@@ -127,7 +177,6 @@ func (s *ESISSO) StartCallbackServer() error {
}
_, _ = io.WriteString(w, "Login successful. You can close this window.")
go func() {
// stop shortly after responding
time.Sleep(200 * time.Millisecond)
_ = s.server.Shutdown(context.Background())
}()
@@ -262,14 +311,17 @@ func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginn
q.Set("destination_id", strconv.FormatInt(destinationID, 10))
q.Set("add_to_beginning", strconv.FormatBool(addToBeginning))
q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers))
endpoint := esiBase + "/v2/ui/autopilot/waypoint/" + "?" + q.Encode()
q.Set("datasource", "tranquility")
endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode()
fmt.Printf("ESI: POST waypoint dest=%d clear=%v addToBeginning=%v\n", destinationID, clearOthers, addToBeginning)
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-User-Agent", "signalerr/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -277,9 +329,11 @@ func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginn
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
fmt.Println("ESI: waypoint set OK", resp.Status)
return nil
}
b, _ := io.ReadAll(resp.Body)
fmt.Printf("ESI: waypoint failed %s body=%s\n", resp.Status, string(b))
return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b))
}
@@ -302,42 +356,138 @@ func (s *ESISSO) Status() SSOStatus {
}
}
// ResolveSystemIDByName searches ESI for a solar system by exact name and returns its ID
// ResolveSystemIDByName searches ESI for a solar system by name and returns its ID
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, errors.New("empty system name")
}
// 1) Prefer universe/ids (name->id) for accuracy
type idsReq struct {
Names []string `json:"names"`
}
body, _ := json.Marshal(idsReq{Names: []string{name}})
idsURL := esiBase + "/v3/universe/ids/?datasource=tranquility"
fmt.Printf("ESI: resolve system id via universe/ids for name=%q\n", name)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, idsURL, strings.NewReader(string(body)))
if err == nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, errDo := http.DefaultClient.Do(req)
if errDo == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var idsResp struct {
Systems []struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"systems"`
}
if err := json.NewDecoder(resp.Body).Decode(&idsResp); err == nil {
if len(idsResp.Systems) > 0 {
fmt.Printf("ESI: universe/ids hit: %s -> %d\n", idsResp.Systems[0].Name, idsResp.Systems[0].ID)
return idsResp.Systems[0].ID, nil
}
}
}
}
}
// 2) Fallback: strict search
q := url.Values{}
q.Set("categories", "solar_system")
q.Set("search", name)
q.Set("strict", "true")
endpoint := esiBase + "/v3/search/?" + q.Encode()
searchURL := esiBase + "/v3/search/?" + q.Encode()
fmt.Printf("ESI: strict search for %q\n", name)
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return 0, err
}
req2.Header.Set("Accept", "application/json")
resp2, err := http.DefaultClient.Do(req2)
if err == nil {
defer resp2.Body.Close()
if resp2.StatusCode == http.StatusOK {
var payload struct {
SolarSystem []int64 `json:"solar_system"`
}
if err := json.NewDecoder(resp2.Body).Decode(&payload); err == nil && len(payload.SolarSystem) > 0 {
fmt.Printf("ESI: strict search hit: %d\n", payload.SolarSystem[0])
return payload.SolarSystem[0], nil
}
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
// 3) Fallback: non-strict search then best-effort name match via universe/names
q.Set("strict", "false")
searchURL2 := esiBase + "/v3/search/?" + q.Encode()
fmt.Printf("ESI: non-strict search for %q\n", name)
req3, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL2, nil)
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
req3.Header.Set("Accept", "application/json")
resp3, err := http.DefaultClient.Do(req3)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("search failed: %s: %s", resp.Status, string(b))
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp3.Body)
return 0, fmt.Errorf("search failed: %s: %s", resp3.Status, string(b))
}
var payload struct {
SolarSystem []int64 `json:"solar_system"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return 0, err
}
if len(payload.SolarSystem) == 0 {
if err := json.NewDecoder(resp3.Body).Decode(&payload); err != nil || len(payload.SolarSystem) == 0 {
return 0, fmt.Errorf("system not found: %s", name)
}
// If one result, return it
if len(payload.SolarSystem) == 1 {
fmt.Printf("ESI: non-strict search single hit: %d\n", payload.SolarSystem[0])
return payload.SolarSystem[0], nil
}
// Multiple: resolve names and pick exact case-insensitive match if possible
ids := payload.SolarSystem
var idNamesReq = make([]int64, 0, len(ids))
idNamesReq = append(idNamesReq, ids...)
namesURL := esiBase + "/v3/universe/names/?datasource=tranquility"
idsBody, _ := json.Marshal(idNamesReq)
req4, err := http.NewRequestWithContext(ctx, http.MethodPost, namesURL, strings.NewReader(string(idsBody)))
if err != nil {
return 0, err
}
req4.Header.Set("Content-Type", "application/json")
req4.Header.Set("Accept", "application/json")
resp4, err := http.DefaultClient.Do(req4)
if err != nil {
return 0, err
}
defer resp4.Body.Close()
if resp4.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp4.Body)
return 0, fmt.Errorf("names lookup failed: %s: %s", resp4.Status, string(b))
}
var namesResp []struct {
Category string `json:"category"`
ID int64 `json:"id"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp4.Body).Decode(&namesResp); err != nil {
return 0, err
}
lower := strings.ToLower(name)
for _, n := range namesResp {
if n.Category == "solar_system" && strings.ToLower(n.Name) == lower {
fmt.Printf("ESI: names resolved exact: %s -> %d\n", n.Name, n.ID)
return n.ID, nil
}
}
// Fallback: return first
fmt.Printf("ESI: names resolved fallback: returning %d for %q\n", namesResp[0].ID, name)
return namesResp[0].ID, nil
}
// Helpers

View File

@@ -11,7 +11,7 @@ import {
} from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import { StartESILogin, ESILoginStatus } from 'wailsjs/go/main/App';
import { StartESILogin, ESILoginStatus, ESILoggedIn } from 'wailsjs/go/main/App';
interface HeaderProps {
title: string;
@@ -32,7 +32,17 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
const handleLogin = async () => {
try {
await StartESILogin();
toast({ title: 'EVE Login', description: 'Complete login in your browser. Reopen menu to refresh status.' });
toast({ title: 'EVE Login', description: 'Complete login in your browser.' });
// Poll a few times to update status after redirect callback
for (let i = 0; i < 20; i++) {
const ok = await ESILoggedIn();
if (ok) {
const s = await ESILoginStatus();
setStatus(s);
break;
}
await new Promise(r => setTimeout(r, 500));
}
} catch (e: any) {
toast({ title: 'Login failed', description: String(e), variant: 'destructive' });
}

View File

@@ -41,7 +41,7 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
toast({ title: 'Destination set', description: `${system.solarSystemName}` });
onClose();
} catch (e: any) {
toast({ title: 'Failed to set destination', description: String(e), variant: 'destructive' });
toast({ title: 'Failed to set destination', description: `${system.solarSystemName}: ${String(e)}`, variant: 'destructive' });
}
};

View File

@@ -1,6 +1,8 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ESILoggedIn():Promise<boolean>;
export function ESILoginStatus():Promise<string>;
export function Greet(arg1:string):Promise<string>;

View File

@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ESILoggedIn() {
return window['go']['main']['App']['ESILoggedIn']();
}
export function ESILoginStatus() {
return window['go']['main']['App']['ESILoginStatus']();
}