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), },