Files
radiantwave-legacy/internal/application/application.go
T
2026-03-13 19:07:34 -05:00

705 lines
19 KiB
Go

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
}