diff --git a/cmd/openfsd-client/gui/app.go b/cmd/openfsd-client/gui/app.go new file mode 100644 index 0000000..b0d1ee1 --- /dev/null +++ b/cmd/openfsd-client/gui/app.go @@ -0,0 +1,77 @@ +//go:build !nogui + +package gui + +import ( + "fmt" + "os" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/driver/desktop" + + "github.com/renorris/openfsd/internal/clientinject" + "github.com/renorris/openfsd/internal/clientinject/adapters" +) + +// Run launches the openfsd Client Setup GUI. Blocks until the window closes. +// engine may be nil — then DefaultEngine is used. +func Run(engine *clientinject.Engine) error { + if engine == nil { + var err error + engine, err = adapters.DefaultEngine() + if err != nil { + return fmt.Errorf("gui: default engine: %w", err) + } + } + + a := app.NewWithID("com.openfsd.client-setup") + + w := a.NewWindow("openfsd Client Setup") + w.Resize(fyne.NewSize(920, 780)) + w.SetMaster() + + ctrl := newController(engine, a, w) + w.SetContent(ctrl.buildUI()) + ctrl.loadSettingsAndRefresh() + + // Prefer showing; on headless Fyne may panic or fail — caller can recover. + w.ShowAndRun() + return nil +} + +// RunDefault is the process entry for no-CLI-args mode. +// Returns an error if the GUI cannot start (e.g. no display); callers may +// fall back to printing CLI help. +func RunDefault() error { + if os.Getenv("OPENFSD_CLIENT_NO_GUI") == "1" { + return fmt.Errorf("gui: disabled by OPENFSD_CLIENT_NO_GUI=1") + } + // Soft check: DISPLAY / macOS always has a session usually. + if err := checkDisplayAvailable(); err != nil { + return err + } + return Run(nil) +} + +func checkDisplayAvailable() error { + // Linux/X11/Wayland headless CI often has no display. + if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + // macOS and Windows do not use DISPLAY; allow those. + if goosIsUnixDisplayRequired() { + return fmt.Errorf("gui: no DISPLAY/WAYLAND_DISPLAY (headless?)") + } + } + return nil +} + +// CanStart reports whether a GUI attempt is reasonable (not a hard guarantee). +func CanStart() bool { + if os.Getenv("OPENFSD_CLIENT_NO_GUI") == "1" { + return false + } + return checkDisplayAvailable() == nil +} + +// Ensure desktop driver is linked on platforms that use it. +var _ desktop.App diff --git a/cmd/openfsd-client/gui/clients.go b/cmd/openfsd-client/gui/clients.go new file mode 100644 index 0000000..b85e667 --- /dev/null +++ b/cmd/openfsd-client/gui/clients.go @@ -0,0 +1,88 @@ +package gui + +import "github.com/renorris/openfsd/internal/clientinject" + +// ClientSlot is one entry in the client picker (enabled or coming-soon). +// Layout must not branch on ClientID for form regions — only Enabled gates +// whether the user can select the slot. +type ClientSlot struct { + ID string + DisplayName string + Enabled bool + // ComingSoonLabel is non-empty when !Enabled (e.g. "Coming soon"). + ComingSoonLabel string +} + +// FutureClientCatalog lists planned multi-client product slots that are not +// yet registered as adapters. When an adapter appears in DefaultAdapters, it +// is merged as Enabled and removed from the "coming soon" presentation. +// +// This is a product catalog, not a layout special-case on a single client_id. +var FutureClientCatalog = []ClientSlot{ + {ID: "xpilot", DisplayName: "xPilot", Enabled: false, ComingSoonLabel: "Coming soon"}, + {ID: "euroscope", DisplayName: "Euroscope", Enabled: false, ComingSoonLabel: "Coming soon"}, + {ID: "vatsys", DisplayName: "vatSys", Enabled: false, ComingSoonLabel: "Coming soon"}, + {ID: "trackaudio", DisplayName: "TrackAudio", Enabled: false, ComingSoonLabel: "Coming soon"}, +} + +// BuildClientSlots merges registered adapters (enabled) with the future catalog +// (disabled). Adapters take precedence by ID. Order: adapters first (registry +// order), then remaining future slots. +func BuildClientSlots(adapters map[string]clientinject.Adapter) []ClientSlot { + seen := make(map[string]struct{}) + var out []ClientSlot + + // Stable order: prefer known adapter IDs in a fixed product order, then any extras. + preferred := []string{"vpilot", "xpilot", "euroscope", "vatsys", "trackaudio"} + for _, id := range preferred { + if a, ok := adapters[id]; ok && a != nil { + out = append(out, ClientSlot{ + ID: a.ClientID(), + DisplayName: a.DisplayName(), + Enabled: true, + }) + seen[id] = struct{}{} + } + } + // Any other registered adapters not in preferred list. + for id, a := range adapters { + if a == nil { + continue + } + if _, ok := seen[id]; ok { + continue + } + out = append(out, ClientSlot{ + ID: a.ClientID(), + DisplayName: a.DisplayName(), + Enabled: true, + }) + seen[id] = struct{}{} + } + // Future catalog entries without a live adapter. + for _, fut := range FutureClientCatalog { + if _, ok := seen[fut.ID]; ok { + continue + } + slot := fut + if slot.ComingSoonLabel == "" { + slot.ComingSoonLabel = "Coming soon" + } + slot.Enabled = false + out = append(out, slot) + seen[fut.ID] = struct{}{} + } + return out +} + +// SlotLabel returns the dropdown label for a slot. +func SlotLabel(s ClientSlot) string { + if s.Enabled { + return s.DisplayName + } + label := s.ComingSoonLabel + if label == "" { + label = "Coming soon" + } + return s.DisplayName + " (" + label + ")" +} diff --git a/cmd/openfsd-client/gui/clients_test.go b/cmd/openfsd-client/gui/clients_test.go new file mode 100644 index 0000000..dd2ede3 --- /dev/null +++ b/cmd/openfsd-client/gui/clients_test.go @@ -0,0 +1,40 @@ +package gui + +import ( + "strings" + "testing" + + "github.com/renorris/openfsd/internal/clientinject/adapters" +) + +func TestBuildClientSlots_WithDefaultAdapters(t *testing.T) { + eng, err := adapters.DefaultEngine() + if err != nil { + t.Fatal(err) + } + slots := BuildClientSlots(eng.Adapters) + var enabled, coming int + var hasVPilot bool + for _, s := range slots { + if s.Enabled { + enabled++ + if s.ID == "vpilot" { + hasVPilot = true + } + if strings.Contains(SlotLabel(s), "Coming soon") { + t.Fatalf("enabled slot labeled coming soon: %+v", s) + } + } else { + coming++ + if !strings.Contains(SlotLabel(s), "Coming soon") { + t.Fatalf("disabled label: %q", SlotLabel(s)) + } + } + } + if !hasVPilot { + t.Fatal("expected vpilot enabled") + } + if enabled < 1 || coming < 1 { + t.Fatalf("enabled=%d coming=%d slots=%d", enabled, coming, len(slots)) + } +} diff --git a/cmd/openfsd-client/gui/display_desktop.go b/cmd/openfsd-client/gui/display_desktop.go new file mode 100644 index 0000000..713b89c --- /dev/null +++ b/cmd/openfsd-client/gui/display_desktop.go @@ -0,0 +1,5 @@ +//go:build !nogui && (windows || darwin) + +package gui + +func goosIsUnixDisplayRequired() bool { return false } diff --git a/cmd/openfsd-client/gui/display_unix.go b/cmd/openfsd-client/gui/display_unix.go new file mode 100644 index 0000000..c455431 --- /dev/null +++ b/cmd/openfsd-client/gui/display_unix.go @@ -0,0 +1,5 @@ +//go:build !nogui && !windows && !darwin + +package gui + +func goosIsUnixDisplayRequired() bool { return true } diff --git a/cmd/openfsd-client/gui/doc.go b/cmd/openfsd-client/gui/doc.go new file mode 100644 index 0000000..39a1428 --- /dev/null +++ b/cmd/openfsd-client/gui/doc.go @@ -0,0 +1,10 @@ +// Package gui implements the openfsd Client Setup desktop UI (Fyne). +// +// Pure helpers (form state, legal banner, VATSIM host warnings, constraint +// formatting, settings) live in this package without Fyne imports so they can +// be unit-tested headless. Fyne window code is gated with //go:build !nogui. +// +// Layout is client-agnostic: enabled clients come from the adapter registry; +// future slots appear as "Coming soon". Constraints and blockers always come +// from Plan / Adapter APIs — never from if client_id == "vpilot" layout branches. +package gui diff --git a/cmd/openfsd-client/gui/hash.go b/cmd/openfsd-client/gui/hash.go new file mode 100644 index 0000000..4326de8 --- /dev/null +++ b/cmd/openfsd-client/gui/hash.go @@ -0,0 +1,11 @@ +package gui + +import ( + "crypto/sha1" + "encoding/hex" +) + +func sha1SumHex(data []byte) string { + sum := sha1.Sum(data) + return hex.EncodeToString(sum[:]) +} diff --git a/cmd/openfsd-client/gui/install.go b/cmd/openfsd-client/gui/install.go new file mode 100644 index 0000000..2f3a740 --- /dev/null +++ b/cmd/openfsd-client/gui/install.go @@ -0,0 +1,113 @@ +package gui + +import ( + "context" + "path/filepath" + "strings" + + "github.com/renorris/openfsd/internal/clientinject" +) + +// BuildInstall constructs a client-agnostic Install for rootDir. +// Uses adapter Discover when the path matches a candidate; otherwise fills +// PrimaryPE from the first matching profile's relative path. Seeds ProfileID / +// HashSHA1 from an existing inject manifest when present. +// +// No client_id switch for layout — only Adapter + ProfileStore data. +func BuildInstall(eng *clientinject.Engine, clientID, rootDir string) clientinject.Install { + rootDir = filepath.Clean(strings.TrimSpace(rootDir)) + install := clientinject.Install{ + ClientID: clientID, + RootDir: rootDir, + } + if eng == nil || rootDir == "" || rootDir == "." { + return install + } + + if a, ok := eng.Adapters[clientID]; ok && a != nil { + if cands, err := a.Discover(context.Background()); err == nil { + for _, c := range cands { + if filepath.Clean(c.RootDir) == rootDir { + install.PrimaryPE = c.PrimaryPE + install.ConfigPaths = append([]string(nil), c.ConfigPaths...) + break + } + } + } + } + + if install.PrimaryPE == "" && eng.Profiles != nil { + for _, p := range eng.Profiles.List() { + if p.ClientID != clientID { + continue + } + if p.PrimaryBinary.RelativePath != "" { + install.PrimaryPE = filepath.Join(rootDir, p.PrimaryBinary.RelativePath) + } + // Config candidates from profile relative paths that exist. + w := eng.Writer + if w == nil { + w = clientinject.OSFileWriter{} + } + for _, cf := range p.ConfigFiles { + if cf.RelativePath == "" { + continue + } + cp := filepath.Join(rootDir, cf.RelativePath) + if _, err := w.Stat(cp); err == nil { + install.ConfigPaths = appendUnique(install.ConfigPaths, cp) + } + } + break + } + } + + seedInstallFromManifest(eng, &install) + return install +} + +func seedInstallFromManifest(eng *clientinject.Engine, install *clientinject.Install) { + if install == nil || install.RootDir == "" { + return + } + var writer clientinject.FileWriter = clientinject.OSFileWriter{} + if eng != nil && eng.Writer != nil { + writer = eng.Writer + } + m, err := clientinject.ReadManifest(writer, install.RootDir) + if err != nil { + return + } + if install.ProfileID == "" && m.ProfileID != "" { + install.ProfileID = m.ProfileID + } + if install.HashSHA1 == "" && m.PESHA1 != "" { + install.HashSHA1 = m.PESHA1 + } +} + +func appendUnique(slice []string, v string) []string { + v = filepath.Clean(v) + for _, s := range slice { + if filepath.Clean(s) == v { + return slice + } + } + return append(slice, v) +} + +// FirstDetectPath returns the first Discover candidate root for clientID, or "". +func FirstDetectPath(eng *clientinject.Engine, clientID string) (clientinject.InstallCandidate, bool) { + if eng == nil { + return clientinject.InstallCandidate{}, false + } + a, ok := eng.Adapters[clientID] + if !ok || a == nil { + return clientinject.InstallCandidate{}, false + } + cands, err := a.Discover(context.Background()) + if err != nil || len(cands) == 0 { + return clientinject.InstallCandidate{}, false + } + return cands[0], true +} diff --git a/cmd/openfsd-client/gui/legal.go b/cmd/openfsd-client/gui/legal.go new file mode 100644 index 0000000..4aa3f08 --- /dev/null +++ b/cmd/openfsd-client/gui/legal.go @@ -0,0 +1,30 @@ +package gui + +// LegalOneLiner is shown in the window header. +const LegalOneLiner = "Private openfsd networks only — never redistribute proprietary clients." + +// LegalApplyBanner is the soft banner shown on every Apply confirmation. +const LegalApplyBanner = "For private openfsd networks you are authorized to use. " + + "Do not use this tool to reconfigure clients for the public VATSIM network." + +// PublicVATSIMHosts is the known public VATSIM host set used for soft warnings +// when WebBaseURL (or related endpoints) point at production VATSIM services. +// Matching does not hard-block Apply; the user may override with "I understand". +var PublicVATSIMHosts = map[string]struct{}{ + "auth.vatsim.net": {}, + "status.vatsim.net": {}, + "data.vatsim.net": {}, + "voice1.vatsim.net": {}, + "voice2.vatsim.net": {}, + "fsd.connect.vatsim.net": {}, + "api.vatsim.net": {}, + "my.vatsim.net": {}, + "metar.vatsim.net": {}, + "server.vatsim.net": {}, + "cert.vatsim.net": {}, + "tracker.vatsim.net": {}, + "map.vatsim.net": {}, + "stats.vatsim.net": {}, + "www.vatsim.net": {}, + "vatsim.net": {}, +} diff --git a/cmd/openfsd-client/gui/model.go b/cmd/openfsd-client/gui/model.go new file mode 100644 index 0000000..2f8bcab --- /dev/null +++ b/cmd/openfsd-client/gui/model.go @@ -0,0 +1,299 @@ +package gui + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "github.com/renorris/openfsd/internal/clientinject" +) + +// FormState holds user-editable Client Setup fields (no passwords). +// Client-agnostic: the same fields apply to every adapter. +type FormState struct { + ClientID string + + InstallPath string + + WebBaseURL string + FSDHost string + FSDPort int // 0 = default 6809 + FSDServerName string + AFVBaseURL string + + ForceDisableAFV bool + PreferShortJWTPath bool + + // UnderstandPublicVATSIM acknowledges the soft warning when WebBaseURL + // host is in PublicVATSIMHosts. Does not store passwords. + UnderstandPublicVATSIM bool +} + +// DefaultFormState returns sensible defaults for a new session. +func DefaultFormState() FormState { + return FormState{ + ClientID: "vpilot", + FSDPort: 0, + FSDServerName: "OPENFSD", + } +} + +// Endpoints maps form fields to clientinject.Endpoints. +func (f FormState) Endpoints() clientinject.Endpoints { + return clientinject.Endpoints{ + WebBaseURL: strings.TrimSpace(f.WebBaseURL), + FSDHost: strings.TrimSpace(f.FSDHost), + FSDPort: f.FSDPort, + FSDServerName: strings.TrimSpace(f.FSDServerName), + AFVBaseURL: strings.TrimSpace(f.AFVBaseURL), + ForceDisableAFV: f.ForceDisableAFV, + PreferShortJWTPath: f.PreferShortJWTPath, + }.Normalize() +} + +// ValidationIssue is a form-level problem before Plan (empty path, bad URL, …). +type ValidationIssue struct { + Field string + Message string +} + +// ValidateForm checks fields that do not need disk/engine access. +func ValidateForm(f FormState) []ValidationIssue { + var issues []ValidationIssue + if strings.TrimSpace(f.ClientID) == "" { + issues = append(issues, ValidationIssue{Field: "ClientID", Message: "select a client"}) + } + if strings.TrimSpace(f.InstallPath) == "" { + issues = append(issues, ValidationIssue{Field: "InstallPath", Message: "install path is required"}) + } + web := strings.TrimSpace(f.WebBaseURL) + if web == "" { + issues = append(issues, ValidationIssue{Field: "WebBaseURL", Message: "web base URL is required"}) + } else if _, err := url.ParseRequestURI(web); err != nil { + issues = append(issues, ValidationIssue{Field: "WebBaseURL", Message: "web base URL is not a valid absolute URL"}) + } + if strings.TrimSpace(f.FSDHost) == "" { + issues = append(issues, ValidationIssue{Field: "FSDHost", Message: "FSD host is required"}) + } + if f.FSDPort < 0 || f.FSDPort > 65535 { + issues = append(issues, ValidationIssue{Field: "FSDPort", Message: "FSD port must be 0–65535 (0 = default 6809)"}) + } + afv := strings.TrimSpace(f.AFVBaseURL) + if afv != "" { + if _, err := url.ParseRequestURI(afv); err != nil { + issues = append(issues, ValidationIssue{Field: "AFVBaseURL", Message: "AFV base URL is not a valid absolute URL"}) + } + } + return issues +} + +// WebBaseHost extracts the hostname from WebBaseURL (lowercased, no port). +func WebBaseHost(webBase string) string { + webBase = strings.TrimSpace(webBase) + if webBase == "" { + return "" + } + u, err := url.Parse(webBase) + if err != nil || u.Host == "" { + // Fallback: treat as bare host + host := webBase + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + host = strings.Split(host, "/")[0] + if h, _, err := net.SplitHostPort(host); err == nil { + return strings.ToLower(h) + } + return strings.ToLower(strings.TrimSpace(host)) + } + host := u.Hostname() + return strings.ToLower(host) +} + +// IsPublicVATSIMHost reports whether host (or WebBaseURL host) is in the known +// public VATSIM set. +func IsPublicVATSIMHost(hostOrURL string) bool { + host := strings.ToLower(strings.TrimSpace(hostOrURL)) + if host == "" { + return false + } + // If looks like a URL, extract host. + if strings.Contains(host, "://") || strings.Contains(host, "/") { + host = WebBaseHost(hostOrURL) + } else if h, _, err := net.SplitHostPort(host); err == nil { + host = strings.ToLower(h) + } + _, ok := PublicVATSIMHosts[host] + return ok +} + +// PublicVATSIMWarning returns a non-empty warning when WebBaseURL points at a +// known public VATSIM host. +func PublicVATSIMWarning(webBase string) string { + host := WebBaseHost(webBase) + if host == "" || !IsPublicVATSIMHost(host) { + return "" + } + return fmt.Sprintf( + "Web base host %q is a known public VATSIM host. This tool is for private openfsd networks you are authorized to use. Check “I understand” only if that is intentional (e.g. local mirror).", + host, + ) +} + +// CanApply reports whether Apply should be enabled given form validation, +// optional public-VATSIM override, and plan blockers. +func CanApply(f FormState, plan *clientinject.Plan, formIssues []ValidationIssue) (ok bool, reason string) { + if len(formIssues) > 0 { + return false, formIssues[0].Message + } + if warn := PublicVATSIMWarning(f.WebBaseURL); warn != "" && !f.UnderstandPublicVATSIM { + return false, "Web base looks like public VATSIM — confirm “I understand” or change the URL" + } + if plan == nil { + return false, "no plan yet" + } + if len(plan.Blockers) > 0 { + return false, "plan has blockers" + } + return true, "" +} + +// FormatConstraintLine formats one Plan.Constraints entry for the panel. +func FormatConstraintLine(c clientinject.Constraint) string { + max := "n/a" + if c.MaxRunes > 0 { + max = strconv.Itoa(c.MaxRunes) + " runes" + } + strat := c.Strategy + if strat == "" { + strat = "n/a" + } + desc := c.Description + if desc == "" { + return fmt.Sprintf("%s — max %s — %s", c.Field, max, strat) + } + return fmt.Sprintf("%s — max %s — %s — %s", c.Field, max, strat, desc) +} + +// FormatConstraints joins constraint lines for the multi-line panel. +func FormatConstraints(cs []clientinject.Constraint) string { + if len(cs) == 0 { + return "(no constraints from plan yet)" + } + var b strings.Builder + for i, c := range cs { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(FormatConstraintLine(c)) + } + return b.String() +} + +// FormatMutations summarizes plan mutations for the log / preview. +func FormatMutations(ms []clientinject.Mutation) string { + if len(ms) == 0 { + return "(no mutations)" + } + var b strings.Builder + for i, m := range ms { + if i > 0 { + b.WriteByte('\n') + } + fmt.Fprintf(&b, "• %s [%s] %s", m.ID, m.Kind, m.Description) + } + return b.String() +} + +// FormatBlockers formats plan blockers. +func FormatBlockers(bs []string) string { + if len(bs) == 0 { + return "" + } + var b strings.Builder + for i, s := range bs { + if i > 0 { + b.WriteByte('\n') + } + fmt.Fprintf(&b, "• %s", s) + } + return b.String() +} + +// FormatWarnings formats plan warnings. +func FormatWarnings(ws []string) string { + if len(ws) == 0 { + return "" + } + var b strings.Builder + for i, s := range ws { + if i > 0 { + b.WriteByte('\n') + } + fmt.Fprintf(&b, "• %s", s) + } + return b.String() +} + +// FormatFingerprint summarizes install identity for the fingerprint panel. +func FormatFingerprint(install clientinject.Install, profileID string, preflightErr error) string { + var b strings.Builder + fmt.Fprintf(&b, "Client: %s\n", install.ClientID) + fmt.Fprintf(&b, "Root: %s\n", install.RootDir) + fmt.Fprintf(&b, "Primary PE: %s\n", install.PrimaryPE) + if install.HashSHA1 != "" { + fmt.Fprintf(&b, "SHA-1: %s\n", install.HashSHA1) + } else { + b.WriteString("SHA-1: (not resolved)\n") + } + if profileID != "" { + fmt.Fprintf(&b, "Profile: %s\n", profileID) + } else if install.ProfileID != "" { + fmt.Fprintf(&b, "Profile: %s\n", install.ProfileID) + } else { + b.WriteString("Profile: (unresolved)\n") + } + if len(install.ConfigPaths) > 0 { + fmt.Fprintf(&b, "Config files:\n") + for _, p := range install.ConfigPaths { + fmt.Fprintf(&b, " • %s\n", p) + } + } else { + b.WriteString("Config files: (none discovered)\n") + } + if preflightErr != nil { + fmt.Fprintf(&b, "\n⚠ Client appears to be running — quit completely before Apply.\n(%v)\n", preflightErr) + } else { + b.WriteString("\nPreflight: PE not locked (or path empty).\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// ParsePortString parses FSD port entry; empty or "6809" → 0 (default). +func ParsePortString(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid port %q", s) + } + if n < 0 || n > 65535 { + return 0, fmt.Errorf("port out of range: %d", n) + } + if n == clientinject.DefaultFSDPort { + return 0, nil + } + return n, nil +} + +// FormatPortString displays FSD port for the form (empty means default). +func FormatPortString(port int) string { + if port == 0 || port == clientinject.DefaultFSDPort { + return strconv.Itoa(clientinject.DefaultFSDPort) + } + return strconv.Itoa(port) +} diff --git a/cmd/openfsd-client/gui/model_test.go b/cmd/openfsd-client/gui/model_test.go new file mode 100644 index 0000000..510bd68 --- /dev/null +++ b/cmd/openfsd-client/gui/model_test.go @@ -0,0 +1,205 @@ +package gui + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/renorris/openfsd/internal/clientinject" +) + +func TestValidateForm_OK(t *testing.T) { + f := FormState{ + ClientID: "vpilot", + InstallPath: "/tmp/vpilot", + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + } + if issues := ValidateForm(f); len(issues) != 0 { + t.Fatalf("unexpected issues: %+v", issues) + } +} + +func TestValidateForm_Missing(t *testing.T) { + issues := ValidateForm(FormState{}) + if len(issues) < 3 { + t.Fatalf("expected multiple issues, got %+v", issues) + } +} + +func TestValidateForm_BadURL(t *testing.T) { + f := FormState{ + ClientID: "vpilot", + InstallPath: "/x", + WebBaseURL: "not-a-url", + FSDHost: "h", + } + issues := ValidateForm(f) + found := false + for _, i := range issues { + if i.Field == "WebBaseURL" { + found = true + } + } + if !found { + t.Fatalf("expected WebBaseURL issue: %+v", issues) + } +} + +func TestWebBaseHost_AndPublicVATSIM(t *testing.T) { + if got := WebBaseHost("https://auth.vatsim.net/api"); got != "auth.vatsim.net" { + t.Fatalf("host=%q", got) + } + if !IsPublicVATSIMHost("https://auth.vatsim.net") { + t.Fatal("expected public VATSIM host") + } + if IsPublicVATSIMHost("https://fsd.ex.co") { + t.Fatal("private host should not warn") + } + warn := PublicVATSIMWarning("https://voice1.vatsim.net") + if warn == "" || !strings.Contains(warn, "voice1.vatsim.net") { + t.Fatalf("warn=%q", warn) + } + if PublicVATSIMWarning("https://openfsd.example.com") != "" { + t.Fatal("unexpected warning") + } +} + +func TestCanApply(t *testing.T) { + f := FormState{ + ClientID: "vpilot", + InstallPath: "/x", + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + } + ok, _ := CanApply(f, &clientinject.Plan{}, nil) + if !ok { + t.Fatal("expected can apply") + } + ok, reason := CanApply(f, &clientinject.Plan{Blockers: []string{"nope"}}, nil) + if ok || reason == "" { + t.Fatalf("expected blockers: ok=%v reason=%q", ok, reason) + } + f.WebBaseURL = "https://auth.vatsim.net" + ok, reason = CanApply(f, &clientinject.Plan{}, nil) + if ok || !strings.Contains(reason, "VATSIM") { + t.Fatalf("expected VATSIM gate: ok=%v reason=%q", ok, reason) + } + f.UnderstandPublicVATSIM = true + ok, _ = CanApply(f, &clientinject.Plan{}, nil) + if !ok { + t.Fatal("override should allow") + } +} + +func TestFormatConstraints(t *testing.T) { + s := FormatConstraints([]clientinject.Constraint{{ + Field: "JWTURL", MaxRunes: 35, Strategy: "in_place", Description: "budget", + }}) + if !strings.Contains(s, "JWTURL") || !strings.Contains(s, "35") { + t.Fatalf("%s", s) + } + if FormatConstraints(nil) == "" { + t.Fatal("empty constraints should still return placeholder") + } +} + +func TestFormatFingerprint_Running(t *testing.T) { + s := FormatFingerprint(clientinject.Install{ + ClientID: "vpilot", + RootDir: "/x", + PrimaryPE: "/x/vPilot.exe", + HashSHA1: "abc", + }, "vpilot-3.12.1", clientinject.ErrClientRunning) + if !strings.Contains(s, "running") { + t.Fatalf("%s", s) + } +} + +func TestParsePortString(t *testing.T) { + n, err := ParsePortString("6809") + if err != nil || n != 0 { + t.Fatalf("n=%d err=%v", n, err) + } + n, err = ParsePortString("6810") + if err != nil || n != 6810 { + t.Fatalf("n=%d err=%v", n, err) + } + if FormatPortString(0) != "6809" { + t.Fatal(FormatPortString(0)) + } +} + +func TestSettingsRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + s := Settings{ + ClientID: "vpilot", + InstallPath: "/install", + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + FSDPort: 6810, + } + if err := SaveSettings(path, s); err != nil { + t.Fatal(err) + } + got, err := LoadSettings(path) + if err != nil { + t.Fatal(err) + } + if got.InstallPath != s.InstallPath || got.WebBaseURL != s.WebBaseURL || got.FSDPort != 6810 { + t.Fatalf("%+v", got) + } + // missing file + empty, err := LoadSettings(filepath.Join(dir, "nope.json")) + if err != nil || empty.ClientID != "" { + t.Fatalf("empty=%+v err=%v", empty, err) + } +} + +func TestBuildClientSlots(t *testing.T) { + // Fake adapter map with only vpilot + type mini struct { + id, name string + } + // Use real engine adapters shape via fake implementing interface is heavy; + // BuildClientSlots with empty map still returns future catalog. + slots := BuildClientSlots(nil) + if len(slots) < 4 { + t.Fatalf("expected future catalog, got %d", len(slots)) + } + for _, s := range slots { + if s.Enabled { + t.Fatalf("nil adapters should not enable: %+v", s) + } + if !strings.Contains(SlotLabel(s), "Coming soon") { + t.Fatalf("label=%q", SlotLabel(s)) + } + } +} + +func TestFormStateEndpoints(t *testing.T) { + f := FormState{ + WebBaseURL: "https://fsd.ex.co/", + FSDHost: "fsd.ex.co", + FSDPort: 6809, + FSDServerName: "OPENFSD", + PreferShortJWTPath: true, + } + ep := f.Endpoints() + if ep.WebBaseURL != "https://fsd.ex.co" { + t.Fatalf("web=%q", ep.WebBaseURL) + } + if ep.JWTURL() != "https://fsd.ex.co/j" { + t.Fatalf("jwt=%q", ep.JWTURL()) + } +} + +func TestLegalConstants(t *testing.T) { + if LegalApplyBanner == "" || LegalOneLiner == "" { + t.Fatal("legal text required") + } + if !strings.Contains(strings.ToLower(LegalApplyBanner), "private") { + t.Fatal(LegalApplyBanner) + } +} diff --git a/cmd/openfsd-client/gui/settings.go b/cmd/openfsd-client/gui/settings.go new file mode 100644 index 0000000..c5d9c06 --- /dev/null +++ b/cmd/openfsd-client/gui/settings.go @@ -0,0 +1,122 @@ +package gui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Settings is the persisted user preferences (paths + endpoints only). +// Never store passwords or network credentials. +type Settings struct { + ClientID string `json:"client_id,omitempty"` + InstallPath string `json:"install_path,omitempty"` + WebBaseURL string `json:"web_base_url,omitempty"` + FSDHost string `json:"fsd_host,omitempty"` + FSDPort int `json:"fsd_port,omitempty"` + FSDServerName string `json:"fsd_server_name,omitempty"` + AFVBaseURL string `json:"afv_base_url,omitempty"` + ForceDisableAFV bool `json:"force_disable_afv,omitempty"` + PreferShortJWTPath bool `json:"prefer_short_jwt_path,omitempty"` + UnderstandPublicVATSIM bool `json:"understand_public_vatsim,omitempty"` +} + +// SettingsFromForm copies persistable fields from FormState. +func SettingsFromForm(f FormState) Settings { + return Settings{ + ClientID: f.ClientID, + InstallPath: f.InstallPath, + WebBaseURL: f.WebBaseURL, + FSDHost: f.FSDHost, + FSDPort: f.FSDPort, + FSDServerName: f.FSDServerName, + AFVBaseURL: f.AFVBaseURL, + ForceDisableAFV: f.ForceDisableAFV, + PreferShortJWTPath: f.PreferShortJWTPath, + UnderstandPublicVATSIM: f.UnderstandPublicVATSIM, + } +} + +// ApplyToForm merges settings into form (non-empty / meaningful fields). +func (s Settings) ApplyToForm(f *FormState) { + if f == nil { + return + } + if s.ClientID != "" { + f.ClientID = s.ClientID + } + if s.InstallPath != "" { + f.InstallPath = s.InstallPath + } + if s.WebBaseURL != "" { + f.WebBaseURL = s.WebBaseURL + } + if s.FSDHost != "" { + f.FSDHost = s.FSDHost + } + f.FSDPort = s.FSDPort + if s.FSDServerName != "" { + f.FSDServerName = s.FSDServerName + } + if s.AFVBaseURL != "" { + f.AFVBaseURL = s.AFVBaseURL + } + f.ForceDisableAFV = s.ForceDisableAFV + f.PreferShortJWTPath = s.PreferShortJWTPath + f.UnderstandPublicVATSIM = s.UnderstandPublicVATSIM +} + +// DefaultConfigDir returns OS user config dir + openfsd-client. +func DefaultConfigDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(base, "openfsd-client"), nil +} + +// DefaultSettingsPath is configDir/settings.json. +func DefaultSettingsPath() (string, error) { + dir, err := DefaultConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "settings.json"), nil +} + +// LoadSettings reads settings from path. Missing file returns empty Settings, nil error. +func LoadSettings(path string) (Settings, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Settings{}, nil + } + return Settings{}, err + } + var s Settings + if err := json.Unmarshal(data, &s); err != nil { + return Settings{}, fmt.Errorf("gui: parse settings: %w", err) + } + return s, nil +} + +// SaveSettings writes settings atomically-ish (write temp + rename). +func SaveSettings(path string, s Settings) error { + if path == "" { + return fmt.Errorf("gui: empty settings path") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/cmd/openfsd-client/gui/window.go b/cmd/openfsd-client/gui/window.go new file mode 100644 index 0000000..fc4e579 --- /dev/null +++ b/cmd/openfsd-client/gui/window.go @@ -0,0 +1,582 @@ +//go:build !nogui + +package gui + +import ( + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/renorris/openfsd/internal/clientinject" +) + +// controller owns form widgets and wires them to the engine. +// All layout is client-agnostic; per-client behavior flows from Adapter + Plan. +type controller struct { + eng *clientinject.Engine + app fyne.App + win fyne.Window + + form FormState + mu sync.Mutex + + slots []ClientSlot + slotByLabel map[string]ClientSlot + + clientSelect *widget.Select + installEntry *widget.Entry + detectBtn *widget.Button + + fingerprint *widget.Label + configList *widget.Label + + webBaseEntry *widget.Entry + fsdHostEntry *widget.Entry + fsdPortEntry *widget.Entry + fsdServerEntry *widget.Entry + afvBaseEntry *widget.Entry + forceNoVoice *widget.Check + preferShortJWT *widget.Check + publicVATSIMAck *widget.Check + publicVATSIMLbl *widget.Label + + constraints *widget.Label + blockers *widget.Label + warnings *widget.Label + mutations *widget.Label + + applyBtn *widget.Button + revertBtn *widget.Button + launchBtn *widget.Button + + logEntry *widget.Entry + + // debounce dry-plan + planTimer *time.Timer + lastPlan *clientinject.Plan + preflight error +} + +func newController(eng *clientinject.Engine, a fyne.App, w fyne.Window) *controller { + c := &controller{ + eng: eng, + app: a, + win: w, + form: DefaultFormState(), + } + c.slots = BuildClientSlots(eng.Adapters) + c.slotByLabel = make(map[string]ClientSlot, len(c.slots)) + for _, s := range c.slots { + c.slotByLabel[SlotLabel(s)] = s + } + return c +} + +func (c *controller) buildUI() fyne.CanvasObject { + // --- Header --- + title := widget.NewLabelWithStyle("openfsd Client Setup", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + legal := widget.NewLabel(LegalOneLiner) + legal.Wrapping = fyne.TextWrapWord + header := container.NewVBox(title, legal, widget.NewSeparator()) + + // --- Client picker --- + var labels []string + var firstEnabled string + for _, s := range c.slots { + lab := SlotLabel(s) + labels = append(labels, lab) + if s.Enabled && firstEnabled == "" { + firstEnabled = lab + } + } + c.clientSelect = widget.NewSelect(labels, func(sel string) { + slot, ok := c.slotByLabel[sel] + if !ok || !slot.Enabled { + // Revert to previous enabled selection. + c.syncClientSelectFromForm() + c.appendLog("Client %q is not available yet.", sel) + return + } + c.form.ClientID = slot.ID + c.schedulePlan() + }) + if firstEnabled != "" { + c.clientSelect.SetSelected(firstEnabled) + if s, ok := c.slotByLabel[firstEnabled]; ok { + c.form.ClientID = s.ID + } + } + + // --- Install path --- + c.installEntry = widget.NewEntry() + c.installEntry.SetPlaceHolder("Client install directory") + c.installEntry.OnChanged = func(s string) { + c.form.InstallPath = s + c.schedulePlan() + } + c.detectBtn = widget.NewButton("Detect", func() { + c.onDetect() + }) + browseBtn := widget.NewButton("Browse…", func() { + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil || uri == nil { + return + } + c.installEntry.SetText(uri.Path()) + }, c.win) + }) + installRow := container.NewBorder(nil, nil, nil, + container.NewHBox(c.detectBtn, browseBtn), + c.installEntry, + ) + + // --- Fingerprint + config --- + c.fingerprint = widget.NewLabel("Fingerprint: (set install path)") + c.fingerprint.Wrapping = fyne.TextWrapWord + c.configList = widget.NewLabel("Config files: —") + c.configList.Wrapping = fyne.TextWrapWord + + // --- Endpoints --- + c.webBaseEntry = widget.NewEntry() + c.webBaseEntry.SetPlaceHolder("https://fsd.example.com") + c.webBaseEntry.OnChanged = func(s string) { + c.form.WebBaseURL = s + c.updatePublicVATSIMHint() + c.schedulePlan() + } + c.fsdHostEntry = widget.NewEntry() + c.fsdHostEntry.SetPlaceHolder("fsd.example.com") + c.fsdHostEntry.OnChanged = func(s string) { + c.form.FSDHost = s + c.schedulePlan() + } + c.fsdPortEntry = widget.NewEntry() + c.fsdPortEntry.SetText(FormatPortString(0)) + c.fsdPortEntry.OnChanged = func(s string) { + n, err := ParsePortString(s) + if err != nil { + return + } + c.form.FSDPort = n + c.schedulePlan() + } + c.fsdServerEntry = widget.NewEntry() + c.fsdServerEntry.SetText("OPENFSD") + c.fsdServerEntry.OnChanged = func(s string) { + c.form.FSDServerName = s + c.schedulePlan() + } + c.afvBaseEntry = widget.NewEntry() + c.afvBaseEntry.SetPlaceHolder("https://voice.example.com (optional)") + c.afvBaseEntry.OnChanged = func(s string) { + c.form.AFVBaseURL = s + c.schedulePlan() + } + c.forceNoVoice = widget.NewCheck("Force disable voice (-novoice)", func(v bool) { + c.form.ForceDisableAFV = v + c.schedulePlan() + }) + c.preferShortJWT = widget.NewCheck("Prefer short JWT path (/j, …)", func(v bool) { + c.form.PreferShortJWTPath = v + c.schedulePlan() + }) + c.publicVATSIMLbl = widget.NewLabel("") + c.publicVATSIMLbl.Wrapping = fyne.TextWrapWord + c.publicVATSIMAck = widget.NewCheck("I understand (public VATSIM host override)", func(v bool) { + c.form.UnderstandPublicVATSIM = v + c.refreshApplyEnabled() + }) + + endpointsForm := widget.NewForm( + widget.NewFormItem("Web base URL", c.webBaseEntry), + widget.NewFormItem("FSD host", c.fsdHostEntry), + widget.NewFormItem("FSD port", c.fsdPortEntry), + widget.NewFormItem("Server name", c.fsdServerEntry), + widget.NewFormItem("AFV base URL", c.afvBaseEntry), + ) + + // --- Constraints / plan panels --- + c.constraints = widget.NewLabel(FormatConstraints(nil)) + c.constraints.Wrapping = fyne.TextWrapWord + c.blockers = widget.NewLabel("") + c.blockers.Wrapping = fyne.TextWrapWord + c.warnings = widget.NewLabel("") + c.warnings.Wrapping = fyne.TextWrapWord + c.mutations = widget.NewLabel("") + c.mutations.Wrapping = fyne.TextWrapWord + + // --- Actions --- + c.applyBtn = widget.NewButtonWithIcon("Apply", theme.ConfirmIcon(), func() { + c.onApply() + }) + c.applyBtn.Importance = widget.HighImportance + c.revertBtn = widget.NewButtonWithIcon("Revert", theme.ContentUndoIcon(), func() { + c.onRevert() + }) + c.launchBtn = widget.NewButtonWithIcon("Launch", theme.MediaPlayIcon(), func() { + c.onLaunch() + }) + actions := container.NewHBox(c.applyBtn, c.revertBtn, c.launchBtn) + + // --- Log --- + c.logEntry = widget.NewMultiLineEntry() + c.logEntry.SetMinRowsVisible(8) + c.logEntry.Wrapping = fyne.TextWrapWord + c.logEntry.Disable() + + // Assemble scrollable body + body := container.NewVBox( + header, + widget.NewCard("Client", "", c.clientSelect), + widget.NewCard("Install location", "", installRow), + widget.NewCard("Fingerprint / preflight", "", container.NewVBox(c.fingerprint, c.configList)), + widget.NewCard("openfsd endpoints", "", container.NewVBox( + endpointsForm, + c.forceNoVoice, + c.preferShortJWT, + c.publicVATSIMLbl, + c.publicVATSIMAck, + )), + widget.NewCard("Plan constraints", "", c.constraints), + widget.NewCard("Blockers", "", c.blockers), + widget.NewCard("Warnings", "", c.warnings), + widget.NewCard("Mutations", "", c.mutations), + widget.NewCard("Actions", "", actions), + widget.NewCard("Log", "", c.logEntry), + ) + + scroll := container.NewVScroll(body) + return scroll +} + +func (c *controller) syncClientSelectFromForm() { + for _, s := range c.slots { + if s.Enabled && s.ID == c.form.ClientID { + c.clientSelect.SetSelected(SlotLabel(s)) + return + } + } +} + +func (c *controller) loadSettingsAndRefresh() { + path, err := DefaultSettingsPath() + if err != nil { + c.appendLog("config dir: %v", err) + return + } + s, err := LoadSettings(path) + if err != nil { + c.appendLog("load settings: %v", err) + return + } + s.ApplyToForm(&c.form) + // Push into widgets without infinite OnChanged loops is fine; they schedule plan. + if c.form.ClientID != "" { + c.syncClientSelectFromForm() + } + c.installEntry.SetText(c.form.InstallPath) + c.webBaseEntry.SetText(c.form.WebBaseURL) + c.fsdHostEntry.SetText(c.form.FSDHost) + c.fsdPortEntry.SetText(FormatPortString(c.form.FSDPort)) + if c.form.FSDServerName != "" { + c.fsdServerEntry.SetText(c.form.FSDServerName) + } + c.afvBaseEntry.SetText(c.form.AFVBaseURL) + c.forceNoVoice.SetChecked(c.form.ForceDisableAFV) + c.preferShortJWT.SetChecked(c.form.PreferShortJWTPath) + c.publicVATSIMAck.SetChecked(c.form.UnderstandPublicVATSIM) + c.updatePublicVATSIMHint() + c.schedulePlan() + c.appendLog("Loaded settings from %s", path) +} + +func (c *controller) saveSettings() { + path, err := DefaultSettingsPath() + if err != nil { + return + } + if err := SaveSettings(path, SettingsFromForm(c.form)); err != nil { + c.appendLog("save settings: %v", err) + return + } +} + +func (c *controller) appendLog(format string, args ...any) { + line := fmt.Sprintf(format, args...) + ts := time.Now().Format("15:04:05") + cur := c.logEntry.Text + if cur != "" && !strings.HasSuffix(cur, "\n") { + cur += "\n" + } + c.logEntry.SetText(cur + ts + " " + line + "\n") + c.logEntry.CursorRow = len(strings.Split(c.logEntry.Text, "\n")) +} + +func (c *controller) updatePublicVATSIMHint() { + warn := PublicVATSIMWarning(c.form.WebBaseURL) + if warn == "" { + c.publicVATSIMLbl.SetText("") + return + } + c.publicVATSIMLbl.SetText("⚠ " + warn) +} + +func (c *controller) schedulePlan() { + c.mu.Lock() + defer c.mu.Unlock() + if c.planTimer != nil { + c.planTimer.Stop() + } + // Debounce dry-plan on keystroke / form change. + c.planTimer = time.AfterFunc(350*time.Millisecond, func() { + c.runDryPlan() + }) +} + +func (c *controller) runDryPlan() { + // Snapshot form under lock-free read (widgets already wrote form fields). + f := c.form + issues := ValidateForm(f) + + install := BuildInstall(c.eng, f.ClientID, f.InstallPath) + var preflightErr error + if pe := clientinject.AbsPrimaryPE(install); pe != "" { + if err := clientinject.PreflightPrimaryPE(pe); err != nil { + preflightErr = err + } + } + // Best-effort hash for fingerprint when PE readable. + if install.HashSHA1 == "" && install.PrimaryPE != "" { + if sum, err := fileSHA1OS(install.PrimaryPE); err == nil { + install.HashSHA1 = sum + } + } + profileID := install.ProfileID + if profileID == "" && c.eng.Profiles != nil { + if p, err := c.eng.ResolveProfile(install); err == nil { + profileID = p.ProfileID + install.ProfileID = p.ProfileID + } + } + + var plan *clientinject.Plan + var planErr error + if len(issues) == 0 && f.InstallPath != "" { + plan, planErr = c.eng.Plan(context.Background(), install, f.Endpoints()) + if planErr != nil { + // Still show fingerprint; plan panel shows error. + } + } + + // Update UI on main thread. + fyne.Do(func() { + c.preflight = preflightErr + c.lastPlan = plan + c.fingerprint.SetText(FormatFingerprint(install, profileID, preflightErr)) + if len(install.ConfigPaths) > 0 { + c.configList.SetText("Config files:\n • " + strings.Join(install.ConfigPaths, "\n • ")) + } else { + c.configList.SetText("Config files: (none discovered yet)") + } + if plan != nil { + c.constraints.SetText(FormatConstraints(plan.Constraints)) + if b := FormatBlockers(plan.Blockers); b != "" { + c.blockers.SetText(b) + } else { + c.blockers.SetText("(none)") + } + if w := FormatWarnings(plan.Warnings); w != "" { + c.warnings.SetText(w) + } else { + c.warnings.SetText("(none)") + } + c.mutations.SetText(FormatMutations(plan.Mutations)) + } else { + msg := "(dry-plan not available)" + if len(issues) > 0 { + msg = "Form: " + issues[0].Message + } + if planErr != nil { + msg = planErr.Error() + } + c.constraints.SetText(msg) + c.blockers.SetText("") + c.warnings.SetText("") + c.mutations.SetText("") + } + c.refreshApplyEnabled() + }) +} + +func (c *controller) refreshApplyEnabled() { + issues := ValidateForm(c.form) + ok, reason := CanApply(c.form, c.lastPlan, issues) + if c.preflight != nil && errors.Is(c.preflight, clientinject.ErrClientRunning) { + ok = false + reason = "client appears to be running — quit before Apply" + } + c.applyBtn.Enable() + if !ok { + c.applyBtn.Disable() + if reason != "" { + c.applyBtn.SetText("Apply (" + truncate(reason, 40) + ")") + } else { + c.applyBtn.SetText("Apply") + } + } else { + c.applyBtn.SetText("Apply") + } + // Revert enabled when install path set + if strings.TrimSpace(c.form.InstallPath) == "" { + c.revertBtn.Disable() + c.launchBtn.Disable() + } else { + c.revertBtn.Enable() + c.launchBtn.Enable() + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + +func (c *controller) onDetect() { + cand, ok := FirstDetectPath(c.eng, c.form.ClientID) + if !ok { + c.appendLog("No install detected for %s — set path manually.", c.form.ClientID) + dialog.ShowInformation("Detect", "No install found for this client on this machine. Enter the install path manually.", c.win) + return + } + c.installEntry.SetText(cand.RootDir) + c.appendLog("Detected: %s (%s)", cand.RootDir, cand.DisplayHint) +} + +func (c *controller) onApply() { + // Soft legal banner every Apply. + msg := LegalApplyBanner + if warn := PublicVATSIMWarning(c.form.WebBaseURL); warn != "" { + msg += "\n\n" + warn + } + if c.lastPlan != nil && len(c.lastPlan.Blockers) > 0 { + dialog.ShowError(fmt.Errorf("plan has blockers — fix endpoints first"), c.win) + return + } + dialog.ShowConfirm("Apply — legal notice", msg+"\n\nApply patch to this install?", func(yes bool) { + if !yes { + c.appendLog("Apply cancelled") + return + } + c.doApply() + }, c.win) +} + +func (c *controller) doApply() { + f := c.form + issues := ValidateForm(f) + if len(issues) > 0 { + dialog.ShowError(fmt.Errorf("%s", issues[0].Message), c.win) + return + } + if warn := PublicVATSIMWarning(f.WebBaseURL); warn != "" && !f.UnderstandPublicVATSIM { + dialog.ShowError(fmt.Errorf("public VATSIM host — check “I understand” or change Web base URL"), c.win) + return + } + install := BuildInstall(c.eng, f.ClientID, f.InstallPath) + plan, err := c.eng.Plan(context.Background(), install, f.Endpoints()) + if err != nil { + c.appendLog("plan: %v", err) + dialog.ShowError(err, c.win) + return + } + if len(plan.Blockers) > 0 { + c.appendLog("plan blocked: %s", strings.Join(plan.Blockers, "; ")) + dialog.ShowError(fmt.Errorf("plan blockers: %s", strings.Join(plan.Blockers, "; ")), c.win) + return + } + c.appendLog("Applying %d mutations…", len(plan.Mutations)) + res, err := c.eng.Apply(context.Background(), plan) + if err != nil { + c.appendLog("apply failed: %v", err) + dialog.ShowError(err, c.win) + c.schedulePlan() + return + } + c.saveSettings() + c.appendLog("Applied OK manifest=%s applied=%v", res.ManifestPath, res.Applied) + dialog.ShowInformation("Apply complete", fmt.Sprintf("Patched successfully.\nManifest: %s\nApplied: %v", res.ManifestPath, res.Applied), c.win) + c.schedulePlan() +} + +func (c *controller) onRevert() { + root := strings.TrimSpace(c.form.InstallPath) + if root == "" { + dialog.ShowError(fmt.Errorf("install path required"), c.win) + return + } + dialog.ShowConfirm("Revert", "Restore stock files from .openfsd-bak for this install?", func(yes bool) { + if !yes { + return + } + if err := c.eng.Revert(context.Background(), filepath.Clean(root)); err != nil { + c.appendLog("revert: %v", err) + dialog.ShowError(err, c.win) + return + } + c.appendLog("Reverted %s", root) + dialog.ShowInformation("Revert complete", "Stock files restored from backup.", c.win) + c.schedulePlan() + }, c.win) +} + +func (c *controller) onLaunch() { + f := c.form + install := BuildInstall(c.eng, f.ClientID, f.InstallPath) + a, ok := c.eng.Adapters[f.ClientID] + if !ok { + dialog.ShowError(fmt.Errorf("no adapter for %q", f.ClientID), c.win) + return + } + args := a.LaunchArgs(install, f.Endpoints()) + pe := clientinject.AbsPrimaryPE(install) + if pe == "" { + dialog.ShowError(fmt.Errorf("no primary PE path"), c.win) + return + } + c.appendLog("Launch: %s %s", pe, strings.Join(args, " ")) + cmd := exec.Command(pe, args...) + cmd.Dir = install.RootDir + if err := cmd.Start(); err != nil { + // Best-effort (Wine / non-Windows may fail). + c.appendLog("launch failed: %v (use printed args manually)", err) + dialog.ShowInformation("Launch", fmt.Sprintf("Could not start process:\n%v\n\nCommand:\n%s %s\n\nWorking directory:\n%s", + err, pe, strings.Join(args, " "), install.RootDir), c.win) + return + } + _ = cmd.Process.Release() + c.appendLog("Started pid (detached)") +} + +func fileSHA1OS(path string) (string, error) { + w := clientinject.OSFileWriter{} + data, err := w.ReadFile(path) + if err != nil { + return "", err + } + // local hash to avoid exporting engine helper + sum := sha1SumHex(data) + return sum, nil +} diff --git a/cmd/openfsd-client/main.go b/cmd/openfsd-client/main.go index b1d1656..7cde9ee 100644 --- a/cmd/openfsd-client/main.go +++ b/cmd/openfsd-client/main.go @@ -1,11 +1,29 @@ -// Command openfsd-client is the openfsd Client Setup tool (headless CLI). -// GUI (Fyne) lands in a later PR; with no subcommand this binary prints help. +//go:build !nogui + +// Command openfsd-client is the openfsd Client Setup tool. +// With no subcommand, launches the Fyne GUI when a display is available; +// CLI subcommands always run headless. package main import ( + "fmt" "os" + + "github.com/renorris/openfsd/cmd/openfsd-client/gui" ) func main() { + if len(os.Args) <= 1 { + if gui.CanStart() { + if err := gui.RunDefault(); err != nil { + fmt.Fprintf(os.Stderr, "gui: %v\n\n", err) + // Fall back to CLI help when GUI cannot run. + os.Exit(Run(nil, os.Stdout, os.Stderr)) + } + return + } + // No display — print help (same as previous headless default). + os.Exit(Run(nil, os.Stdout, os.Stderr)) + } os.Exit(Run(os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/cmd/openfsd-client/main_nogui.go b/cmd/openfsd-client/main_nogui.go new file mode 100644 index 0000000..b0ad01a --- /dev/null +++ b/cmd/openfsd-client/main_nogui.go @@ -0,0 +1,10 @@ +//go:build nogui + +// Headless build: CLI only (no Fyne). Use: go build -tags nogui ./cmd/openfsd-client +package main + +import "os" + +func main() { + os.Exit(Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/cmd/openfsd-client/run.go b/cmd/openfsd-client/run.go index 788eb14..3f04133 100644 --- a/cmd/openfsd-client/run.go +++ b/cmd/openfsd-client/run.go @@ -25,10 +25,10 @@ const ( ExitPlanBlockers = 6 ) -const usageText = `openfsd-client — openfsd Client Setup (headless CLI) +const usageText = `openfsd-client — openfsd Client Setup (GUI + headless CLI) Usage: - openfsd-client Print this help (GUI lands in a later PR) + openfsd-client Launch GUI when a display is available; else this help openfsd-client list-profiles openfsd-client detect --client vpilot openfsd-client plan|apply|revert|health|launch [flags] diff --git a/go.mod b/go.mod index 7fbd78b..dcc79ac 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/renorris/openfsd go 1.26.0 require ( + fyne.io/fyne/v2 v2.6.3 github.com/fergusstrange/embedded-postgres v1.34.0 github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -20,40 +21,65 @@ require ( ) require ( + fyne.io/systray v1.12.2 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fredbi/uri v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect + github.com/fyne-io/glfw-js v0.4.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-text/render v0.2.1 // indirect + github.com/go-text/typesetting v0.3.4 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/panjf2000/ants/v2 v2.12.1 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rymdport/portal v0.4.2 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + github.com/yuin/goldmark v1.8.2 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect golang.org/x/arch v0.29.0 // indirect + golang.org/x/image v0.24.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum index 89685dc..9f18d8b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA= +fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= @@ -12,14 +18,34 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s= github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= +github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= +github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo= +github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= +github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -28,10 +54,18 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg= +github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -43,10 +77,18 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -66,12 +108,18 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/panjf2000/ants/v2 v2.12.1 h1:BWvU2wHpyXWxhhNXsGB6JXLCNbshyLd1QxvoAmZnu10= github.com/panjf2000/ants/v2 v2.12.1/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= github.com/panjf2000/gnet/v2 v2.10.0 h1:rC4jNF+jtXj/FH+8JOIQ3XxjD+yBunYBLKg9TE3dc4g= github.com/panjf2000/gnet/v2 v2.10.0/go.mod h1:f9wdbOFsdbZqlSvXctWbPRW5bB/W++q8Zqz+D7tQIVQ= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -85,8 +133,14 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= +github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/sethvargo/go-envconfig v1.4.3 h1:9RJrW9aiy3SJVRJ1svntpZvBw3ghj941u/BseS/TokY= github.com/sethvargo/go-envconfig v1.4.3/go.mod h1:ebe6rgj7KzrRZPzDXU4W6WZWDEirQwvcgmS0bmC3Sjg= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -106,6 +160,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -124,6 +180,8 @@ golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=