diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aa09b99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,922 @@ +# AGENTS.md + +> This file defines how Codex and other coding agents should operate in this repository. +> It is intentionally strict, verbose, and easy to trim down. +> If any instruction here conflicts with an explicit user request in the chat, the user request wins. +> If any instruction here conflicts with a deeper repository-local instruction in a subdirectory `AGENTS.md`, the deeper file wins for files inside that subtree. + +--- + +## 1. Purpose + +This repository is developed primarily in Go. + +The agent must optimize for: + +- correctness before speed, +- readability before cleverness, +- explicit behavior before hidden magic, +- small, reviewable changes, +- reproducible builds and tests, +- clear written reasoning for non-obvious decisions. + +The agent should behave like a careful senior Go engineer working in an existing codebase with real maintenance costs. + +--- + +## 2. Core operating rules + +### 2.1 Main priorities + +When making changes, follow this order of priority: + +1. Preserve correctness. +2. Preserve or improve clarity. +3. Preserve compatibility unless the task explicitly allows breaking changes. +4. Keep the diff minimal. +5. Keep the implementation idiomatic for modern Go. +6. Keep performance reasonable, but do not micro-optimize without evidence. + +### 2.2 What the agent must not do + +The agent must not: + +- rewrite large areas of code without clear need, +- introduce speculative abstractions, +- rename many symbols “for cleanliness” unless required, +- mix unrelated refactors with the requested task, +- silently change public behavior, +- silently change wire formats, database semantics, or API contracts, +- add dependencies unless necessary, +- invent requirements not stated by the user or codebase, +- leave TODOs instead of implementing the requested behavior, unless explicitly asked, +- claim code was tested if it was not actually tested, +- claim a root cause without evidence, +- fix extra bugs opportunistically unless they are tightly adjacent and clearly explained. + +### 2.3 Expected default behavior + +Unless the user asks otherwise, the agent should: + +- inspect the relevant code path before editing, +- understand current behavior before proposing changes, +- prefer the smallest correct patch, +- update or add tests for every functional change, +- keep public interfaces stable, +- preserve log/event/metric semantics unless a change is needed, +- explain assumptions, +- mention trade-offs when they matter. + +### 2.3 Expected documentation behavior + +Unless the user asks otherwise, the agent should: + +- supply added packages, types, funcs, consts and vars with a commentaries explaining its purpose and behavior, +- supply public functions with a more comprehensive commentary and supplemental funcs with more concise comments, +- provide comments respecting the Go Doc Comments syntax, +- provide comments only in English language, +- translate any non-English commetraries met in existing code, +- correct obvious grammatical and style errors in existing commentaries. + +--- + +## 3. Repository familiarization workflow + +Before making non-trivial changes, the agent should quickly map the local conventions. + +### 3.1 Files to inspect first + +Prefer inspecting, when present: + +- `go.mod` +- `go.sum` +- `README.md` +- `Makefile` +- `Taskfile.yml` / `Taskfile.yaml` +- `.golangci.yml` / `.golangci.yaml` +- `.editorconfig` +- `buf.yaml` +- `buf.gen.yaml` +- `Dockerfile*` +- `compose*.yml` +- CI files under `.github/workflows/`, `.gitlab-ci.yml`, etc. +- migration directories +- existing `AGENTS.md` files in subdirectories +- representative files in the affected package +- representative tests in the affected package + +### 3.2 Conventions to infer + +The agent should infer and follow: + +- package layout style, +- naming conventions, +- error handling conventions, +- logging conventions, +- context usage conventions, +- test style, +- benchmark style, +- dependency injection pattern, +- API versioning conventions, +- DTO/model separation style, +- storage and transaction conventions, +- lint and formatting requirements. + +If conventions are inconsistent, prefer the one used in the closest affected code. + +--- + +## 4. Scope control + +### 4.1 Stay within scope + +The agent must solve the user’s request directly and avoid unrelated cleanup. + +Allowed adjacent changes: + +- fixing a test broken by the main change, +- adding a missing helper required by the main change, +- small refactors necessary to make the change safe, +- updating documentation directly affected by the change. + +Not allowed without explicit justification: + +- formatting unrelated files, +- reorganizing package structure, +- replacing libraries, +- changing error taxonomy globally, +- changing logging framework, +- broad “modernization” passes, +- large dependency bumps. + +### 4.2 When the requested change is underspecified + +If details are missing, the agent should: + +1. infer the most conservative behavior from existing code, +2. avoid breaking current behavior, +3. document the chosen assumption in the final response. + +Do not block on avoidable clarification if a reasonable implementation path exists. + +--- + +## 5. Go version and language guidance + +### 5.1 Target version + +Target the Go version declared in `go.mod`. + +If the repository does not make this obvious, assume modern stable Go and avoid experimental features unless already present. + +### 5.2 Idiomatic Go requirements + +The agent should prefer: + +- simple package APIs, +- concrete types when interfaces are not needed, +- small interfaces defined by consumers, +- explicit error handling, +- early returns, +- table-driven tests where appropriate, +- `context.Context` as the first parameter for request-scoped operations, +- `errors.AsType` first, `errors.Is` / `errors.As` last, +- standard library first. + +The agent should avoid: + +- unnecessary generics, +- unnecessary reflection, +- hidden global state, +- panics for expected errors, +- overuse of empty interfaces or `any`, +- deeply nested control flow, +- concurrency without clear benefit, +- channel-based designs where a simple call flow is better. + +### 5.3 Style details + +Prefer: + +- short, focused functions, +- package-level cohesion, +- exported identifiers only when needed, +- comments for exported symbols, +- comments explaining “why”, not narrating trivial code, +- stable and unsurprising zero values where appropriate. + +Avoid: + +- single-letter names except tight local scopes, +- clever helper layers that obscure flow, +- Boolean parameter lists that are hard to read, +- hidden side effects, +- magic constants without names. + +--- + +## 6. Editing rules for Go code + +### 6.1 Function and type changes + +When modifying a function or method, the agent should: + +- preserve signature compatibility unless the task explicitly requires change, +- preserve context and cancellation behavior, +- preserve caller expectations, +- update all call sites, +- update tests that express expected behavior. + +When adding new exported API: + +- keep it minimal, +- document it, +- justify why export is needed, +- prefer package-private helpers if external use is not required. + +### 6.2 Error handling + +The agent must: + +- return errors, not swallow them, +- wrap errors when adding useful context, +- avoid duplicative wrapping, +- preserve sentinel errors or typed errors already used in the codebase, +- use `%w` correctly, +- not log and return the same error at multiple layers unless the codebase explicitly does that. + +If the codebase distinguishes user-facing, domain, transport, and storage errors, preserve that separation. + +### 6.3 Context usage + +The agent must: + +- pass context through relevant call chains, +- not store contexts in structs, +- not use `context.Background()` in request flows unless clearly appropriate, +- respect cancellation and deadlines when existing code expects that, +- avoid creating child contexts unnecessarily. + +### 6.4 Concurrency + +Only introduce concurrency if it clearly improves the requested behavior and does not degrade maintainability. + +If adding concurrency, the agent must consider: + +- cancellation, +- data races, +- goroutine lifetime, +- bounded parallelism, +- error propagation, +- testability, +- deterministic shutdown. + +Avoid spawning goroutines without a clear ownership model. + +### 6.5 Logging and observability + +Follow existing repository conventions. + +The agent should: + +- keep logs structured if the codebase uses structured logging, +- avoid logging sensitive values, +- avoid noisy logs in hot paths, +- preserve stable field names when logs are used operationally, +- update metrics/traces only when directly relevant. + +Do not add logs as a substitute for error handling. + +--- + +## 7. Testing requirements + +### 7.1 General rule + +Every behavior change should be covered by tests unless the repository clearly does not test that layer. + +A functional code change without tests requires a clear reason in the final response. + +### 7.2 Preferred testing style + +Prefer: + +- table-driven tests, +- focused tests per behavior, +- `testify` for assertions and requirements if the repository already uses it or if new tests are added and no conflicting convention exists, +- deterministic tests, +- subtests with meaningful names, +- minimal fixtures, +- clear failure messages. + +### 7.3 What tests should verify + +Tests should verify: + +- externally observable behavior, +- error cases, +- edge cases, +- nil / empty / zero-value behavior where relevant, +- backward compatibility where relevant, +- concurrency behavior if changed, +- serialization/deserialization boundaries if relevant. + +### 7.4 What tests should avoid + +Avoid tests that are: + +- tightly coupled to private implementation details without need, +- flaky, +- timing-sensitive without control, +- dependent on wall clock when fake time can be used, +- dependent on random behavior without fixed seed, +- dependent on external services unless the repository already uses integration test infrastructure. + +### 7.5 Test commands + +Prefer repository-native commands first. + +Common examples: + +```bash +go test ./... +go test ./... -race +go test ./... -cover +``` + +If a narrower command is sufficient, use the smallest command that provides confidence. + +--- + +## 8. Dependency policy + +### 8.1 Default rule + +Prefer the Go standard library and existing repository dependencies. + +Do not add a new dependency unless it provides clear value that is difficult to replicate safely with existing tools. + +### 8.2 If adding a dependency is necessary + +The agent must: + +- choose a well-maintained package, +- minimize dependency surface, +- avoid dependency overlap, +- explain why the new dependency is needed, +- update tests and usage accordingly. + +Avoid adding heavy frameworks into lightweight packages. + +--- + +## 9. Performance policy + +### 9.1 Default stance + +Do not optimize speculatively. + +Prefer clear code first, then optimize only if: + +- the task is explicitly performance-related, +- the affected path is obviously hot, +- profiling evidence is available, +- the repository already treats this path as performance-sensitive. + +### 9.2 When performance matters + +The agent should consider: + +- allocations, +- copies, +- unnecessary conversions, +- lock contention, +- query count, +- I/O amplification, +- algorithmic complexity. + +If making a performance optimization, document the trade-off and preserve readability as much as possible. + +--- + +## 10. API, wire format, and compatibility rules + +### 10.1 Backward compatibility + +Assume compatibility matters unless the task says otherwise. + +The agent must not casually change: + +- JSON field names, +- protobuf field numbers, +- SQL schema semantics, +- HTTP status codes, +- error codes, +- event payloads, +- config keys, +- environment variable names, +- CLI flags, +- file formats. + +### 10.2 If a breaking change is necessary + +The agent should: + +- keep the change localized, +- update affected tests, +- update docs and examples, +- explicitly call out the break in the final response. + +--- + +## 11. Database and persistence guidance + +If the repository interacts with a database, the agent should preserve data safety first. + +### 11.1 Queries and mutations + +The agent must: + +- understand existing transaction boundaries, +- avoid introducing N+1 query patterns, +- preserve idempotency where relevant, +- preserve isolation expectations, +- handle `sql.ErrNoRows` or equivalent consistently. + +### 11.2 Migrations + +If adding or changing migrations: + +- make them forward-safe, +- avoid destructive changes unless explicitly requested, +- preserve rollback strategy if the repository uses one, +- avoid combining schema and risky data backfills blindly, +- update related models, queries, and tests. + +### 11.3 Data correctness + +The agent must be conservative with: + +- nullability, +- defaults, +- unique constraints, +- indexes, +- timestamp semantics, +- timezone handling, +- soft-delete semantics. + +--- + +## 12. HTTP / RPC / messaging guidance + +### 12.1 Handlers and transport code + +When editing transport-layer code, preserve: + +- status code semantics, +- request validation behavior, +- response shape, +- middleware expectations, +- authn/authz boundaries, +- timeout and cancellation behavior. + +### 12.2 Serialization + +The agent must: + +- keep wire compatibility, +- avoid changing omitempty behavior casually, +- handle unknown fields according to existing patterns, +- preserve canonical formats if already established. + +### 12.3 Messaging / events + +For queues, streams, or pub/sub: + +- preserve event contract stability, +- preserve delivery assumptions, +- preserve idempotency handling, +- avoid changing partitioning or keys without reason. + +--- + +## 13. CLI and developer-experience guidance + +If the repository includes CLI commands or tooling, the agent should preserve UX consistency. + +Do not casually change: + +- command names, +- flag names, +- exit code semantics, +- help text style, +- config resolution order. + +When adding a flag or command: + +- keep naming consistent, +- document defaults, +- handle invalid input cleanly, +- add tests where feasible. + +--- + +## 14. Security and secrets handling + +The agent must treat security as a default concern. + +### 14.1 Must avoid + +Never: + +- commit secrets, +- log tokens, passwords, cookies, private keys, or connection strings, +- weaken auth checks casually, +- disable TLS verification without explicit reason, +- interpolate untrusted input into shell/SQL/HTML/paths unsafely, +- introduce path traversal risks, +- trust user input without validation. + +### 14.2 Must consider + +Consider: + +- input validation, +- output encoding, +- least privilege, +- SSRF risk, +- command injection, +- SQL injection, +- deserialization safety, +- sensitive data redaction, +- constant-time comparisons where relevant, +- secure defaults. + +### 14.3 Authentication and authorization + +Preserve existing auth boundaries. + +If a task touches auth logic, the agent must be especially conservative and update tests for both allowed and denied cases. + +--- + +## 15. Configuration guidance + +The agent should preserve current configuration patterns. + +Do not casually change: + +- env var names, +- precedence rules, +- default values, +- required/optional behavior, +- config file schema. + +When adding configuration: + +- prefer clear names, +- define sane defaults, +- validate values, +- document behavior, +- update examples if present. + +--- + +## 16. Documentation update policy + +Update documentation when the user-visible or developer-visible behavior changes. + +Potential files to update: + +- `README.md` +- package docs +- API docs +- CLI help +- examples +- migration notes +- deployment docs + +Do not rewrite large docs unless necessary. + +--- + +## 17. Commenting policy + +### 17.1 Code comments + +Use comments sparingly but effectively. + +Add comments when: + +- exporting a symbol, +- explaining why a non-obvious approach is used, +- documenting invariants, +- clarifying ownership/lifecycle/concurrency rules. + +Do not add comments that merely restate obvious code. + +### 17.2 Commit-style explanations in response + +In the final response, the agent should explain: + +- what changed, +- why it changed, +- what assumptions were made, +- what was tested, +- any notable trade-offs. + +--- + +## 18. How to present work in chat + +When the agent responds with implementation details, it should be concise but complete. + +### 18.1 Final response should usually include + +- a short summary of the change, +- the key files modified, +- important reasoning or assumptions, +- test commands executed, +- any remaining risks or follow-ups if relevant. + +### 18.2 The agent must not + +- dump huge irrelevant code blocks if files were already edited, +- exaggerate confidence, +- claim tests passed if they were not run, +- omit important caveats. + +--- + +## 19. Patch construction guidance + +### 19.1 Preferred change shape + +Prefer a sequence like: + +1. smallest safe production change, +2. tests that capture behavior, +3. minimal docs update if needed. + +### 19.2 Refactoring threshold + +Refactor only when necessary to support the requested change. + +Good reasons: + +- current structure prevents a safe fix, +- testability is too poor to validate behavior, +- the bug stems from tangled responsibilities, +- a small extraction materially reduces risk. + +Bad reasons: + +- personal style preference, +- “cleaner architecture” ambitions, +- speculative future use cases. + +--- + +## 20. Large or risky changes + +For changes with broad blast radius, the agent should be more conservative. + +Examples: + +- auth, +- billing, +- persistence, +- migrations, +- concurrency, +- public APIs, +- shared libraries, +- critical hot paths. + +In such cases, the agent should: + +- minimize the changed surface area, +- add focused regression coverage, +- call out risk explicitly, +- avoid mixing in refactors. + +--- + +## 21. When the agent should stop and report limits + +The agent should explicitly say so if: + +- the repository is missing files needed to implement safely, +- tests cannot be run in the environment, +- behavior depends on unknown external systems, +- a breaking design choice is required but unspecified, +- the requested change would be unsafe without broader context. + +In those cases, still provide the best grounded partial result possible. + +--- + +## 22. Preferred workflow for bug fixes + +When fixing a bug, the agent should generally follow this order: + +1. identify the failing behavior, +2. inspect the smallest relevant code path, +3. preserve existing public contract, +4. implement the minimal fix, +5. add or update regression tests, +6. verify no adjacent behavior was unintentionally changed. + +If the root cause is uncertain, state that clearly and avoid overstating certainty. + +--- + +## 23. Preferred workflow for new features + +When implementing a feature, the agent should generally: + +1. inspect similar existing features, +2. match established architecture, +3. add the smallest useful surface area, +4. keep compatibility where possible, +5. add tests for success and failure paths, +6. update minimal necessary docs. + +--- + +## 24. Preferred workflow for refactoring + +For refactors, the agent must preserve behavior. + +The agent should: + +- keep refactors mechanical and reviewable, +- avoid semantic drift, +- maintain test coverage, +- separate pure refactor from behavior change whenever practical. + +If both are unavoidable in one patch, explain that clearly. + +--- + +## 25. Monorepo / multi-package guidance + +If this repository contains multiple services or packages, the agent should: + +- change only the relevant module/package unless broader edits are required, +- respect local conventions of the touched area, +- check for local `AGENTS.md` files, +- avoid introducing cross-package coupling casually. + +--- + +## 26. File and package organization guidance + +When adding new files: + +- place them near the owning package, +- use existing naming conventions, +- avoid generic names like `common.go`, `helpers.go`, `utils.go` unless that pattern already exists, +- keep package boundaries clear. + +When adding helpers, prefer names tied to the domain or behavior. + +--- + +## 27. Example Go-specific preferences + +These are defaults unless the repository already uses a different style. + +### 27.1 Error examples + +Preferred: + +```go +func ParsePort(s string) (int, error) { + port, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("parse port %q: %w", s, err) + } + if port < 1 || port > 65535 { + return 0, fmt.Errorf("parse port %q: out of range", s) + } + return port, nil +} +``` + +Avoid: + +```go +func ParsePort(s string) (int, error) { + i, _ := strconv.Atoi(s) + return i, nil +} +``` + +### 27.2 Context examples + +Preferred: + +```go +func (s *Service) Fetch(ctx context.Context, id string) (*Item, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return s.repo.Fetch(ctx, id) +} +``` + +Avoid: + +```go +func (s *Service) Fetch(id string) (*Item, error) { + return s.repo.Fetch(context.Background(), id) +} +``` + +### 27.3 Table-driven tests + +Preferred: + +```go +func TestParsePort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want int + wantErr bool + }{ + {name: "valid", input: "8080", want: 8080}, + {name: "non-numeric", input: "abc", wantErr: true}, + {name: "out of range", input: "70000", wantErr: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ParsePort(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} +``` + +--- + +## 28. Suggested command checklist + +Before concluding, the agent should use the smallest relevant subset of these commands when available and appropriate: + +```bash +go test ./... +go test ./... -race +go test ./... -cover +go vet ./... +golangci-lint run +staticcheck ./... +go test ./path/to/pkg -run TestName -v +``` + +Use repository-native wrappers first if they exist, for example: + +```bash +make test +make lint +task test +task lint +``` + +--- + +## 29. Suggested final response template + +Use this shape unless the user asked for something else: + +1. What changed. +2. Why it changed. +3. Files touched. +4. Tests run. +5. Assumptions or caveats. + +Be direct. Do not pad the response. + +--- + +## 30. Bottom-line instruction + +When in doubt, the agent should choose the safest change that: + +- solves the actual user request, +- matches existing repository conventions, +- preserves compatibility, +- adds or updates tests, +- keeps the diff small and reviewable. diff --git a/client/client.go b/client/client.go index 8f3e403..84a98e4 100644 --- a/client/client.go +++ b/client/client.go @@ -1,24 +1,203 @@ package client import ( + "image" + "sync" + + "galaxy/client/world" + "galaxy/connector" + mc "galaxy/model/client" + "galaxy/model/report" + "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" ) -type ui struct { +type client struct { + conn connector.UIConnector app fyne.App window fyne.Window + + world *world.World + drawer *world.GGDrawer + raster *canvas.Raster + co *RasterCoalescer[world.RenderParams] + pan *PanController + + // Protected camera/options state (UI-facing). This is the "base" params snapshot. + // Viewport/margins are NOT stored here; they come from raster draw callback. + mu sync.RWMutex + wp *world.RenderParams + canvasScale float32 + + // Latest raster geometry metadata for correct event->pixel conversion: + // - logical size: raster.Size() (Fyne units) + // - pixel size: last (wPx,hPx) passed to draw callback + metaMu sync.RWMutex + lastRasterLogicW float32 + lastRasterLogicH float32 + lastRasterPxW int + lastRasterPxH int + lastCanvasScale float32 // optional, useful for debugging + + // Snapshot of params actually used for the last render (includes viewport/margins). + // Used for HitTest and to keep UI interactions consistent with what the user sees. + lastRenderedMu sync.RWMutex + lastRenderedParams world.RenderParams + + // Indexing / backing-canvas caches (owned by client because it depends on UI geometry) + lastIndexedViewportW int + lastIndexedViewportH int + lastIndexedZoomFp int + + lastCanvasW int + lastCanvasH int + + viewportImg *image.RGBA + viewportW int + viewportH int + + hits []world.Hit } -func NewUI() *ui { - c := &ui{} - c.app = app.New() - c.window = c.app.NewWindow("Galaxy Plus") - client := NewClient() - client.BuildUI(c.window) - return c +func NewClient(conn connector.UIConnector, app fyne.App, settings mc.Settings) (mc.Client, error) { + e := &client{ + conn: conn, + app: app, + window: app.NewWindow("Galaxy Plus"), + world: nil, + wp: &world.RenderParams{ + CameraZoom: 1.0, + Options: &world.RenderOptions{DisableWrapScroll: false}, + }, + lastCanvasScale: 1.0, + hits: make([]world.Hit, 5), + } + + e.drawer = &world.GGDrawer{DC: nil} + + e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image { + return e.draw(wPx, hPx) + }) + + e.pan = NewPanController(e) + + e.co = NewRasterCoalescer( + FyneExecutor{}, + e.raster, + func(wPx, hPx int, p world.RenderParams) image.Image { + return e.renderRasterImage(wPx, hPx, p) + }, + ) + + return e, nil } -func (c *ui) Run() { - c.window.ShowAndRun() +func (e *client) loadReport(t uint) { + e.conn.FetchReport("GAME_ID", t, func(r report.Report, err error) { + if err != nil { + e.handlerError(err) + } else { + e.setReport(r) + } + }) } + +func (e *client) setReport(r report.Report) { + w := world.NewWorld(int(r.Width), int(r.Height)) + for i := range r.LocalPlanet { + p := r.LocalPlanet[i] + w.AddCircle(p.X.F(), p.Y.F(), p.Size.F()) + } + for i := range r.UnidentifiedPlanet { + p := r.UnidentifiedPlanet[i] + w.AddPoint(p.X.F(), p.Y.F()) + } + e.loadWorld(w) +} + +func (e *client) handlerError(err error) { + +} + +func (e *client) BuildUI(w fyne.Window) { + mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) + mapCanvas.SetMinSize(fyne.NewSize(292, 292)) + + toolbar := widget.NewToolbar( + widget.NewToolbarAction( + theme.FolderIcon(), + func() { e.loadWorld(mockWorld()) }), + widget.NewToolbarSeparator(), + widget.NewToolbarAction( + theme.NavigateBackIcon(), + func() {}), + widget.NewToolbarAction( + theme.NavigateNextIcon(), + func() {}), + ) + + tabs := container.NewAppTabs( + container.NewTabItemWithIcon( + "Map", + theme.GridIcon(), + mapCanvas), + container.NewTabItemWithIcon( + "Calculator", + theme.ComputerIcon(), + container.NewStack(widget.NewButton("Calc", func() {})), + ), + ) + + content := container.NewBorder( + toolbar, // top + nil, // bottom + nil, // left + nil, // right + tabs, // center + ) + + w.CenterOnScreen() + w.SetContent(content) +} + +func (e *client) loadWorld(w *world.World) { + w.SetCircleRadiusScaleFp(world.SCALE / 4) + e.world = w + // TODO: store camera position in user settings + e.wp.CameraXWorldFp = w.W / 2 + e.wp.CameraYWorldFp = w.H / 2 + e.world.SetTheme(world.ThemeDark) + + // if e.world == nil { + // w.SetCircleRadiusScaleFp(world.SCALE / 4) + // e.world = w + // e.wp.CameraXWorldFp = w.W / 2 + // e.wp.CameraYWorldFp = w.H / 2 + // e.world.SetTheme(world.ThemeDark) + // } else { + // if e.world.Theme().ID() == "theme.light.v1" { + // e.world.SetTheme(world.ThemeDark) + // } else { + // e.world.SetTheme(world.ThemeLight) + // } + // } + + e.RequestRefresh() +} + +func (e *client) Run() error { + e.BuildUI(e.window) + e.window.ShowAndRun() + e.RequestRefresh() + return nil +} + +func (e *client) Shutdown() { + e.window.Close() +} + +func (e *client) OnConnection(bool) {} diff --git a/client/cmd/ui/main.go b/client/cmd/ui/main.go index 0e47493..b5cb12f 100644 --- a/client/cmd/ui/main.go +++ b/client/cmd/ui/main.go @@ -1,8 +1,35 @@ package main -import "galaxy/client" +import ( + "errors" + "fmt" + "galaxy/client" + mc "galaxy/model/client" + "os" + + "fyne.io/fyne/v2/app" +) func main() { - c := client.NewUI() - c.Run() + var err error + defer func() { + if err == nil { + if r := recover(); r != nil { + err = errors.Join(err, fmt.Errorf("app panics: %v", r)) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }() + app := app.New() + settings := mc.Settings{ + StoragePath: ".", + } + c, err := client.NewClient(nil, app, settings) + if err != nil { + return + } + err = c.Run() } diff --git a/client/connector.go b/client/connector.go deleted file mode 100644 index 4874bad..0000000 --- a/client/connector.go +++ /dev/null @@ -1,9 +0,0 @@ -package client - -import ( - "galaxy/model/report" -) - -type Connector interface { - Turn(uint, func(report.Report, error)) error -} diff --git a/client/connector/http/http.go b/client/connector/http/http.go deleted file mode 100644 index e7d682a..0000000 --- a/client/connector/http/http.go +++ /dev/null @@ -1,9 +0,0 @@ -package http - -type httpConnector struct { -} - -func NewHttpConnector() *httpConnector { - h := &httpConnector{} - return h -} diff --git a/client/editor.go b/client/editor.go deleted file mode 100644 index a218441..0000000 --- a/client/editor.go +++ /dev/null @@ -1,289 +0,0 @@ -package client - -import ( - "fmt" - "image" - "math" - "sync" - - "galaxy/client/world" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" -) - -type client struct { - world *world.World - drawer *world.GGDrawer - raster *canvas.Raster - co *RasterCoalescer[world.RenderParams] - pan *PanController - - // Protected camera/options state (UI-facing). This is the "base" params snapshot. - // Viewport/margins are NOT stored here; they come from raster draw callback. - mu sync.RWMutex - wp *world.RenderParams - canvasScale float32 - - // Latest raster geometry metadata for correct event->pixel conversion: - // - logical size: raster.Size() (Fyne units) - // - pixel size: last (wPx,hPx) passed to draw callback - metaMu sync.RWMutex - lastRasterLogicW float32 - lastRasterLogicH float32 - lastRasterPxW int - lastRasterPxH int - lastCanvasScale float32 // optional, useful for debugging - - // Snapshot of params actually used for the last render (includes viewport/margins). - // Used for HitTest and to keep UI interactions consistent with what the user sees. - lastRenderedMu sync.RWMutex - lastRenderedParams world.RenderParams - - // Indexing / backing-canvas caches (owned by client because it depends on UI geometry) - lastIndexedViewportW int - lastIndexedViewportH int - lastIndexedZoomFp int - - lastCanvasW int - lastCanvasH int - - viewportImg *image.RGBA - viewportW int - viewportH int - - hits []world.Hit -} - -func NewClient() *client { - e := &client{ - world: nil, - wp: &world.RenderParams{ - CameraZoom: 1.0, - Options: &world.RenderOptions{DisableWrapScroll: false}, - }, - lastCanvasScale: 1.0, - hits: make([]world.Hit, 5), - } - - e.drawer = &world.GGDrawer{DC: nil} - - e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image { - return e.draw(wPx, hPx) - }) - - e.pan = NewPanController(e) - - e.co = NewRasterCoalescer( - FyneExecutor{}, - e.raster, - func(wPx, hPx int, p world.RenderParams) image.Image { - return e.renderRasterImage(wPx, hPx, p) - }, - ) - - e.RequestRefresh() - return e -} - -func (e *client) CanvasScale() float32 { - e.metaMu.RLock() - defer e.metaMu.RUnlock() - if e.lastCanvasScale <= 0 { - return 1 - } - return e.lastCanvasScale -} - -func (e *client) ForceFullRedraw() { - if e.world == nil { - return - } - e.world.ForceFullRedrawNext() -} - -func (e *client) onRasterWidgetLayout(fyne.Size) { - e.updateSizes() -} - -// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw. -// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback). -func (e *client) updateSizes() { - canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster) - if canvasObj == nil { - return - } - - sz := e.raster.Size() // logical (Fyne units) - scale := canvasObj.Scale() - - e.metaMu.Lock() - e.lastRasterLogicW = sz.Width - e.lastRasterLogicH = sz.Height - e.lastCanvasScale = scale - e.metaMu.Unlock() - - e.RequestRefresh() -} - -func (e *client) onDragged(ev *fyne.DragEvent) { - e.pan.Dragged(ev) -} - -func (e *client) onDradEnd() { - e.pan.DragEnd() -} - -func (e *client) onTapped(ev *fyne.PointEvent) { - if e.world == nil || ev == nil { - return - } - - xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y) - if !ok { - return - } - - params := e.getLastRenderedParams() - hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx) - if err != nil { - // In UI you probably don't want panic; keep your existing handling. - panic(err) - } - - m := func(v int) float64 { return float64(v) / float64(world.SCALE) } - - for _, hit := range hits { - var coord string - if hit.Kind == world.KindLine { - coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2)) - } else { - coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y)) - } - fmt.Println("hit:", hit.ID, "Coord:", coord) - } -} - -func (e *client) onScrolled(s *fyne.ScrollEvent) { - if e.world == nil || s == nil { - return - } - - // Use last rendered viewport sizes (pixel) for zoom logic. - e.metaMu.RLock() - vw := e.lastRasterPxW - vh := e.lastRasterPxH - e.metaMu.RUnlock() - if vw <= 0 || vh <= 0 { - return - } - - cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y) - if !ok { - return - } - - e.mu.Lock() - oldZoom := e.wp.CameraZoom - - // Exponential zoom factor; tune later. - const base = 1.005 - delta := float64(s.Scrolled.DY) - newZoom := oldZoom * math.Pow(base, delta) - - newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh) - if newZoom == oldZoom { - e.mu.Unlock() - return - } - - oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom) - if err != nil { - e.mu.Unlock() - return - } - newZoomFp, err := world.CameraZoomToWorldFixed(newZoom) - if err != nil { - e.mu.Unlock() - return - } - - // Pivot zoom for no-wrap behavior. - newCamX, newCamY := world.PivotZoomCameraNoWrap( - e.wp.CameraXWorldFp, e.wp.CameraYWorldFp, - vw, vh, - cxPx, cyPx, - oldZoomFp, newZoomFp, - ) - - e.wp.CameraZoom = newZoom - e.wp.CameraXWorldFp = newCamX - e.wp.CameraYWorldFp = newCamY - e.mu.Unlock() - - // Any zoom change should rebuild index and force full redraw. - e.world.ForceFullRedrawNext() - e.RequestRefresh() -} - -func (e *client) BuildUI(w fyne.Window) { - mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) - mapCanvas.SetMinSize(fyne.NewSize(292, 292)) - - toolbar := widget.NewToolbar( - widget.NewToolbarAction( - theme.FolderIcon(), - func() { e.loadWorld(mockWorld()) }), - widget.NewToolbarSeparator(), - widget.NewToolbarAction( - theme.NavigateBackIcon(), - func() {}), - widget.NewToolbarAction( - theme.NavigateNextIcon(), - func() {}), - ) - - tabs := container.NewAppTabs( - container.NewTabItemWithIcon( - "Map", - theme.GridIcon(), - mapCanvas), - container.NewTabItemWithIcon( - "Calculator", - theme.ComputerIcon(), - container.NewStack(widget.NewButton("Calc", func() {})), - ), - ) - - content := container.NewBorder( - toolbar, // top - nil, // bottom - nil, // left - nil, // right - tabs, // center - ) - - w.CenterOnScreen() - w.SetContent(content) -} - -func (e *client) loadWorld(w *world.World) { - if e.world == nil { - w.SetCircleRadiusScaleFp(world.SCALE / 4) - e.world = w - // TODO: store camera position in user settings - e.wp.CameraXWorldFp = w.W / 2 - e.wp.CameraYWorldFp = w.H / 2 - e.world.SetTheme(world.ThemeDark) - } else { - if e.world.Theme().ID() == "theme.light.v1" { - e.world.SetTheme(world.ThemeDark) - } else { - e.world.SetTheme(world.ThemeLight) - } - } - e.RequestRefresh() -} diff --git a/client/model/state.go b/client/model/state.go new file mode 100644 index 0000000..8b53790 --- /dev/null +++ b/client/model/state.go @@ -0,0 +1 @@ +package model diff --git a/client/storage.go b/client/storage.go new file mode 100644 index 0000000..6e43711 --- /dev/null +++ b/client/storage.go @@ -0,0 +1,42 @@ +package client + +import ( + model "galaxy/model/client" + "galaxy/model/order" + "galaxy/model/report" +) + +// 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) + + // 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)) + + // 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)) + + // 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)) + + // 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)) + + // 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)) + + // 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)) +} diff --git a/client/storage/storage.go b/client/storage/storage.go new file mode 100644 index 0000000..87e4f37 --- /dev/null +++ b/client/storage/storage.go @@ -0,0 +1,48 @@ +package storage + +import ( + "errors" + "fmt" + "galaxy/util" + "path/filepath" +) + +const ( + // Name of the file under the storage's root where [model.State] is stored. + stateFileName = "state.dat" + + // Suffix of a Game's file inder the storage's root where [model.GameData] is stored. + gameDataFileSuffix = ".dat" +) + +type storage struct { + root string +} + +// NewStorage returns implementation of the "galaxy/client.Storage" interface +// or nil with a non-nil error when filesystem storage initialisation failed. +func NewStorage(rootPath string) (*storage, error) { + ok, err := util.Writable(rootPath) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("user does not have write permissions to the storage root") + } + s := &storage{ + root: rootPath, + } + return s, nil +} + +// StateFilePath returns client's state file path relative to the root, +// file name and extension are pre-defined constant. +func StateFilePath(root string) string { + return filepath.Join(root, stateFileName) +} + +// GameDataPath returns game's data file path relative to the root, +// data file name is GameID string representation and extension is a pre-defined constant. +func GameDataFilePath(root string, id fmt.Stringer) string { + return filepath.Join(root, id.String()) + gameDataFileSuffix +} diff --git a/client/ui.go b/client/ui.go index 6795817..d5daa1e 100644 --- a/client/ui.go +++ b/client/ui.go @@ -1,6 +1,7 @@ package client import ( + "fmt" "image" "math" @@ -193,6 +194,146 @@ func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool) return x, y, true } +func (e *client) CanvasScale() float32 { + e.metaMu.RLock() + defer e.metaMu.RUnlock() + if e.lastCanvasScale <= 0 { + return 1 + } + return e.lastCanvasScale +} + +func (e *client) ForceFullRedraw() { + if e.world == nil { + return + } + e.world.ForceFullRedrawNext() +} + +func (e *client) onRasterWidgetLayout(fyne.Size) { + e.updateSizes() +} + +// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw. +// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback). +func (e *client) updateSizes() { + canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster) + if canvasObj == nil { + return + } + + sz := e.raster.Size() // logical (Fyne units) + scale := canvasObj.Scale() + + e.metaMu.Lock() + e.lastRasterLogicW = sz.Width + e.lastRasterLogicH = sz.Height + e.lastCanvasScale = scale + e.metaMu.Unlock() + + e.RequestRefresh() +} + +func (e *client) onDragged(ev *fyne.DragEvent) { + e.pan.Dragged(ev) +} + +func (e *client) onDradEnd() { + e.pan.DragEnd() +} + +func (e *client) onTapped(ev *fyne.PointEvent) { + if e.world == nil || ev == nil { + return + } + + xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y) + if !ok { + return + } + + params := e.getLastRenderedParams() + hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx) + if err != nil { + // In UI you probably don't want panic; keep your existing handling. + panic(err) + } + + m := func(v int) float64 { return float64(v) / float64(world.SCALE) } + + for _, hit := range hits { + var coord string + if hit.Kind == world.KindLine { + coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2)) + } else { + coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y)) + } + fmt.Println("hit:", hit.ID, "Coord:", coord) + } +} + +func (e *client) onScrolled(s *fyne.ScrollEvent) { + if e.world == nil || s == nil { + return + } + + // Use last rendered viewport sizes (pixel) for zoom logic. + e.metaMu.RLock() + vw := e.lastRasterPxW + vh := e.lastRasterPxH + e.metaMu.RUnlock() + if vw <= 0 || vh <= 0 { + return + } + + cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y) + if !ok { + return + } + + e.mu.Lock() + oldZoom := e.wp.CameraZoom + + // Exponential zoom factor; tune later. + const base = 1.005 + delta := float64(s.Scrolled.DY) + newZoom := oldZoom * math.Pow(base, delta) + + newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh) + if newZoom == oldZoom { + e.mu.Unlock() + return + } + + oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom) + if err != nil { + e.mu.Unlock() + return + } + newZoomFp, err := world.CameraZoomToWorldFixed(newZoom) + if err != nil { + e.mu.Unlock() + return + } + + // Pivot zoom for no-wrap behavior. + newCamX, newCamY := world.PivotZoomCameraNoWrap( + e.wp.CameraXWorldFp, e.wp.CameraYWorldFp, + vw, vh, + cxPx, cyPx, + oldZoomFp, newZoomFp, + ) + + e.wp.CameraZoom = newZoom + e.wp.CameraXWorldFp = newCamX + e.wp.CameraYWorldFp = newCamY + e.mu.Unlock() + + // Any zoom change should rebuild index and force full redraw. + e.world.ForceFullRedrawNext() + e.RequestRefresh() +} + // copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA. // dst must be sized exactly (0,0)-(vw,vh). This is allocation-free. // It avoids SubImage aliasing issues: dst becomes independent from src backing memory. diff --git a/client/world/world.go b/client/world/world.go index 46b77f8..644f817 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -147,18 +147,11 @@ func (w *World) Theme() StyleTheme { } // SetTheme updates the world's current theme. -// Step 1 behavior: -// - Does NOT mutate built-in default styles (1/2/3). -// - Materializes three theme default styles as new StyleIDs in the style table. -// - New objects (and later, theme-relative ones) can use these IDs. -// - Forces next render to full redraw. func (w *World) SetTheme(theme StyleTheme) { if theme == nil { theme = DefaultTheme{} } - // fmt.Println("current theme:", w.theme.ID()) w.theme = theme - // fmt.Println("new theme:", w.theme.ID()) // Drop derived cache when theme changes to avoid unbounded growth. for k := range w.derivedCache { @@ -174,7 +167,6 @@ func (w *World) SetTheme(theme StyleTheme) { // Full redraw to apply new background and base styles. w.renderState.Reset() - // w.forceFullRedraw = true w.ForceFullRedrawNext() } @@ -503,36 +495,6 @@ func (w *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, e return id, nil } -// func (g *World) resolvePointStyleID(o PointOptions) StyleID { -// if o.hasStyleID { -// return o.StyleID -// } -// if o.Override.IsZero() { -// return StyleIDDefaultPoint -// } -// return g.styles.AddDerived(StyleIDDefaultPoint, o.Override) -// } - -// func (g *World) resolveCircleStyleID(o CircleOptions) StyleID { -// if o.hasStyleID { -// return o.StyleID -// } -// if o.Override.IsZero() { -// return StyleIDDefaultCircle -// } -// return g.styles.AddDerived(StyleIDDefaultCircle, o.Override) -// } - -// func (g *World) resolveLineStyleID(o LineOptions) StyleID { -// if o.hasStyleID { -// return o.StyleID -// } -// if o.Override.IsZero() { -// return StyleIDDefaultLine -// } -// return g.styles.AddDerived(StyleIDDefaultLine, o.Override) -// } - // worldToCellX converts a fixed-point X coordinate to a grid column index. func (w *World) worldToCellX(x int) int { return worldToCell(x, w.W, w.cols, w.cellSize) diff --git a/connector/connector.go b/connector/connector.go new file mode 100644 index 0000000..37bf169 --- /dev/null +++ b/connector/connector.go @@ -0,0 +1,35 @@ +package connector + +import ( + model "galaxy/model/client" + "galaxy/model/report" +) + +// Connector is a main interface to provide connectivity with app's server. +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. + CheckConnection() bool + + // CheckVersion is called asynchronously every 30 minutes and receives from backend server information about currently available app versions. + CheckVersion() ([]VersionInfo, error) + + // DownloadVersion asynchronously retrieves from a specific string URL a binary artifact from backend server. + DownloadVersion(string) ([]byte, error) +} + +// UIConnector contains only funcs are needed for the client app to be functional. +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(model.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 +} diff --git a/connector/go.mod b/connector/go.mod new file mode 100644 index 0000000..9f88da7 --- /dev/null +++ b/connector/go.mod @@ -0,0 +1,3 @@ +module galaxy/connector + +go 1.26.0 diff --git a/connector/http/http.go b/connector/http/http.go new file mode 100644 index 0000000..bb901be --- /dev/null +++ b/connector/http/http.go @@ -0,0 +1,24 @@ +// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol +package http + +import ( + "context" + "net/url" +) + +type httpConnector struct { + ctx context.Context + backendURL *url.URL // HTTP REST API Server URL +} + +func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) { + u, err := url.Parse(backendURL) + if err != nil { + return nil, err + } + h := &httpConnector{ + ctx: ctx, + backendURL: u, + } + return h, nil +} diff --git a/go.work b/go.work index 79a15e1..60cb86a 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,8 @@ go 1.26.0 use ( ./client + ./connector + ./loader ./pkg/error ./pkg/model ./pkg/util diff --git a/loader/cmd/main.go b/loader/cmd/main.go new file mode 100644 index 0000000..55064f1 --- /dev/null +++ b/loader/cmd/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "errors" + "fmt" + "galaxy/loader" + "os" + "os/signal" + + "fyne.io/fyne/v2/app" +) + +func main() { + var err error + defer func() { + if err == nil { + if r := recover(); r != nil { + err = errors.Join(err, fmt.Errorf("app panics: %v", r)) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }() + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + app := app.New() + l, err := loader.NewLoader(app, nil) + if err != nil { + return + } + + err = l.Run(ctx) +} diff --git a/loader/go.mod b/loader/go.mod new file mode 100644 index 0000000..ad9b7d7 --- /dev/null +++ b/loader/go.mod @@ -0,0 +1,42 @@ +module galaxy/loader + +go 1.26.0 + +require fyne.io/fyne/v2 v2.7.3 + +require ( + fyne.io/systray v1.12.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/davecgh/go-spew v1.1.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.0 // indirect + github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.3.3 // 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.1 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // 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/stretchr/testify v1.11.1 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + golang.org/x/image v0.36.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/loader/go.sum b/loader/go.sum new file mode 100644 index 0000000..1044293 --- /dev/null +++ b/loader/go.sum @@ -0,0 +1,41 @@ +fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= +github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc= +github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/loader/loader.go b/loader/loader.go new file mode 100644 index 0000000..8a0dff2 --- /dev/null +++ b/loader/loader.go @@ -0,0 +1,85 @@ +package loader + +import ( + "context" + "errors" + "fmt" + "galaxy/connector" + mc "galaxy/model/client" + "plugin" + "time" + + "fyne.io/fyne/v2" +) + +type ClientInit func(connector.UIConnector, fyne.App, mc.Settings) (mc.Client, error) + +type loader struct { + conn connector.Connector + cli mc.Client +} + +func NewLoader(app fyne.App, conn connector.Connector) (*loader, error) { + app.Storage().List() + settings := mc.Settings{} + cli, err := loadClientPlugin(conn, app, settings, "./client.so", "NewClient") + if err != nil { + return nil, err + } + l := &loader{ + conn: conn, + cli: cli, + } + return l, nil +} + +func (l *loader) Run(ctx context.Context) error { + final := make(chan struct{}, 1) + go l.backgroundLoop(ctx, final) + if err := l.cli.Run(); err != nil { + return err + } + final <- struct{}{} + return nil +} + +func (l *loader) backgroundLoop(ctx context.Context, final <-chan struct{}) { + t := time.NewTicker(time.Second * 5) + for { + select { + case <-ctx.Done(): + l.cli.Shutdown() + return + case <-final: + return + case <-t.C: + isGood := l.conn.CheckConnection() + l.cli.OnConnection(isGood) + } + } +} + +// 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(conn connector.UIConnector, app fyne.App, s mc.Settings, path, name string) (mc.Client, error) { + if path == "" { + return nil, errors.New("no plugin path given") + } + + plug, err := plugin.Open(path) + if err != nil { + return nil, fmt.Errorf("open plugin %q: %w", path, err) + } + + sym, err := plug.Lookup(name) + if err != nil { + return nil, fmt.Errorf("lookup symbol %q: %w", name, err) + } + + initializerPtr, ok := sym.(*ClientInit) + if !ok { + return nil, fmt.Errorf("unexpected type %T; want %T", sym, initializerPtr) + } + + return (*initializerPtr)(conn, app, s) +} diff --git a/pkg/model/client/client.go b/pkg/model/client/client.go new file mode 100644 index 0000000..c87118c --- /dev/null +++ b/pkg/model/client/client.go @@ -0,0 +1,47 @@ +package client + +import ( + "galaxy/model/order" + "galaxy/model/report" +) + +type Client interface { + // 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 + + // Shutdown closes client's main window and performing all necessary data persistence. + Shutdown() + + // OnConnection receives an event when connection with client's server may be established (true) or connectivity lost (false). + OnConnection(bool) +} + +type GameID string + +func (i GameID) String() string { + return string(i) +} + +type State struct { + // TODO: store user login key + GameState []GameState `json:"gameState"` + ActiveGameID GameID `json:"activeGameId"` +} + +type GameState struct { + ID GameID `json:"id"` + LastTurn uint `json:"lastTurn"` + ActiveTurn uint `json:"activeTurn"` +} + +type GameData struct { + Turn uint `json:"turn"` + 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/model/order/order.go b/pkg/model/order/order.go index 8e3db80..ea2a3a4 100644 --- a/pkg/model/order/order.go +++ b/pkg/model/order/order.go @@ -5,7 +5,9 @@ import ( ) type Order struct { - Commands []DecodableCommand `json:"cmd"` + // TODO: check with already stored order, if any, and generate an error, if newer order exists + UpdatedAt int `json:"updatedAt"` + Commands []DecodableCommand `json:"cmd"` } func (o Order) MarshalBinary() (data []byte, err error) { diff --git a/pkg/model/report/report.go b/pkg/model/report/report.go index bcc691e..e824e76 100644 --- a/pkg/model/report/report.go +++ b/pkg/model/report/report.go @@ -14,6 +14,10 @@ func F(v float64) Float { return Float(u.Fixed3(v)) } +func (f Float) F() float64 { + return float64(f) +} + type Report struct { Version uint `json:"version"` Turn uint `json:"turn"` diff --git a/pkg/util/fs.go b/pkg/util/fs.go index 5bdf790..14f0a49 100644 --- a/pkg/util/fs.go +++ b/pkg/util/fs.go @@ -1,6 +1,7 @@ package util import ( + "fmt" "os" "testing" ) @@ -17,3 +18,25 @@ func CreateWorkDir(t *testing.T) (string, func()) { } } } + +func DirExists(path string) (bool, error) { + return PathExists(path, true) +} + +func FileExists(path string) (bool, error) { + return PathExists(path, false) +} + +func PathExists(path string, isDir bool) (bool, error) { + if fi, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } else { + if isDir != fi.IsDir() { + return false, fmt.Errorf("wrong type: "+path+" mode=%s isDir=%t", fi.Mode(), isDir) + } + return true, nil + } +} diff --git a/server/internal/repo/fs/util_test.go b/pkg/util/fs_test.go similarity index 77% rename from server/internal/repo/fs/util_test.go rename to pkg/util/fs_test.go index 5e65edf..3eff438 100644 --- a/server/internal/repo/fs/util_test.go +++ b/pkg/util/fs_test.go @@ -1,12 +1,11 @@ -package fs +package util_test import ( + "galaxy/util" "os" "path/filepath" "testing" - "galaxy/util" - "github.com/google/uuid" "github.com/stretchr/testify/assert" ) @@ -14,32 +13,20 @@ import ( func TestPathExists(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - testDirExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, true) }) - testFileExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, false) }) + testDirExistsFunc(t, root, func(s string) (bool, error) { return util.PathExists(s, true) }) + testFileExistsFunc(t, root, func(s string) (bool, error) { return util.PathExists(s, false) }) } func TestDirExists(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - testDirExistsFunc(t, root, dirExists) + testDirExistsFunc(t, root, util.DirExists) } func TestFileExists(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - testFileExistsFunc(t, root, fileExists) -} - -func TestWritable(t *testing.T) { - root, cleanup := util.CreateWorkDir(t) - defer cleanup() - ok, err := writable(root) - assert.NoError(t, err, "directory writable check") - assert.True(t, ok, "directory should be writable") - - ok, err = writable(nonWritableDir) - assert.NoError(t, err, "system directory writable check") - assert.False(t, ok, "system directory should not be writable") + testFileExistsFunc(t, root, util.FileExists) } func testDirExistsFunc(t *testing.T, root string, dirCheck func(string) (bool, error)) { diff --git a/server/internal/repo/fs/util_unix.go b/pkg/util/fs_unix.go similarity index 75% rename from server/internal/repo/fs/util_unix.go rename to pkg/util/fs_unix.go index b875ce3..356a1a8 100644 --- a/server/internal/repo/fs/util_unix.go +++ b/pkg/util/fs_unix.go @@ -1,15 +1,15 @@ //go:build unix || (js && wasm) || wasip1 -package fs +package util import "golang.org/x/sys/unix" -// writable reports whether path is writable on Windows. +// Writable reports whether path is Writable on Windows. // // Semantics: // - for an existing regular file, it tries to open it for writing; // - for an existing directory, it tries to create and remove a temp file inside it; // - for other file types, it returns false with no error. -func writable(filepath string) (bool, error) { +func Writable(filepath string) (bool, error) { return unix.Access(filepath, unix.W_OK) == nil, nil } diff --git a/pkg/util/fs_unix_test.go b/pkg/util/fs_unix_test.go new file mode 100644 index 0000000..a45ecb4 --- /dev/null +++ b/pkg/util/fs_unix_test.go @@ -0,0 +1,22 @@ +//go:build unix || (js && wasm) || wasip1 + +package util_test + +import ( + "galaxy/util" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWritable(t *testing.T) { + root := t.ArtifactDir() + + ok, err := util.Writable(root) + assert.NoError(t, err, "directory writable check") + assert.True(t, ok, "directory should be writable") + + ok, err = util.Writable(nonWritableDir) + assert.NoError(t, err, "system directory writable check") + assert.False(t, ok, "system directory should not be writable") +} diff --git a/server/internal/repo/fs/util_windows.go b/pkg/util/fs_windows.go similarity index 94% rename from server/internal/repo/fs/util_windows.go rename to pkg/util/fs_windows.go index 6da35d3..ac49354 100644 --- a/server/internal/repo/fs/util_windows.go +++ b/pkg/util/fs_windows.go @@ -1,4 +1,4 @@ -package fs +package util import ( "errors" @@ -7,7 +7,7 @@ import ( "syscall" ) -// writable reports whether path is writable on Windows. +// Writable reports whether path is Writable on Windows. // // Semantics: // - for an existing regular file, it tries to open it for writing; @@ -17,7 +17,7 @@ import ( // This is intentionally an operational check, not a mode-bit check, because // on Windows effective writability is determined by ACLs and file attributes, // not by POSIX-like permission bits from os.FileMode. -func writable(path string) (bool, error) { +func Writable(path string) (bool, error) { info, err := os.Stat(path) if err != nil { return false, err diff --git a/server/internal/repo/fs/util_windows_test.go b/pkg/util/fs_windows_test.go similarity index 91% rename from server/internal/repo/fs/util_windows_test.go rename to pkg/util/fs_windows_test.go index 6ed5d60..f1fde28 100644 --- a/server/internal/repo/fs/util_windows_test.go +++ b/pkg/util/fs_windows_test.go @@ -1,6 +1,6 @@ //go:build windows -package fs +package util import ( "os" @@ -20,7 +20,7 @@ func TestWritable_NewFile(t *testing.T) { err := os.WriteFile(path, []byte("x"), 0o600) require.NoError(t, err) - ok, err := writable(path) + ok, err := Writable(path) require.NoError(t, err) require.True(t, ok) } @@ -32,7 +32,7 @@ func TestWritable_NewDirectory(t *testing.T) { dir := t.TempDir() - ok, err := writable(dir) + ok, err := Writable(dir) require.NoError(t, err) require.True(t, ok) } @@ -44,7 +44,7 @@ func TestWritable_MissingPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "missing") - ok, err := writable(path) + ok, err := Writable(path) require.Error(t, err) require.False(t, ok) } diff --git a/pkg/util/go.mod b/pkg/util/go.mod index 98542d3..3522a7c 100644 --- a/pkg/util/go.mod +++ b/pkg/util/go.mod @@ -2,7 +2,11 @@ module galaxy/util go 1.26.0 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.41.0 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/pkg/util/go.sum b/pkg/util/go.sum index 240dea8..d3f9635 100644 --- a/pkg/util/go.sum +++ b/pkg/util/go.sum @@ -1,8 +1,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/util/util_helper_test.go b/pkg/util/util_helper_test.go new file mode 100644 index 0000000..8bbfa1b --- /dev/null +++ b/pkg/util/util_helper_test.go @@ -0,0 +1,5 @@ +package util_test + +const ( + nonWritableDir = "/usr/lib" +) diff --git a/server/go.mod b/server/go.mod index da190c4..7c06152 100644 --- a/server/go.mod +++ b/server/go.mod @@ -7,7 +7,6 @@ require ( github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 - golang.org/x/sys v0.41.0 ) require ( @@ -39,6 +38,7 @@ require ( golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/server/internal/repo/fs/fs.go b/server/internal/repo/fs/fs.go index 9d5254d..e3f4c04 100644 --- a/server/internal/repo/fs/fs.go +++ b/server/internal/repo/fs/fs.go @@ -4,6 +4,7 @@ import ( "encoding" "errors" "fmt" + "galaxy/util" "math/big" "os" "path/filepath" @@ -28,13 +29,13 @@ func NewFileStorage(path string) (*fs, error) { if err != nil { return nil, fmt.Errorf("path %s invalid: %s", path, err) } - if ok, err := dirExists(absPath); err != nil { + if ok, err := util.DirExists(absPath); err != nil { return nil, fmt.Errorf("check dir exist: %s", err) } else if !ok { return nil, errors.New("directory does not exist: " + absPath) } - if ok, err := writable(absPath); err != nil { + if ok, err := util.Writable(absPath); err != nil { return nil, fmt.Errorf("check dir access: %s", err) } else if !ok { return nil, errors.New("directory should have read-write access: " + absPath) @@ -48,7 +49,7 @@ func NewFileStorage(path string) (*fs, error) { func (f *fs) Lock() (func() error, error) { lockPath := f.lockFilePath() - exists, err := fileExists(lockPath) + exists, err := util.FileExists(lockPath) if err != nil { return nil, fmt.Errorf("check lock file exists: %s", err) } @@ -77,7 +78,7 @@ func (f *fs) Lock() (func() error, error) { } func (f *fs) Exists(path string) (bool, error) { - return fileExists(filepath.Join(f.root, path)) + return util.FileExists(filepath.Join(f.root, path)) } func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error { @@ -97,7 +98,7 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error { targetDir := filepath.Dir(targetFilePath) if targetDir != f.root { - ok, err := dirExists(targetDir) + ok, err := util.DirExists(targetDir) if err != nil { return fmt.Errorf("check target dir exists: %s", err) } @@ -110,12 +111,12 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error { } oldFilePath := targetFilePath + oldFileSuffix - targetExists, err := fileExists(targetFilePath) + targetExists, err := util.FileExists(targetFilePath) if err != nil { return fmt.Errorf("check target file exists: %s", err) } if targetExists { - oldFileExists, err := fileExists(oldFilePath) + oldFileExists, err := util.FileExists(oldFilePath) if err != nil { return fmt.Errorf("check old file exists: %s", err) } @@ -125,7 +126,7 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error { } newFilePath := targetFilePath + newFileSuffix - newFileExists, err := fileExists(newFilePath) + newFileExists, err := util.FileExists(newFilePath) if err != nil { return fmt.Errorf("check new file exists: %s", err) } diff --git a/server/internal/repo/fs/fs_test.go b/server/internal/repo/fs/fs_test.go index 6935eec..a57f0e9 100644 --- a/server/internal/repo/fs/fs_test.go +++ b/server/internal/repo/fs/fs_test.go @@ -1,26 +1,45 @@ -package fs +package fs_test import ( "os" "path/filepath" + "slices" "testing" + "galaxy/server/internal/repo/fs" "galaxy/util" "github.com/stretchr/testify/assert" ) +const ( + lockFile = ".lock" +) + +type sampleData struct { + data []byte +} + +func (sd *sampleData) UnmarshalBinary(data []byte) error { + sd.data = slices.Clone(data) + return nil +} + +func (sd sampleData) MarshalBinary() (data []byte, err error) { + return sd.data, nil +} + func TestNewFileStorageSuccess(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - _, err := NewFileStorage(root) + _, err := fs.NewFileStorage(root) assert.NoError(t, err) } func TestLock(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - fs, err := NewFileStorage(root) + fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage") unlock, err := fs.Lock() assert.NoError(t, err, "acquire lock") @@ -40,7 +59,7 @@ func TestExist(t *testing.T) { t.Fatal(err) } - fs, err := NewFileStorage(root) + fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage") exist, err := fs.Exists(fileName) @@ -56,7 +75,7 @@ func TestWrite(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - fs, err := NewFileStorage(root) + fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) unlock, err := fs.Lock() @@ -99,7 +118,7 @@ func TestRead(t *testing.T) { sd := new(sampleData) - fs, err := NewFileStorage(root) + fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) assert.EqualError(t, fs.Read("some.file", sd), "lock must be acquired before read") @@ -144,7 +163,7 @@ func TestRead(t *testing.T) { func TestWriteErrorWithoutLock(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - fs, err := NewFileStorage(root) + fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage") sd := &sampleData{[]byte{0, 1, 2, 3}} err = fs.Write("some/path", sd) @@ -153,7 +172,7 @@ func TestWriteErrorWithoutLock(t *testing.T) { } func TestNewFileStorageErrorNotExists(t *testing.T) { - _, err := NewFileStorage(filepath.Join(os.TempDir(), "non-existent-dir")) + _, err := fs.NewFileStorage(filepath.Join(os.TempDir(), "non-existent-dir")) assert.Error(t, err) } @@ -165,7 +184,7 @@ func TestNewFileStorageErrorNotADirectory(t *testing.T) { if err := f.Close(); err != nil { t.Fatal(err) } - _, err = NewFileStorage(f.Name()) + _, err = fs.NewFileStorage(f.Name()) assert.Error(t, err) if err := os.Remove(f.Name()); err != nil { t.Fatal(err) @@ -173,6 +192,6 @@ func TestNewFileStorageErrorNotADirectory(t *testing.T) { } func TestNewFileStorageErrorNoAccess(t *testing.T) { - _, err := NewFileStorage(nonWritableDir) + _, err := fs.NewFileStorage("/some/random/dir") assert.Error(t, err) } diff --git a/server/internal/repo/fs/helper_test.go b/server/internal/repo/fs/helper_test.go deleted file mode 100644 index 58a2d4a..0000000 --- a/server/internal/repo/fs/helper_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package fs - -import ( - "slices" -) - -const ( - nonWritableDir = "/usr/lib" -) - -type sampleData struct { - data []byte -} - -func (sd *sampleData) UnmarshalBinary(data []byte) error { - sd.data = slices.Clone(data) - return nil -} - -func (sd sampleData) MarshalBinary() (data []byte, err error) { - return sd.data, nil -} diff --git a/server/internal/repo/fs/util.go b/server/internal/repo/fs/util.go deleted file mode 100644 index 6905a2c..0000000 --- a/server/internal/repo/fs/util.go +++ /dev/null @@ -1,29 +0,0 @@ -// for windows builds func [writable] should be refactored -package fs - -import ( - "fmt" - "os" -) - -func dirExists(path string) (bool, error) { - return pathExists(path, true) -} - -func fileExists(path string) (bool, error) { - return pathExists(path, false) -} - -func pathExists(path string, isDir bool) (bool, error) { - if fi, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } else { - if isDir != fi.IsDir() { - return false, fmt.Errorf("wrong type: "+path+" mode=%s isDir=%t", fi.Mode(), isDir) - } - return true, nil - } -}