client io architecture

This commit is contained in:
Ilia Denisov
2026-03-12 18:45:46 +02:00
committed by GitHub
parent 2dafa69b93
commit 079b9facb0
36 changed files with 1810 additions and 460 deletions
+922
View File
@@ -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 users 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.
+190 -11
View File
@@ -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) {}
+30 -3
View File
@@ -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()
}
-9
View File
@@ -1,9 +0,0 @@
package client
import (
"galaxy/model/report"
)
type Connector interface {
Turn(uint, func(report.Report, error)) error
}
-9
View File
@@ -1,9 +0,0 @@
package http
type httpConnector struct {
}
func NewHttpConnector() *httpConnector {
h := &httpConnector{}
return h
}
-289
View File
@@ -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, &params, 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()
}
+1
View File
@@ -0,0 +1 @@
package model
+42
View File
@@ -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))
}
+48
View File
@@ -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
}
+141
View File
@@ -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, &params, 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.
-38
View File
@@ -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)
+35
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module galaxy/connector
go 1.26.0
+24
View File
@@ -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
}
+2
View File
@@ -2,6 +2,8 @@ go 1.26.0
use (
./client
./connector
./loader
./pkg/error
./pkg/model
./pkg/util
+38
View File
@@ -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)
}
+42
View File
@@ -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
)
+41
View File
@@ -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=
+85
View File
@@ -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)
}
+47
View File
@@ -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
}
+3 -1
View File
@@ -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) {
+4
View File
@@ -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"`
+23
View File
@@ -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
}
}
@@ -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)) {
@@ -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
}
+22
View File
@@ -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")
}
@@ -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
@@ -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)
}
+5 -1
View File
@@ -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
+2
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
package util_test
const (
nonWritableDir = "/usr/lib"
)
+1 -1
View File
@@ -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
+9 -8
View File
@@ -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)
}
+29 -10
View File
@@ -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)
}
-22
View File
@@ -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
}
-29
View File
@@ -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
}
}