package app import ( "database/sql" "encoding/hex" "fmt" "log/slog" "os" "path/filepath" "github.com/vstockwell/wraith/internal/ai" "github.com/vstockwell/wraith/internal/connections" "github.com/vstockwell/wraith/internal/credentials" "github.com/vstockwell/wraith/internal/db" "github.com/vstockwell/wraith/internal/plugin" "github.com/vstockwell/wraith/internal/rdp" "github.com/vstockwell/wraith/internal/session" "github.com/vstockwell/wraith/internal/settings" "github.com/vstockwell/wraith/internal/sftp" "github.com/vstockwell/wraith/internal/ssh" "github.com/vstockwell/wraith/internal/theme" "github.com/vstockwell/wraith/internal/updater" "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 SSH *ssh.SSHService SFTP *sftp.SFTPService RDP *rdp.RDPService Credentials *credentials.CredentialService AI *ai.AIService Updater *updater.UpdateService oauthMgr *ai.OAuthManager unlocked bool } // New creates and initializes the WraithApp, opening the database, running // migrations, creating all services, and seeding built-in themes. // The version string is the build-time semver (e.g. "0.2.0") used by the // auto-updater to check for newer releases. func New(version string) (*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() // No-op output handler — Wails event emission will be wired at runtime. sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) { // TODO: Emit Wails event "ssh:output" with sessionID + data _ = sessionID _ = data }) sftpSvc := sftp.NewSFTPService() // RDP service with platform-aware backend factory. // On Windows the factory returns a FreeRDPBackend backed by libfreerdp3.dll; // on other platforms it falls back to MockBackend for development. rdpSvc := rdp.NewRDPService(func() rdp.RDPBackend { return rdp.NewProductionBackend() }) // CredentialService requires the vault to be unlocked, so it starts nil. // It is created lazily after the vault is unlocked via initCredentials(). // AI copilot services — OAuthManager starts without a vault reference; // it will be wired once the vault is unlocked. oauthMgr := ai.NewOAuthManager(settingsSvc, nil) toolRouter := ai.NewToolRouter() toolRouter.SetServices(sshSvc, sftpSvc, rdpSvc, sessionMgr, connSvc) convMgr := ai.NewConversationManager(database) aiSvc := ai.NewAIService(oauthMgr, toolRouter, convMgr) toolRouter.SetAIService(aiSvc) // 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) } updaterSvc := updater.NewUpdateService(version) return &WraithApp{ db: database, Settings: settingsSvc, Connections: connSvc, Themes: themeSvc, Sessions: sessionMgr, Plugins: pluginReg, SSH: sshSvc, SFTP: sftpSvc, RDP: rdpSvc, AI: aiSvc, Updater: updaterSvc, oauthMgr: oauthMgr, }, 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 a.initCredentials() 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 a.initCredentials() slog.Info("vault unlocked successfully") return nil } // IsUnlocked returns whether the vault is currently unlocked. func (a *WraithApp) IsUnlocked() bool { return a.unlocked } // initCredentials creates the CredentialService after the vault is unlocked // and wires the vault to services that need it (OAuth, etc.). func (a *WraithApp) initCredentials() { if a.Vault != nil { a.Credentials = credentials.NewCredentialService(a.db, a.Vault) if a.oauthMgr != nil { a.oauthMgr.SetVault(a.Vault) } } }