From 8b891dca0068b474f032d398664831c868ac35a7 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 06:28:32 -0400 Subject: [PATCH] feat: wire all services into Wails app entry point Create WraithApp struct in internal/app that initializes SQLite, runs migrations, seeds themes, and exposes vault management methods (IsFirstRun, CreateVault, Unlock, IsUnlocked) to the frontend. Register WraithApp, ConnectionService, ThemeService, and SettingsService as Wails v3 bound services. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/app/app.go | 167 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 16 ++++- 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 internal/app/app.go diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..aac1439 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,167 @@ +package app + +import ( + "database/sql" + "encoding/hex" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/vstockwell/wraith/internal/connections" + "github.com/vstockwell/wraith/internal/db" + "github.com/vstockwell/wraith/internal/plugin" + "github.com/vstockwell/wraith/internal/session" + "github.com/vstockwell/wraith/internal/settings" + "github.com/vstockwell/wraith/internal/theme" + "github.com/vstockwell/wraith/internal/vault" +) + +// WraithApp is the main application struct that wires together all services +// and exposes vault management methods to the frontend via Wails bindings. +type WraithApp struct { + db *sql.DB + Vault *vault.VaultService + Settings *settings.SettingsService + Connections *connections.ConnectionService + Themes *theme.ThemeService + Sessions *session.Manager + Plugins *plugin.Registry + unlocked bool +} + +// New creates and initializes the WraithApp, opening the database, running +// migrations, creating all services, and seeding built-in themes. +func New() (*WraithApp, error) { + dataDir := dataDirectory() + dbPath := filepath.Join(dataDir, "wraith.db") + + slog.Info("opening database", "path", dbPath) + database, err := db.Open(dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + if err := db.Migrate(database); err != nil { + return nil, fmt.Errorf("run migrations: %w", err) + } + + settingsSvc := settings.NewSettingsService(database) + connSvc := connections.NewConnectionService(database) + themeSvc := theme.NewThemeService(database) + sessionMgr := session.NewManager() + pluginReg := plugin.NewRegistry() + + // Seed built-in themes on every startup (INSERT OR IGNORE keeps it idempotent) + if err := themeSvc.SeedBuiltins(); err != nil { + slog.Warn("failed to seed themes", "error", err) + } + + return &WraithApp{ + db: database, + Settings: settingsSvc, + Connections: connSvc, + Themes: themeSvc, + Sessions: sessionMgr, + Plugins: pluginReg, + }, nil +} + +// dataDirectory returns the path where Wraith stores its data. +// On Windows with APPDATA set, it uses %APPDATA%\Wraith. +// On macOS/Linux with XDG_DATA_HOME or HOME, it uses the appropriate path. +// Falls back to the current working directory for development. +func dataDirectory() string { + // Windows + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "Wraith") + } + + // macOS / Linux: use XDG_DATA_HOME or fallback to ~/.local/share + if home, err := os.UserHomeDir(); err == nil { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "wraith") + } + return filepath.Join(home, ".local", "share", "wraith") + } + + // Dev fallback + return "." +} + +// IsFirstRun checks whether the vault has been set up by looking for vault_salt in settings. +func (a *WraithApp) IsFirstRun() bool { + salt, _ := a.Settings.Get("vault_salt") + return salt == "" +} + +// CreateVault sets up the vault with a master password. It generates a salt, +// derives an encryption key, and stores the salt and a check value in settings. +func (a *WraithApp) CreateVault(password string) error { + salt, err := vault.GenerateSalt() + if err != nil { + return fmt.Errorf("generate salt: %w", err) + } + + key := vault.DeriveKey(password, salt) + a.Vault = vault.NewVaultService(key) + + // Store salt as hex in settings + if err := a.Settings.Set("vault_salt", hex.EncodeToString(salt)); err != nil { + return fmt.Errorf("store salt: %w", err) + } + + // Encrypt a known check value — used to verify the password on unlock + check, err := a.Vault.Encrypt("wraith-vault-check") + if err != nil { + return fmt.Errorf("encrypt check value: %w", err) + } + if err := a.Settings.Set("vault_check", check); err != nil { + return fmt.Errorf("store check value: %w", err) + } + + a.unlocked = true + slog.Info("vault created successfully") + return nil +} + +// Unlock verifies the master password against the stored check value and +// initializes the vault service for decryption. +func (a *WraithApp) Unlock(password string) error { + saltHex, err := a.Settings.Get("vault_salt") + if err != nil || saltHex == "" { + return fmt.Errorf("vault not set up — call CreateVault first") + } + + salt, err := hex.DecodeString(saltHex) + if err != nil { + return fmt.Errorf("decode salt: %w", err) + } + + key := vault.DeriveKey(password, salt) + vs := vault.NewVaultService(key) + + // Verify by decrypting the stored check value + checkEncrypted, err := a.Settings.Get("vault_check") + if err != nil || checkEncrypted == "" { + return fmt.Errorf("vault check value missing") + } + + checkPlain, err := vs.Decrypt(checkEncrypted) + if err != nil { + return fmt.Errorf("incorrect master password") + } + if checkPlain != "wraith-vault-check" { + return fmt.Errorf("incorrect master password") + } + + a.Vault = vs + a.unlocked = true + slog.Info("vault unlocked successfully") + return nil +} + +// IsUnlocked returns whether the vault is currently unlocked. +func (a *WraithApp) IsUnlocked() bool { + return a.unlocked +} diff --git a/main.go b/main.go index b2ab1d0..d44a7d3 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,9 @@ package main import ( "embed" "log" + "log/slog" + wraithapp "github.com/vstockwell/wraith/internal/app" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -11,10 +13,22 @@ import ( var assets embed.FS func main() { + slog.Info("starting Wraith") + + wraith, err := wraithapp.New() + if err != nil { + log.Fatalf("failed to initialize: %v", err) + } + app := application.New(application.Options{ Name: "Wraith", Description: "SSH + RDP + SFTP Desktop Client", - Services: []application.Service{}, + Services: []application.Service{ + application.NewService(wraith), + application.NewService(wraith.Connections), + application.NewService(wraith.Themes), + application.NewService(wraith.Settings), + }, Assets: application.AssetOptions{ Handler: application.BundledAssetFileServer(assets), },