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:
Reese Norris
2026-07-28 23:16:08 -04:00
parent c4d93489a8
commit 6630751e06
18 changed files with 1703 additions and 4 deletions

View 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

View 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 + ")"
}

View 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))
}
}

View File

@@ -0,0 +1,5 @@
//go:build !nogui && (windows || darwin)
package gui
func goosIsUnixDisplayRequired() bool { return false }

View File

@@ -0,0 +1,5 @@
//go:build !nogui && !windows && !darwin
package gui
func goosIsUnixDisplayRequired() bool { return true }

View 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

View 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[:])
}

View 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
}

View 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": {},
}

View 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 065535 (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)
}

View 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)
}
}

View 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)
}

View 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
}

View File

@@ -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))
}

View 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))
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=