mirror of
https://github.com/renorris/openfsd
synced 2026-08-11 12:05:34 +08:00
openfsd-client: Fyne multi-client GUI (vPilot enabled)
Add openfsd Client Setup desktop UI: client picker, install path, endpoints form, Plan constraints panel, Apply/Revert/Launch, and legal banner. GUI stays client-agnostic via Adapter + Plan.
This commit is contained in:
77
cmd/openfsd-client/gui/app.go
Normal file
77
cmd/openfsd-client/gui/app.go
Normal file
@@ -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
|
||||
88
cmd/openfsd-client/gui/clients.go
Normal file
88
cmd/openfsd-client/gui/clients.go
Normal file
@@ -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 + ")"
|
||||
}
|
||||
40
cmd/openfsd-client/gui/clients_test.go
Normal file
40
cmd/openfsd-client/gui/clients_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
5
cmd/openfsd-client/gui/display_desktop.go
Normal file
5
cmd/openfsd-client/gui/display_desktop.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !nogui && (windows || darwin)
|
||||
|
||||
package gui
|
||||
|
||||
func goosIsUnixDisplayRequired() bool { return false }
|
||||
5
cmd/openfsd-client/gui/display_unix.go
Normal file
5
cmd/openfsd-client/gui/display_unix.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !nogui && !windows && !darwin
|
||||
|
||||
package gui
|
||||
|
||||
func goosIsUnixDisplayRequired() bool { return true }
|
||||
10
cmd/openfsd-client/gui/doc.go
Normal file
10
cmd/openfsd-client/gui/doc.go
Normal file
@@ -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
|
||||
11
cmd/openfsd-client/gui/hash.go
Normal file
11
cmd/openfsd-client/gui/hash.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func sha1SumHex(data []byte) string {
|
||||
sum := sha1.Sum(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
113
cmd/openfsd-client/gui/install.go
Normal file
113
cmd/openfsd-client/gui/install.go
Normal file
@@ -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
|
||||
}
|
||||
30
cmd/openfsd-client/gui/legal.go
Normal file
30
cmd/openfsd-client/gui/legal.go
Normal file
@@ -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": {},
|
||||
}
|
||||
299
cmd/openfsd-client/gui/model.go
Normal file
299
cmd/openfsd-client/gui/model.go
Normal file
@@ -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)
|
||||
}
|
||||
205
cmd/openfsd-client/gui/model_test.go
Normal file
205
cmd/openfsd-client/gui/model_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
122
cmd/openfsd-client/gui/settings.go
Normal file
122
cmd/openfsd-client/gui/settings.go
Normal file
@@ -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)
|
||||
}
|
||||
582
cmd/openfsd-client/gui/window.go
Normal file
582
cmd/openfsd-client/gui/window.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
10
cmd/openfsd-client/main_nogui.go
Normal file
10
cmd/openfsd-client/main_nogui.go
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
26
go.mod
26
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
|
||||
|
||||
58
go.sum
58
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=
|
||||
|
||||
Reference in New Issue
Block a user