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 / wailshost / wailshost.go
wailshost.go 5.6K
1// Package wailshost is the window-shaped half of the desktop host: the native2// file picker, and the window handle a picker has to be asked for through.3//4// It is the one package in the application that knows what a webview is. The5// handler asks [hostenv.Hostenv.PickFile] for a file; the OS host turns that6// into an [oshost.Picker] call; this turns that call into a WKOpenPanel, a GTK7// chooser or a Windows common dialog, depending on what the binary was built8// for. Everything else the desktop host needs -- the library of .atlas files,9// the session records -- is [oshost]'s and is the same code the headless host10// runs, so the two hosts differ by exactly this file (issue #5 ยง3.3).11//12// It lives under hostenv because that is where the contract says host13// implementations live: depcheck's hostenv analyzer permits the toolkit import14// below here and nowhere else in internal/app.15package wailshost1617import (18 "context"19 "fmt"20 "io"21 "os"22 "path/filepath"23 "sync"24 "sync/atomic"2526 wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"2728 "github.com/FelineStateMachine/atlas/internal/app/hostenv"29 "github.com/FelineStateMachine/atlas/internal/logging"30 "log/slog"31)3233// Window is the live window, or the absence of one.34//35// A Wails context exists only between startup and shutdown, and a native36// dialog can only be asked for through it. [Window.Opened] takes it at37// startup -- it is the shell's OnStartup, spelled to be passed directly --38// and [Window.Pick] reads it. The zero value is a host whose window has not39// opened yet, which is a state a request can genuinely arrive in: the page is40// served over the same asset server that the window is still starting.41type Window struct {42 live atomic.Pointer[context.Context]43 mu sync.Mutex44 install func(name string, content io.Reader) error45 refresh func(context.Context)46 pending []string47 flushing bool48}4950// Accept gives every native file-open surface the application's one install51// command. Paths remain a window concern and never enter the application.52func (w *Window) Accept(install func(name string, content io.Reader) error) {53 w.mu.Lock()54 w.install = install55 w.mu.Unlock()56}5758// QueuePaths accepts launch arguments and second-instance open events. Before59// startup they wait; after startup they are installed immediately.60func (w *Window) QueuePaths(paths []string) {61 w.mu.Lock()62 w.pending = append(w.pending, atlasPaths(paths)...)63 live := w.live.Load() != nil64 w.mu.Unlock()65 if live {66 w.flush()67 }68}6970// Opened records the window context and enables native drag/drop over the71// entire application surface.72func (w *Window) Opened(ctx context.Context) {73 w.live.Store(&ctx)74 w.mu.Lock()75 if w.refresh == nil {76 w.refresh = wailsruntime.WindowReload77 }78 w.mu.Unlock()79 wailsruntime.OnFileDrop(ctx, func(_, _ int, paths []string) { w.QueuePaths(paths) })80 w.flush()81}8283func (w *Window) flush() {84 w.mu.Lock()85 if w.flushing {86 w.mu.Unlock()87 return88 }89 w.flushing = true90 w.mu.Unlock()91 installed := false92 for {93 w.mu.Lock()94 paths := append([]string(nil), w.pending...)95 w.pending = nil96 install := w.install97 refresh := w.refresh98 if len(paths) == 0 || install == nil {99 if install == nil {100 w.pending = append(paths, w.pending...)101 }102 w.flushing = false103 w.mu.Unlock()104 if installed && refresh != nil {105 if live := w.live.Load(); live != nil {106 refresh(*live)107 }108 }109 return110 }111 w.mu.Unlock()112 for _, path := range paths {113 file, err := os.Open(path)114 if err == nil {115 err = install(filepath.Base(path), file)116 closeErr := file.Close()117 if err == nil {118 err = closeErr119 }120 }121 if err != nil {122 slog.Warn("native file open refused", logging.Op("install"), logging.Path(path), slog.Any("error", err))123 } else {124 installed = true125 }126 }127 }128}129130func atlasPaths(paths []string) []string {131 accepted := make([]string, 0, len(paths))132 for _, path := range paths {133 if filepath.Ext(path) == ".atlas" {134 accepted = append(accepted, path)135 }136 }137 return accepted138}139140// Pick puts the native file picker in front of the reader and opens what they141// chose.142//143// The request's own context is deliberately unused: a native panel is modal144// on the window, not on the HTTP request that raised it, and a reader who145// navigates away mid-dialog should get their file picker dismissed by146// dismissing it rather than by a cancelled fetch. What the panel needs is the147// window's context, which is the one this holds.148//149// The three answers of the contract are all reachable: a file, ErrNoSelection150// when the reader closed the panel with nothing chosen, and ErrNotAvailable151// before there is a window to be modal on -- refused rather than queued,152// because "there is no window yet" is an answer and a hung import is not.153func (w *Window) Pick(context.Context) (io.ReadCloser, string, error) {154 held := w.live.Load()155 if held == nil {156 return nil, "", fmt.Errorf("choosing a bundle: %w", hostenv.ErrNotAvailable)157 }158 chosen, err := wailsruntime.OpenFileDialog(*held, wailsruntime.OpenDialogOptions{159 Title: "Add a volume",160 Filters: []wailsruntime.FileFilter{161 {DisplayName: "Atlas volumes (*.atlas)", Pattern: "*.atlas"},162 },163 })164 if err != nil {165 return nil, "", fmt.Errorf("choosing a bundle: %w", err)166 }167 if chosen == "" {168 return nil, "", hostenv.ErrNoSelection169 }170 file, err := os.Open(chosen)171 if err != nil {172 return nil, "", fmt.Errorf("opening the chosen bundle: %w", err)173 }174 // The store is handed a stream and the name it was offered under, never a175 // path: the name is what the installed file is called, and where it came176 // from is the picker's business and ends here.177 return file, filepath.Base(chosen), nil178}