monorepo/native/desktop/maplefile/internal/config/config.go

270 lines
9.7 KiB
Go

// Package config provides a unified API for managing application configuration
// Location: monorepo/native/desktop/maplefile/internal/config/config.go
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
const (
// AppNameBase is the base name of the desktop application
AppNameBase = "maplefile"
// AppNameDev is the app name used in development mode
AppNameDev = "maplefile-dev"
)
// BuildMode is set at compile time via -ldflags
// Example: go build -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=dev"
// This must be set alongside the app.BuildMode for consistent behavior.
var BuildMode string
// GetAppName returns the appropriate app name based on the current mode.
// In dev mode, returns "maplefile-dev" to keep dev and production data separate.
// In production mode (or when mode is not set), returns "maplefile".
func GetAppName() string {
mode := GetBuildMode()
if mode == "dev" || mode == "development" {
return AppNameDev
}
return AppNameBase
}
// GetBuildMode returns the current build mode from environment or compile-time variable.
// Priority: 1) Environment variable, 2) Compile-time variable, 3) Default to production
// This is used early in initialization before the full app is set up.
func GetBuildMode() string {
// Check environment variable first
if mode := os.Getenv("MAPLEFILE_MODE"); mode != "" {
return mode
}
// Check compile-time variable
if BuildMode != "" {
return BuildMode
}
// Default to production (secure default)
return "production"
}
// Config holds all application configuration in a flat structure
type Config struct {
// CloudProviderAddress is the URI backend to make all calls to from this application for E2EE cloud operations.
CloudProviderAddress string `json:"cloud_provider_address"`
Credentials *Credentials `json:"credentials"`
// Desktop-specific settings
WindowWidth int `json:"window_width"`
WindowHeight int `json:"window_height"`
Theme string `json:"theme"` // light, dark, auto
Language string `json:"language"` // en, es, fr, etc.
SyncMode string `json:"sync_mode"` // encrypted_only, hybrid, decrypted_only
AutoSync bool `json:"auto_sync"` // Enable automatic synchronization
SyncIntervalMinutes int `json:"sync_interval_minutes"` // Sync interval in minutes
ShowHiddenFiles bool `json:"show_hidden_files"` // Show hidden files in file manager
DefaultView string `json:"default_view"` // list, grid
SortBy string `json:"sort_by"` // name, date, size, type
SortOrder string `json:"sort_order"` // asc, desc
}
// Credentials holds all user credentials for authentication and authorization.
// Values are decrypted for convenience purposes as we assume threat actor cannot access the decrypted values on the user's device.
type Credentials struct {
// Email is the unique registered email of the user whom successfully logged into the system.
Email string `json:"email"`
AccessToken string `json:"access_token"`
AccessTokenExpiryTime *time.Time `json:"access_token_expiry_time"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiryTime *time.Time `json:"refresh_token_expiry_time"`
}
// ConfigService defines the unified interface for all configuration operations
type ConfigService interface {
GetConfig(ctx context.Context) (*Config, error)
GetAppDataDirPath(ctx context.Context) (string, error)
GetCloudProviderAddress(ctx context.Context) (string, error)
SetCloudProviderAddress(ctx context.Context, address string) error
GetLoggedInUserCredentials(ctx context.Context) (*Credentials, error)
SetLoggedInUserCredentials(
ctx context.Context,
email string,
accessToken string,
accessTokenExpiryTime *time.Time,
refreshToken string,
refreshTokenExpiryTime *time.Time,
) error
ClearLoggedInUserCredentials(ctx context.Context) error
// User-specific storage methods
// These return paths that are isolated per user and per environment (dev/production)
GetUserDataDirPath(ctx context.Context, userEmail string) (string, error)
GetUserFilesDirPath(ctx context.Context, userEmail string) (string, error)
GetUserSearchIndexDir(ctx context.Context, userEmail string) (string, error)
GetLoggedInUserEmail(ctx context.Context) (string, error)
// Desktop-specific methods
GetWindowSize(ctx context.Context) (width int, height int, err error)
SetWindowSize(ctx context.Context, width int, height int) error
GetTheme(ctx context.Context) (string, error)
SetTheme(ctx context.Context, theme string) error
GetLanguage(ctx context.Context) (string, error)
SetLanguage(ctx context.Context, language string) error
GetSyncMode(ctx context.Context) (string, error)
SetSyncMode(ctx context.Context, mode string) error
GetAutoSync(ctx context.Context) (bool, error)
SetAutoSync(ctx context.Context, enabled bool) error
GetSyncInterval(ctx context.Context) (int, error)
SetSyncInterval(ctx context.Context, minutes int) error
GetShowHiddenFiles(ctx context.Context) (bool, error)
SetShowHiddenFiles(ctx context.Context, show bool) error
GetDefaultView(ctx context.Context) (string, error)
SetDefaultView(ctx context.Context, view string) error
GetSortPreferences(ctx context.Context) (sortBy string, sortOrder string, err error)
SetSortPreferences(ctx context.Context, sortBy string, sortOrder string) error
}
// repository defines the interface for loading and saving configuration
type repository interface {
// LoadConfig loads the configuration, returning defaults if file doesn't exist
LoadConfig(ctx context.Context) (*Config, error)
// SaveConfig saves the configuration to persistent storage
SaveConfig(ctx context.Context, config *Config) error
}
// configService implements the ConfigService interface
type configService struct {
repo repository
mu sync.RWMutex // Thread safety
}
// fileRepository implements the repository interface with file-based storage
type fileRepository struct {
configPath string
appName string
}
// New creates a new configuration service with default settings
// This is the Wire provider function
func New() (ConfigService, error) {
appName := GetAppName()
repo, err := newFileRepository(appName)
if err != nil {
return nil, err
}
// Wrap with integrity checking
fileRepo := repo.(*fileRepository)
integrityRepo, err := NewIntegrityAwareRepository(repo, appName, fileRepo.configPath)
if err != nil {
// Fall back to basic repository if integrity service fails
// This allows the app to still function
return &configService{
repo: repo,
}, nil
}
return &configService{
repo: integrityRepo,
}, nil
}
// NewForTesting creates a configuration service with the specified repository (for testing)
func NewForTesting(repo repository) ConfigService {
return &configService{
repo: repo,
}
}
// newFileRepository creates a new instance of repository
func newFileRepository(appName string) (repository, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
// Create app-specific config directory with restrictive permissions (owner only)
// 0700 = owner read/write/execute, no access for group or others
appConfigDir := filepath.Join(configDir, appName)
if err := os.MkdirAll(appConfigDir, 0700); err != nil {
return nil, err
}
configPath := filepath.Join(appConfigDir, "config.json")
return &fileRepository{
configPath: configPath,
appName: appName,
}, nil
}
// LoadConfig loads the configuration from file, or returns defaults if file doesn't exist
func (r *fileRepository) LoadConfig(ctx context.Context) (*Config, error) {
// Check if the config file exists
if _, err := os.Stat(r.configPath); os.IsNotExist(err) {
// Return default config if file doesn't exist
defaults := getDefaultConfig()
// Save the defaults for future use
if err := r.SaveConfig(ctx, defaults); err != nil {
return nil, err
}
return defaults, nil
}
// Read config from file
data, err := os.ReadFile(r.configPath)
if err != nil {
return nil, err
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// SaveConfig saves the configuration to file with restrictive permissions
func (r *fileRepository) SaveConfig(ctx context.Context, config *Config) error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
// Use 0600 permissions (owner read/write only) for security
return os.WriteFile(r.configPath, data, 0600)
}
// getDefaultConfig returns the default configuration values
// Note: This function is only called from LoadConfig after the config directory
// has already been created by newFileRepository, so no directory creation needed here.
func getDefaultConfig() *Config {
return &Config{
CloudProviderAddress: "http://localhost:8000",
Credentials: &Credentials{
Email: "", // Leave blank because no user was authenticated.
AccessToken: "", // Leave blank because no user was authenticated.
AccessTokenExpiryTime: nil, // Leave blank because no user was authenticated.
RefreshToken: "", // Leave blank because no user was authenticated.
RefreshTokenExpiryTime: nil, // Leave blank because no user was authenticated.
},
// Desktop-specific defaults
WindowWidth: 1440,
WindowHeight: 900,
Theme: "auto",
Language: "en",
SyncMode: "hybrid",
AutoSync: true,
SyncIntervalMinutes: 30,
ShowHiddenFiles: false,
DefaultView: "list",
SortBy: "name",
SortOrder: "asc",
}
}