diff --git a/app.go b/app.go index 60d91d6..a25748d 100644 --- a/app.go +++ b/app.go @@ -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 { diff --git a/esi_sso.go b/esi_sso.go index 4951812..8b17f3d 100644 --- a/esi_sso.go +++ b/esi_sso.go @@ -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) } - return payload.SolarSystem[0], nil + // 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 diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b520468..8511848 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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' }); } diff --git a/frontend/src/components/SystemContextMenu.tsx b/frontend/src/components/SystemContextMenu.tsx index c56d106..3ceda3a 100644 --- a/frontend/src/components/SystemContextMenu.tsx +++ b/frontend/src/components/SystemContextMenu.tsx @@ -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' }); } }; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 3718087..87d63e8 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1,6 +1,8 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function ESILoggedIn():Promise; + export function ESILoginStatus():Promise; export function Greet(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index ea1c61a..b79c77a 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); }