From e1371e226a062911e83aa8b28ec6272a17c51c18 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Wed, 10 Jun 2026 20:12:03 +0300 Subject: [PATCH] feat: implement dynamic server list loading from API Add internal/api package to fetch server list from /api/servers.json endpoint Replace hardcoded server list with dynamic API call Implement loading states, error handling, and refresh capability --- internal/api/server.go | 72 +++++++++++++++ internal/api/server_test.go | 133 ++++++++++++++++++++++++++++ internal/ui/screens/screens.go | 62 +++++++++---- internal/ui/screens/screens_test.go | 10 +++ internal/ui/ui.go | 39 ++++++-- internal/ui/ui_test.go | 9 ++ 6 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 internal/api/server.go create mode 100644 internal/api/server_test.go create mode 100644 internal/ui/screens/screens_test.go create mode 100644 internal/ui/ui_test.go diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..a8a67b4 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,72 @@ +// package api handles launcher-specific API calls to the backend. +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/screens" +) + +// ServerResponse represents the JSON response from /api/servers.json endpoint. +type ServerResponse struct { + Servers []ServerInfo `json:"servers"` +} + +// ServerInfo represents a single server/modpack entry from the API. +type ServerInfo struct { + Slug string `json:"slug"` + Name string `json:"name"` + Version string `json:"version"` + IP string `json:"ip"` +} + +// FetchServerList retrieves and parses the server list from the backend API. +// Returns a slice of Modpack objects suitable for UI consumption. +func FetchServerList(serverURL string) ([]screens.Modpack, error) { + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Create request with context for cancellation + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/servers.json", serverURL), nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + // Make the HTTP request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + // Check HTTP status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Decode JSON response + var serverResp ServerResponse + if err := json.NewDecoder(resp.Body).Decode(&serverResp); err != nil { + return nil, fmt.Errorf("decoding JSON: %w", err) + } + + // Convert API server info to UI Modpack format + var modpacks []screens.Modpack + for _, server := range serverResp.Servers { + modpacks = append(modpacks, screens.Modpack{ + Slug: server.Slug, + Name: server.Name, + }) + } + + return modpacks, nil +} \ No newline at end of file diff --git a/internal/api/server_test.go b/internal/api/server_test.go new file mode 100644 index 0000000..053c59a --- /dev/null +++ b/internal/api/server_test.go @@ -0,0 +1,133 @@ +// package api handles launcher-specific API calls to the backend. +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/screens" + "github.com/stretchr/testify/require" +) + +func TestFetchServerList_Success(t *testing.T) { + // Arrange - create mock server response + serverResp := ServerResponse{ + Servers: []ServerInfo{ + {Slug: "hitech", Name: "HiTech 1.21", Version: "1.21", IP: "192.168.1.100"}, + {Slug: "vanilla", Name: "Vanilla 1.20", Version: "1.20", IP: "192.168.1.101"}, + }, + } + + // Create test server with mock response + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/servers.json" { + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if r.Method != http.MethodGet { + t.Errorf("unexpected method: %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(serverResp) + })) + defer testServer.Close() + + // Act - call FetchServerList with test server URL + modpacks, err := FetchServerList(testServer.URL) + require.NoError(t, err) + + // Assert - check results + require.Len(t, modpacks, 2) + require.Equal(t, screens.Modpack{Slug: "hitech", Name: "HiTech 1.21"}, modpacks[0]) + require.Equal(t, screens.Modpack{Slug: "vanilla", Name: "Vanilla 1.20"}, modpacks[1]) +} + +func TestFetchServerList_EmptyList(t *testing.T) { + // Arrange - create mock server with empty servers list + serverResp := ServerResponse{Servers: []ServerInfo{}} + + // Create test server with mock response + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/servers.json" { + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if r.Method != http.MethodGet { + t.Errorf("unexpected method: %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(serverResp) + })) + defer testServer.Close() + + // Act - call FetchServerList with test server URL + modpacks, err := FetchServerList(testServer.URL) + require.NoError(t, err) + + // Assert - should return empty slice + require.Empty(t, modpacks) +} + +func TestFetchServerList_NetworkError(t *testing.T) { + // Act - call FetchServerList with invalid URL that will fail to connect + modpacks, err := FetchServerList("http://localhost:12345/api/servers.json") // Assuming no server on this port + + // Assert - should return error + require.Error(t, err) + require.Nil(t, modpacks) + require.Contains(t, err.Error(), "making request") +} + +func TestFetchServerList_HTTPError(t *testing.T) { + // Arrange - create test server that returns 500 error + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer testServer.Close() + + // Act - call FetchServerList with test server URL + modpacks, err := FetchServerList(testServer.URL) + require.Error(t, err) + require.Nil(t, modpacks) + require.Contains(t, err.Error(), "unexpected status code: 500") +} + +func TestFetchServerList_InvalidJSON(t *testing.T) { + // Arrange - create test server that returns invalid JSON + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"invalid": json}`)) + })) + defer testServer.Close() + + // Act - call FetchServerList with test server URL + modpacks, err := FetchServerList(testServer.URL) + require.Error(t, err) + require.Nil(t, modpacks) + require.Contains(t, err.Error(), "decoding JSON") +} + +func TestFetchServerList_Timeout(t *testing.T) { + // Arrange - create test server that delays response to trigger timeout + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Sleep longer than our timeout to trigger timeout error + time.Sleep(15 * time.Second) + })) + defer testServer.Close() + + // Act - call FetchServerList with test server URL + modpacks, err := FetchServerList(testServer.URL) + require.Error(t, err) + require.Nil(t, modpacks) + require.Contains(t, err.Error(), "context deadline exceeded") + require.Contains(t, err.Error(), "making request") +} \ No newline at end of file diff --git a/internal/ui/screens/screens.go b/internal/ui/screens/screens.go index 8c45863..df319bb 100644 --- a/internal/ui/screens/screens.go +++ b/internal/ui/screens/screens.go @@ -28,27 +28,54 @@ func MainScreen( serverList []Modpack, onPlay func(), onSettings func(), + onServerListRefresh func(), + isServerListLoading bool, + serverListError error, ) fyne.CanvasObject { // ── Sidebar (Left) ────────────────────────────────────── - serverLabels := make([]*widget.Label, len(serverList)) - serverContainer := container.NewVBox() + var sidebar fyne.CanvasObject + if isServerListLoading { + // Show loading state + loadingLabel := widget.NewLabelWithStyle("Loading server list...", fyne.TextAlignCenter, fyne.TextStyle{}) + sidebar = container.NewVScroll(container.NewVBox(loadingLabel)) + } else if len(serverList) == 0 && serverListError == nil { + // Show empty state (no error, just empty list) + emptyLabel := widget.NewLabelWithStyle("No servers available", fyne.TextAlignCenter, fyne.TextStyle{}) + sidebar = container.NewVScroll(container.NewVBox(emptyLabel)) + } else if serverListError != nil { + // Show error state + errorLabel := widget.NewLabelWithStyle("Failed to load server list", fyne.TextAlignCenter, fyne.TextStyle{}) + detailLabel := widget.NewLabel(serverListError.Error()) + detailLabel.Wrapping = fyne.TextWrapWord + retryBtn := components.SettingsButton(onServerListRefresh) + retryBtn.Text = "Retry" + sidebar = container.NewVScroll(container.NewVBox( + errorLabel, + detailLabel, + retryBtn, + )) + } else { + // Show normal server list + serverLabels := make([]*widget.Label, len(serverList)) + serverContainer := container.NewVBox() - for i, sp := range serverList { - label := widget.NewLabel(sp.Name) - serverLabels[i] = label - index := i - card := components.ServerCard(sp.Name, index == 0, func() { - for j, l := range serverLabels { - l.TextStyle.Bold = (j == index) - l.Refresh() - } - }) - serverContainer.Add(card) + for i, sp := range serverList { + label := widget.NewLabel(sp.Name) + serverLabels[i] = label + index := i + card := components.ServerCard(sp.Name, index == 0, func() { + for j, l := range serverLabels { + l.TextStyle.Bold = (j == index) + l.Refresh() + } + }) + serverContainer.Add(card) + } + + sidebar = container.NewVScroll(serverContainer) } - sidebar := container.NewVScroll(serverContainer) - // ── Center (background + info) ────────────────────────── bg := widget.NewRichTextFromMarkdown( "# Welcome to MrixsCraft\n\n" + @@ -93,9 +120,14 @@ func MainScreen( playBtn := components.PlayButton(onPlay) settingsBtn := components.SettingsButton(onSettings) + // Refresh button for server list + refreshBtn := components.SettingsButton(onServerListRefresh) + bottomRight := container.NewHBox( settingsBtn, widget.NewSeparator(), + refreshBtn, + widget.NewSeparator(), playBtn, ) diff --git a/internal/ui/screens/screens_test.go b/internal/ui/screens/screens_test.go new file mode 100644 index 0000000..64548ba --- /dev/null +++ b/internal/ui/screens/screens_test.go @@ -0,0 +1,10 @@ +// package screens implements application screens (login, main menu, settings). +package screens + +import "testing" + +// Dummy test to ensure package compiles +func TestScreensPackage_Compiles(t *testing.T) { + // This test ensures the screens package compiles without errors + // We're not testing the actual UI output as that requires a GUI environment +} \ No newline at end of file diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2c4e93a..a73dfab 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -5,6 +5,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/api" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/screens" @@ -20,12 +21,13 @@ func Launch(client *auth.Client, session *auth.Session, settings config.Settings w.Resize(fyne.NewSize(float32(settings.Width), float32(settings.Height))) w.CenterOnScreen() - // TODO: fetch from /api/servers.json - serverList := []screens.Modpack{ - {Slug: "hitech", Name: "HiTech 1.21"}, - {Slug: "vanilla", Name: "Vanilla 1.20"}, - } + // State for server list loading + var serverList []screens.Modpack + var isServerListLoading bool + var serverListError error + _ = serverListError // Use variable to prevent "declared and not used" error + // Callback functions onPlay := func() { // TODO: launch selected modpack } @@ -38,15 +40,38 @@ func Launch(client *auth.Client, session *auth.Session, settings config.Settings }) } - content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings) + // Function to refresh server list + var refreshServerList func() + refreshServerList = func() { + isServerListLoading = true + serverListError = nil + // Update UI to show loading state + content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings, refreshServerList, isServerListLoading, serverListError) + w.SetContent(content) + + // Fetch server list in goroutine to avoid blocking UI + go func() { + serverList, serverListError = api.FetchServerList(settings.ServerURL) + isServerListLoading = false + // Update UI with result + content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings, refreshServerList, isServerListLoading, serverListError) + w.SetContent(content) + }() + } + + // Initial load of server list + content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings, refreshServerList, isServerListLoading, serverListError) w.SetContent(content) + // Start fetching server list in background + go refreshServerList() + // If no session, show login modal after the window is rendered. if session == nil { w.Show() screens.LoginScreen(w, client, func(sess *auth.Session) { // Refresh UI with logged-in state. - content := screens.MainScreen(w, client, sess, serverList, onPlay, onSettings) + content := screens.MainScreen(w, client, sess, serverList, onPlay, onSettings, refreshServerList, isServerListLoading, serverListError) w.SetContent(content) }) } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..b44d219 --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,9 @@ +// package ui contains the Fyne GUI bootstrap and wiring. +package ui + +import "testing" + +// Dummy test to ensure package compiles +func TestUIPackage_Compiles(t *testing.T) { + // This test ensures the ui package compiles without errors +} \ No newline at end of file