clone

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

main
atlas / internal / app / hostenv / hostenv.go
hostenv.go 7.4K
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/bundle"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 import -- no directory66// watching (issue #5 §2, decision 15). A file dropped into the library from67// outside appears at the next launch. The fold that decides which build of a68// volume serves is pure and lives in format/bundle; a store only has to walk69// whatever it keeps its bundles in and hand 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 // Manifest is the volume's own account of itself, already validated.98 Manifest() bundle.Manifest99100 // Open reads one entry of the archive by its full name --101 // "worlds/hyrule.json", "tiles/hyrule/0/0/0.jpg" -- and reports its102 // uncompressed size, so a server can announce a length before streaming.103 Open(entry string) (io.ReadCloser, int64, error)104}105106// Installed is what an import turned out to be: the volume it was a build of,107// and what the rescan that followed it changed.108type Installed struct {109 // Slug and Title identify the volume the bundle is a build of.110 Slug string111 Title string112113 // Stamp is the build's content fingerprint, in full.114 Stamp string115116 // Bytes is the size of the installed file where the store knows it.117 Bytes int64118119 // Already is true when the library already held this exact build, which120 // is a successful import that copied nothing.121 Already bool122123 // Changed names the volumes whose serving build moved as a result,124 // which is what the application announces over SSE.125 Changed []string126}127128// SessionStore keeps the application's discrete state across restarts: one129// record per volume, plus the small record that remembers which volume was130// last open (issue #5 §4.1).131//132// The store is bytes under a name. What those bytes mean is the handler's133// business -- the record schema is documented in docs/app.md and versioned134// there -- which is what lets a file, an OPFS handle, and a test's map all be135// the same store.136type SessionStore interface {137 // Load reads a record. A store that does not hold it returns138 // [ErrNoSession].139 Load(name string) ([]byte, error)140141 // Save writes a record whole, replacing whatever was there.142 Save(name string, data []byte) error143144 // Delete forgets a record. A store that does not hold it has already done145 // what was asked and answers nil: the caller wants the record gone, and a146 // record that was never there is gone. It is the whole of the blunt reset147 // -- the state a volume opens with is synthesized from the world itself,148 // so deleting the record is how a reader gets it back.149 Delete(name string) error150151 // Names lists the records held, sorted.152 Names() ([]string, error)153}154155// ValidName admits the record names a [SessionStore] may be asked for:156// lowercase letters, digits, hyphen, underscore and dot, not leading with a157// separator and never carrying a path separator.158//159// The rule exists because a name reaches a filesystem on one host and a key160// space on another. Session names are built from volume slugs, which161// [bundle.ValidSlug] already holds to a narrower rule; this is the second162// gate, stated where the store is, so a store implementation may trust it.163func ValidName(name string) error {164 if name == "" {165 return errors.New("empty")166 }167 if name[0] == '-' || name[0] == '_' || name[0] == '.' {168 return errors.New("starts with a separator")169 }170 for _, r := range name {171 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {172 continue173 }174 return errors.New("contains an unusable character")175 }176 return nil177}