package application import ( "errors" "io/fs" "os" "path/filepath" "time" "github.com/go-gl/gl/v3.3-core/gl" "github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/ttf" "gorm.io/gorm" "rhemawavetech.com/rhemawave/internal/assets" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/download" "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" "rhemawavetech.com/rhemawave/internal/sync" ) const ( targetFPS = 60.0 openGLMajorVersion = 3 openGLMinorVersion = 3 // volumeStepSize = 16 // apiURL = "https://portal.rhemawavetech.com/api/v1" ) type State int const ( FIRST_RUN State = iota NEEDS_INTERNET NEEDS_LICENSE_KEY NEEDS_EMAIL_ADDRESS DOWNLOADING_ASSETS READY ) type Application struct { Window *sdl.Window License *license.Response pageStack []page.Page pendingAction func() running bool DB *gorm.DB PageRouter *page.Router state State downloadsComplete bool downloadFailed bool } var lastNetworkCheckTime time.Time var networkIsUp bool // DataDirFor returns the per-user data directory for the application. func DataDirFor(homeDir string) string { return filepath.Join(homeDir, ".local", "share", "radiantwave") } // ---------------------------------------------------------------------------- // Main Entry Point // ---------------------------------------------------------------------------- func Run(bundled fs.FS) { var err error var homeDir string var dataDir string var DB *gorm.DB networkIsUp = false logger.Info("Starting Application") homeDir, err = os.UserHomeDir() if err != nil { logger.Error("unable to fetch user $HOME directory: %v", err) return } dataDir = DataDirFor(homeDir) if err := os.MkdirAll(dataDir, 0755); err != nil { logger.Error("unable to create data directory %s %v", dataDir, err) return } testFile := filepath.Join(dataDir, ".access_test") f, err := os.Create(testFile) if err != nil { logger.Error("data %s is not writeable: %v", dataDir, err) return } f.Close() os.Remove(testFile) logger.AddFile(filepath.Join(dataDir, "log.txt")) logger.SetLevel = logger.LevelInfo DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite")) if err != nil { logger.Error("application failed to connect to the database: %v", err) return } if err = db.Migrate(DB); err != nil { logger.Error("application failed to perform database migrations: %v", err) return } firstRun := false // Populate the initial Config cache OR create defaults cfg, err := db.GetConfig(DB) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { firstRun = true cfg = &db.AppConfig{ Model: gorm.Model{ID: 1}, DataDir: dataDir, AssetsDir: dataDir, SystemLevel: "BASIC", SelectedAudioDevice: "default", InitialFontSize: 128, StandardFontSize: 32, LinePattern: "fibonacci", AudioEnabled: true, SavedVolume: 1.0, } if err := DB.Create(cfg).Error; err != nil { logger.Error("failed to create config: %v", err) return } } } logger.AddDatabase(DB) // Ensure bundled static assets (fonts, shaders, tos) are present in the // assets directory before any manager tries to load them. config, err := db.GetConfig(DB) if err != nil { panic(err) } if err := assets.Provision(bundled, config.AssetsDir); err != nil { logger.Error("failed to provision bundled assets into %s: %v", config.AssetsDir, err) } app := &Application{ DB: DB, } logger.Debug("==== Debug Statement ====") if firstRun { app.state = FIRST_RUN } w, h := app.initializeSDLAndOpenGL() defer app.cleanup() app.initializeManagers() app.initializeAudio() // Create a buffered channel so log writes don't slow down downloads. downloadProgressChan := make(chan download.DownloadStatus, 1000) downloadDone := make(chan struct{}) app.PageRouter = page.NewRouter(page.PageCtx{ DbPtr: app.DB, W: w, H: h, MessageChannel: downloadProgressChan, DownloadDone: downloadDone, }) // Sync the proclamations from the API to the Database sync.SyncProclamations(DB) sync.SyncCategories(DB) sync.SyncFrequencies(DB) // audioFileNames := []string{ // "OriginalAudio.wav", // "174Hz.wav", // "396Hz.wav", // "417Hz.wav", // "432Hz.wav", // "639Hz.wav", // "741Hz.wav", // "852Hz.wav", // "963Hz.wav", // "1111Hz.wav", // "14Hz_Binaural.wav", // "40Hz_Binaural.wav", // "AlphaWaves.wav", // "PinealGland.wav", // } audioDir := filepath.Join(homeDir, ".radiantwave", "audio") _, err = os.Stat(audioDir) if os.IsNotExist(err) { if err = os.MkdirAll(audioDir, 0755); err != nil { logger.Error("unable to create audio directory: %v", err) } } go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, app.DB, audioDir, "https://files.radiantwavetech.com/audio/") app.runEventLoop() } // computeState determines what state the application should be in based on // current conditions, without performing any navigation. It returns the first // unmet prerequisite in priority order: network > email > license > downloads > ready. func (app *Application) computeState() State { if app.state == READY { return READY } // Check if downloads have finished (non-blocking) if !app.downloadsComplete { select { case <-app.PageRouter.Context.DownloadDone: app.downloadsComplete = true default: } } // Check if there is an internet connection if !networkIsUp { if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { lastNetworkCheckTime = time.Now() if !network.HasInternet() { return NEEDS_INTERNET } networkIsUp = true // internet confirmed, don't check again } else { return app.state // throttled, can't determine yet } } // Check for email address config, err := db.GetConfig(app.DB) if err != nil { panic(err) } // A rather annoying, insecure, but sometimes necessary "print the config" statement // data, err := json.MarshalIndent(config, "", " ") // if err != nil { // logger.Error("failed to marshal config: %v", err) // } else { // logger.Debug("AppConfig: %s", data) // } if config.EmailAddress == "" { return NEEDS_EMAIL_ADDRESS } // Check for license key config, err = db.GetConfig(app.DB) if err != nil { panic(err) } if config.LicenseKey == "" { return NEEDS_LICENSE_KEY } // All config prerequisites met -- now wait for downloads to finish if !app.downloadsComplete { return DOWNLOADING_ASSETS } return READY } // checkState keeps watch of the application state and sets pages // accordingly. This function runs once per frame. func (app *Application) checkState() { newState := app.computeState() if newState == app.state { return } oldState := app.state app.state = newState // Both DOWNLOADING_ASSETS and READY map to the HOME page. // Skip re-navigation when transitioning between them to avoid // destroying and recreating the Home page (which would close // the already-closed DownloadDone channel). oldIsHome := oldState == DOWNLOADING_ASSETS || oldState == READY newIsHome := newState == DOWNLOADING_ASSETS || newState == READY if oldIsHome && newIsHome { return } switch newState { case NEEDS_INTERNET: app.PageRouter.NavigateTo(page.WIFI) case NEEDS_EMAIL_ADDRESS: logger.Warning("No email address detected") app.PageRouter.NavigateTo(page.EMAIL_ADDRESS) case NEEDS_LICENSE_KEY: app.PageRouter.NavigateTo(page.LICENSE_KEY) case DOWNLOADING_ASSETS, READY: app.PageRouter.NavigateTo(page.HOME) } } func (app *Application) initializeSDLAndOpenGL() (W, H int32) { 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") return graphics.VirtualWidth, graphics.VirtualHeight } func (app *Application) initializeManagers() { logger.Info("Initializing ShaderManager") config, err := db.GetConfig(app.DB) if err != nil { panic(err) } shaderDir := filepath.Join(config.AssetsDir, "shaders") logger.Info("Loading shaders from %s", shaderDir) if err := shaderManager.InitShaderManager(shaderDir); err != nil { logger.Fatal("ShaderManager initialization failed: %v", err) } logger.Info("Initializing FontManager") if err := fontManager.InitFontManager(config); err != nil { logger.Fatal("FontManager initialization failed: %v", err) } } func (app *Application) initializeAudio() { logger.Info("Initializing audio") config, err := db.GetConfig(app.DB) if err != nil { panic(err) } if err := mixer.Init(config, app.refreshCache); err != nil { logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err) logger.Error("Audio will be unavailable until a device is configured") } else { logger.Info("Audio initialized with device: '%s'", config.SelectedAudioDevice) } } func (app *Application) refreshCache() {} func (app *Application) cleanup() { logger.Info("Cleaning up") if app.PageRouter != nil && app.PageRouter.ActivePage != nil { app.PageRouter.ActivePage.Destroy() } 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 app.PageRouter == nil || app.PageRouter.ActivePage == nil { logger.Fatal("No initial page loaded, Router 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.checkState() app.handleEvents() if !app.running { break } deltaTime := float32(time.Since(lastTime).Seconds()) lastTime = time.Now() app.updateCurrentPage(deltaTime) app.renderFrame() 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.KeyboardEvent: 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.PageRouter.ActivePage; 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.PageRouter.ActivePage; 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.PageRouter.ActivePage; currentPage != nil { currentPage.Render() } graphics.BlitToScreen() app.Window.GLSwap() } // ---------------------------------------------------------------------------- // Keybind Registration // ---------------------------------------------------------------------------- func (app *Application) initKeybinds() { keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() { if app.state != READY && app.state != DOWNLOADING_ASSETS { return } app.PageRouter.NavigateTo(page.SETTINGS) }) keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() { if app.state != READY && app.state != DOWNLOADING_ASSETS { return } app.PageRouter.NavigateTo(page.SCROLLER) }) keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { if app.state != READY && app.state != DOWNLOADING_ASSETS { return } app.PageRouter.NavigateTo(page.HOME) }) keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() { app.Stop() }) } func (app *Application) Stop() { app.running = false } func (app *Application) Quit() { logger.Info("User has quit the application") app.running = false }