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>
290 lines
8.9 KiB
Go
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
|
|
}
|