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 / main.go
main.go 9.9K
1// Command atlas is the desktop shell: one window over the application handler.2//3// Everything in this file is host wiring (issue #5 §3.4). The application is4// one pure http.Handler that touches no filesystem and opens no dialog; what5// is here is the three things a desktop makes of it -- a window to draw it in,6// an operating system to keep the library and the session records on, and a7// native panel to choose a bundle with. Move those three and the same handler8// is `atlas serve`, which is exactly what cmd/atlas/serve.go does in fewer9// lines still.10//11// Two things this shell deliberately does not have. There is no framework: the12// application it hosts consumed exactly one event subscription and one file13// dialog from the one it used to have, and both are here in plain sight. And14// there is no Wails runtime JavaScript in the page -- nothing is bound, no15// bindings JSON is generated, and the page hears that the library moved over16// the application's own SSE stream (issue #5 §4.6). The one place Wails would17// inject its runtime is a 200 text/html answer at a path ending in "/", which18// this application only ever gives on an empty library: every explorer page is19// at /v/<volume>/<world> and is served exactly as the headless host serves it.20//21// The build is a plain `go build`, not `wails build`, which is why there is no22// wails.json: the Wails CLI's job is scaffolding a Vite frontend and this tree23// has none. See the Makefile's `desktop` target and .github/workflows/release.yml24// for the recipe, tags and all.25package main2627import (28 "embed"29 "fmt"30 "io"31 "io/fs"32 "log/slog"33 "os"34 "path/filepath"3536 "github.com/wailsapp/wails/v2"37 "github.com/wailsapp/wails/v2/pkg/options"38 "github.com/wailsapp/wails/v2/pkg/options/assetserver"39 "github.com/wailsapp/wails/v2/pkg/options/mac"4041 "github.com/FelineStateMachine/atlas/format/vnext"42 "github.com/FelineStateMachine/atlas/internal/app"43 "github.com/FelineStateMachine/atlas/internal/app/hostenv/oshost"44 "github.com/FelineStateMachine/atlas/internal/app/hostenv/wailshost"45 "github.com/FelineStateMachine/atlas/internal/authoring"46 "github.com/FelineStateMachine/atlas/internal/logging"47)4849// The seam, embedded, so that the shipped application is one file. `make seam`50// writes render/dist/app.js and `make static` copies it here; a build that51// skipped both ships a static/ holding only its own README, /static/app.js52// answers 404, and the application works without the seam -- which is the53// deletability principle of issue #5 §3.2 standing up in the shipping binary54// rather than only in the test suite.55//56// The stylesheet system is not here. It is internal/app/assets, embedded by57// the application itself and served from /assets, because it is the58// application's own chrome and deleting the seam must not cost the page its59// appearance.60//61//go:embed static62var seam embed.FS6364// The included Earth volume: a real, ordinary .atlas bundle -- NASA's Blue65// Marble base map, composed by `make included-earth` and committed at66// included/ -- embedded so a first launch opens onto a world instead of an67// empty library. It is embedded here and only here: the desktop shell is the68// host that owns a library, where `atlas serve` and the CLI use exactly the69// registry the operator gives them. included/README.md is the provenance and70// the regeneration recipe.71//72//go:embed included/*.atlas73var included embed.FS7475// appIdentifier is the directory the application keeps its data under, on76// whichever configuration directory the machine calls its own. It is the77// identifier the pre-rewrite shell used, unchanged and on purpose: a reader's78// library is where it always was and this build finds it there.79//80// cmd/atlas/serve.go spells the same three constants for the headless host.81// They are duplicated rather than shared because they are each host's own82// account of where it looks, and a host that is not an operating system --83// the WASM one of §3.3 -- will have neither an environment nor a84// configuration directory to read.85const (86 appIdentifier = "dev.felinestatemachine.atlas"87 bundlesDirEnv = "ATLAS_BUNDLES_DIR"88 dataDirEnv = "ATLAS_DATA_DIR"89)9091// version is replaced from the release tag by the packaging workflow.92var version = "dev"9394func main() {95 if len(os.Args) == 2 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {96 fmt.Println("Atlas " + version)97 return98 }99 if err := run(); err != nil {100 fmt.Fprintln(os.Stderr, "atlas:", err)101 os.Exit(1)102 }103}104105func run() error {106 if _, err := logging.Setup(logging.Options{}); err != nil {107 return err108 }109110 data, err := dataDir()111 if err != nil {112 return err113 }114 library := os.Getenv(bundlesDirEnv)115 if library == "" {116 library = filepath.Join(data, "bundles")117 }118 // The library is created rather than merely looked for: a first launch119 // should show a reader an empty library with a path they can drop a120 // bundle into, not an error about a directory nobody has made yet.121 if err := os.MkdirAll(library, 0o755); err != nil {122 return fmt.Errorf("creating the library at %s: %w", library, err)123 }124 // The included Earth build lands before the host is constructed, so the125 // host's first scan of the library already sees it. Installation goes126 // through format/vnext's own path -- validated, versioned, staged,127 // idempotent -- so an already-installed build is a no-op and another Earth128 // build in the library is left exactly where it is, side by side, for the129 // registry fold to order. A built-in that cannot be installed is a startup130 // error rather than a silent absence: the application would be shipping a131 // broken asset, and that has to be heard about.132 if err := installIncluded(library); err != nil {133 return fmt.Errorf("installing the included Earth volume: %w", err)134 }135136 // The window is declared before the host because the host holds its137 // picker and the window learns its context at startup, after the handler138 // is already serving. An import that races the window loses and is told139 // so (wailshost.Window.Pick).140 var window wailshost.Window141 host, err := oshost.New(oshost.Options{142 BundlesDir: library,143 SessionsDir: filepath.Join(data, "sessions"),144 Pick: window.Pick,145 })146 if err != nil {147 return err148 }149150 static, err := fs.Sub(seam, "static")151 if err != nil {152 return err153 }154 minter, err := authoring.NewNativeMinter(authoring.NativeMinterOptions{155 RecipesDir: filepath.Join(data, "recipes"),156 CacheDir: filepath.Join(data, "cache"),157 LibraryDir: library,158 })159 if err != nil {160 return fmt.Errorf("opening the native Atlas minter: %w", err)161 }162 handler := app.New(host, app.Options{Static: static, Minter: minter})163 window.Accept(func(name string, content io.Reader) error {164 _, err := handler.Install(name, content)165 return err166 })167 window.QueuePaths(os.Args[1:])168169 slog.Info("opening", logging.Op("desktop"), logging.Path(library),170 slog.Int("volumes", len(host.Volumes().Volumes())))171172 return wails.Run(&options.App{173 Title: "Atlas — World Explorer",174 Mac: &mac.Options{OnFileOpen: func(path string) {175 // Finder, `open`, and another application handing Atlas a file all176 // arrive here. They join launch arguments and window drops at the177 // same validated, atomic intake queue.178 window.QueuePaths([]string{path})179 }},180 // The application *is* the asset server. Every request the page makes181 // -- pages, partials, the /data plane, the events stream -- is served182 // by the same handler the headless host serves, over the webview's own183 // scheme instead of a socket. The one thing that scheme cannot carry184 // is a redirect, so the host walks those itself (redirects.go).185 AssetServer: &assetserver.Options{Handler: followRedirects(handler)},186 OnStartup: window.Opened,187 DragAndDrop: &options.DragAndDrop{188 EnableFileDrop: true,189 // The webview must never navigate to or download a dropped .atlas;190 // Wails hands the native path to Window instead.191 DisableWebViewDrop: true,192 },193 SingleInstanceLock: &options.SingleInstanceLock{194 UniqueId: appIdentifier,195 OnSecondInstanceLaunch: func(data options.SecondInstanceData) {196 window.QueuePaths(data.Args)197 },198 },199 // A map wants every pixel it can have, so the window opens filling the200 // screen it lands on rather than at a size chosen here. Not fullscreen:201 // the menu bar and the dock stay, and the window is still a window.202 // Width and Height are what it returns to when unzoomed.203 WindowStartState: options.Maximised,204 Width: 1440,205 Height: 920,206 MinWidth: 900,207 MinHeight: 600,208 })209}210211// installIncluded installs every embedded bundle into the library. After it212// returns, the embedded copies are done with: opening, rendering, analysis and213// export all read the installed files through the same paths any imported214// bundle is read through.215func installIncluded(library string) error {216 names, err := fs.Glob(included, "included/*.atlas")217 if err != nil {218 return err219 }220 for _, name := range names {221 data, err := included.ReadFile(name)222 if err != nil {223 return fmt.Errorf("%s: %w", name, err)224 }225 installed, err := vnext.InstallBytes(library, data, vnext.StandardSchema())226 if err != nil {227 return fmt.Errorf("%s: %w", name, err)228 }229 slog.Info("included volume installed", logging.Op("desktop"),230 logging.Volume(installed.Slug), logging.Stamp(vnext.ShortStamp(installed.Stamp)),231 logging.Path(installed.Locator))232 }233 return nil234}235236// dataDir is where the application keeps what is its own: the library of237// installed volumes and the session records that remember where a reader was.238// ATLAS_DATA_DIR points the whole of it somewhere else, which is how a239// development run reads and writes beside a checkout instead of in the240// reader's real library.241func dataDir() (string, error) {242 if fromEnv := os.Getenv(dataDirEnv); fromEnv != "" {243 return fromEnv, nil244 }245 base, err := os.UserConfigDir()246 if err != nil {247 return "", fmt.Errorf("no %s given and no configuration directory to fall back on: %w",248 dataDirEnv, err)249 }250 return filepath.Join(base, appIdentifier), nil251}