wraith/internal/app/app.go
Vantz Stockwell a1dce82d99 fix: wire vault persistence, connection loading, and MobaXterm import to real Go backend
Replace all TODO stubs in frontend stores with real Wails Call.ByName
bindings. The app store now calls WraithApp.IsFirstRun/CreateVault/Unlock
so vault state persists across launches. The connection store loads from
ConnectionService.ListConnections/ListGroups instead of hardcoded mock
data. The import dialog calls a new WraithApp.ImportMobaConf method that
parses the file, creates groups and connections in SQLite, and stores
host keys. ConnectionEditDialog also uses real Go CRUD calls. MainLayout
loads connections on mount after vault unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:27:50 -04:00

290 lines
8.9 KiB
Go

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/importer"
"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)
}
}
}
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
// (groups, connections, host keys) into the database.
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {
imp := &importer.MobaConfImporter{}
result, err := imp.Parse([]byte(fileContent))
if err != nil {
return nil, fmt.Errorf("parse mobaconf: %w", err)
}
// Create groups and track name -> ID mapping
groupMap := make(map[string]int64)
for _, g := range result.Groups {
created, err := a.Connections.CreateGroup(g.Name, nil)
if err != nil {
slog.Warn("failed to create group during import", "name", g.Name, "error", err)
continue
}
groupMap[g.Name] = created.ID
}
// Create connections
for _, c := range result.Connections {
var groupID *int64
if id, ok := groupMap[c.GroupName]; ok {
groupID = &id
}
_, err := a.Connections.CreateConnection(connections.CreateConnectionInput{
Name: c.Name,
Hostname: c.Hostname,
Port: c.Port,
Protocol: c.Protocol,
GroupID: groupID,
Notes: c.Notes,
})
if err != nil {
slog.Warn("failed to create connection during import", "name", c.Name, "error", err)
}
}
// Store host keys
for _, hk := range result.HostKeys {
_, err := a.db.Exec(
`INSERT OR IGNORE INTO host_keys (hostname, port, key_type, fingerprint) VALUES (?, ?, ?, ?)`,
hk.Hostname, hk.Port, hk.KeyType, hk.Fingerprint,
)
if err != nil {
slog.Warn("failed to store host key during import", "hostname", hk.Hostname, "error", err)
}
}
slog.Info("mobaconf import complete",
"groups", len(result.Groups),
"connections", len(result.Connections),
"hostKeys", len(result.HostKeys),
)
return result, nil
}