clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / app / hostenv / hostenv.go
hostenv.go 8.7K
1// Package hostenv is the seam between the application and the machine it runs2// on. The application is one pure http.Handler; everything OS-shaped -- the3// library of .atlas files, the place session records are kept, the file4// picker -- arrives through the small interfaces declared here (issue #55// §3.3).6//7// The rule the interfaces exist to enforce: the handler never touches os,8// file paths, or dialogs. It asks a [VolumeStore] what is installed and opens9// entries out of it; it asks a [SessionStore] for bytes under a name; it asks10// [Hostenv.PickFile] for a file the reader chose. Three hosts can then mount11// the same handler -- a Wails webview, the headless atlas serve, and, later, a12// WASM service worker whose stores are backed by OPFS -- without the handler13// knowing which one it is running under.14//15// This package declares the contract and nothing that binds it to a machine:16// no os, no path/filepath, no toolkit. The OS implementations live beside it17// in hostenv/oshost, so a host that is not an OS links none of them.18package hostenv1920import (21 "context"22 "errors"23 "io"2425 "github.com/FelineStateMachine/atlas/format/vnext"26)2728// ErrNoSession is what a [SessionStore] returns for a record it does not29// hold. A first launch reads every record and finds none; that is not a30// failure, and a store says so with a sentinel rather than an empty result31// that cannot be told from an empty record.32var ErrNoSession = errors.New("hostenv: no such session record")3334// ErrNotAvailable is what a host returns for a capability it does not have:35// the headless server has no window to put a file dialog in front of, so its36// PickFile refuses rather than pretending the reader cancelled.37var ErrNotAvailable = errors.New("hostenv: not available on this host")3839// Hostenv is everything the application needs from the machine it runs on.40type Hostenv interface {41 // Volumes is the library of installed .atlas files.42 Volumes() VolumeStore4344 // Sessions is where per-volume session records are kept.45 Sessions() SessionStore4647 // PickFile puts a file picker in front of the reader and returns what48 // they chose: an open reader over the file, the name it was chosen49 // under, and an error. A host with no window returns [ErrNotAvailable];50 // a reader who cancelled gets [ErrNoSelection]. The caller closes the51 // reader.52 PickFile(ctx context.Context) (io.ReadCloser, string, error)53}5455// ErrNoSelection is what PickFile returns when the reader closed the picker56// without choosing anything. It is not an error condition -- nothing failed --57// but it is not a file either, and the caller has to be able to tell the two58// apart.59var ErrNoSelection = errors.New("hostenv: nothing was chosen")6061// VolumeStore is the library of installed volumes as the handler sees it: the62// serving build of each volume, a way to open entries out of one, and the two63// operations that change what is installed.64//65// The registry model is scan at launch, rescan on install -- no directory66// watching (issue #5 §2, decision 15). A file copied directly into the library67// outside Atlas appears at the next launch. Desktop open and drop events use68// Install instead. The serving-build fold is pure and lives in format/vnext;69// a store only walks its library and hands the fold its descriptors.70type VolumeStore interface {71 // Volumes lists the serving build of every installed volume, one per72 // slug, sorted by slug. The result is a snapshot: it does not change73 // under a request that is already reading it.74 Volumes() []Volume7576 // Rescan re-reads the library and reports the volumes whose serving77 // build moved -- arrivals, departures, and winners whose stamp changed78 // (see [bundle.Changed]).79 Rescan() ([]string, error)8081 // Install takes bundle content into the library under the name it was82 // offered as, validating before anything is kept, and rescans. A bundle83 // that is already installed is a no-op that still reports what it is.84 Install(name string, content io.Reader) (Installed, error)8586 // Location names where the library lives, in words the host's reader87 // would recognise -- a directory path on an OS host, whatever an OPFS88 // host calls its root. The catalog carries it so an empty library can89 // say where a bundle goes. It is a label, never a path the handler90 // takes apart.91 Location() string92}9394// Volume is one serving build: what the bundle says about itself, and a door95// to the entries inside it.96type Volume interface {97 // Info is the small, schema-independent account used by navigation and98 // catalog views without decoding physical blocks.99 Info() VolumeInfo100101 // Outline is the canonical graph restored from typed blocks without feature102 // rows. It is safe to use while composing page zero.103 Outline() vnext.Volume104105 // FeaturePage reads a bounded page from the physical feature partitions.106 FeaturePage(request vnext.FeaturePageRequest) (vnext.FeaturePageResult, error)107 FeatureSetSummaries() ([]vnext.FeatureSetSummary, error)108 DemandAddressedFeatures() bool109110 // RasterTile reads one verified logical tile slice from its physical shard.111 RasterTile(entry string) (vnext.RasterTile, error)112113 // Blob reads one opaque payload such as a raster tile or image asset.114 Blob(entry string) ([]byte, error)115116 // Schema and TableBlock expose the native typed data plane to clients that117 // decode columns directly.118 Schema() ([]byte, error)119 TableBlock(entry string) ([]byte, error)120}121122type VolumeInfo struct {123 Slug string `json:"slug"`124 Title string `json:"title"`125 Stamp string `json:"stamp"`126 Release vnext.Release `json:"release"`127 Worlds []WorldInfo `json:"worlds"`128 TileGrid TileGrid `json:"tileGrid"`129}130131type WorldInfo struct {132 Slug string `json:"slug"`133 Title string `json:"title"`134 Parent string `json:"parent,omitempty"`135 Points int `json:"points"`136 Paths int `json:"paths"`137 Areas int `json:"areas"`138 UpdatedAt string `json:"updatedAt"`139}140141type TileGrid struct {142 SourceZoom int `json:"sourceZoom"`143 OriginX int `json:"originX"`144 OriginY int `json:"originY"`145 FirstTile int `json:"firstTile"`146 TileSize int `json:"tileSize"`147 Size int `json:"size"`148}149150// Installed is what an import turned out to be: the volume it was a build of,151// and what the rescan that followed it changed.152type Installed struct {153 // Slug and Title identify the volume the bundle is a build of.154 Slug string155 Title string156157 // Stamp is the build's content fingerprint, in full.158 Stamp string159160 // Bytes is the size of the installed file where the store knows it.161 Bytes int64162163 // Already is true when the library already held this exact build, which164 // is a successful import that copied nothing.165 Already bool166167 // Changed names the volumes whose serving build moved as a result,168 // which is what the application announces over SSE.169 Changed []string170}171172// SessionStore keeps the application's discrete state across restarts: one173// record per volume, plus the small record that remembers which volume was174// last open (issue #5 §4.1).175//176// The store is bytes under a name. What those bytes mean is the handler's177// business -- the record schema is documented in docs/app.md and versioned178// there -- which is what lets a file, an OPFS handle, and a test's map all be179// the same store.180type SessionStore interface {181 // Load reads a record. A store that does not hold it returns182 // [ErrNoSession].183 Load(name string) ([]byte, error)184185 // Save writes a record whole, replacing whatever was there.186 Save(name string, data []byte) error187188 // Delete forgets a record. A store that does not hold it has already done189 // what was asked and answers nil: the caller wants the record gone, and a190 // record that was never there is gone. It is the whole of the blunt reset191 // -- the state a volume opens with is synthesized from the world itself,192 // so deleting the record is how a reader gets it back.193 Delete(name string) error194195 // Names lists the records held, sorted.196 Names() ([]string, error)197}198199// ValidName admits the record names a [SessionStore] may be asked for:200// lowercase letters, digits, hyphen, underscore and dot, not leading with a201// separator and never carrying a path separator.202//203// The rule exists because a name reaches a filesystem on one host and a key204// space on another. Session names are built from volume slugs, which205// [bundle.ValidSlug] already holds to a narrower rule; this is the second206// gate, stated where the store is, so a store implementation may trust it.207func ValidName(name string) error {208 if name == "" {209 return errors.New("empty")210 }211 if name[0] == '-' || name[0] == '_' || name[0] == '.' {212 return errors.New("starts with a separator")213 }214 for _, r := range name {215 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {216 continue217 }218 return errors.New("contains an unusable character")219 }220 return nil221}