package application import ( "fmt" "io" "net/http" "os" "path/filepath" "sync/atomic" "time" "github.com/go-gl/gl/v3.3-core/gl" "github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/ttf" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/graphics" "rhemawavetech.com/rhemawave/internal/keybinds" "rhemawavetech.com/rhemawave/internal/license" "rhemawavetech.com/rhemawave/internal/logger" "rhemawavetech.com/rhemawave/internal/mixer" "rhemawavetech.com/rhemawave/internal/network" "rhemawavetech.com/rhemawave/internal/page" "rhemawavetech.com/rhemawave/internal/shaderManager" ) const ( targetFPS = 60.0 volumeStepSize = 16 openGLMajorVersion = 3 openGLMinorVersion = 3 apiURL = "http://localhost:8000" apiPath = "/api/v1/licenseKey/validate" audioFileURL = "https://files.neilclack.com/audio.wav" ) type Application struct { Window *sdl.Window License *license.Response pageStack []page.Page pendingAction func() running bool } // ---------------------------------------------------------------------------- // Main Entry Point // ---------------------------------------------------------------------------- func Run() { logger.Info("Starting application") appDataDir, err := initializeApplicationDirectory() if err != nil { logger.Fatal("Failed to initialize application directory: %v", err) } logger.Info("App directory: %s", appDataDir) logger.AddFile(filepath.Join(appDataDir, "log.txt")) if err := initializeDatabase(appDataDir); err != nil { logger.Fatal("Database initialization failed: %v", err) } app := &Application{} app.initializeSDLAndOpenGL() defer app.cleanup() app.initializeManagers() app.initializeAudio() app.validateLicenseKey() app.startupPageStack() app.runEventLoop() } // ---------------------------------------------------------------------------- // Initialization Functions // ---------------------------------------------------------------------------- func initializeApplicationDirectory() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get user home directory: %w", err) } appDataDir := filepath.Join(homeDir, ".local", "share", "rhemawave") if err := os.MkdirAll(appDataDir, 0755); err != nil { return "", fmt.Errorf("could not create application data directory %s: %w", appDataDir, err) } testFile := filepath.Join(appDataDir, ".access_test") f, err := os.Create(testFile) if err != nil { return "", fmt.Errorf("application data directory %s is not writable: %w", appDataDir, err) } f.Close() os.Remove(testFile) return appDataDir, nil } func initializeDatabase(appDataDir string) error { dbPath := filepath.Join(appDataDir, "data.db") if err := db.InitDatabase(dbPath); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } logger.AddDatabase(db.DB) return nil } type progressWriter struct { w io.Writer written *atomic.Int64 } func (pw *progressWriter) Write(p []byte) (int, error) { n, err := pw.w.Write(p) pw.written.Add(int64(n)) return n, err } // ensureAudioFile checks whether the selected audio file exists. If it does, // it returns nil. Otherwise it starts a background download and returns a // DownloadProgressPage that tracks the download so it can be pushed onto the // page stack. func ensureAudioFile() *page.DownloadProgressPage { assetsDir, err := db.GetConfigValue("assets_dir") if err != nil { logger.Error("Failed to get assets_dir: %v", err) return nil } selectedAudio, err := db.GetConfigValue("selected_audio") if err != nil || selectedAudio == "" { logger.Warning("No selected_audio configured") return nil } audioPath := filepath.Join(assetsDir, selectedAudio) if _, err := os.Stat(audioPath); err == nil { logger.Info("Audio file found: %s", audioPath) return nil } logger.Info("Audio file not found at %s, downloading from %s", audioPath, audioFileURL) totalBytes := &atomic.Int64{} bytesWritten := &atomic.Int64{} done := &atomic.Bool{} hasError := &atomic.Bool{} downloadPage := &page.DownloadProgressPage{ TotalBytes: totalBytes, BytesWritten: bytesWritten, Done: done, HasError: hasError, } go func() { defer done.Store(true) if err := os.MkdirAll(filepath.Dir(audioPath), 0755); err != nil { logger.Error("Failed to create audio directory: %v", err) hasError.Store(true) return } resp, err := http.Get(audioFileURL) if err != nil { logger.Error("Failed to download audio file: %v", err) hasError.Store(true) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logger.Error("Audio download returned status %d", resp.StatusCode) hasError.Store(true) return } if resp.ContentLength > 0 { totalBytes.Store(resp.ContentLength) } tmp := audioPath + ".tmp" f, err := os.Create(tmp) if err != nil { logger.Error("Failed to create audio file: %v", err) hasError.Store(true) return } pw := &progressWriter{w: f, written: bytesWritten} _, err = io.Copy(pw, resp.Body) f.Close() if err != nil { os.Remove(tmp) logger.Error("Failed to write audio file: %v", err) hasError.Store(true) return } if err := os.Rename(tmp, audioPath); err != nil { os.Remove(tmp) logger.Error("Failed to move audio file into place: %v", err) hasError.Store(true) return } logger.Info("Audio file downloaded successfully (%d bytes)", bytesWritten.Load()) }() return downloadPage } func (app *Application) initializeSDLAndOpenGL() { logger.Info("Initializing SDL") if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil { logger.Fatal("SDL init failed: %v", err) } logger.Info("Initializing TTF") if err := ttf.Init(); err != nil { logger.Fatal("TTF init failed: %v", err) } sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, openGLMajorVersion) sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, openGLMinorVersion) sdl.GLSetAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE) sdl.ShowCursor(sdl.DISABLE) sdl.SetRelativeMouseMode(true) logger.Info("Querying display mode") displayMode, err := sdl.GetDisplayMode(0, 0) if err != nil { logger.Fatal("Display mode query failed: %v", err) } if displayMode.W == 0 || displayMode.H == 0 { logger.Fatal("Invalid display dimensions: %dx%d", displayMode.W, displayMode.H) } logger.Info("Display: %dx%d @ %dHz", displayMode.W, displayMode.H, displayMode.RefreshRate) logger.Info("Creating window") window, err := sdl.CreateWindow( "Rhema Wave", sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, displayMode.W, displayMode.H, sdl.WINDOW_OPENGL|sdl.WINDOW_FULLSCREEN_DESKTOP|sdl.WINDOW_SHOWN, ) if err != nil { logger.Fatal("Window creation failed: %v", err) } app.Window = window logger.Info("Creating OpenGL context") if _, err := app.Window.GLCreateContext(); err != nil { logger.Fatal("OpenGL context creation failed: %v", err) } logger.Info("Initializing go-gl") if err := gl.InitWithProcAddrFunc(sdl.GLGetProcAddress); err != nil { logger.Fatal("go-gl initialization failed: %v", err) } logger.Info("OpenGL version: %s", gl.GoStr(gl.GetString(gl.VERSION))) logger.Info("OpenGL renderer: %s", gl.GoStr(gl.GetString(gl.RENDERER))) if err := sdl.GLSetSwapInterval(1); err != nil { logger.Warning("Failed to enable vsync: %v", err) } w, h := app.Window.GLGetDrawableSize() logger.Info("Drawable size: %dx%d", w, h) logger.Info("Initializing virtual framebuffer (%dx%d -> %dx%d)", graphics.VirtualWidth, graphics.VirtualHeight, w, h) if err := graphics.InitVirtualFramebuffer(w, h); err != nil { logger.Fatal("Virtual framebuffer init failed: %v", err) } app.initKeybinds() logger.Info("SDL and OpenGL initialization complete") } func (app *Application) initializeManagers() { logger.Info("Initializing ShaderManager") if err := shaderManager.InitShaderManager(); err != nil { logger.Fatal("ShaderManager initialization failed: %v", err) } logger.Info("Initializing FontManager") if err := fontManager.InitFontManager(); err != nil { logger.Fatal("FontManager initialization failed: %v", err) } } func (app *Application) initializeAudio() { logger.Info("Initializing audio") audioDevice, err := db.GetConfigValue("audio_device_name") if err != nil { logger.Warning("Failed to get audio device name from config: %v", err) } if err := mixer.Init(audioDevice); err != nil { logger.Error("Unable to initialize audio device '%s': %v", audioDevice, err) logger.Error("Audio will be unavailable until a device is configured") } else { logger.Info("Audio initialized with device: '%s'", audioDevice) } } func (app *Application) cleanup() { logger.Info("Cleaning up") for _, p := range app.pageStack { p.Destroy() } app.pageStack = nil graphics.DestroyVirtualFramebuffer() if app.Window != nil { app.Window.Destroy() } ttf.Quit() sdl.Quit() logger.Info("Cleanup complete") logger.Close() } // ---------------------------------------------------------------------------- // Event Loop // ---------------------------------------------------------------------------- func (app *Application) runEventLoop() { if len(app.pageStack) == 0 { logger.Fatal("No initial page loaded, pageStack is empty") } const frameDelay = time.Second / targetFPS app.running = true lastTime := time.Now() logger.Info("Entering event loop") for app.running { frameStart := time.Now() app.handleEvents() if !app.running { break } deltaTime := float32(time.Since(lastTime).Seconds()) lastTime = time.Now() app.updateCurrentPage(deltaTime) app.renderFrame() app.applyPendingAction() frameTime := time.Since(frameStart) if frameTime < frameDelay { time.Sleep(frameDelay - frameTime) } } logger.Info("Event loop exited") } func (app *Application) handleEvents() { for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() { switch e := event.(type) { case *sdl.QuitEvent: app.running = false return case *sdl.TextInputEvent: logger.Info("[EVENT] TextInputEvent: text=%q, IsTextInputActive=%v", e.GetText(), sdl.IsTextInputActive()) case *sdl.KeyboardEvent: eventType := "KEYDOWN" if e.Type == sdl.KEYUP { eventType = "KEYUP" } logger.Info("[EVENT] KeyboardEvent: type=%s, sym=%d (%s), scancode=%d, mod=0x%04x, IsTextInputActive=%v", eventType, e.Keysym.Sym, sdl.GetKeyName(e.Keysym.Sym), e.Keysym.Scancode, e.Keysym.Mod, sdl.IsTextInputActive()) if e.Type == sdl.KEYDOWN && e.Repeat == 0 { isCtrlPressed := (e.Keysym.Mod&sdl.KMOD_CTRL) != 0 || (e.Keysym.Mod&sdl.KMOD_GUI) != 0 if e.Keysym.Sym == sdl.K_c && isCtrlPressed { continue } keybinds.PerformAction(e) } } if currentPage := app.currentPage(); currentPage != nil { if err := currentPage.HandleEvent(&event); err != nil { logger.Error("Page event handler error: %v", err) } } } } func (app *Application) updateCurrentPage(deltaTime float32) { if currentPage := app.currentPage(); currentPage != nil { currentPage.Update(deltaTime) } } func (app *Application) renderFrame() { graphics.BeginVirtualRender() gl.ClearColor(0.0, 0.0, 0.0, 1.0) gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) if currentPage := app.currentPage(); currentPage != nil { currentPage.Render() } graphics.BlitToScreen() app.Window.GLSwap() } // ---------------------------------------------------------------------------- // Keybind Registration // ---------------------------------------------------------------------------- func (app *Application) initKeybinds() { keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() { app.PushPage(&page.Settings{}) }) keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() { downloadPage := ensureAudioFile() if downloadPage != nil { downloadPage.NextPage = &page.ScrollerPage{} app.PushPage(downloadPage) } else { app.PushPage(&page.ScrollerPage{}) } }) keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { app.UnwindToPage(&page.Welcome{}) }) keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() { app.Stop() }) keybinds.Register(sdl.K_UP, sdl.KMOD_SHIFT, func() { mixer.SetVolume128(volumeStepSize) }) keybinds.Register(sdl.K_DOWN, sdl.KMOD_SHIFT, func() { mixer.SetVolume128(-volumeStepSize) }) } func (app *Application) Stop() { app.running = false } // ---------------------------------------------------------------------------- // Page Management // ---------------------------------------------------------------------------- func (app *Application) currentPage() page.Page { if len(app.pageStack) == 0 { return nil } return app.pageStack[len(app.pageStack)-1] } func (app *Application) applyPendingAction() { if app.pendingAction != nil { app.pendingAction() app.pendingAction = nil } } func (app *Application) schedulePush(p page.Page) { app.pendingAction = func() { if err := p.Init(app); err != nil { logger.Error("Failed to init page on push: %v", err) return } if topPage := app.currentPage(); topPage != nil { topPage.Destroy() } app.pageStack = append(app.pageStack, p) } } func (app *Application) scheduleSwitch(p page.Page) { app.pendingAction = func() { if topPage := app.currentPage(); topPage != nil { topPage.Destroy() app.pageStack = app.pageStack[:len(app.pageStack)-1] } if err := p.Init(app); err != nil { logger.Error("Failed to init page on switch: %v", err) return } app.pageStack = append(app.pageStack, p) } } func (app *Application) scheduleUnwind(targetPage page.Page) { app.pendingAction = func() { foundIndex := -1 for i := len(app.pageStack) - 1; i >= 0; i-- { if app.pageStack[i] == targetPage { foundIndex = i break } } if foundIndex != -1 { for i := len(app.pageStack) - 1; i > foundIndex; i-- { app.pageStack[i].Destroy() } app.pageStack = app.pageStack[:foundIndex+1] } else { if topPage := app.currentPage(); topPage != nil { topPage.Destroy() app.pageStack = app.pageStack[:len(app.pageStack)-1] } if err := targetPage.Init(app); err != nil { logger.Error("Failed to init page on unwind: %v", err) return } app.pageStack = append(app.pageStack, targetPage) } } } func (app *Application) SchedulePop() { app.pendingAction = func() { if len(app.pageStack) == 0 { return } topPage := app.pageStack[len(app.pageStack)-1] topPage.Destroy() app.pageStack = app.pageStack[:len(app.pageStack)-1] if newTop := app.currentPage(); newTop != nil { if err := newTop.Init(app); err != nil { logger.Error("Failed to re-init page after pop: %v", err) } } } } // ---------------------------------------------------------------------------- // Public Page Navigation Interface // ---------------------------------------------------------------------------- func (app *Application) PushPage(p page.Page) { app.schedulePush(p) } func (app *Application) SwitchPage(p page.Page) { app.scheduleSwitch(p) } func (app *Application) UnwindToPage(p page.Page) { app.scheduleUnwind(p) } func (app *Application) GetDrawableSize() (w, h int32) { return graphics.VirtualWidth, graphics.VirtualHeight } func (app *Application) PopPage() { app.SchedulePop() } // ---------------------------------------------------------------------------- // License Validation // ---------------------------------------------------------------------------- func (app *Application) validateLicenseKey() { logger.Info("Validating license key") validator := license.NewValidator(apiURL, apiPath) key, err := db.GetConfigValue("license_key") if err != nil { logger.Error("Failed to fetch license key from database: %v", err) } email, err := db.GetConfigValue("email_address") if err != nil { logger.Error("Failed to fetch email from database: %v", err) } result, err := validator.Validate(key, email) if err != nil { logger.Error("License validation failed: %v", err) app.License = nil return } logger.Info("License validation successful") app.License = result } // ---------------------------------------------------------------------------- // Startup Page Stack // ---------------------------------------------------------------------------- func (app *Application) startupPageStack() { app.PushPage(&page.Welcome{}) app.applyPendingAction() key, _ := db.GetConfigValue("license_key") if key == "" { logger.Info("No license key found, pushing license key page") app.PushPage(&page.LicenseKeyPage{}) app.applyPendingAction() } else { // Ensure the license key file exists for the updater script. // Older versions only stored the key in the database. if homeDir, err := os.UserHomeDir(); err == nil { keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key") if _, err := os.Stat(keyPath); os.IsNotExist(err) { logger.Info("License key file missing, writing from database") if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil { logger.Error("Failed to create license key directory: %v", err) } else if err := os.WriteFile(keyPath, []byte(key), 0644); err != nil { logger.Error("Failed to write license key file: %v", err) } } } } email, _ := db.GetConfigValue("email_address") if email == "" { logger.Info("No email address found, pushing email address page") app.PushPage(&page.EmailAddressPage{}) app.applyPendingAction() } // TOS pages — must be accepted before anything else. // Pushed last so they sit on top of the stack (shown first). sections, err := db.GetTOSSections() if err != nil { logger.Error("Failed to load TOS sections: %v", err) } // Push in reverse order so section 1 is on top of the stack for i := len(sections) - 1; i >= 0; i-- { s := sections[i] configKey := fmt.Sprintf("tos_section_%d_accepted", s.SectionNumber) accepted, _ := db.GetConfigValue(configKey) if accepted != "true" { app.PushPage(&page.TOSPage{ Title: s.Title, Content: s.Content, ConfigKey: configKey, }) app.applyPendingAction() } } // Internet check — pushed last so it appears first if no internet. // Skip WiFi setup if ethernet is connected (it auto-configures) or if // internet is already reachable through any interface. nm, err := network.New() if err != nil { logger.Warning("Could not initialize network manager: %v", err) } else { status, err := nm.CheckInternetConnection() if err != nil { logger.Warning("Network connectivity check failed: %v", err) } else { switch status.StatusCode { case network.StatusEthernetConnectedInternetUp, network.StatusEthernetConnectedNoInternet, network.StatusWifiConnectedInternetUp: // Internet is up or ethernet is connected and will auto-configure default: logger.Info("No internet connection, pushing WiFi setup page") app.PushPage(&page.WiFiSetupPage{}) app.applyPendingAction() } } } } func (app *Application) Quit() { logger.Info("User has quit the application") app.running = false }