diff --git a/client/client.go b/client/client.go index 5261d11..f060d46 100644 --- a/client/client.go +++ b/client/client.go @@ -9,6 +9,7 @@ import ( "galaxy/connector" mc "galaxy/model/client" "galaxy/model/report" + "galaxy/storage" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -17,7 +18,10 @@ import ( "fyne.io/fyne/v2/widget" ) +const version = "1.0.0" + type client struct { + s storage.UIStorage conn connector.UIConnector app fyne.App window fyne.Window @@ -66,8 +70,9 @@ type client struct { hits []world.Hit } -func NewClient(ctx context.Context, conn connector.UIConnector, app fyne.App, settings mc.Settings) (mc.Client, error) { +func NewClient(ctx context.Context, s storage.UIStorage, conn connector.UIConnector, app fyne.App) (mc.Client, error) { e := &client{ + s: s, conn: conn, app: app, window: app.NewWindow("Galaxy Plus"), @@ -206,3 +211,5 @@ func (e *client) Shutdown() { } func (e *client) OnConnection(bool) {} + +func (e *client) Version() string { return version } diff --git a/client/cmd/ui/main.go b/client/cmd/ui/main.go index 81b5a73..8b76bf3 100644 --- a/client/cmd/ui/main.go +++ b/client/cmd/ui/main.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "galaxy/client" - mc "galaxy/model/client" "os" "os/signal" @@ -26,14 +25,11 @@ func main() { } }() app := app.New() - settings := mc.Settings{ - StoragePath: ".", - } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - c, err := client.NewClient(ctx, nil, app, settings) + c, err := client.NewClient(ctx, nil, nil, app) if err != nil { return } diff --git a/connector/connector.go b/connector/connector.go index e32698d..9893a3c 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -2,7 +2,7 @@ package connector import ( "context" - model "galaxy/model/client" + "galaxy/model/client" "galaxy/model/report" ) @@ -11,7 +11,7 @@ type Connector interface { UIConnector // CheckConnection is called asynchronously every 5 seconds and tests is connection available with a specific backend server endpoint. - // There is guaranteed backoff 5s -> 15s -> 30s -> 60s when no connection is available. + // There is guaranteed jittered backoff with caps 5s -> 15s -> 30s -> 60s when no connection is available. CheckConnection() bool // CheckVersion is called asynchronously every 30 minutes and receives from backend server information about currently available app versions. @@ -26,11 +26,11 @@ type UIConnector interface { // FetchReport asynchronously requests from backend server a [report.Report] for a given [model.GameID] and turn number. // Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried, // otherwise callback func accepts loaded [report.Report]. - FetchReport(context.Context, model.GameID, uint, func(report.Report, error)) + FetchReport(context.Context, client.GameID, uint, func(report.Report, error)) } type VersionInfo struct { OS string `json:"os"` // Operating System name (unix, darwin, windows, etc.) Version string `json:"version"` // Semver format: X.Y.Z - URL string `json:"url"` // URL for download artifacto for this version + URL string `json:"url"` // Artifact download URL for this version } diff --git a/connector/http/http.go b/connector/http/http.go index bb901be..85b7084 100644 --- a/connector/http/http.go +++ b/connector/http/http.go @@ -3,12 +3,46 @@ package http import ( "context" + "encoding/json" + "errors" + "fmt" + "galaxy/connector" + "math/rand/v2" + "net" + "net/http" "net/url" + "path" + "strings" + "time" ) +const ( + // checkConnectionPath is backend endpoint path used to test server reachability. + checkConnectionPath = "api/v1/status" + // checkVersionPath is backend endpoint path used to load available app versions. + checkVersionPath = "api/v1/versions" + + // connectTimeout is max time for establishing TCP connection. + connectTimeout = 3 * time.Second + // responseTimeout is max time for waiting response headers from backend. + responseTimeout = 3 * time.Second +) + +// defaultRetryCaps defines connect-timeout retry caps for full-jitter backoff. +var defaultRetryCaps = []time.Duration{ + 5 * time.Second, + 15 * time.Second, + 30 * time.Second, + 60 * time.Second, +} + type httpConnector struct { ctx context.Context backendURL *url.URL // HTTP REST API Server URL + httpClient *http.Client + retryCaps []time.Duration + jitterFn func(time.Duration) time.Duration + sleepFn func(context.Context, time.Duration) error } func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) { @@ -19,6 +53,173 @@ func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, e h := &httpConnector{ ctx: ctx, backendURL: u, + httpClient: newHTTPClient(connectTimeout, responseTimeout), + retryCaps: append([]time.Duration(nil), defaultRetryCaps...), + jitterFn: fullJitter, + sleepFn: sleepWithContext, } return h, nil } + +// newHTTPClient builds dedicated HTTP client with separate timeouts +// for connect and response phases. +func newHTTPClient(connectTimeout, responseTimeout time.Duration) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = (&net.Dialer{ + Timeout: connectTimeout, + KeepAlive: 30 * time.Second, + }).DialContext + transport.TLSHandshakeTimeout = connectTimeout + transport.ResponseHeaderTimeout = responseTimeout + + return &http.Client{ + Transport: transport, + } +} + +func (h *httpConnector) requestContext() context.Context { + if h.ctx != nil { + return h.ctx + } + + return context.Background() +} + +// fullJitter calculates random wait duration in [0, cap]. +func fullJitter(cap time.Duration) time.Duration { + if cap <= 0 { + return 0 + } + + return time.Duration(rand.Int64N(cap.Nanoseconds() + 1)) +} + +// sleepWithContext blocks for the given duration or until context cancellation. +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } + } + + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// isConnectTimeout returns true for dial and TLS-handshake timeout errors. +func isConnectTimeout(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + + if strings.Contains(err.Error(), "timeout awaiting response headers") { + return false + } + if strings.Contains(err.Error(), "TLS handshake timeout") { + return true + } + + var opErr *net.OpError + if errors.As(err, &opErr) { + return opErr.Op == "dial" && opErr.Timeout() + } + + return false +} + +// CheckConnection probes backend status endpoint and reports whether server is reachable. +func (h *httpConnector) CheckConnection() bool { + resp, err := h.doRequest(h.requestContext(), checkConnectionPath) + if err != nil { + return false + } + defer resp.Body.Close() + + return true +} + +// CheckVersion loads available app versions from backend and returns parsed version metadata. +func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) { + resp, err := h.doRequest(h.requestContext(), checkVersionPath) + if err != nil { + return nil, fmt.Errorf("request versions from backend: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode) + } + + var versions []connector.VersionInfo + if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil { + return nil, fmt.Errorf("decode versions response: %w", err) + } + + return versions, nil +} + +// doRequest performs GET request for a backend relative endpoint with passed context. +func (h *httpConnector) doRequest(ctx context.Context, relativePath string) (*http.Response, error) { + requestURL := *h.backendURL + requestURL.Path = path.Join(requestURL.Path, relativePath) + + retryCaps := h.retryCaps + if retryCaps == nil { + retryCaps = defaultRetryCaps + } + jitterFn := h.jitterFn + if jitterFn == nil { + jitterFn = fullJitter + } + sleepFn := h.sleepFn + if sleepFn == nil { + sleepFn = sleepWithContext + } + + var lastErr error + for attempt := 0; attempt <= len(retryCaps); attempt++ { + if attempt > 0 { + delay := jitterFn(retryCaps[attempt-1]) + if delay < 0 { + delay = 0 + } + if err := sleepFn(ctx, delay); err != nil { + return nil, err + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := h.httpClient.Do(req) + if err == nil { + return resp, nil + } + if !isConnectTimeout(err) { + return nil, err + } + lastErr = err + } + + return nil, lastErr +} diff --git a/connector/http/http_test.go b/connector/http/http_test.go new file mode 100644 index 0000000..0bf25f1 --- /dev/null +++ b/connector/http/http_test.go @@ -0,0 +1,595 @@ +package http + +import ( + "context" + "errors" + "galaxy/connector" + "io" + "net" + stdhttp "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" +) + +// checkConnectionCase describes one CheckConnection behavior scenario. +type checkConnectionCase struct { + name string + setup func(t *testing.T) (*httpConnector, <-chan string) + want bool + wantPath string +} + +// checkVersionCase describes one CheckVersion behavior scenario. +type checkVersionCase struct { + name string + setup func(t *testing.T) (*httpConnector, <-chan string) + want []connector.VersionInfo + wantErr bool + wantPath string +} + +// TestCheckConnection verifies backend reachability probe behavior. +func TestCheckConnection(t *testing.T) { + tests := []checkConnectionCase{ + { + name: "status 200 returns true", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newServerConnector(t, context.Background(), stdhttp.StatusOK, "") + }, + want: true, + wantPath: "/api/v1/status", + }, + { + name: "non-2xx status returns true", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newServerConnector(t, context.Background(), stdhttp.StatusServiceUnavailable, "") + }, + want: true, + wantPath: "/api/v1/status", + }, + { + name: "canceled context returns false", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + conn, err := NewHttpConnector(ctx, "http://127.0.0.1") + if err != nil { + t.Fatalf("NewHttpConnector() error = %v", err) + } + return conn, nil + }, + want: false, + }, + { + name: "transport failure returns false", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newUnreachableConnector(t, context.Background()), nil + }, + want: false, + }, + { + name: "backend path prefix is preserved", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newServerConnector(t, context.Background(), stdhttp.StatusOK, "/base") + }, + want: true, + wantPath: "/base/api/v1/status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn, pathCh := tt.setup(t) + + got := conn.CheckConnection() + if got != tt.want { + t.Fatalf("CheckConnection() = %v, want %v", got, tt.want) + } + + if tt.wantPath == "" { + return + } + + select { + case gotPath := <-pathCh: + if gotPath != tt.wantPath { + t.Fatalf("request path = %q, want %q", gotPath, tt.wantPath) + } + default: + t.Fatalf("expected request path %q, got no request", tt.wantPath) + } + }) + } +} + +// TestCheckVersion verifies versions retrieval behavior. +func TestCheckVersion(t *testing.T) { + tests := []checkVersionCase{ + { + name: "status 200 with valid body returns versions", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newVersionServerConnector( + t, + context.Background(), + stdhttp.StatusOK, + `[{"os":"darwin","version":"1.2.3","url":"https://example.com/darwin"}]`, + "", + ) + }, + want: []connector.VersionInfo{ + {OS: "darwin", Version: "1.2.3", URL: "https://example.com/darwin"}, + }, + wantPath: "/api/v1/versions", + }, + { + name: "status 200 with invalid json returns error", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newVersionServerConnector( + t, + context.Background(), + stdhttp.StatusOK, + `{"versions":`, + "", + ) + }, + wantErr: true, + wantPath: "/api/v1/versions", + }, + { + name: "non-200 status returns error", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newVersionServerConnector( + t, + context.Background(), + stdhttp.StatusServiceUnavailable, + `[]`, + "", + ) + }, + wantErr: true, + wantPath: "/api/v1/versions", + }, + { + name: "canceled context returns error", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + conn, err := NewHttpConnector(ctx, "http://127.0.0.1") + if err != nil { + t.Fatalf("NewHttpConnector() error = %v", err) + } + return conn, nil + }, + wantErr: true, + }, + { + name: "transport failure returns error", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newUnreachableConnector(t, context.Background()), nil + }, + wantErr: true, + }, + { + name: "backend path prefix is preserved", + setup: func(t *testing.T) (*httpConnector, <-chan string) { + return newVersionServerConnector( + t, + context.Background(), + stdhttp.StatusOK, + `[{"os":"linux","version":"2.0.0","url":"https://example.com/linux"}]`, + "/base", + ) + }, + want: []connector.VersionInfo{ + {OS: "linux", Version: "2.0.0", URL: "https://example.com/linux"}, + }, + wantPath: "/base/api/v1/versions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn, pathCh := tt.setup(t) + + got, err := conn.CheckVersion() + if tt.wantErr { + if err == nil { + t.Fatal("CheckVersion() error = nil, want non-nil") + } + } else { + if err != nil { + t.Fatalf("CheckVersion() error = %v, want nil", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("CheckVersion() = %#v, want %#v", got, tt.want) + } + } + + if tt.wantPath == "" { + return + } + + select { + case gotPath := <-pathCh: + if gotPath != tt.wantPath { + t.Fatalf("request path = %q, want %q", gotPath, tt.wantPath) + } + default: + t.Fatalf("expected request path %q, got no request", tt.wantPath) + } + }) + } +} + +// TestDoRequestUsesPassedContext verifies request context is provided by caller. +func TestDoRequestUsesPassedContext(t *testing.T) { + conn, pathCh := newServerConnector(t, context.Background(), stdhttp.StatusOK, "") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := conn.doRequest(ctx, checkConnectionPath) + if err == nil { + t.Fatal("doRequest() error = nil, want non-nil") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("doRequest() error = %v, want context canceled", err) + } + + select { + case gotPath := <-pathCh: + t.Fatalf("expected no request with canceled context, got %q", gotPath) + default: + } +} + +// TestDoRequestResponseHeaderTimeout verifies client distinguishes response timeout. +func TestDoRequestResponseHeaderTimeout(t *testing.T) { + const ( + dialTimeout = time.Second + headerTimeout = 30 * time.Millisecond + serverHeaderDelayTime = 150 * time.Millisecond + ) + + server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + time.Sleep(serverHeaderDelayTime) + w.WriteHeader(stdhttp.StatusOK) + })) + t.Cleanup(server.Close) + + backendURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse backend URL error = %v", err) + } + + conn := &httpConnector{ + ctx: context.Background(), + backendURL: backendURL, + httpClient: newHTTPClient(dialTimeout, headerTimeout), + } + + start := time.Now() + _, err = conn.doRequest(context.Background(), checkConnectionPath) + elapsed := time.Since(start) + if err == nil { + t.Fatal("doRequest() error = nil, want timeout") + } + if elapsed < headerTimeout { + t.Fatalf("doRequest() elapsed = %v, want >= %v", elapsed, headerTimeout) + } + + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("doRequest() error = %v, want timeout error", err) + } +} + +// TestDoRequestSuccessFirstAttemptNoRetry verifies successful call does not schedule retries. +func TestDoRequestSuccessFirstAttemptNoRetry(t *testing.T) { + attempts := 0 + sleepCalls := 0 + jitterCalls := 0 + + conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) { + attempts++ + return &stdhttp.Response{ + StatusCode: stdhttp.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + conn.jitterFn = func(cap time.Duration) time.Duration { + jitterCalls++ + return cap + } + conn.sleepFn = func(ctx context.Context, d time.Duration) error { + sleepCalls++ + return nil + } + + resp, err := conn.doRequest(context.Background(), checkConnectionPath) + if err != nil { + t.Fatalf("doRequest() error = %v, want nil", err) + } + defer resp.Body.Close() + + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } + if jitterCalls != 0 { + t.Fatalf("jitter calls = %d, want 0", jitterCalls) + } + if sleepCalls != 0 { + t.Fatalf("sleep calls = %d, want 0", sleepCalls) + } +} + +// TestDoRequestConnectTimeoutRetriesWithJitter verifies retries for connect timeout errors. +func TestDoRequestConnectTimeoutRetriesWithJitter(t *testing.T) { + attempts := 0 + jitterCaps := make([]time.Duration, 0) + sleepDurations := make([]time.Duration, 0) + + conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) { + attempts++ + if attempts <= 2 { + return nil, newDialTimeoutError() + } + return &stdhttp.Response{ + StatusCode: stdhttp.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + conn.jitterFn = func(cap time.Duration) time.Duration { + jitterCaps = append(jitterCaps, cap) + return cap + } + conn.sleepFn = func(ctx context.Context, d time.Duration) error { + sleepDurations = append(sleepDurations, d) + return nil + } + + resp, err := conn.doRequest(context.Background(), checkConnectionPath) + if err != nil { + t.Fatalf("doRequest() error = %v, want nil", err) + } + defer resp.Body.Close() + + if attempts != 3 { + t.Fatalf("attempts = %d, want 3", attempts) + } + + wantCaps := []time.Duration{5 * time.Second, 15 * time.Second} + if !reflect.DeepEqual(jitterCaps, wantCaps) { + t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps) + } + if !reflect.DeepEqual(sleepDurations, wantCaps) { + t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps) + } +} + +// TestDoRequestConnectTimeoutExhaustsRetries verifies retry count and final timeout error. +func TestDoRequestConnectTimeoutExhaustsRetries(t *testing.T) { + attempts := 0 + jitterCaps := make([]time.Duration, 0) + sleepDurations := make([]time.Duration, 0) + + conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) { + attempts++ + return nil, newDialTimeoutError() + }) + conn.jitterFn = func(cap time.Duration) time.Duration { + jitterCaps = append(jitterCaps, cap) + return cap + } + conn.sleepFn = func(ctx context.Context, d time.Duration) error { + sleepDurations = append(sleepDurations, d) + return nil + } + + _, err := conn.doRequest(context.Background(), checkConnectionPath) + if err == nil { + t.Fatal("doRequest() error = nil, want timeout") + } + if !isConnectTimeout(err) { + t.Fatalf("doRequest() error = %v, want connect timeout", err) + } + + wantCaps := append([]time.Duration(nil), defaultRetryCaps...) + if attempts != len(wantCaps)+1 { + t.Fatalf("attempts = %d, want %d", attempts, len(wantCaps)+1) + } + if !reflect.DeepEqual(jitterCaps, wantCaps) { + t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps) + } + if !reflect.DeepEqual(sleepDurations, wantCaps) { + t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps) + } +} + +// TestDoRequestResponseTimeoutNoRetry verifies response timeout does not trigger retries. +func TestDoRequestResponseTimeoutNoRetry(t *testing.T) { + attempts := 0 + sleepCalls := 0 + jitterCalls := 0 + + conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) { + attempts++ + return nil, newResponseHeaderTimeoutError() + }) + conn.jitterFn = func(cap time.Duration) time.Duration { + jitterCalls++ + return cap + } + conn.sleepFn = func(ctx context.Context, d time.Duration) error { + sleepCalls++ + return nil + } + + _, err := conn.doRequest(context.Background(), checkConnectionPath) + if err == nil { + t.Fatal("doRequest() error = nil, want timeout") + } + + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("doRequest() error = %v, want timeout error", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } + if jitterCalls != 0 { + t.Fatalf("jitter calls = %d, want 0", jitterCalls) + } + if sleepCalls != 0 { + t.Fatalf("sleep calls = %d, want 0", sleepCalls) + } +} + +// TestDoRequestContextCanceledDuringBackoff verifies cancellation interrupts retry wait. +func TestDoRequestContextCanceledDuringBackoff(t *testing.T) { + attempts := 0 + sleepCalls := 0 + + conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) { + attempts++ + return nil, newDialTimeoutError() + }) + ctx, cancel := context.WithCancel(context.Background()) + conn.jitterFn = func(cap time.Duration) time.Duration { + return cap + } + conn.sleepFn = func(ctx context.Context, d time.Duration) error { + sleepCalls++ + cancel() + return ctx.Err() + } + + _, err := conn.doRequest(ctx, checkConnectionPath) + if !errors.Is(err, context.Canceled) { + t.Fatalf("doRequest() error = %v, want context canceled", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } + if sleepCalls != 1 { + t.Fatalf("sleep calls = %d, want 1", sleepCalls) + } +} + +type roundTripperFunc func(req *stdhttp.Request) (*stdhttp.Response, error) + +// RoundTrip implements [http.RoundTripper]. +func (f roundTripperFunc) RoundTrip(req *stdhttp.Request) (*stdhttp.Response, error) { + return f(req) +} + +// timeoutError simulates a timeout error returned by transport. +type timeoutError struct { + message string +} + +func (e timeoutError) Error() string { + return e.message +} + +func (e timeoutError) Timeout() bool { + return true +} + +func (e timeoutError) Temporary() bool { + return true +} + +// newDialTimeoutError builds a connect timeout shaped like dial failure. +func newDialTimeoutError() error { + return &net.OpError{ + Op: "dial", + Net: "tcp", + Err: timeoutError{message: "i/o timeout"}, + } +} + +// newResponseHeaderTimeoutError builds timeout error for response header wait. +func newResponseHeaderTimeoutError() error { + return timeoutError{message: "net/http: timeout awaiting response headers"} +} + +// newTransportConnector creates connector with custom round tripper for request tests. +func newTransportConnector(t *testing.T, transport roundTripperFunc) *httpConnector { + t.Helper() + + backendURL, err := url.Parse("http://example.com") + if err != nil { + t.Fatalf("parse backend URL error = %v", err) + } + + return &httpConnector{ + ctx: context.Background(), + backendURL: backendURL, + httpClient: &stdhttp.Client{Transport: transport}, + retryCaps: append([]time.Duration(nil), defaultRetryCaps...), + } +} + +// newServerConnector creates connector backed by an HTTP test server and captures requested path. +func newServerConnector(t *testing.T, ctx context.Context, status int, backendPath string) (*httpConnector, <-chan string) { + t.Helper() + + pathCh := make(chan string, 1) + server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + pathCh <- r.URL.Path + w.WriteHeader(status) + })) + t.Cleanup(server.Close) + + conn, err := NewHttpConnector(ctx, server.URL+backendPath) + if err != nil { + t.Fatalf("NewHttpConnector() error = %v", err) + } + + return conn, pathCh +} + +// newVersionServerConnector creates connector with configurable response body for versions endpoint tests. +func newVersionServerConnector(t *testing.T, ctx context.Context, status int, body, backendPath string) (*httpConnector, <-chan string) { + t.Helper() + + pathCh := make(chan string, 1) + server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + pathCh <- r.URL.Path + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + conn, err := NewHttpConnector(ctx, server.URL+backendPath) + if err != nil { + t.Fatalf("NewHttpConnector() error = %v", err) + } + + return conn, pathCh +} + +// newUnreachableConnector creates connector pointing to a closed localhost TCP address. +func newUnreachableConnector(t *testing.T, ctx context.Context) *httpConnector { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen error = %v", err) + } + addr := ln.Addr().String() + if err := ln.Close(); err != nil { + t.Fatalf("close listener error = %v", err) + } + + conn, err := NewHttpConnector(ctx, "http://"+addr) + if err != nil { + t.Fatalf("NewHttpConnector() error = %v", err) + } + return conn +} diff --git a/go.work b/go.work index 60cb86a..5303702 100644 --- a/go.work +++ b/go.work @@ -6,6 +6,7 @@ use ( ./loader ./pkg/error ./pkg/model + ./pkg/storage ./pkg/util ./server ) @@ -13,5 +14,6 @@ use ( replace ( galaxy/error v0.0.0 => ./pkg/error galaxy/model v0.0.0 => ./pkg/model + galaxy/storage v0.0.0 => ./pkg/storage galaxy/util v0.0.0 => ./pkg/util ) diff --git a/loader/download.go b/loader/download.go new file mode 100644 index 0000000..019276a --- /dev/null +++ b/loader/download.go @@ -0,0 +1,17 @@ +package loader + +import "galaxy/connector" + +func (l *loader) newerVersion(version string) bool { + current := l.cli.Version() + return compareSemver(current, version) > 0 +} + +// downloadVersion fetches given version artifact, when newer to the current version, +// and stores at the App's local storage with a pre-defined name with semver suffix +func (l *loader) downloadVersion(v connector.VersionInfo) { + if !l.newerVersion(v.Version) { + return + } + l.conn.DownloadVersion(v.URL) +} diff --git a/loader/loader.go b/loader/loader.go index f39c7f6..14fbc4c 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -6,29 +6,44 @@ import ( "fmt" "galaxy/connector" mc "galaxy/model/client" + "galaxy/storage" "plugin" "time" "fyne.io/fyne/v2" ) -type ClientInit func(context.Context, connector.UIConnector, fyne.App, mc.Settings) (mc.Client, error) +type ClientInit func(context.Context, storage.UIStorage, connector.UIConnector, fyne.App) (mc.Client, error) type loader struct { - conn connector.Connector - cli mc.Client + conn connector.Connector + cli mc.Client + storagePath string } +const ( + clientLibraryFile = "client" +) + +var ( + checkConnectionTimeout = time.Second * 5 + checkVersionTimeout = time.Minute * 60 +) + func NewLoader(ctx context.Context, app fyne.App, conn connector.Connector) (*loader, error) { - app.Storage().List() - settings := mc.Settings{} - cli, err := loadClientPlugin(ctx, conn, app, settings, "./client.so", "NewClient") + storagePath, err := initStorage(app) + if err != nil { + return nil, err + } + var s storage.Storage = nil + cli, err := loadClientPlugin(ctx, s, conn, app, "./client.so", "NewClient") if err != nil { return nil, err } l := &loader{ - conn: conn, - cli: cli, + conn: conn, + cli: cli, + storagePath: storagePath, } return l, nil } @@ -44,7 +59,12 @@ func (l *loader) Run(ctx context.Context) error { } func (l *loader) backgroundLoop(ctx context.Context, final <-chan struct{}) { - t := time.NewTicker(time.Second * 5) + checkConnTimer := time.NewTimer(checkConnectionTimeout) + checkVersionTimer := time.NewTimer(checkVersionTimeout) + defer func() { + checkConnTimer.Stop() + checkVersionTimer.Stop() + }() for { select { case <-ctx.Done(): @@ -52,16 +72,25 @@ func (l *loader) backgroundLoop(ctx context.Context, final <-chan struct{}) { return case <-final: return - case <-t.C: + case <-checkConnTimer.C: isGood := l.conn.CheckConnection() l.cli.OnConnection(isGood) + checkConnTimer.Reset(checkConnectionTimeout) + case <-checkVersionTimer.C: + versions, err := l.conn.CheckVersion() + if err != nil { + // propagate error to the UI + } else if latest, ok := latestVersion(versions); ok { + l.downloadVersion(latest) + } + checkVersionTimer.Reset(checkVersionTimeout) } } } // loadClientPlugin loads a Client implementation from a shared object (.so) file at the specified path. // It calls the constructor function by name, passing the necessary dependencies, and returns the initialized Client. -func loadClientPlugin(ctx context.Context, conn connector.UIConnector, app fyne.App, s mc.Settings, path, name string) (mc.Client, error) { +func loadClientPlugin(ctx context.Context, s storage.UIStorage, conn connector.UIConnector, app fyne.App, path, name string) (mc.Client, error) { if path == "" { return nil, errors.New("no plugin path given") } @@ -81,5 +110,5 @@ func loadClientPlugin(ctx context.Context, conn connector.UIConnector, app fyne. return nil, fmt.Errorf("unexpected type %T; want %T", sym, initializerPtr) } - return (*initializerPtr)(ctx, conn, app, s) + return (*initializerPtr)(ctx, s, conn, app) } diff --git a/loader/storage.go b/loader/storage.go new file mode 100644 index 0000000..049a7f3 --- /dev/null +++ b/loader/storage.go @@ -0,0 +1,8 @@ +package loader + +func (l *loader) clientPluginVersionExists(version string) (bool, error) { + file := resolvePluginFile(version) + _ = file + // check file existence + return false, nil +} diff --git a/loader/util.go b/loader/util.go new file mode 100644 index 0000000..63823b1 --- /dev/null +++ b/loader/util.go @@ -0,0 +1,33 @@ +package loader + +import ( + "galaxy/connector" + "runtime" + "slices" + + "fyne.io/fyne/v2" +) + +func resolvePluginFile(version string) string { + return clientLibraryFile + "-" + version +} + +func compareSemver(a, b string) int { + return 0 +} + +func latestVersion(versions []connector.VersionInfo) (connector.VersionInfo, bool) { + os := runtime.GOOS + versions = slices.DeleteFunc(versions, func(v connector.VersionInfo) bool { return v.OS != os }) + if len(versions) == 0 { + return connector.VersionInfo{}, false + } + slices.SortFunc(versions, func(a, b connector.VersionInfo) int { return compareSemver(b.Version, a.Version) }) + return versions[0], true +} + +// initStorage returns filesystem storage root or error if initialization fails. +func initStorage(app fyne.App) (string, error) { + _ = app.Storage() // use fyne.App's Storage + return "", nil +} diff --git a/pkg/model/client/client.go b/pkg/model/client/client.go index c87118c..9ffe880 100644 --- a/pkg/model/client/client.go +++ b/pkg/model/client/client.go @@ -6,6 +6,9 @@ import ( ) type Client interface { + // Version returns semantic version of the Client + Version() string + // Run initializes necessary UI layout an settings, and activates client's main window. // This is a blocking operation until client's main window is closed. Run() error @@ -24,7 +27,7 @@ func (i GameID) String() string { } type State struct { - // TODO: store user login key + // TODO: store user's login key GameState []GameState `json:"gameState"` ActiveGameID GameID `json:"activeGameId"` } @@ -40,8 +43,3 @@ type GameData struct { Report report.Report `json:"report"` Order *order.Order `json:"order,omitempty"` } - -type Settings struct { - // TODO: use fyne.Storage for initializing and storing data - StoragePath string -} diff --git a/pkg/storage/go.mod b/pkg/storage/go.mod new file mode 100644 index 0000000..9965869 --- /dev/null +++ b/pkg/storage/go.mod @@ -0,0 +1,3 @@ +module galaxy/storage + +go 1.26.0 diff --git a/client/storage.go b/pkg/storage/storage.go similarity index 51% rename from client/storage.go rename to pkg/storage/storage.go index 6e43711..d5227c5 100644 --- a/client/storage.go +++ b/pkg/storage/storage.go @@ -1,42 +1,74 @@ -package client +package storage import ( - model "galaxy/model/client" + "fmt" + + "galaxy/model/client" "galaxy/model/order" "galaxy/model/report" + "galaxy/util" ) -// Storage manages Client's data local storing and retrieval. -// It performs all I/O operations asynchronously to avoid UI main thread blocking. type Storage interface { - // StateExists check if previously saved [model.State] exists on filesystem and returns result. - // I/O error may occur, it that case returned result will be false and error is non-nil. - StateExists() (bool, error) + UIStorage + Exists(string) (bool, error) + ReadFile(string) ([]byte, error) + WriteFile(string, []byte) error +} + +// UIStorage manages Client's data local storing and retrieval. +// It performs all I/O operations asynchronously to avoid UI main thread blocking. +type UIStorage interface { + // StateExists check asynchronously for previously saved [model.State] exists on the filesystem. + // Passed callback func will will accept false and non-nil error in case of I/O or decoding errors occuried, + // otherwise bool parameter will indicate existence of previously stores state + StateExists(func(bool, error)) // LoadState loads Client's [model.State] from filesystem data asynchronously. // Passed callback func will accept non-nil error in case of I/O or decoding errors occuried, // otherwise callback func accepts loaded [model.State]. - LoadState(func(model.State, error)) + LoadState(func(client.State, error)) // SaveState stores Client's state at the filesystem asynchronously. // I/O or encoding error may occur, it that case callback func will be called with non-nil error. - SaveState(model.State, func(error)) + SaveState(client.State, func(error)) // LoadReport loads a [report.Report] for a given [model.GameID] and turn number from filesystem asynchronously. // Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried, // otherwise callback func accepts loaded [report.Report]. - LoadReport(model.GameID, uint, func(report.Report, error)) + LoadReport(client.GameID, uint, func(report.Report, error)) // PutReport stores given [report.Report] for a given [model.GameID] and turn number at the filesystem asynchronously. // I/O or encoding error may occur, it that case callback func will be called with non-nil error. - PutReport(model.GameID, uint, report.Report, func(error)) + PutReport(client.GameID, uint, report.Report, func(error)) // LoadOrder loads a [order.Order] for a given [model.GameID] and turn number from filesystem asynchronously. // Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried, // otherwise callback func accepts loaded [order.Order]. - LoadOrder(model.GameID, uint, func(order.Order, error)) + LoadOrder(client.GameID, uint, func(order.Order, error)) // PutOrder stores given [order.Order] for a given [model.GameID] and turn number at the filesystem asynchronously. // I/O or encoding error may occur, it that case callback func will be called with non-nil error. - PutOrder(model.GameID, uint, order.Order, func(error)) + PutOrder(client.GameID, uint, order.Order, func(error)) +} + +type storage struct { + path string +} + +func NewStorage(path string) (*storage, error) { + if ok, err := util.PathExists(path, true); err != nil { + return nil, fmt.Errorf("new storage: check path %q exists: %w", path, err) + } else if !ok { + return nil, fmt.Errorf("new storage: path %q does not exists", path) + } + if ok, err := util.Writable(path); err != nil { + return nil, fmt.Errorf("new storage: check path %q writable: %w", path, err) + } else if !ok { + return nil, fmt.Errorf("new storage: path %q is not writable", path) + } + s := &storage{ + path: path, + } + return s, nil }