package app import ( "context" "fmt" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/collection" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/session" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/auth" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/httpclient" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/keycache" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/passwordstore" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/ratelimiter" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/search" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/securitylog" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/storagemanager" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/sync" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/tokenmanager" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils" ) // Application is the main Wails application struct type Application struct { ctx context.Context logger *zap.Logger config config.ConfigService authService *auth.Service tokenManager *tokenmanager.Manager passwordStore *passwordstore.Service keyCache *keycache.Service rateLimiter *ratelimiter.Service httpClient *httpclient.Service syncService sync.Service storageManager *storagemanager.Manager securityLog *securitylog.Service searchService search.SearchService } // ProvideApplication creates the Application for Wire func ProvideApplication( logger *zap.Logger, configService config.ConfigService, authService *auth.Service, tokenManager *tokenmanager.Manager, passwordStore *passwordstore.Service, keyCache *keycache.Service, rateLimiter *ratelimiter.Service, httpClient *httpclient.Service, syncService sync.Service, storageManager *storagemanager.Manager, securityLog *securitylog.Service, searchService search.SearchService, ) *Application { return &Application{ logger: logger, config: configService, authService: authService, tokenManager: tokenManager, passwordStore: passwordStore, keyCache: keyCache, rateLimiter: rateLimiter, httpClient: httpClient, syncService: syncService, storageManager: storageManager, securityLog: securityLog, searchService: searchService, } } // getFileRepo returns the file repository for the current user. // Returns nil if no user is logged in (storage not initialized). func (a *Application) getFileRepo() file.Repository { return a.storageManager.GetFileRepository() } // mustGetFileRepo returns the file repository for the current user. // Logs an error and returns a no-op repository if storage is not initialized. // Use this in places where you expect the user to be logged in. // The returned repository will never be nil - it returns a safe no-op implementation // if the actual repository is not available. func (a *Application) mustGetFileRepo() file.Repository { repo := a.storageManager.GetFileRepository() if repo == nil { a.logger.Error("File repository not available - user storage not initialized") return &noOpFileRepository{} } return repo } // getCollectionRepo returns the collection repository for the current user. // Returns nil if no user is logged in (storage not initialized). func (a *Application) getCollectionRepo() collection.Repository { return a.storageManager.GetCollectionRepository() } // noOpFileRepository is a safe no-op implementation of file.Repository // that returns empty results instead of causing nil pointer dereferences. // This is used when the actual repository is not available (user not logged in). type noOpFileRepository struct{} func (r *noOpFileRepository) Get(id string) (*file.File, error) { return nil, fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) List() ([]*file.File, error) { return []*file.File{}, fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) ListByCollection(collectionID string) ([]*file.File, error) { return []*file.File{}, fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) Create(f *file.File) error { return fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) Update(f *file.File) error { return fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) Delete(id string) error { return fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) ListByStatus(status file.SyncStatus) ([]*file.File, error) { return []*file.File{}, fmt.Errorf("storage not initialized - user must be logged in") } func (r *noOpFileRepository) Exists(id string) (bool, error) { return false, fmt.Errorf("storage not initialized - user must be logged in") } // Startup is called when the app starts (Wails lifecycle hook) func (a *Application) Startup(ctx context.Context) { a.ctx = ctx a.logger.Info("MapleFile desktop application started") a.securityLog.LogAppLifecycle(securitylog.EventAppStart) // Check if there's a valid session from a previous run session, err := a.authService.GetCurrentSession(ctx) if err != nil { a.logger.Debug("No existing session on startup", zap.Error(err)) return } if session == nil { a.logger.Info("No session found on startup") return } if !session.IsValid() { a.logger.Info("Session expired on startup, clearing", zap.Time("expired_at", session.ExpiresAt)) _ = a.authService.Logout(ctx) return } // Valid session found - restore it a.logger.Info("Resuming valid session from previous run", zap.String("user_id", session.UserID), zap.String("email", utils.MaskEmail(session.Email)), zap.Time("expires_at", session.ExpiresAt)) // Restore tokens to API client if err := a.authService.RestoreSession(ctx, session); err != nil { a.logger.Error("Failed to restore session", zap.Error(err)) return } // SECURITY: Validate session with server before fully restoring // This prevents using stale/revoked sessions from previous runs if err := a.validateSessionWithServer(ctx, session); err != nil { a.logger.Warn("Session validation with server failed, clearing session", zap.String("email", utils.MaskEmail(session.Email)), zap.Error(err)) _ = a.authService.Logout(ctx) return } a.logger.Info("Session validated with server successfully") // Initialize user-specific storage for the logged-in user if err := a.storageManager.InitializeForUser(session.Email); err != nil { a.logger.Error("Failed to initialize user storage", zap.Error(err)) _ = a.authService.Logout(ctx) return } a.logger.Info("User storage initialized", zap.String("email", utils.MaskEmail(session.Email))) // Initialize search index for the logged-in user if err := a.searchService.Initialize(ctx, session.Email); err != nil { a.logger.Error("Failed to initialize search index", zap.Error(err)) // Don't fail startup if search initialization fails - it's not critical // The app can still function without search } else { a.logger.Info("Search index initialized", zap.String("email", utils.MaskEmail(session.Email))) // Rebuild search index from local data in the background userEmail := session.Email // Capture email before goroutine go func() { if err := a.rebuildSearchIndexForUser(userEmail); err != nil { a.logger.Warn("Failed to rebuild search index on startup", zap.Error(err)) } }() } // Start token manager for automatic refresh a.tokenManager.Start() a.logger.Info("Token manager started for resumed session") // Run background cleanup of deleted files go a.cleanupDeletedFiles() } // validateSessionWithServer validates the stored session by making a request to the server. // This is a security measure to ensure the session hasn't been revoked server-side. func (a *Application) validateSessionWithServer(ctx context.Context, session *session.Session) error { apiClient := a.authService.GetAPIClient() if apiClient == nil { return fmt.Errorf("API client not available") } // Set tokens in the API client apiClient.SetTokens(session.AccessToken, session.RefreshToken) // Make a lightweight request to validate the token // GetMe is a good choice as it's a simple authenticated endpoint _, err := apiClient.GetMe(ctx) if err != nil { return fmt.Errorf("server validation failed: %w", err) } return nil } // Shutdown is called when the app shuts down (Wails lifecycle hook) func (a *Application) Shutdown(ctx context.Context) { a.logger.Info("MapleFile desktop application shutting down") a.securityLog.LogAppLifecycle(securitylog.EventAppShutdown) // Calculate timeout from Wails context timeout := 3 * time.Second if deadline, ok := ctx.Deadline(); ok { remaining := time.Until(deadline) if remaining > 500*time.Millisecond { // Leave 500ms buffer for other cleanup timeout = remaining - 500*time.Millisecond } else if remaining > 0 { timeout = remaining } else { timeout = 100 * time.Millisecond } } // Stop token manager gracefully stopCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := a.tokenManager.Stop(stopCtx); err != nil { a.logger.Error("Token manager shutdown error", zap.Error(err)) } // Cleanup password store (destroy RAM enclaves) a.logger.Info("Clearing all passwords from secure RAM") a.passwordStore.Cleanup() a.logger.Info("Password cleanup completed") // Cleanup key cache (destroy cached master keys) a.logger.Info("Clearing all cached master keys from secure memory") a.keyCache.Cleanup() a.logger.Info("Key cache cleanup completed") // Cleanup search index a.logger.Info("Closing search index") if err := a.searchService.Close(); err != nil { a.logger.Error("Search index close error", zap.Error(err)) } else { a.logger.Info("Search index closed successfully") } // Cleanup user-specific storage a.logger.Info("Cleaning up user storage") a.storageManager.Cleanup() a.logger.Info("User storage cleanup completed") a.logger.Sync() }