Compare commits
16 Commits
v3.1.0
...
a584bc9c55
Author | SHA1 | Date | |
---|---|---|---|
a584bc9c55 | |||
e1e961ebea | |||
f06a60c701 | |||
ef57bf4cde | |||
cd1cc6dc5f | |||
e7a8014a50 | |||
13da1c8340 | |||
3f9d315978 | |||
ca610000db | |||
33fcaaaf52 | |||
1b73642940 | |||
aa15a8c2c9 | |||
478a628b6f | |||
98b6397dcc | |||
715e4559aa | |||
d42c245c9d |
101
app.go
101
app.go
@@ -2,12 +2,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
ssi *ESISSO
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
@@ -19,9 +24,105 @@ func NewApp() *App {
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
|
||||
clientID := os.Getenv("EVE_SSO_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
clientID = "5091f74037374697938384bdbac2698c"
|
||||
}
|
||||
redirectURI := os.Getenv("EVE_SSO_REDIRECT_URI")
|
||||
if redirectURI == "" {
|
||||
redirectURI = "http://localhost:8080/callback"
|
||||
}
|
||||
|
||||
a.ssi = NewESISSO(clientID, redirectURI, []string{"esi-ui.write_waypoint.v1"})
|
||||
}
|
||||
|
||||
// Greet returns a greeting for the given name
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hello %s, It's show time!", name)
|
||||
}
|
||||
|
||||
// StartESILogin begins the PKCE SSO flow and opens a browser to the EVE login page
|
||||
func (a *App) StartESILogin() (string, error) {
|
||||
if a.ssi == nil {
|
||||
return "", errors.New("ESI not initialised")
|
||||
}
|
||||
url, err := a.ssi.BuildAuthorizeURL()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := a.ssi.StartCallbackServerAsync(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
runtime.BrowserOpenURL(a.ctx, url)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (a *App) ESILoginStatus() string {
|
||||
if a.ssi == nil {
|
||||
return "not initialised"
|
||||
}
|
||||
st := a.ssi.Status()
|
||||
if st.LoggedIn {
|
||||
return fmt.Sprintf("logged in as %s (%d)", st.CharacterName, st.CharacterID)
|
||||
}
|
||||
return "not logged in"
|
||||
}
|
||||
|
||||
func (a *App) ESILoggedIn() bool {
|
||||
if a.ssi == nil {
|
||||
return false
|
||||
}
|
||||
return a.ssi.Status().LoggedIn
|
||||
}
|
||||
|
||||
func (a *App) SetDestinationForAll(systemName string, clearOthers bool, addToBeginning bool) error {
|
||||
if a.ssi == nil {
|
||||
return errors.New("ESI not initialised")
|
||||
}
|
||||
id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.ssi.PostWaypointForAll(id, clearOthers, addToBeginning)
|
||||
}
|
||||
|
||||
func (a *App) AddWaypointForAllByName(systemName string, addToBeginning bool) error {
|
||||
if a.ssi == nil {
|
||||
return errors.New("ESI not initialised")
|
||||
}
|
||||
id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.ssi.PostWaypointForAll(id, false, addToBeginning)
|
||||
}
|
||||
|
||||
// PostRouteForAllByNames posts a full route: via names (in order) then destination at the end (clearing first)
|
||||
func (a *App) PostRouteForAllByNames(destination string, vias []string) error {
|
||||
if a.ssi == nil {
|
||||
return errors.New("ESI not initialised")
|
||||
}
|
||||
ids, err := a.ssi.ResolveSystemIDsByNames(a.ctx, append(vias, destination))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
viaIDs := ids[:len(vias)]
|
||||
destID := ids[len(vias)]
|
||||
return a.ssi.PostRouteForAll(destID, viaIDs)
|
||||
}
|
||||
|
||||
func (a *App) ListCharacters() ([]CharacterInfo, error) {
|
||||
if a.ssi == nil || a.ssi.db == nil {
|
||||
return nil, errors.New("ESI not initialised")
|
||||
}
|
||||
var tokens []ESIToken
|
||||
if err := a.ssi.db.Find(&tokens).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]CharacterInfo, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
list = append(list, CharacterInfo{CharacterID: t.CharacterID, CharacterName: t.CharacterName})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
657
esi_sso.go
Normal file
657
esi_sso.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize"
|
||||
issuerTokenURL = "https://login.eveonline.com/v2/oauth/token"
|
||||
esiBase = "https://esi.evetech.net"
|
||||
)
|
||||
|
||||
type ESISSO struct {
|
||||
clientID string
|
||||
redirectURI string
|
||||
scopes []string
|
||||
|
||||
state string
|
||||
codeVerifier string
|
||||
codeChallenge string
|
||||
|
||||
mu sync.Mutex
|
||||
accessToken string
|
||||
refreshToken string
|
||||
expiresAt time.Time
|
||||
|
||||
characterID int64
|
||||
characterName string
|
||||
|
||||
callbackOnce sync.Once
|
||||
server *http.Server
|
||||
|
||||
db *gorm.DB
|
||||
|
||||
nameCacheOnce sync.Once
|
||||
nameToID map[string]int64 // lowercased name -> id
|
||||
}
|
||||
|
||||
type SolarSystem struct {
|
||||
SolarSystemID int64 `gorm:"column:solarSystemID;primaryKey"`
|
||||
SolarSystemName string `gorm:"column:solarSystemName"`
|
||||
}
|
||||
|
||||
func (SolarSystem) TableName() string { return "mapSolarSystems" }
|
||||
|
||||
type ESIToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CharacterID int64 `gorm:"index"`
|
||||
CharacterName string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CharacterInfo struct {
|
||||
CharacterID int64 `json:"character_id"`
|
||||
CharacterName string `json:"character_name"`
|
||||
}
|
||||
|
||||
func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO {
|
||||
s := &ESISSO{
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
scopes: scopes,
|
||||
}
|
||||
_ = s.initDB()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ESISSO) initDB() error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPath := filepath.Join(home, ".industrializer", "sqlite-latest.sqlite")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&ESIToken{}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) saveToken() {
|
||||
if s.db == nil || s.characterID == 0 {
|
||||
return
|
||||
}
|
||||
t := ESIToken{}
|
||||
s.db.Where("character_id = ?", s.characterID).First(&t)
|
||||
t.CharacterID = s.characterID
|
||||
t.CharacterName = s.characterName
|
||||
t.AccessToken = s.accessToken
|
||||
t.RefreshToken = s.refreshToken
|
||||
t.ExpiresAt = s.expiresAt
|
||||
s.db.Save(&t)
|
||||
}
|
||||
|
||||
func (s *ESISSO) loadToken() {
|
||||
if s.db == nil || s.characterID == 0 {
|
||||
return
|
||||
}
|
||||
t := ESIToken{}
|
||||
if err := s.db.Where("character_id = ?", s.characterID).First(&t).Error; err == nil {
|
||||
s.accessToken = t.AccessToken
|
||||
s.refreshToken = t.RefreshToken
|
||||
s.expiresAt = t.ExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ESISSO) BuildAuthorizeURL() (string, error) {
|
||||
if s.clientID == "" {
|
||||
return "", errors.New("EVE_SSO_CLIENT_ID not set")
|
||||
}
|
||||
verifier, challenge, err := generatePKCE()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.codeVerifier = verifier
|
||||
s.codeChallenge = challenge
|
||||
s.state = randString(24)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", s.clientID)
|
||||
q.Set("redirect_uri", s.redirectURI)
|
||||
if len(s.scopes) > 0 {
|
||||
q.Set("scope", strings.Join(s.scopes, " "))
|
||||
}
|
||||
q.Set("state", s.state)
|
||||
q.Set("code_challenge", s.codeChallenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
|
||||
return issuerAuthorizeURL + "?" + q.Encode(), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *ESISSO) StartCallbackServer() 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}
|
||||
return s.server.Serve(ln)
|
||||
}
|
||||
|
||||
func (s *ESISSO) exchangeToken(ctx context.Context, code string) error {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", s.clientID)
|
||||
form.Set("code_verifier", s.codeVerifier)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("token exchange failed: %s: %s", resp.Status, string(b))
|
||||
}
|
||||
var tr tokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if tr.AccessToken != "" {
|
||||
s.accessToken = tr.AccessToken
|
||||
}
|
||||
if tr.RefreshToken != "" {
|
||||
s.refreshToken = tr.RefreshToken
|
||||
}
|
||||
if tr.ExpiresIn > 0 {
|
||||
s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
name, cid := parseTokenCharacter(tr.AccessToken)
|
||||
s.characterName = name
|
||||
s.characterID = cid
|
||||
s.saveToken()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) refresh(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
rt := s.refreshToken
|
||||
s.mu.Unlock()
|
||||
if rt == "" {
|
||||
return errors.New("no refresh token")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", rt)
|
||||
form.Set("client_id", s.clientID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b))
|
||||
}
|
||||
var tr tokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.accessToken = tr.AccessToken
|
||||
if tr.RefreshToken != "" {
|
||||
s.refreshToken = tr.RefreshToken
|
||||
}
|
||||
if tr.ExpiresIn > 0 {
|
||||
s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
name, cid := parseTokenCharacter(tr.AccessToken)
|
||||
s.characterName = name
|
||||
s.characterID = cid
|
||||
s.saveToken()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) refreshForToken(ctx context.Context, t *ESIToken) (*ESIToken, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", t.RefreshToken)
|
||||
form.Set("client_id", s.clientID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b))
|
||||
}
|
||||
var tr tokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.AccessToken = tr.AccessToken
|
||||
if tr.RefreshToken != "" {
|
||||
t.RefreshToken = tr.RefreshToken
|
||||
}
|
||||
if tr.ExpiresIn > 0 {
|
||||
t.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
|
||||
}
|
||||
if s.db != nil {
|
||||
_ = s.db.Save(t).Error
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) ensureAccessToken(ctx context.Context) (string, error) {
|
||||
s.mu.Lock()
|
||||
if s.accessToken == "" && s.db != nil && s.characterID != 0 {
|
||||
s.mu.Unlock()
|
||||
s.loadToken()
|
||||
s.mu.Lock()
|
||||
}
|
||||
tok := s.accessToken
|
||||
exp := s.expiresAt
|
||||
s.mu.Unlock()
|
||||
if tok == "" {
|
||||
return "", errors.New("not logged in")
|
||||
}
|
||||
if time.Now().After(exp.Add(-60 * time.Second)) {
|
||||
if err := s.refresh(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
tok = s.accessToken
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) ensureAccessTokenFor(ctx context.Context, t *ESIToken) (string, error) {
|
||||
if t.AccessToken == "" || time.Now().After(t.ExpiresAt.Add(-60*time.Second)) {
|
||||
nt, err := s.refreshForToken(ctx, t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nt.AccessToken, nil
|
||||
}
|
||||
return t.AccessToken, nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||
tok, err := s.ensureAccessToken(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning)
|
||||
}
|
||||
|
||||
func (s *ESISSO) postWaypointWithToken(tok string, destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||
q := url.Values{}
|
||||
q.Set("destination_id", strconv.FormatInt(destinationID, 10))
|
||||
q.Set("add_to_beginning", strconv.FormatBool(addToBeginning))
|
||||
q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers))
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
func (s *ESISSO) PostWaypointForAll(destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||
if s.db == nil {
|
||||
return errors.New("db not initialised")
|
||||
}
|
||||
var tokens []ESIToken
|
||||
if err := s.db.Find(&tokens).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for i := range tokens {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
tok, err := s.ensureAccessTokenFor(ctx, &tokens[i])
|
||||
cancel()
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *ESISSO) Status() SSOStatus {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return SSOStatus{
|
||||
LoggedIn: s.accessToken != "",
|
||||
CharacterID: s.characterID,
|
||||
CharacterName: s.characterName,
|
||||
ExpiresAt: s.expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
type SSOStatus struct {
|
||||
LoggedIn bool
|
||||
CharacterID int64
|
||||
CharacterName string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func generatePKCE() (verifier string, challenge string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
v := base64.RawURLEncoding.EncodeToString(buf)
|
||||
h := sha256.Sum256([]byte(v))
|
||||
c := base64.RawURLEncoding.EncodeToString(h[:])
|
||||
return v, c, nil
|
||||
}
|
||||
|
||||
func randString(n int) string {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func parseTokenCharacter(jwt string) (name string, id int64) {
|
||||
parts := strings.Split(jwt, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", 0
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", 0
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(payload, &m); err != nil {
|
||||
return "", 0
|
||||
}
|
||||
if v, ok := m["name"].(string); ok {
|
||||
name = v
|
||||
}
|
||||
if v, ok := m["sub"].(string); ok {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx > -1 {
|
||||
if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil {
|
||||
id = idv
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ensureNameCache loads a lowercase name->id map from the local DB once
|
||||
func (s *ESISSO) ensureNameCache() error {
|
||||
var err error
|
||||
s.nameCacheOnce.Do(func() {
|
||||
cache := make(map[string]int64, 50000)
|
||||
if s.db != nil {
|
||||
var rows []SolarSystem
|
||||
// Only select required columns
|
||||
if e := s.db.Select("solarSystemID, solarSystemName").Find(&rows).Error; e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
cache[strings.ToLower(r.SolarSystemName)] = r.SolarSystemID
|
||||
}
|
||||
}
|
||||
s.nameToID = cache
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveSystemIDByName resolves using ONLY the local DB cache (case-insensitive)
|
||||
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
|
||||
if err := s.ensureNameCache(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if id, ok := s.nameToID[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("system not found in local DB: %s", name)
|
||||
}
|
||||
|
||||
// ResolveSystemIDsByNames returns IDs in the same order as names using ONLY the local DB cache
|
||||
func (s *ESISSO) ResolveSystemIDsByNames(ctx context.Context, names []string) ([]int64, error) {
|
||||
if err := s.ensureNameCache(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]int64, len(names))
|
||||
missing := []string{}
|
||||
for i, n := range names {
|
||||
key := strings.ToLower(strings.TrimSpace(n))
|
||||
if id, ok := s.nameToID[key]; ok {
|
||||
out[i] = id
|
||||
} else {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("systems not found in local DB: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PostRouteForAll clears route and posts vias then destination last
|
||||
func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) error {
|
||||
if s.db == nil { return errors.New("db not initialised") }
|
||||
var tokens []ESIToken
|
||||
if err := s.db.Find(&tokens).Error; err != nil { return err }
|
||||
// Deduplicate by CharacterID
|
||||
uniq := make(map[int64]ESIToken, len(tokens))
|
||||
for _, t := range tokens {
|
||||
uniq[t.CharacterID] = t
|
||||
}
|
||||
uniqueTokens := make([]ESIToken, 0, len(uniq))
|
||||
for _, t := range uniq { uniqueTokens = append(uniqueTokens, t) }
|
||||
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
var wg sync.WaitGroup
|
||||
// Run per-character in parallel
|
||||
for i := range uniqueTokens {
|
||||
wg.Add(1)
|
||||
go func(t ESIToken) {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
tok, err := s.ensureAccessTokenFor(ctx, &t)
|
||||
cancel()
|
||||
if err != nil {
|
||||
mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return
|
||||
}
|
||||
// Post sequence for this character
|
||||
if len(viaIDs) > 0 {
|
||||
if err := s.postWaypointWithToken(tok, viaIDs[0], true, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
for _, id := range viaIDs[1:] {
|
||||
if err := s.postWaypointWithToken(tok, id, false, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
}
|
||||
if err := s.postWaypointWithToken(tok, destID, false, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
} else {
|
||||
if err := s.postWaypointWithToken(tok, destID, true, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
}
|
||||
}(uniqueTokens[i])
|
||||
}
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
}
|
@@ -1,20 +1,9 @@
|
||||
FROM oven/bun:1.0.25-slim as builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY bun.lockb ./
|
||||
COPY dist dist
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN bun run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
@@ -1,14 +1,17 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { StartESILogin, ESILoggedIn, ListCharacters } from 'wailsjs/go/main/App';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
@@ -18,12 +21,42 @@ interface HeaderProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CharacterInfo { character_id: number; character_name: string }
|
||||
|
||||
export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [chars, setChars] = useState<CharacterInfo[]>([]);
|
||||
|
||||
const refreshState = async () => {
|
||||
try {
|
||||
const list = await ListCharacters();
|
||||
setChars((list as any[]).map((c: any) => ({ character_id: c.character_id, character_name: c.character_name })));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshState();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await StartESILogin();
|
||||
toast({ title: 'EVE Login', description: 'Complete login in your browser.' });
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const ok = await ESILoggedIn();
|
||||
if (ok) {
|
||||
await refreshState();
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Login failed', description: String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 py-4 px-4 border-b border-purple-500/20">
|
||||
{/* Breadcrumb Navigation */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<Breadcrumb>
|
||||
@@ -52,8 +85,21 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{chars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 max-w-[50vw] justify-end">
|
||||
{chars.map((c) => (
|
||||
<span key={c.character_id} className="px-2 py-1 rounded-full bg-purple-500/20 text-purple-200 border border-purple-400/40 text-xs whitespace-nowrap">
|
||||
{c.character_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="bg-purple-600 hover:bg-purple-700" onClick={handleLogin}>Log in</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -15,6 +15,7 @@ interface MapNodeProps {
|
||||
security?: number;
|
||||
signatures?: number;
|
||||
isDraggable?: boolean;
|
||||
disableNavigate?: boolean;
|
||||
}
|
||||
|
||||
export const MapNode: React.FC<MapNodeProps> = ({
|
||||
@@ -30,7 +31,8 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
||||
type,
|
||||
security,
|
||||
signatures,
|
||||
isDraggable = false
|
||||
isDraggable = false,
|
||||
disableNavigate = false,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -65,14 +67,11 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
||||
onDragEnd?.(e);
|
||||
};
|
||||
|
||||
const nodeColor = security !== undefined
|
||||
? getSecurityColor(security)
|
||||
: '#a855f7'; // fallback purple color
|
||||
const nodeColor = security !== undefined ? getSecurityColor(security) : '#a855f7';
|
||||
|
||||
if (type === 'region') {
|
||||
// Further reduce region size to prevent overlap - made even smaller
|
||||
const pillWidth = Math.max(name.length * 5, 40); // Reduced from 8 to 5, min from 60 to 40
|
||||
const pillHeight = 18; // Reduced from 24 to 18
|
||||
const pillWidth = Math.max(name.length * 5, 40);
|
||||
const pillHeight = 18;
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -129,9 +128,8 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
||||
</g>
|
||||
);
|
||||
} else {
|
||||
// Render system as a dot with external label
|
||||
const nodeSize = 6;
|
||||
const textOffset = 20; // Position text below the dot - moved down more
|
||||
const textOffset = 20;
|
||||
|
||||
return (
|
||||
<g
|
||||
|
@@ -8,6 +8,8 @@ import { loadWormholeSystems, saveWormholeSystem, deleteWormholeSystem } from '@
|
||||
import { System, Position, Connection as ConnectionType } from '@/lib/types';
|
||||
import { getSecurityColor } from '@/utils/securityColors';
|
||||
import { Header } from './Header';
|
||||
import { ListCharacters, StartESILogin, SetDestinationForAll, AddWaypointForAllByName, PostRouteForAllByNames } from 'wailsjs/go/main/App';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
interface RegionMapProps {
|
||||
regionName: string;
|
||||
@@ -67,24 +69,46 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
const [positions, setPositions] = useState<Record<string, Position>>({});
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const [viaMode, setViaMode] = useState(false);
|
||||
const [viaDest, setViaDest] = useState<string | null>(null);
|
||||
const [viaQueue, setViaQueue] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && viaMode) {
|
||||
try {
|
||||
if (!(await ensureAnyLoggedIn())) return;
|
||||
if (viaDest) {
|
||||
await PostRouteForAllByNames(viaDest, viaQueue);
|
||||
toast({ title: 'Route set', description: `${viaDest}${viaQueue.length ? ' via ' + viaQueue.join(', ') : ''}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Failed to set route', description: String(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setViaMode(false);
|
||||
setViaDest(null);
|
||||
setViaQueue([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [viaMode, viaDest, viaQueue]);
|
||||
|
||||
const { data: rsystems, isLoading, error } = useRegionData(regionName);
|
||||
useEffect(() => {
|
||||
if (!isLoading && error == null && rsystems && rsystems.size > 0)
|
||||
setSystems(rsystems);
|
||||
}, [rsystems, isLoading, error]);
|
||||
|
||||
// Process connections when systems or nodePositions change
|
||||
useEffect(() => {
|
||||
if (!systems || systems.size === 0) return;
|
||||
console.log("Computing node positions");
|
||||
const positions = computeNodePositions(systems);
|
||||
setPositions(positions);
|
||||
console.log("Computing node connections");
|
||||
const connections = computeNodeConnections(systems);
|
||||
setConnections(connections);
|
||||
}, [systems]);
|
||||
|
||||
// Load wormhole systems on mount if in wormhole region
|
||||
useEffect(() => {
|
||||
if (isWormholeRegion) {
|
||||
loadWormholeSystems().then(wormholeSystems => {
|
||||
@@ -93,7 +117,31 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
}
|
||||
}, [isWormholeRegion]);
|
||||
|
||||
const handleSystemClick = (systemName: string) => {
|
||||
const ensureAnyLoggedIn = async () => {
|
||||
try {
|
||||
const list = await ListCharacters();
|
||||
if (Array.isArray(list) && list.length > 0) return true;
|
||||
await StartESILogin();
|
||||
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.' });
|
||||
return false;
|
||||
} catch (e: any) {
|
||||
await StartESILogin();
|
||||
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.', variant: 'destructive' });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSystemClick = async (systemName: string) => {
|
||||
if (viaMode) {
|
||||
setViaQueue(prev => {
|
||||
if (prev.includes(systemName)) return prev;
|
||||
const next = [...prev, systemName];
|
||||
return next;
|
||||
});
|
||||
console.log('Queued waypoint:', systemName);
|
||||
toast({ title: 'Waypoint queued', description: systemName });
|
||||
return;
|
||||
}
|
||||
if (focusSystem === systemName) return;
|
||||
navigate(`/regions/${regionName}/${systemName}`);
|
||||
};
|
||||
@@ -231,36 +279,20 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
if (!svgRef.current) return;
|
||||
setIsPanning(true);
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
setLastPanPoint({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanning || !svgRef.current) return;
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const currentPoint = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
const deltaX = (lastPanPoint.x - currentPoint.x) * (viewBox.width / rect.width);
|
||||
const deltaY = (lastPanPoint.y - currentPoint.y) * (viewBox.height / rect.height);
|
||||
|
||||
setViewBox(prev => ({
|
||||
...prev,
|
||||
x: prev.x + deltaX,
|
||||
y: prev.y + deltaY
|
||||
}));
|
||||
|
||||
setViewBox(prev => ({ ...prev, x: prev.x + deltaX, y: prev.y + deltaY }));
|
||||
setLastPanPoint(currentPoint);
|
||||
}, [isPanning, lastPanPoint, viewBox.width, viewBox.height]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
const handleMouseUp = useCallback(() => { setIsPanning(false); }, []);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -289,7 +321,6 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
}, [viewBox]);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, system: System) => {
|
||||
if (!isWormholeRegion) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -364,6 +395,25 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
}
|
||||
};
|
||||
|
||||
const onSetDestination = async (systemName: string, wantVia: boolean) => {
|
||||
try {
|
||||
if (!(await ensureAnyLoggedIn())) return;
|
||||
if (wantVia) {
|
||||
setViaDest(systemName);
|
||||
setViaQueue([]);
|
||||
setViaMode(true);
|
||||
console.log('Via mode start, dest:', systemName);
|
||||
toast({ title: 'Via mode', description: `Destination ${systemName}. Click systems to add waypoints. Esc to commit.` });
|
||||
} else {
|
||||
await SetDestinationForAll(systemName, true, false);
|
||||
toast({ title: 'Destination set', description: systemName });
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to set destination:', e);
|
||||
toast({ title: 'Failed to set destination', description: String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
// Close context menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setContextMenu(null);
|
||||
@@ -406,20 +456,8 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={(e) => {
|
||||
if (isPanning) {
|
||||
handleMouseMove(e);
|
||||
} else {
|
||||
handleSvgMouseMove(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (isPanning) {
|
||||
handleMouseUp();
|
||||
} else {
|
||||
handleSvgMouseUp(e);
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => { if (isPanning) { handleMouseMove(e); } else if (draggingNode) { handleSvgMouseMove(e); } }}
|
||||
onMouseUp={(e) => { if (isPanning) { handleMouseUp(); } else if (draggingNode) { handleSvgMouseUp(e); } }}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleMapDoubleClick}
|
||||
@@ -470,6 +508,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
security={system.security}
|
||||
signatures={system.signatures}
|
||||
isDraggable={isWormholeRegion}
|
||||
disableNavigate={viaMode}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -498,6 +537,12 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{viaMode && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 rounded bg-emerald-600 text-white text-xs shadow">
|
||||
VIA mode: Dest {viaDest} ({viaQueue.length} waypoints). Esc to commit
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<SystemContextMenu
|
||||
@@ -507,6 +552,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
onRename={(newName) => handleRenameSystem(contextMenu.system.solarSystemName, newName)}
|
||||
onDelete={handleDeleteSystem}
|
||||
onClearConnections={handleClearConnections}
|
||||
onSetDestination={(systemName) => onSetDestination(systemName, true)}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
@@ -8,10 +8,11 @@ interface SystemContextMenuProps {
|
||||
onRename: (newName: string) => void;
|
||||
onDelete: (system: System) => void;
|
||||
onClearConnections: (system: System) => void;
|
||||
onSetDestination?: (systemName: string, viaMode: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearConnections, onClose }: SystemContextMenuProps) => {
|
||||
export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearConnections, onSetDestination, onClose }: SystemContextMenuProps) => {
|
||||
if (!system) {
|
||||
return null;
|
||||
}
|
||||
@@ -27,6 +28,16 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleSetDestinationClick = (e: React.MouseEvent) => {
|
||||
const via = !!e.shiftKey;
|
||||
if (typeof onSetDestination === 'function') {
|
||||
onSetDestination(system.solarSystemName, via);
|
||||
} else {
|
||||
console.error('onSetDestination not provided');
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
@@ -82,6 +93,14 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="h-px bg-slate-700 my-1" />
|
||||
<button
|
||||
onClick={handleSetDestinationClick}
|
||||
className="w-full px-3 py-1 text-left text-emerald-400 hover:bg-slate-700 rounded text-sm"
|
||||
title="Shift-click to enter via mode and append waypoints"
|
||||
>
|
||||
Set destination
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -22,6 +22,7 @@ export const SystemView = () => {
|
||||
}
|
||||
|
||||
const saveSignature = async (signature: Signature): Promise<void> => {
|
||||
console.log(signature);
|
||||
try {
|
||||
// Check if signature already exists
|
||||
let existingRecord: SignatureRecord | null = null;
|
||||
@@ -34,7 +35,7 @@ export const SystemView = () => {
|
||||
const newScannedPercentage = parseScannedPercentage(signature.scanned);
|
||||
|
||||
if (existingRecord) {
|
||||
const updatedSignature: Pick<SignatureRecord, 'updated' | 'type' | 'name' | 'scanned'> = {
|
||||
const updatedSignature: Pick<SignatureRecord, 'updated' | 'type' | 'name' | 'scanned' | 'note'> = {
|
||||
updated: new Date().toISOString()
|
||||
}
|
||||
// Existing record has no type and our new signature has a type
|
||||
@@ -47,6 +48,8 @@ export const SystemView = () => {
|
||||
const existingScannedPercentage = parseScannedPercentage(existingRecord.scanned);
|
||||
if (newScannedPercentage >= existingScannedPercentage)
|
||||
updatedSignature.scanned = signature.scanned;
|
||||
if (!!!existingRecord.note && !!signature.note)
|
||||
updatedSignature.note = signature.note;
|
||||
await pb.collection('signature').update(existingRecord.id, updatedSignature);
|
||||
console.log(`Updated signature ${signature.identifier}: ${existingScannedPercentage}% -> ${newScannedPercentage}%`);
|
||||
} else {
|
||||
@@ -57,7 +60,8 @@ export const SystemView = () => {
|
||||
name: signature.signame,
|
||||
type: signature.type,
|
||||
dangerous: signature.dangerous,
|
||||
scanned: signature.scanned
|
||||
scanned: signature.scanned,
|
||||
note: signature.note
|
||||
});
|
||||
console.log(`Created new signature ${signature.identifier} with ${newScannedPercentage}% scan`);
|
||||
}
|
||||
@@ -70,10 +74,10 @@ export const SystemView = () => {
|
||||
const deleteSignature = async (signatureId: string): Promise<void> => {
|
||||
try {
|
||||
await pb.collection('signature').delete(signatureId);
|
||||
|
||||
|
||||
// Invalidate queries to refresh the data
|
||||
queryClient.invalidateQueries({ queryKey: ['signatures', system] });
|
||||
|
||||
|
||||
toast({
|
||||
title: "Signature Deleted",
|
||||
description: "The signature has been successfully deleted.",
|
||||
@@ -93,14 +97,14 @@ export const SystemView = () => {
|
||||
try {
|
||||
// Get the system ID for the current system
|
||||
const systemId = await getSystemId(system);
|
||||
|
||||
|
||||
console.log('Updating signature:', {
|
||||
identifier: updatedSignature.identifier,
|
||||
systemId,
|
||||
system,
|
||||
updatedSignature
|
||||
});
|
||||
|
||||
|
||||
// Find the signature by identifier and system
|
||||
const existingRecord = await pb.collection('signature').getFirstListItem(
|
||||
`identifier='${updatedSignature.identifier}' && system='${systemId}'`
|
||||
@@ -132,10 +136,10 @@ export const SystemView = () => {
|
||||
console.log('Update data:', updateData);
|
||||
|
||||
await pb.collection('signature').update(existingRecord.id, updateData);
|
||||
|
||||
|
||||
// Invalidate queries to refresh the data
|
||||
queryClient.invalidateQueries({ queryKey: ['signatures', system] });
|
||||
|
||||
|
||||
toast({
|
||||
title: "Signature Updated",
|
||||
description: "The signature has been successfully updated.",
|
||||
|
@@ -1,5 +1,28 @@
|
||||
import { SigviewRecord as Signature } from "@/lib/pbtypes";
|
||||
|
||||
const oneOutOfTen = [
|
||||
"Minmatar Contracted Bio-Farm",
|
||||
"Old Meanie - Cultivation Center",
|
||||
"Pith Robux Asteroid Mining & Co.",
|
||||
"Sansha Military Outpost",
|
||||
"Serpentis Drug Outlet",
|
||||
];
|
||||
const twoOutOfTen = [
|
||||
"Angel Creo-Corp Mining",
|
||||
"Blood Raider Human Farm",
|
||||
"Pith Merchant Depot",
|
||||
"Sansha Acclimatization Facility",
|
||||
"Serpentis Live Cargo Distribution Facilities",
|
||||
"Rogue Drone Infestation Sprout",
|
||||
];
|
||||
const threeOutOfTen = [
|
||||
"Angel Repurposed Outpost",
|
||||
"Blood Raider Intelligence Collection Point",
|
||||
"Guristas Guerilla Grounds",
|
||||
"Sansha's Command Relay Outpost",
|
||||
"Serpentis Narcotic Warehouses",
|
||||
"Rogue Drone Asteroid Infestation",
|
||||
];
|
||||
const fourOutOfTen = [
|
||||
"Angel Cartel Occupied Mining Colony",
|
||||
"Mul-Zatah Monastery",
|
||||
|
@@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => ({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"wailsjs": path.resolve(__dirname, "./wailsjs"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
15
frontend/wailsjs/go/main/App.d.ts
vendored
15
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -1,4 +1,19 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {main} from '../models';
|
||||
|
||||
export function AddWaypointForAllByName(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function ESILoggedIn():Promise<boolean>;
|
||||
|
||||
export function ESILoginStatus():Promise<string>;
|
||||
|
||||
export function Greet(arg1:string):Promise<string>;
|
||||
|
||||
export function ListCharacters():Promise<Array<main.CharacterInfo>>;
|
||||
|
||||
export function PostRouteForAllByNames(arg1:string,arg2:Array<string>):Promise<void>;
|
||||
|
||||
export function SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StartESILogin():Promise<string>;
|
||||
|
@@ -2,6 +2,34 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AddWaypointForAllByName(arg1, arg2) {
|
||||
return window['go']['main']['App']['AddWaypointForAllByName'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ESILoggedIn() {
|
||||
return window['go']['main']['App']['ESILoggedIn']();
|
||||
}
|
||||
|
||||
export function ESILoginStatus() {
|
||||
return window['go']['main']['App']['ESILoginStatus']();
|
||||
}
|
||||
|
||||
export function Greet(arg1) {
|
||||
return window['go']['main']['App']['Greet'](arg1);
|
||||
}
|
||||
|
||||
export function ListCharacters() {
|
||||
return window['go']['main']['App']['ListCharacters']();
|
||||
}
|
||||
|
||||
export function PostRouteForAllByNames(arg1, arg2) {
|
||||
return window['go']['main']['App']['PostRouteForAllByNames'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetDestinationForAll(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SetDestinationForAll'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StartESILogin() {
|
||||
return window['go']['main']['App']['StartESILogin']();
|
||||
}
|
||||
|
19
frontend/wailsjs/go/models.ts
Normal file
19
frontend/wailsjs/go/models.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export namespace main {
|
||||
|
||||
export class CharacterInfo {
|
||||
character_id: number;
|
||||
character_name: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CharacterInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.character_id = source["character_id"];
|
||||
this.character_name = source["character_name"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
2
frontend/wailsjs/runtime/runtime.d.ts
vendored
2
frontend/wailsjs/runtime/runtime.d.ts
vendored
@@ -134,7 +134,7 @@ export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): Promise<Size>;
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
|
47
go.mod
47
go.mod
@@ -1,39 +1,46 @@
|
||||
module signalerr
|
||||
|
||||
go 1.21
|
||||
go 1.22.0
|
||||
|
||||
toolchain go1.23.6
|
||||
|
||||
require github.com/wailsapp/wails/v2 v2.9.2
|
||||
require (
|
||||
github.com/wailsapp/wails/v2 v2.10.2
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.10.2 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||
github.com/leaanthony/gosod v1.0.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.19 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
// replace github.com/wailsapp/wails/v2 v2.9.2 => C:\Users\Administrator\go\pkg\mod
|
||||
|
109
go.sum
109
go.sum
@@ -1,94 +1,91 @@
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
|
||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ=
|
||||
github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4=
|
||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.16 h1:wffnvnkkLvhRex/aOrA3R7FP7rkvOqL/bir1br7BekU=
|
||||
github.com/wailsapp/go-webview2 v1.0.16/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
|
||||
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
|
||||
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
|
||||
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk=
|
||||
github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
|
Reference in New Issue
Block a user