diff --git a/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 0000000..877af8c
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,6 @@
+version: '3'
+
+tasks:
+ build:
+ cmds:
+ - go build -o build/bin/core cmd/app/main.go
diff --git a/cmd/app/frontend/dist/assets/app.js b/cmd/app/frontend/dist/assets/app.js
new file mode 100644
index 0000000..28abaa3
--- /dev/null
+++ b/cmd/app/frontend/dist/assets/app.js
@@ -0,0 +1 @@
+console.log("Hello from app.js!");
diff --git a/cmd/app/frontend/dist/index.html b/cmd/app/frontend/dist/index.html
new file mode 100644
index 0000000..916c9c4
--- /dev/null
+++ b/cmd/app/frontend/dist/index.html
@@ -0,0 +1,10 @@
+
+
+
+ Core
+
+
+ Core
+
+
+
diff --git a/cmd/app/main.go b/cmd/app/main.go
new file mode 100644
index 0000000..01fd1d1
--- /dev/null
+++ b/cmd/app/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "embed"
+ "fmt"
+
+ "core"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+ app := application.New(application.Options{
+ Services: []application.Service{
+ application.NewService(core.New(assets)),
+ },
+ })
+
+ core.Setup(app)
+
+ app.Event.On("setup-done", func(e *application.CustomEvent) {
+ fmt.Println("Setup done!")
+ })
+
+ err := app.Run()
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..72bea66
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,52 @@
+package config
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+)
+
+// Config holds the resolved paths and user-configurable settings for the application.
+type Config struct {
+ // --- Dynamic Paths (not stored in config.json) ---
+ DataDir string `json:"-"`
+ ConfigDir string `json:"-"`
+ CacheDir string `json:"-"`
+ WorkspacesDir string `json:"-"`
+ RootDir string `json:"-"`
+ UserHomeDir string `json:"-"`
+ IsNew bool `json:"-"` // Flag indicating if the config was newly created.
+
+ // --- Storable Settings (persisted in config.json) ---
+ DefaultRoute string `json:"defaultRoute,omitempty"`
+ Features []string `json:"features,omitempty"`
+ Language string `json:"language,omitempty"`
+}
+
+// Key retrieves a configuration value by its key. It checks JSON tags and field names (case-insensitive).
+func (c *Config) Key(key string) (interface{}, error) {
+ // Use reflection to inspect the struct fields.
+ val := reflect.ValueOf(c).Elem()
+ typ := val.Type()
+
+ for i := 0; i < val.NumField(); i++ {
+ field := typ.Field(i)
+ fieldName := field.Name
+
+ // Check the field name first.
+ if strings.EqualFold(fieldName, key) {
+ return val.Field(i).Interface(), nil
+ }
+
+ // Then check the `json` tag.
+ jsonTag := field.Tag.Get("json")
+ if jsonTag != "" && jsonTag != "-" {
+ jsonName := strings.Split(jsonTag, ",")[0]
+ if strings.EqualFold(jsonName, key) {
+ return val.Field(i).Interface(), nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("key '%s' not found in config", key)
+}
diff --git a/config/service.go b/config/service.go
new file mode 100644
index 0000000..745f358
--- /dev/null
+++ b/config/service.go
@@ -0,0 +1,175 @@
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/adrg/xdg"
+)
+
+const appName = "lethean"
+const configFileName = "config.json"
+
+// ErrSetupRequired is returned by ServiceStartup if config.json is missing.
+var ErrSetupRequired = errors.New("setup required: config.json not found")
+
+// Service provides access to the application's configuration.
+type Service struct {
+ config *Config
+}
+
+// NewService creates and initializes a new configuration service.
+// It loads an existing configuration or creates a default one if not found.
+func NewService() (*Service, error) {
+ // 1. Determine the config directory path to check for an existing file.
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("could not resolve user home directory: %w", err)
+ }
+ userHomeDir := filepath.Join(homeDir, appName)
+ configDir := filepath.Join(userHomeDir, "config")
+ configPath := filepath.Join(configDir, configFileName)
+
+ var cfg *Config
+ configNeedsSaving := false
+
+ // 2. Check if the config file exists.
+ if _, err := os.Stat(configPath); err == nil {
+ // --- Config file EXISTS ---
+
+ // First, get the base config with all the dynamic paths and directory structures.
+ cfg, err = newDefaultConfig()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create base config structure: %w", err)
+ }
+ cfg.IsNew = false // Mark that we are loading an existing config.
+
+ // Now, load the storable values from the existing file, which will override the defaults.
+ fileData, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read existing config file at %s: %w", configPath, err)
+ }
+
+ if err := json.Unmarshal(fileData, cfg); err != nil {
+ // If unmarshalling fails, we log a warning but proceed with the default config.
+ // This prevents a corrupted config.json from crashing the app.
+ fmt.Fprintf(os.Stderr, "Warning: Failed to unmarshal config.json at %s, using defaults: %v\n", configPath, err)
+ }
+
+ } else if errors.Is(err, os.ErrNotExist) {
+ // --- Config file DOES NOT EXIST ---
+ configNeedsSaving = true
+
+ // Create a fresh default config. This sets up paths and a default "en" language.
+ cfg, err = newDefaultConfig()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create default config: %w", err)
+ }
+ cfg.IsNew = true // Mark that this is a new config.
+
+ } else {
+ // Another error occurred (e.g., permissions).
+ return nil, fmt.Errorf("failed to check for config file at %s: %w", configPath, err)
+ }
+
+ service := &Service{config: cfg}
+
+ // If the config file didn't exist, save the newly generated one.
+ if configNeedsSaving {
+ if err := service.Save(); err != nil {
+ return nil, fmt.Errorf("failed to save initial config: %w", err)
+ }
+ }
+
+ return service, nil
+}
+
+// newDefaultConfig creates a default configuration with resolved paths and ensures directories exist.
+func newDefaultConfig() (*Config, error) {
+ if strings.Contains(appName, "..") || strings.Contains(appName, string(filepath.Separator)) {
+ return nil, fmt.Errorf("invalid app name '%s': contains path traversal characters", appName)
+ }
+
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("could not resolve user home directory: %w", err)
+ }
+ userHomeDir := filepath.Join(homeDir, appName)
+
+ rootDir, err := xdg.DataFile(appName)
+ if err != nil {
+ return nil, fmt.Errorf("could not resolve data directory: %w", err)
+ }
+
+ cacheDir, err := xdg.CacheFile(appName)
+ if err != nil {
+ return nil, fmt.Errorf("could not resolve cache directory: %w", err)
+ }
+
+ cfg := &Config{
+ UserHomeDir: userHomeDir,
+ RootDir: rootDir,
+ CacheDir: cacheDir,
+ ConfigDir: filepath.Join(userHomeDir, "config"),
+ DataDir: filepath.Join(userHomeDir, "data"),
+ WorkspacesDir: filepath.Join(userHomeDir, "workspaces"),
+ DefaultRoute: "/",
+ Features: []string{},
+ Language: "en", // Hardcoded default, will be overridden if loaded or detected
+ }
+
+ dirs := []string{cfg.RootDir, cfg.ConfigDir, cfg.DataDir, cfg.CacheDir, cfg.WorkspacesDir, cfg.UserHomeDir}
+ for _, dir := range dirs {
+ if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+ return nil, fmt.Errorf("could not create directory %s: %w", dir, err)
+ }
+ }
+
+ return cfg, nil
+}
+
+// Get returns the loaded configuration.
+func (s *Service) Get() *Config {
+ return s.config
+}
+
+// Save writes the current configuration to config.json.
+func (s *Service) Save() error {
+ configPath := filepath.Join(s.config.ConfigDir, configFileName)
+
+ data, err := json.MarshalIndent(s.config, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal config: %w", err)
+ }
+
+ if err := os.WriteFile(configPath, data, 0644); err != nil {
+ return fmt.Errorf("failed to write config file: %w", err)
+ }
+ return nil
+}
+
+// IsFeatureEnabled checks if a given feature is enabled in the configuration.
+func (s *Service) IsFeatureEnabled(feature string) bool {
+ for _, f := range s.config.Features {
+ if f == feature {
+ return true
+ }
+ }
+ return false
+}
+
+// EnableFeature adds a feature to the list of enabled features and saves the config.
+func (s *Service) EnableFeature(feature string) error {
+ if s.IsFeatureEnabled(feature) {
+ return nil
+ }
+ s.config.Features = append(s.config.Features, feature)
+ if err := s.Save(); err != nil {
+ return fmt.Errorf("failed to save config after enabling feature %s: %w", feature, err)
+ }
+ return nil
+}
diff --git a/config/service_test.go b/config/service_test.go
new file mode 100644
index 0000000..c3484ee
--- /dev/null
+++ b/config/service_test.go
@@ -0,0 +1,165 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/adrg/xdg"
+)
+
+// setupTestEnv creates temporary directories and sets environment variables
+// to simulate a specific user home and XDG base directories for testing.
+// It returns the path to the temporary home directory and a cleanup function.
+func setupTestEnv(t *testing.T) (string, func()) {
+ // Create a temporary directory for the user's home
+ tempHomeDir, err := os.MkdirTemp("", "test_home")
+ if err != nil {
+ t.Fatalf("Failed to create temp home directory: %v", err)
+ }
+
+ // Store original HOME environment variable to restore it later
+ oldHome := os.Getenv("HOME")
+
+ // Set HOME environment variable for the test
+ os.Setenv("HOME", tempHomeDir)
+
+ cleanup := func() {
+ // Restore original HOME environment variable
+ os.Setenv("HOME", oldHome)
+ // Clean up temporary directories
+ os.RemoveAll(tempHomeDir)
+ }
+
+ return tempHomeDir, cleanup
+}
+
+func TestNewService(t *testing.T) {
+ tempHomeDir, cleanup := setupTestEnv(t)
+ defer cleanup()
+
+ service, err := NewService()
+ if err != nil {
+ t.Fatalf("NewService() failed: %v", err)
+ }
+
+ cfg := service.Get()
+
+ // These paths are based on the mocked HOME directory
+ expectedUserHomeDir := filepath.Join(tempHomeDir, appName)
+ expectedConfigDir := filepath.Join(expectedUserHomeDir, "config")
+ expectedDataDir := filepath.Join(expectedUserHomeDir, "data")
+ expectedWorkspacesDir := filepath.Join(expectedUserHomeDir, "workspaces")
+
+ // For RootDir and CacheDir, xdg library's init() might have already run
+ // before our test's os.Setenv calls take effect for xdg. So, we calculate
+ // the *expected* values based on what xdg *actually* returns in the
+ // current process, which will likely be the system defaults or whatever
+ // was set before the test started.
+ actualXDGDataFile, err := xdg.DataFile(appName)
+ if err != nil {
+ t.Fatalf("xdg.DataFile failed: %v", err)
+ }
+ actualXDGCacheFile, err := xdg.CacheFile(appName)
+ if err != nil {
+ t.Fatalf("xdg.CacheFile failed: %v", err)
+ }
+
+ expectedRootDir := actualXDGDataFile
+ expectedCacheDir := actualXDGCacheFile
+
+ tests := []struct {
+ name string
+ actual string
+ expected string
+ }{
+ {"UserHomeDir", cfg.UserHomeDir, expectedUserHomeDir},
+ {"RootDir", cfg.RootDir, expectedRootDir},
+ {"ConfigDir", cfg.ConfigDir, expectedConfigDir},
+ {"DataDir", cfg.DataDir, expectedDataDir},
+ {"CacheDir", cfg.CacheDir, expectedCacheDir},
+ {"WorkspacesDir", cfg.WorkspacesDir, expectedWorkspacesDir},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.actual != tt.expected {
+ t.Errorf("Mismatch for %s: got %q, want %q", tt.name, tt.actual, tt.expected)
+ }
+ // Also check if the directory was actually created
+ if info, err := os.Stat(tt.actual); err != nil {
+ t.Errorf("Directory %q for %s was not created: %v", tt.actual, tt.name, err)
+ } else if !info.IsDir() {
+ t.Errorf("Path %q for %s is not a directory", tt.actual, tt.name)
+ }
+ })
+ }
+}
+
+func TestNewService_DirectoryCreationFails(t *testing.T) {
+ // Create a temporary directory that we will make read-only
+ tempHomeDir, err := os.MkdirTemp("", "test_readonly_home")
+ if err != nil {
+ t.Fatalf("Failed to create temp home directory: %v", err)
+ }
+ // Ensure cleanup happens, and restore permissions before removing
+ defer func() {
+ os.Chmod(tempHomeDir, 0755) // Restore write permissions for os.RemoveAll
+ os.RemoveAll(tempHomeDir)
+ }()
+
+ // Make the temporary home directory read-only
+ if err := os.Chmod(tempHomeDir, 0555); err != nil { // r-xr-xr-x
+ t.Fatalf("Failed to make temp home directory read-only: %v", err)
+ }
+
+ // Store original HOME environment variable to restore it later
+ oldHome := os.Getenv("HOME")
+ os.Setenv("HOME", tempHomeDir)
+ defer os.Setenv("HOME", oldHome)
+
+ // NewService should now fail because it cannot create subdirectories in tempHomeDir
+ _, err = NewService()
+ if err == nil {
+ t.Errorf("NewService() expected to fail when directory creation is impossible, but it succeeded")
+ }
+ // Optionally, check for a specific error message or type
+ if err != nil && !strings.Contains(err.Error(), "could not create directory") {
+ t.Errorf("NewService() failed with unexpected error: %v", err)
+ }
+}
+
+func TestNewService_PathTraversalAttempt(t *testing.T) {
+
+ problematicAppName := "../lethean"
+
+ // Simulate the validation logic from NewService
+ if !strings.Contains(problematicAppName, "..") && !strings.Contains(problematicAppName, string(filepath.Separator)) {
+ t.Errorf("Expected problematicAppName to contain path traversal characters, but it didn't")
+ }
+
+ // We'll create a temporary function to simulate the validation within NewService
+ validateAppName := func(name string) error {
+ if strings.Contains(name, "..") || strings.Contains(name, string(filepath.Separator)) {
+ return fmt.Errorf("invalid app name '%s': contains path traversal characters", name)
+ }
+ return nil
+ }
+
+ // Test with a problematic app name
+ err := validateAppName(problematicAppName)
+ if err == nil {
+ t.Errorf("validateAppName expected to fail for %q, but it succeeded", problematicAppName)
+ }
+ if err != nil && !strings.Contains(err.Error(), "path traversal characters") {
+ t.Errorf("validateAppName failed for %q with unexpected error: %v", problematicAppName, err)
+ }
+ // Test with a safe app name
+ safeAppName := "lethean"
+ err = validateAppName(safeAppName)
+ if err != nil {
+ t.Errorf("validateAppName expected to succeed for %q, but it failed with error: %v", safeAppName, err)
+ }
+}
diff --git a/core.go b/core.go
new file mode 100644
index 0000000..973702f
--- /dev/null
+++ b/core.go
@@ -0,0 +1,148 @@
+package core
+
+import (
+ "embed"
+ "fmt"
+ "sync"
+
+ "core/config"
+ "core/crypt"
+ "core/display"
+ "core/docs"
+ "core/filesystem"
+ "core/filesystem/local"
+ "core/workspace"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// Service provides access to all core application services.
+type Service struct {
+ app *application.App
+ configService *config.Service
+ displayService *display.Service
+ docsService *docs.Service
+ cryptService *crypt.Service
+ workspaceService *workspace.Service
+}
+
+var (
+ instance *Service
+ once sync.Once
+ initErr error
+)
+
+// New performs Phase 1 of initialization: Instantiation.
+// It creates the raw service objects without wiring them together.
+func New(assets embed.FS) *Service {
+ once.Do(func() {
+ // Instantiate services in the correct order of dependency.
+ configService, err := config.NewService()
+ if err != nil {
+ initErr = fmt.Errorf("failed to initialize config service: %w", err)
+ return
+ }
+
+ // Initialize the local filesystem medium
+ filesystem.Local, err = local.New(configService.Get().RootDir)
+ if err != nil {
+ initErr = fmt.Errorf("failed to initialize local filesystem: %w", err)
+ return
+ }
+
+ displayService := display.NewService(display.ClientHub, assets)
+ docsService := docs.NewService(assets)
+ cryptService := crypt.NewService(configService.Get())
+ workspaceService := workspace.NewService(configService.Get(), workspace.NewLocalMedium())
+
+ instance = &Service{
+ configService: configService,
+ displayService: displayService,
+ docsService: docsService,
+ cryptService: cryptService,
+ workspaceService: workspaceService,
+ }
+ })
+
+ if initErr != nil {
+ panic(initErr) // A failure in a core service is fatal.
+ }
+
+ return instance
+}
+
+// Setup performs Phase 2 of initialization: Wiring.
+// It injects the required dependencies into each service.
+func Setup(app *application.App) {
+ if instance == nil {
+ panic("core.Setup() called before core.New() was successfully initialized")
+ }
+ instance.app = app
+
+ // Wire the services with their dependencies.
+ instance.displayService.Setup(app, instance.configService, nil)
+ instance.docsService.Setup(app, instance.displayService)
+}
+
+// App returns the global application instance.
+func App() *application.App {
+ if instance == nil || instance.app == nil {
+ panic("core.App() called before core.Setup() was successfully initialized")
+ }
+ return instance.app
+}
+
+// Config returns the singleton instance of the ConfigService.
+func Config() *config.Service {
+ if instance == nil {
+ panic("core.Config() called before core.New() was successfully initialized")
+ }
+ return instance.configService
+}
+
+// Display returns the singleton instance of the display.Service.
+func Display() *display.Service {
+ if instance == nil {
+ panic("core.Display() called before core.New() was successfully initialized")
+ }
+ return instance.displayService
+}
+
+// Docs returns the singleton instance of the DocsService.
+func Docs() *docs.Service {
+ if instance == nil {
+ panic("core.Docs() called before core.New() was successfully initialized")
+ }
+ return instance.docsService
+}
+
+// Crypt returns the singleton instance of the CryptService.
+func Crypt() *crypt.Service {
+ if instance == nil {
+ panic("core.Crypt() called before core.New() was successfully initialized")
+ }
+ return instance.cryptService
+}
+
+// Filesystem returns the singleton instance of the FilesystemService.
+func Filesystem() filesystem.Medium {
+ if instance == nil {
+ panic("core.Filesystem() called before core.New() was successfully initialized")
+ }
+ return filesystem.Local
+}
+
+// Workspace returns the singleton instance of the WorkspaceService.
+func Workspace() *workspace.Service {
+ if instance == nil {
+ panic("core.Workspace() called before core.New() was successfully initialized")
+ }
+ return instance.workspaceService
+}
+
+// Runtime returns the singleton instance of the Service.
+func Runtime() *Service {
+ if instance == nil {
+ panic("core.Runtime() called before core.New() was successfully initialized")
+ }
+ return instance
+}
diff --git a/crypt/crypt.go b/crypt/crypt.go
new file mode 100644
index 0000000..b6cff01
--- /dev/null
+++ b/crypt/crypt.go
@@ -0,0 +1,23 @@
+package crypt
+
+import (
+ "core/config"
+)
+
+// HashType defines the supported hashing algorithms.
+type HashType string
+
+const (
+ LTHN HashType = "lthn"
+ SHA512 HashType = "sha512"
+ SHA256 HashType = "sha256"
+ SHA1 HashType = "sha1"
+ MD5 HashType = "md5"
+)
+
+// Service provides cryptographic functions.
+// It is the main entry point for all cryptographic operations
+// and is bound to the frontend.
+type Service struct {
+ config *config.Config
+}
diff --git a/crypt/crypt_test.go b/crypt/crypt_test.go
new file mode 100644
index 0000000..2cde507
--- /dev/null
+++ b/crypt/crypt_test.go
@@ -0,0 +1,20 @@
+package crypt
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHash(t *testing.T) {
+ s := &Service{}
+ payload := "hello"
+ hash := s.Hash(LTHN, payload)
+ assert.NotEmpty(t, hash)
+}
+
+func TestLuhn(t *testing.T) {
+ s := &Service{}
+ assert.True(t, s.Luhn("79927398713"))
+ assert.False(t, s.Luhn("79927398714"))
+}
diff --git a/crypt/hash.go b/crypt/hash.go
new file mode 100644
index 0000000..723471d
--- /dev/null
+++ b/crypt/hash.go
@@ -0,0 +1,33 @@
+package crypt
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/sha512"
+ "encoding/hex"
+
+ "core/crypt/lib/lthn"
+)
+
+// Hash computes a hash of the payload using the specified algorithm.
+func (s *Service) Hash(lib HashType, payload string) string {
+ switch lib {
+ case LTHN:
+ return lthn.Hash(payload)
+ case SHA512:
+ hash := sha512.Sum512([]byte(payload))
+ return hex.EncodeToString(hash[:])
+ case SHA1:
+ hash := sha1.Sum([]byte(payload))
+ return hex.EncodeToString(hash[:])
+ case MD5:
+ hash := md5.Sum([]byte(payload))
+ return hex.EncodeToString(hash[:])
+ case SHA256:
+ fallthrough
+ default:
+ hash := sha256.Sum256([]byte(payload))
+ return hex.EncodeToString(hash[:])
+ }
+}
diff --git a/crypt/lib/lthn/hash.go b/crypt/lib/lthn/hash.go
new file mode 100644
index 0000000..c9f0ac0
--- /dev/null
+++ b/crypt/lib/lthn/hash.go
@@ -0,0 +1,46 @@
+package lthn
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+)
+
+// SetKeyMap sets the key map for the notarisation process.
+func SetKeyMap(newKeyMap map[rune]rune) {
+ keyMap = newKeyMap
+}
+
+// GetKeyMap gets the current key map.
+func GetKeyMap() map[rune]rune {
+ return keyMap
+}
+
+// Hash creates a reproducible hash from a string.
+func Hash(input string) string {
+ salt := createSalt(input)
+ hash := sha256.Sum256([]byte(input + salt))
+ return hex.EncodeToString(hash[:])
+}
+
+// createSalt creates a quasi-salt from a string by reversing it and swapping characters.
+func createSalt(input string) string {
+ if input == "" {
+ return ""
+ }
+ runes := []rune(input)
+ salt := make([]rune, len(runes))
+ for i := 0; i < len(runes); i++ {
+ char := runes[len(runes)-1-i]
+ if replacement, ok := keyMap[char]; ok {
+ salt[i] = replacement
+ } else {
+ salt[i] = char
+ }
+ }
+ return string(salt)
+}
+
+// Verify checks if an input string matches a given hash.
+func Verifyf(input string, hash string) bool {
+ return Hash(input) == hash
+}
diff --git a/crypt/lib/lthn/hash_test.go b/crypt/lib/lthn/hash_test.go
new file mode 100644
index 0000000..463ea5d
--- /dev/null
+++ b/crypt/lib/lthn/hash_test.go
@@ -0,0 +1,48 @@
+package lthn
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHash(t *testing.T) {
+ input := "test_string"
+ expectedHash := "45d4027179b17265c38732fb1e7089a0b1adfe1d3ba4105fce66f7d46ba42f7d"
+
+ hashed := Hash(input)
+ fmt.Printf("Hash for \"%s\": %s\n", input, hashed)
+
+ assert.Equal(t, expectedHash, hashed, "The hash should match the expected value")
+}
+
+func TestCreateSalt(t *testing.T) {
+ // Test with default keyMap
+ SetKeyMap(map[rune]rune{})
+ assert.Equal(t, "gnirts_tset", createSalt("test_string"))
+ assert.Equal(t, "", createSalt(""))
+ assert.Equal(t, "A", createSalt("A"))
+
+ // Test with a custom keyMap
+ customKeyMap := map[rune]rune{
+ 'a': 'x',
+ 'b': 'y',
+ 'c': 'z',
+ }
+ SetKeyMap(customKeyMap)
+ assert.Equal(t, "zyx", createSalt("abc"))
+ assert.Equal(t, "gnirts_tset", createSalt("test_string")) // 'test_string' doesn't have 'a', 'b', 'c'
+
+ // Reset keyMap to default for other tests
+ SetKeyMap(map[rune]rune{})
+}
+
+func TestVerify(t *testing.T) {
+ input := "another_test_string"
+ hashed := Hash(input)
+
+ assert.True(t, Verifyf(input, hashed), "Verifyf should return true for a matching hash")
+ assert.False(t, Verifyf(input, "wrong_hash"), "Verifyf should return false for a non-matching hash")
+ assert.False(t, Verifyf("different_input", hashed), "Verifyf should return false for different input")
+}
diff --git a/crypt/lib/lthn/lthn.go b/crypt/lib/lthn/lthn.go
new file mode 100644
index 0000000..5a1f6e1
--- /dev/null
+++ b/crypt/lib/lthn/lthn.go
@@ -0,0 +1,16 @@
+package lthn
+
+// keyMap is the default character-swapping map used for the quasi-salting process.
+var keyMap = map[rune]rune{
+ 'o': '0',
+ 'l': '1',
+ 'e': '3',
+ 'a': '4',
+ 's': 'z',
+ 't': '7',
+ '0': 'o',
+ '1': 'l',
+ '3': 'e',
+ '4': 'a',
+ '7': 't',
+}
diff --git a/crypt/lib/openpgp/encrypt.go b/crypt/lib/openpgp/encrypt.go
new file mode 100644
index 0000000..9da18e7
--- /dev/null
+++ b/crypt/lib/openpgp/encrypt.go
@@ -0,0 +1,106 @@
+package openpgp
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+
+ "core/filesystem"
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+)
+
+// EncryptPGP encrypts data for a recipient, optionally signing it.
+func EncryptPGP(medium filesystem.Medium, recipientPath, data string, signerPath, signerPassphrase *string) (string, error) {
+ recipient, err := GetPublicKey(medium, recipientPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to get recipient public key: %w", err)
+ }
+
+ var signer *openpgp.Entity
+ if signerPath != nil && signerPassphrase != nil {
+ signer, err = GetPrivateKey(medium, *signerPath, *signerPassphrase)
+ if err != nil {
+ return "", fmt.Errorf("could not get private key for signing: %w", err)
+ }
+ }
+
+ buf := new(bytes.Buffer)
+ armoredWriter, err := armor.Encode(buf, pgpMessageHeader, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create armored writer: %w", err)
+ }
+
+ plaintextWriter, err := openpgp.Encrypt(armoredWriter, []*openpgp.Entity{recipient}, signer, nil, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to encrypt: %w", err)
+ }
+
+ if _, err := plaintextWriter.Write([]byte(data)); err != nil {
+ return "", fmt.Errorf("failed to write plaintext data: %w", err)
+ }
+
+ if err := plaintextWriter.Close(); err != nil {
+ return "", fmt.Errorf("failed to close plaintext writer: %w", err)
+ }
+ if err := armoredWriter.Close(); err != nil {
+ return "", fmt.Errorf("failed to close armored writer: %w", err)
+ }
+
+ // Debug print the encrypted message
+ fmt.Printf("Encrypted Message:\n%s\n", buf.String())
+
+ return buf.String(), nil
+}
+
+// DecryptPGP decrypts a PGP message, optionally verifying the signature.
+func DecryptPGP(medium filesystem.Medium, recipientPath, message, passphrase string, signerPath *string) (string, error) {
+ privateKeyEntity, err := GetPrivateKey(medium, recipientPath, passphrase)
+ if err != nil {
+ return "", fmt.Errorf("failed to get private key: %w", err)
+ }
+
+ // For this API version, the keyring must contain all keys for decryption and verification.
+ keyring := openpgp.EntityList{privateKeyEntity}
+ var expectedSigner *openpgp.Entity
+
+ if signerPath != nil {
+ publicKeyEntity, err := GetPublicKey(medium, *signerPath)
+ if err != nil {
+ return "", fmt.Errorf("could not get public key for verification: %w", err)
+ }
+ keyring = append(keyring, publicKeyEntity)
+ expectedSigner = publicKeyEntity
+ }
+
+ // Debug print the message before decryption
+ fmt.Printf("Message to Decrypt:\n%s\n", message)
+
+ // We pass the combined keyring, and nil for the prompt function because the private key is already decrypted.
+ md, err := openpgp.ReadMessage(strings.NewReader(message), keyring, nil, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to read PGP message: %w", err)
+ }
+
+ decrypted, err := io.ReadAll(md.UnverifiedBody)
+ if err != nil {
+ return "", fmt.Errorf("failed to read decrypted body: %w", err)
+ }
+
+ // The signature is checked automatically if the public key is in the keyring.
+ // We still need to check for errors and that the signer was who we expected.
+ if signerPath != nil {
+ if md.SignatureError != nil {
+ return "", fmt.Errorf("signature verification failed: %w", md.SignatureError)
+ }
+ if md.SignedBy == nil {
+ return "", fmt.Errorf("message is not signed, but signature verification was requested")
+ }
+ if expectedSigner.PrimaryKey.KeyId != md.SignedBy.PublicKey.KeyId {
+ return "", fmt.Errorf("signature from unexpected key id: got %X, want %X", md.SignedBy.PublicKey.KeyId, expectedSigner.PrimaryKey.KeyId)
+ }
+ }
+
+ return string(decrypted), nil
+}
diff --git a/crypt/lib/openpgp/key.go b/crypt/lib/openpgp/key.go
new file mode 100644
index 0000000..7173444
--- /dev/null
+++ b/crypt/lib/openpgp/key.go
@@ -0,0 +1,226 @@
+package openpgp
+
+import (
+ "bytes"
+ "crypto"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+ "core/crypt/lib/lthn"
+ "core/filesystem"
+)
+
+// CreateKeyPair generates a new OpenPGP key pair.
+// The password parameter is optional. If not provided, the private key will not be encrypted.
+func CreateKeyPair(username string, passwords ...string) (*KeyPair, error) {
+ var password string
+ if len(passwords) > 0 {
+ password = passwords[0]
+ }
+
+ entity, err := openpgp.NewEntity(username, "Lethean Desktop", "", &packet.Config{
+ RSABits: 4096,
+ DefaultHash: crypto.SHA256,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create new entity: %w", err)
+ }
+
+ // The private key is initially unencrypted after NewEntity.
+ // Generate revocation certificate while the private key is unencrypted.
+ revocationCert, err := createRevocationCertificate(entity)
+ if err != nil {
+ revocationCert = "" // Non-critical, proceed without it if it fails
+ }
+
+ // Encrypt the private key only if a password is provided, after revocation cert generation.
+ if password != "" {
+ if err := entity.PrivateKey.Encrypt([]byte(password)); err != nil {
+ return nil, fmt.Errorf("failed to encrypt private key: %w", err)
+ }
+ }
+
+ publicKey, err := serializeEntity(entity, openpgp.PublicKeyType, "") // Public key doesn't need password
+ if err != nil {
+ return nil, err
+ }
+
+ // Private key serialization. The key is already in its final encrypted/unencrypted state.
+ privateKey, err := serializeEntity(entity, openpgp.PrivateKeyType, "") // No password needed here for serialization
+ if err != nil {
+ return nil, err
+ }
+
+ return &KeyPair{
+ PublicKey: publicKey,
+ PrivateKey: privateKey,
+ RevocationCertificate: revocationCert,
+ }, nil
+}
+
+// CreateServerKeyPair creates and stores a key pair for the server in a specific directory.
+func CreateServerKeyPair(keysDir string) error {
+ serverKeyPath := filepath.Join(keysDir, "server.lthn.pub")
+ // Passphrase is derived from the path itself, consistent with original logic.
+ passphrase := lthn.Hash(serverKeyPath)
+ return createAndStoreKeyPair("server", passphrase, keysDir)
+}
+
+// GetPublicKey retrieves an armored public key for a given ID.
+func GetPublicKey(medium filesystem.Medium, path string) (*openpgp.Entity, error) {
+ return readEntity(medium, path)
+}
+
+// GetPrivateKey retrieves and decrypts an armored private key.
+func GetPrivateKey(medium filesystem.Medium, path, passphrase string) (*openpgp.Entity, error) {
+ entity, err := readEntity(medium, path)
+ if err != nil {
+ return nil, err
+ }
+
+ if entity.PrivateKey == nil {
+ return nil, fmt.Errorf("no private key found for path %s", path)
+ }
+
+ if entity.PrivateKey.Encrypted {
+ if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
+ return nil, fmt.Errorf("failed to decrypt private key for path %s: %w", path, err)
+ }
+ }
+
+ var primaryIdentity *openpgp.Identity
+ for _, identity := range entity.Identities {
+ if identity.SelfSignature.IsPrimaryId != nil && *identity.SelfSignature.IsPrimaryId {
+ primaryIdentity = identity
+ break
+ }
+ }
+ if primaryIdentity == nil {
+ for _, identity := range entity.Identities {
+ primaryIdentity = identity
+ break
+ }
+ }
+
+ if primaryIdentity == nil {
+ return nil, fmt.Errorf("key for %s has no identity", path)
+ }
+
+ if primaryIdentity.SelfSignature.KeyLifetimeSecs != nil {
+ if primaryIdentity.SelfSignature.CreationTime.Add(time.Duration(*primaryIdentity.SelfSignature.KeyLifetimeSecs) * time.Second).Before(time.Now()) {
+ return nil, fmt.Errorf("key for %s has expired", path)
+ }
+ }
+
+ return entity, nil
+}
+
+// --- Helper Functions ---
+
+func createAndStoreKeyPair(id, password, dir string) error {
+ var keyPair *KeyPair
+ var err error
+
+ if password != "" {
+ keyPair, err = CreateKeyPair(id, password)
+ } else {
+ keyPair, err = CreateKeyPair(id)
+ }
+
+ if err != nil {
+ return fmt.Errorf("failed to create key pair for id %s: %w", id, err)
+ }
+
+ if err := filesystem.Local.EnsureDir(dir); err != nil {
+ return fmt.Errorf("failed to ensure key directory exists: %w", err)
+ }
+
+ files := map[string]string{
+ filepath.Join(dir, fmt.Sprintf("%s.lthn.pub", id)): keyPair.PublicKey,
+ filepath.Join(dir, fmt.Sprintf("%s.lthn.key", id)): keyPair.PrivateKey,
+ filepath.Join(dir, fmt.Sprintf("%s.lthn.rev", id)): keyPair.RevocationCertificate, // Re-enabled
+ }
+
+ for path, content := range files {
+ if content == "" {
+ continue
+ }
+ if err := filesystem.Local.Write(path, content); err != nil {
+ return fmt.Errorf("failed to write key file %s: %w", path, err)
+ }
+ }
+ return nil
+}
+
+func readEntity(m filesystem.Medium, path string) (*openpgp.Entity, error) {
+ keyArmored, err := m.Read(path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read key file %s: %w", path, err)
+ }
+
+ entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(keyArmored))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse key file %s: %w", path, err)
+ }
+ if len(entityList) == 0 {
+ return nil, fmt.Errorf("no entity found in key file %s", path)
+ }
+ return entityList[0], nil
+}
+
+func serializeEntity(entity *openpgp.Entity, keyType string, password string) (string, error) {
+ buf := new(bytes.Buffer)
+ writer, err := armor.Encode(buf, keyType, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create armor encoder: %w", err)
+ }
+
+ if keyType == openpgp.PrivateKeyType {
+ // Serialize the private key in its current in-memory state.
+ // Encryption is handled by CreateKeyPair before this function is called.
+ err = entity.SerializePrivateWithoutSigning(writer, nil)
+ } else {
+ err = entity.Serialize(writer)
+ }
+
+ if err != nil {
+ return "", fmt.Errorf("failed to serialize entity: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ return "", fmt.Errorf("failed to close armor writer: %w", err)
+ }
+ return buf.String(), nil
+}
+
+func createRevocationCertificate(entity *openpgp.Entity) (string, error) {
+ buf := new(bytes.Buffer)
+ writer, err := armor.Encode(buf, openpgp.SignatureType, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create armor encoder for revocation: %w", err)
+ }
+
+ sig := &packet.Signature{
+ SigType: packet.SigTypeKeyRevocation,
+ PubKeyAlgo: entity.PrimaryKey.PubKeyAlgo,
+ Hash: crypto.SHA256,
+ CreationTime: time.Now(),
+ IssuerKeyId: &entity.PrimaryKey.KeyId,
+ }
+
+ // SignKey requires an unencrypted private key.
+ if err := sig.SignKey(entity.PrimaryKey, entity.PrivateKey, nil); err != nil {
+ return "", fmt.Errorf("failed to sign revocation: %w", err)
+ }
+ if err := sig.Serialize(writer); err != nil {
+ return "", fmt.Errorf("failed to serialize revocation signature: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ return "", fmt.Errorf("failed to close revocation writer: %w", err)
+ }
+ return buf.String(), nil
+}
diff --git a/crypt/lib/openpgp/openpgp.go b/crypt/lib/openpgp/openpgp.go
new file mode 100644
index 0000000..1e604a5
--- /dev/null
+++ b/crypt/lib/openpgp/openpgp.go
@@ -0,0 +1,12 @@
+package openpgp
+
+// pgpMessageHeader is the standard armor header for PGP messages.
+const pgpMessageHeader = "PGP MESSAGE"
+
+// KeyPair holds the generated armored keys and revocation certificate.
+// This is the primary data structure representing a user's PGP identity within the system.
+type KeyPair struct {
+ PublicKey string
+ PrivateKey string
+ RevocationCertificate string
+}
diff --git a/crypt/lib/openpgp/sign.go b/crypt/lib/openpgp/sign.go
new file mode 100644
index 0000000..2fd8b90
--- /dev/null
+++ b/crypt/lib/openpgp/sign.go
@@ -0,0 +1,39 @@
+package openpgp
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "core/filesystem"
+ "github.com/ProtonMail/go-crypto/openpgp"
+)
+
+// Sign creates a detached signature for the data.
+func Sign(medium filesystem.Medium, data, privateKeyPath, passphrase string) (string, error) {
+ signer, err := GetPrivateKey(medium, privateKeyPath, passphrase)
+ if err != nil {
+ return "", fmt.Errorf("failed to get private key for signing: %w", err)
+ }
+
+ buf := new(bytes.Buffer)
+ if err := openpgp.ArmoredDetachSign(buf, signer, strings.NewReader(data), nil); err != nil {
+ return "", fmt.Errorf("failed to create detached signature: %w", err)
+ }
+
+ return buf.String(), nil
+}
+
+// Verify checks a detached signature.
+func Verify(medium filesystem.Medium, data, signature, publicKeyPath string) (bool, error) {
+ keyring, err := GetPublicKey(medium, publicKeyPath)
+ if err != nil {
+ return false, fmt.Errorf("failed to get public key for verification: %w", err)
+ }
+
+ _, err = openpgp.CheckArmoredDetachedSignature(openpgp.EntityList{keyring}, strings.NewReader(data), strings.NewReader(signature), nil)
+ if err != nil {
+ return false, fmt.Errorf("signature verification failed: %w", err)
+ }
+ return true, nil
+}
diff --git a/crypt/service.go b/crypt/service.go
new file mode 100644
index 0000000..728cc88
--- /dev/null
+++ b/crypt/service.go
@@ -0,0 +1,43 @@
+package crypt
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "path/filepath"
+
+ "core/config"
+ "core/crypt/lib/openpgp"
+ "core/filesystem"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// createServerKeyPair is a package-level variable that can be swapped for testing.
+var createServerKeyPair = openpgp.CreateServerKeyPair
+
+// NewService creates a new crypt.Service, accepting a config service instance.
+func NewService(cfg *config.Config) *Service {
+ return &Service{
+ config: cfg,
+ }
+}
+
+// ServiceStartup Startup is called when the app starts. It handles one-time cryptographic setup.
+func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
+ // Define the directory for server keys based on the central config.
+ serverKeysDir := filepath.Join(s.config.DataDir, "server_keys")
+ if err := filesystem.EnsureDir(filesystem.Local, serverKeysDir); err != nil {
+ return fmt.Errorf("failed to create server keys directory: %w", err)
+ }
+
+ // Check for server key pair using the configured path.
+ serverKeyPath := filepath.Join(serverKeysDir, "server.lthn.pub")
+ if !filesystem.IsFile(filesystem.Local, serverKeyPath) {
+ log.Println("Creating server key pair...")
+ if err := createServerKeyPair(serverKeysDir); err != nil {
+ return fmt.Errorf("failed to create server key pair: %w", err)
+ }
+ log.Println("Server key pair created.")
+ }
+ return nil
+}
diff --git a/crypt/sum.go b/crypt/sum.go
new file mode 100644
index 0000000..7453037
--- /dev/null
+++ b/crypt/sum.go
@@ -0,0 +1,77 @@
+package crypt
+
+import (
+ "encoding/binary"
+ "strconv"
+ "strings"
+)
+
+// Luhn validates a number using the Luhn algorithm.
+func (s *Service) Luhn(payload string) bool {
+ payload = strings.ReplaceAll(payload, " ", "")
+ sum := 0
+ isSecond := false
+ for i := len(payload) - 1; i >= 0; i-- {
+ digit, err := strconv.Atoi(string(payload[i]))
+ if err != nil {
+ return false // Contains non-digit
+ }
+
+ if isSecond {
+ digit = digit * 2
+ if digit > 9 {
+ digit = digit - 9
+ }
+ }
+
+ sum += digit
+ isSecond = !isSecond
+ }
+ return sum%10 == 0
+}
+
+// Fletcher16 computes the Fletcher-16 checksum.
+func (s *Service) Fletcher16(payload string) uint16 {
+ data := []byte(payload)
+ var sum1, sum2 uint16
+ for _, b := range data {
+ sum1 = (sum1 + uint16(b)) % 255
+ sum2 = (sum2 + sum1) % 255
+ }
+ return (sum2 << 8) | sum1
+}
+
+// Fletcher32 computes the Fletcher-32 checksum.
+func (s *Service) Fletcher32(payload string) uint32 {
+ data := []byte(payload)
+ // Pad with 0 to make it even length for uint16 conversion
+ if len(data)%2 != 0 {
+ data = append(data, 0)
+ }
+
+ var sum1, sum2 uint32
+ for i := 0; i < len(data); i += 2 {
+ val := binary.LittleEndian.Uint16(data[i : i+2])
+ sum1 = (sum1 + uint32(val)) % 65535
+ sum2 = (sum2 + sum1) % 65535
+ }
+ return (sum2 << 16) | sum1
+}
+
+// Fletcher64 computes the Fletcher-64 checksum.
+func (s *Service) Fletcher64(payload string) uint64 {
+ data := []byte(payload)
+ // Pad to multiple of 4
+ if len(data)%4 != 0 {
+ padding := 4 - (len(data) % 4)
+ data = append(data, make([]byte, padding)...)
+ }
+
+ var sum1, sum2 uint64
+ for i := 0; i < len(data); i += 4 {
+ val := binary.LittleEndian.Uint32(data[i : i+4])
+ sum1 = (sum1 + uint64(val)) % 4294967295
+ sum2 = (sum2 + sum1) % 4294967295
+ }
+ return (sum2 << 32) | sum1
+}
diff --git a/display/apptray.png b/display/apptray.png
new file mode 100644
index 0000000..0778fc6
Binary files /dev/null and b/display/apptray.png differ
diff --git a/display/display.go b/display/display.go
new file mode 100644
index 0000000..b50a0c7
--- /dev/null
+++ b/display/display.go
@@ -0,0 +1,32 @@
+package display
+
+import (
+ "embed"
+
+ "core/config"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// Brand defines the type for different application brands.
+type Brand string
+
+const (
+ AdminHub Brand = "admin-hub"
+ ServerHub Brand = "server-hub"
+ GatewayHub Brand = "gateway-hub"
+ DeveloperHub Brand = "developer-hub"
+ ClientHub Brand = "client-hub"
+)
+
+// Service manages all OS-level UI interactions (menus, windows, tray).
+// It is the main entry point for all display-related operations.
+type Service struct {
+ // --- Injected Dependencies ---
+ app *application.App
+ configService *config.Service
+
+ // --- Internal State ---
+ brand Brand
+ assets embed.FS
+ windowHandles map[string]*application.WebviewWindow
+}
diff --git a/display/menu.go b/display/menu.go
new file mode 100644
index 0000000..69059c1
--- /dev/null
+++ b/display/menu.go
@@ -0,0 +1,32 @@
+package display
+
+import (
+ "runtime"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// buildMenu creates and sets the main application menu.
+func (s *Service) buildMenu() {
+ appMenu := s.app.Menu.New()
+ if runtime.GOOS == "darwin" {
+ appMenu.AddRole(application.AppMenu)
+ }
+ appMenu.AddRole(application.FileMenu)
+ appMenu.AddRole(application.ViewMenu)
+ appMenu.AddRole(application.EditMenu)
+
+ workspace := appMenu.AddSubmenu("Workspace")
+ workspace.Add("New").OnClick(func(ctx *application.Context) { /* TODO */ })
+ workspace.Add("List").OnClick(func(ctx *application.Context) { /* TODO */ })
+
+ // Add brand-specific menu items
+ if s.brand == DeveloperHub {
+ appMenu.AddSubmenu("Developer")
+ }
+
+ appMenu.AddRole(application.WindowMenu)
+ appMenu.AddRole(application.HelpMenu)
+
+ s.app.Menu.Set(appMenu)
+}
diff --git a/display/service.go b/display/service.go
new file mode 100644
index 0000000..5afd490
--- /dev/null
+++ b/display/service.go
@@ -0,0 +1,146 @@
+package display
+
+import (
+ "context"
+ "embed"
+ "fmt"
+
+ "core/config"
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/events"
+)
+
+// NewService creates a new DisplayService.
+func NewService(brand Brand, assets embed.FS) *Service {
+ return &Service{
+ brand: brand,
+ assets: assets,
+ windowHandles: make(map[string]*application.WebviewWindow),
+ }
+}
+
+// Setup initializes the display service with the application instance and other core services.
+func (s *Service) Setup(app *application.App, configService *config.Service, i18nService interface{}) {
+ s.app = app
+ s.configService = configService
+
+ s.analyzeScreens()
+ s.monitorScreenChanges()
+ s.buildMenu()
+ s.setupTray()
+}
+
+func (s *Service) analyzeScreens() {
+ s.app.Logger.Info("Screen analysis", "count", len(s.app.Screen.GetAll()))
+
+ primary := s.app.Screen.GetPrimary()
+ if primary != nil {
+ s.app.Logger.Info("Primary screen",
+ "name", primary.Name,
+ "size", fmt.Sprintf("%dx%d", primary.Size.Width, primary.Size.Height),
+ "scaleFactor", primary.ScaleFactor,
+ "workArea", primary.WorkArea,
+ )
+ scaleFactor := primary.ScaleFactor
+
+ switch {
+ case scaleFactor == 1.0:
+ s.app.Logger.Info("Standard DPI display", "screen", primary.Name)
+ case scaleFactor == 1.25:
+ s.app.Logger.Info("125% scaled display", "screen", primary.Name)
+ case scaleFactor == 1.5:
+ s.app.Logger.Info("150% scaled display", "screen", primary.Name)
+ case scaleFactor == 2.0:
+ s.app.Logger.Info("High DPI display (200%)", "screen", primary.Name)
+ default:
+ s.app.Logger.Info("Custom scale display",
+ "screen", primary.Name,
+ "scale", scaleFactor,
+ )
+ }
+ } else {
+ s.app.Logger.Info("No primary screen found")
+ }
+
+ for i, screen := range s.app.Screen.GetAll() {
+ s.app.Logger.Info("Screen details",
+ "index", i,
+ "name", screen.Name,
+ "primary", screen.IsPrimary,
+ "bounds", screen.Bounds,
+ "scaleFactor", screen.ScaleFactor,
+ )
+ }
+}
+
+func (s *Service) monitorScreenChanges() {
+ // Monitor for screen configuration changes
+ s.app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) {
+ s.app.Logger.Info("Screen configuration changed")
+
+ // Re-analyze screens
+ s.app.Logger.Info("Updated screen count", "count", len(s.app.Screen.GetAll()))
+
+ // Could reposition windows here if needed
+ })
+}
+
+func (s *Service) ShowEnvironmentDialog() {
+ envInfo := s.app.Env.Info()
+
+ details := fmt.Sprintf(`Environment Information:
+
+Operating System: %s
+Architecture: %s
+Debug Mode: %t
+
+Dark Mode: %t
+
+Platform Information:`,
+ envInfo.OS,
+ envInfo.Arch,
+ envInfo.Debug,
+ s.app.Env.IsDarkMode()) // Use s.app
+
+ // Add platform-specific details
+ for key, value := range envInfo.PlatformInfo {
+ details += fmt.Sprintf("\n%s: %v", key, value)
+ }
+
+ if envInfo.OSInfo != nil {
+ details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s",
+ envInfo.OSInfo.Name,
+ envInfo.OSInfo.Version)
+ }
+
+ dialog := s.app.Dialog.Info()
+ dialog.SetTitle("Environment Information")
+ dialog.SetMessage(details)
+ dialog.Show()
+}
+
+func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
+ // Check the IsNew flag from the config service, which is the single source of truth.
+ if s.configService.Get().IsNew {
+ // If the config was just created, open the setup window.
+ s.OpenWindow("main", application.WebviewWindowOptions{
+ Title: "Setup",
+ URL: "#/setup",
+ })
+ } else {
+ // If the config already existed, open the main window with the default route.
+ defaultRoute, err := s.configService.Get().Key("DefaultRoute")
+ if err != nil {
+ defaultRoute = "/" // Fallback to a safe default if the key is somehow missing.
+ s.app.Logger.Error("Could not get DefaultRoute from config, using fallback", "error", err)
+ }
+
+ s.OpenWindow("main", application.WebviewWindowOptions{
+ Title: "Core",
+ Height: 900,
+ Width: 1280,
+ URL: "#" + defaultRoute.(string),
+ })
+ }
+ return nil
+}
diff --git a/display/tray.go b/display/tray.go
new file mode 100644
index 0000000..d01c797
--- /dev/null
+++ b/display/tray.go
@@ -0,0 +1,74 @@
+package display
+
+import (
+ "runtime"
+
+ _ "embed"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+//go:embed apptray.png
+var appTrayIcon []byte
+
+// setupTray configures and creates the system tray icon and menu.
+func (s *Service) setupTray() {
+
+ systray := s.app.SystemTray.New()
+ systray.SetTooltip("Lethean Desktop")
+
+ if runtime.GOOS == "darwin" {
+ systray.SetTemplateIcon(appTrayIcon)
+ } else {
+ // Support for light/dark mode icons
+ systray.SetDarkModeIcon(appTrayIcon)
+ systray.SetIcon(appTrayIcon)
+ }
+ // Create a hidden window for the system tray menu to interact with
+ trayWindow := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Title: "System Tray Status",
+ URL: "/#/system-tray",
+ Width: 400,
+ Frameless: true,
+ Hidden: true,
+ })
+ systray.AttachWindow(trayWindow).WindowOffset(5)
+
+ // --- Build Tray Menu ---
+ trayMenu := s.app.Menu.New()
+ trayMenu.Add("Open Desktop").OnClick(func(ctx *application.Context) {
+ for _, window := range s.app.Window.GetAll() {
+ window.Show()
+ }
+ })
+ trayMenu.Add("Close Desktop").OnClick(func(ctx *application.Context) {
+ for _, window := range s.app.Window.GetAll() {
+ window.Hide()
+ }
+ })
+
+ trayMenu.Add("Environment Info").OnClick(func(ctx *application.Context) {
+ s.ShowEnvironmentDialog()
+ })
+ // Add brand-specific menu items
+ switch s.brand {
+ case AdminHub:
+ trayMenu.Add("Manage Workspace").OnClick(func(ctx *application.Context) { /* TODO */ })
+ case ServerHub:
+ trayMenu.Add("Server Control").OnClick(func(ctx *application.Context) { /* TODO */ })
+ case GatewayHub:
+ trayMenu.Add("Routing Table").OnClick(func(ctx *application.Context) { /* TODO */ })
+ case DeveloperHub:
+ trayMenu.Add("Debug Console").OnClick(func(ctx *application.Context) { /* TODO */ })
+ case ClientHub:
+ trayMenu.Add("Connect").OnClick(func(ctx *application.Context) { /* TODO */ })
+ trayMenu.Add("Disconnect").OnClick(func(ctx *application.Context) { /* TODO */ })
+ }
+
+ trayMenu.AddSeparator()
+ trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
+ s.app.Quit()
+ })
+
+ systray.SetMenu(trayMenu)
+}
diff --git a/display/window.go b/display/window.go
new file mode 100644
index 0000000..d376cb8
--- /dev/null
+++ b/display/window.go
@@ -0,0 +1,28 @@
+package display
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// OpenWindow creates and shows a new webview window.
+// This function is callable from the frontend.
+func (s *Service) OpenWindow(name string, options application.WebviewWindowOptions) {
+ // Check if a window with that name already exists
+ if window, exists := s.app.Window.GetByName(name); exists {
+ window.Focus()
+ return
+ }
+
+ window := s.app.Window.NewWithOptions(options)
+ s.windowHandles[name] = window
+ window.Show()
+}
+
+// SelectDirectory opens a directory selection dialog and returns the selected path.
+func (s *Service) SelectDirectory() (string, error) {
+ dialog := application.OpenFileDialog()
+ dialog.SetTitle("Select Project Directory")
+ if path, err := dialog.PromptForSingleSelection(); err == nil {
+ // Use selected directory path
+ return path, nil
+ }
+ return "", nil
+}
diff --git a/docs/docs.go b/docs/docs.go
new file mode 100644
index 0000000..88c5efb
--- /dev/null
+++ b/docs/docs.go
@@ -0,0 +1,27 @@
+package docs
+
+import (
+ "embed"
+
+ "core/display"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// displayer is an interface that defines the functionality docs needs from a display service.
+// This avoids a direct dependency on the display package or the core package.
+type displayer interface {
+ OpenWindow(name string, options application.WebviewWindowOptions) (*application.WebviewWindow, error)
+}
+
+// Service manages the documentation display and serving of assets.
+type Service struct {
+ // --- Injected Dependencies ---
+ app *application.App
+ displayService *display.Service // Depends on the local interface, not a concrete type from another package.
+
+ // --- Internal State ---
+ assets embed.FS
+}
+
+//go:embed all:static/**/*
+var docsStatic embed.FS
diff --git a/docs/service.go b/docs/service.go
new file mode 100644
index 0000000..1fe41fa
--- /dev/null
+++ b/docs/service.go
@@ -0,0 +1,54 @@
+package docs
+
+import (
+ "embed"
+ "net/http"
+ "strings"
+
+ "core/display"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// NewService creates a new, un-wired documentation service.
+func NewService(assets embed.FS) *Service {
+ return &Service{
+ assets: assets,
+ }
+}
+
+// Setup injects the required dependencies into the service.
+func (s *Service) Setup(app *application.App, displayService *display.Service) {
+ s.app = app
+ s.displayService = displayService
+}
+
+// OpenDocsWindow opens a new window with the documentation.
+func (s *Service) OpenDocsWindow(path ...string) {
+ url := "/docs/"
+ if len(path) > 0 {
+ fullPath := path[0]
+ if strings.Contains(fullPath, "#") {
+ parts := strings.SplitN(fullPath, "#", 2)
+ pagePath := parts[0]
+ fragment := parts[1]
+ url += pagePath + "/#" + fragment
+ } else {
+ url += fullPath
+ }
+ }
+
+ // Use the injected displayService, which satisfies the local displayer interface.
+ s.displayService.OpenWindow("docs", application.WebviewWindowOptions{
+ Title: "Lethean Documentation",
+ Height: 600,
+ Width: 1000,
+ URL: url,
+ AlwaysOnTop: true,
+ Frameless: false,
+ })
+}
+
+// ServeHTTP serves the embedded documentation assets.
+func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ http.FileServerFS(docsStatic).ServeHTTP(w, r)
+}
diff --git a/docs/static/assets/style.css b/docs/static/assets/style.css
new file mode 100644
index 0000000..e69de29
diff --git a/docs/static/index.html b/docs/static/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/filesystem/client.go b/filesystem/client.go
new file mode 100644
index 0000000..6b22d19
--- /dev/null
+++ b/filesystem/client.go
@@ -0,0 +1,45 @@
+package filesystem
+
+import (
+ "core/filesystem/sftp"
+ "core/filesystem/webdav"
+)
+
+// NewSFTPMedium creates and returns a new SFTP medium.
+func NewSFTPMedium(cfg sftp.ConnectionConfig) (Medium, error) {
+ return sftp.New(cfg)
+}
+
+// NewWebDAVMedium creates and returns a new WebDAV medium.
+func NewWebDAVMedium(cfg webdav.ConnectionConfig) (Medium, error) {
+ return webdav.New(cfg)
+}
+
+// Read retrieves the content of a file from the given medium.
+func Read(m Medium, path string) (string, error) {
+ return m.Read(path)
+}
+
+// Write saves content to a file on the given medium.
+func Write(m Medium, path, content string) error {
+ return m.Write(path, content)
+}
+
+// EnsureDir ensures a directory exists on the given medium.
+func EnsureDir(m Medium, path string) error {
+ return m.EnsureDir(path)
+}
+
+// IsFile checks if a path is a file on the given medium.
+func IsFile(m Medium, path string) bool {
+ return m.IsFile(path)
+}
+
+// Copy copies a file from a source medium to a destination medium.
+func Copy(sourceMedium Medium, sourcePath string, destMedium Medium, destPath string) error {
+ content, err := sourceMedium.Read(sourcePath)
+ if err != nil {
+ return err
+ }
+ return destMedium.Write(destPath, content)
+}
diff --git a/filesystem/client_test.go b/filesystem/client_test.go
new file mode 100644
index 0000000..5bf1a5c
--- /dev/null
+++ b/filesystem/client_test.go
@@ -0,0 +1,31 @@
+package filesystem
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRead(t *testing.T) {
+ m := NewMockMedium()
+ m.Files["test.txt"] = "hello"
+ content, err := Read(m, "test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", content)
+}
+
+func TestWrite(t *testing.T) {
+ m := NewMockMedium()
+ err := Write(m, "test.txt", "hello")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", m.Files["test.txt"])
+}
+
+func TestCopy(t *testing.T) {
+ source := NewMockMedium()
+ dest := NewMockMedium()
+ source.Files["test.txt"] = "hello"
+ err := Copy(source, "test.txt", dest, "test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", dest.Files["test.txt"])
+}
diff --git a/filesystem/filesystem.go b/filesystem/filesystem.go
new file mode 100644
index 0000000..a9beecd
--- /dev/null
+++ b/filesystem/filesystem.go
@@ -0,0 +1,29 @@
+package filesystem
+
+import ()
+
+// Medium defines the standard interface for a storage backend.
+// This allows for different implementations (e.g., local disk, S3, SFTP)
+// to be used interchangeably.
+type Medium interface {
+ // Read retrieves the content of a file as a string.
+ Read(path string) (string, error)
+
+ // Write saves the given content to a file, overwriting it if it exists.
+ Write(path, content string) error
+
+ // EnsureDir makes sure a directory exists, creating it if necessary.
+ EnsureDir(path string) error
+
+ // IsFile checks if a path exists and is a regular file.
+ IsFile(path string) bool
+
+ // FileGet is a convenience function that reads a file from the medium.
+ FileGet(path string) (string, error)
+
+ // FileSet is a convenience function that writes a file to the medium.
+ FileSet(path, content string) error
+}
+
+// Pre-initialized, sandboxed medium for the local filesystem.
+var Local Medium
diff --git a/filesystem/filesystem_test.go b/filesystem/filesystem_test.go
new file mode 100644
index 0000000..8d6e6ae
--- /dev/null
+++ b/filesystem/filesystem_test.go
@@ -0,0 +1,3 @@
+package filesystem
+
+import ()
diff --git a/filesystem/local/client.go b/filesystem/local/client.go
new file mode 100644
index 0000000..0efe171
--- /dev/null
+++ b/filesystem/local/client.go
@@ -0,0 +1,83 @@
+package local
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// New creates a new instance of the local storage medium.
+// It requires a root path to sandbox all file operations.
+func New(rootPath string) (*Medium, error) {
+ if err := os.MkdirAll(rootPath, os.ModePerm); err != nil {
+ return nil, fmt.Errorf("could not create root directory at %s: %w", rootPath, err)
+ }
+ return &Medium{root: rootPath}, nil
+}
+
+// path returns a full, safe path within the medium's root.
+func (m *Medium) path(subpath string) (string, error) {
+ if strings.Contains(subpath, "..") {
+ return "", fmt.Errorf("path traversal attempt detected")
+ }
+ return filepath.Join(m.root, subpath), nil
+}
+
+// Read retrieves the content of a file from the local disk.
+func (m *Medium) Read(path string) (string, error) {
+ safePath, err := m.path(path)
+ if err != nil {
+ return "", err
+ }
+ data, err := os.ReadFile(safePath)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+// Write saves the given content to a file on the local disk.
+func (m *Medium) Write(path, content string) error {
+ safePath, err := m.path(path)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(safePath)
+ if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+ return err
+ }
+ return os.WriteFile(safePath, []byte(content), 0644)
+}
+
+// EnsureDir makes sure a directory exists on the local disk.
+func (m *Medium) EnsureDir(path string) error {
+ safePath, err := m.path(path)
+ if err != nil {
+ return err
+ }
+ return os.MkdirAll(safePath, os.ModePerm)
+}
+
+// IsFile checks if a path exists and is a regular file on the local disk.
+func (m *Medium) IsFile(path string) bool {
+ safePath, err := m.path(path)
+ if err != nil {
+ return false
+ }
+ info, err := os.Stat(safePath)
+ if os.IsNotExist(err) {
+ return false
+ }
+ return !info.IsDir()
+}
+
+// FileGet is a convenience function that reads a file from the medium.
+func (m *Medium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+// FileSet is a convenience function that writes a file to the medium.
+func (m *Medium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/filesystem/local/client_test.go b/filesystem/local/client_test.go
new file mode 100644
index 0000000..ff3dce7
--- /dev/null
+++ b/filesystem/local/client_test.go
@@ -0,0 +1,154 @@
+package local
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNew(t *testing.T) {
+ // Create a temporary directory for testing
+ testRoot, err := os.MkdirTemp("", "local_test_root")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot) // Clean up after the test
+
+ // Test successful creation
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+ assert.NotNil(t, medium)
+ assert.Equal(t, testRoot, medium.root)
+
+ // Verify the root directory exists
+ info, err := os.Stat(testRoot)
+ assert.NoError(t, err)
+ assert.True(t, info.IsDir())
+
+ // Test creating a new instance with an existing directory (should not error)
+ medium2, err := New(testRoot)
+ assert.NoError(t, err)
+ assert.NotNil(t, medium2)
+}
+
+func TestPath(t *testing.T) {
+ testRoot := "/tmp/test_root"
+ medium := &Medium{root: testRoot}
+
+ // Valid path
+ validPath, err := medium.path("file.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(testRoot, "file.txt"), validPath)
+
+ // Subdirectory path
+ subDirPath, err := medium.path("dir/sub/file.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(testRoot, "dir", "sub", "file.txt"), subDirPath)
+
+ // Path traversal attempt
+ _, err = medium.path("../secret.txt")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+
+ _, err = medium.path("dir/../../secret.txt")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+}
+
+func TestReadWrite(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_read_write_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ fileName := "testfile.txt"
+ filePath := filepath.Join("subdir", fileName)
+ content := "Hello, Gopher!\nThis is a test file."
+
+ // Test Write
+ err = medium.Write(filePath, content)
+ assert.NoError(t, err)
+
+ // Verify file content by reading directly from OS
+ readContent, err := os.ReadFile(filepath.Join(testRoot, filePath))
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(readContent))
+
+ // Test Read
+ readByMedium, err := medium.Read(filePath)
+ assert.NoError(t, err)
+ assert.Equal(t, content, readByMedium)
+
+ // Test Read non-existent file
+ _, err = medium.Read("nonexistent.txt")
+ assert.Error(t, err)
+ assert.True(t, os.IsNotExist(err))
+
+ // Test Write to a path with traversal attempt
+ writeErr := medium.Write("../badfile.txt", "malicious content")
+ assert.Error(t, writeErr)
+ assert.Contains(t, writeErr.Error(), "path traversal attempt detected")
+}
+
+func TestEnsureDir(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_ensure_dir_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ dirName := "newdir/subdir"
+ dirPath := filepath.Join(testRoot, dirName)
+
+ // Test creating a new directory
+ err = medium.EnsureDir(dirName)
+ assert.NoError(t, err)
+ info, err := os.Stat(dirPath)
+ assert.NoError(t, err)
+ assert.True(t, info.IsDir())
+
+ // Test ensuring an existing directory (should not error)
+ err = medium.EnsureDir(dirName)
+ assert.NoError(t, err)
+
+ // Test ensuring a directory with path traversal attempt
+ err = medium.EnsureDir("../bad_dir")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+}
+
+func TestIsFile(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_is_file_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ // Create a test file
+ fileName := "existing_file.txt"
+ filePath := filepath.Join(testRoot, fileName)
+ err = os.WriteFile(filePath, []byte("content"), 0644)
+ assert.NoError(t, err)
+
+ // Create a test directory
+ dirName := "existing_dir"
+ dirPath := filepath.Join(testRoot, dirName)
+ err = os.Mkdir(dirPath, 0755)
+ assert.NoError(t, err)
+
+ // Test with an existing file
+ assert.True(t, medium.IsFile(fileName))
+
+ // Test with a non-existent file
+ assert.False(t, medium.IsFile("nonexistent_file.txt"))
+
+ // Test with a directory
+ assert.False(t, medium.IsFile(dirName))
+
+ // Test with path traversal attempt
+ assert.False(t, medium.IsFile("../bad_file.txt"))
+}
diff --git a/filesystem/local/local.go b/filesystem/local/local.go
new file mode 100644
index 0000000..61f2447
--- /dev/null
+++ b/filesystem/local/local.go
@@ -0,0 +1,6 @@
+package local
+
+// Medium implements the filesystem.Medium interface for the local disk.
+type Medium struct {
+ root string
+}
diff --git a/filesystem/mock.go b/filesystem/mock.go
new file mode 100644
index 0000000..e97327b
--- /dev/null
+++ b/filesystem/mock.go
@@ -0,0 +1,47 @@
+package filesystem
+
+import "github.com/stretchr/testify/assert"
+
+// MockMedium implements the Medium interface for testing purposes.
+type MockMedium struct {
+ Files map[string]string
+ Dirs map[string]bool
+}
+
+func NewMockMedium() *MockMedium {
+ return &MockMedium{
+ Files: make(map[string]string),
+ Dirs: make(map[string]bool),
+ }
+}
+
+func (m *MockMedium) Read(path string) (string, error) {
+ content, ok := m.Files[path]
+ if !ok {
+ return "", assert.AnError // Simulate file not found error
+ }
+ return content, nil
+}
+
+func (m *MockMedium) Write(path, content string) error {
+ m.Files[path] = content
+ return nil
+}
+
+func (m *MockMedium) EnsureDir(path string) error {
+ m.Dirs[path] = true
+ return nil
+}
+
+func (m *MockMedium) IsFile(path string) bool {
+ _, ok := m.Files[path]
+ return ok
+}
+
+func (m *MockMedium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+func (m *MockMedium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/filesystem/sftp/client.go b/filesystem/sftp/client.go
new file mode 100644
index 0000000..a745a90
--- /dev/null
+++ b/filesystem/sftp/client.go
@@ -0,0 +1,125 @@
+package sftp
+
+import (
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+
+ "github.com/pkg/sftp"
+ "github.com/skeema/knownhosts"
+ "golang.org/x/crypto/ssh"
+)
+
+// New creates a new, connected instance of the SFTP storage medium.
+func New(cfg ConnectionConfig) (*Medium, error) {
+ var authMethods []ssh.AuthMethod
+
+ if cfg.KeyFile != "" {
+ key, err := os.ReadFile(cfg.KeyFile)
+ if err != nil {
+ return nil, fmt.Errorf("unable to read private key: %w", err)
+ }
+ signer, err := ssh.ParsePrivateKey(key)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse private key: %w", err)
+ }
+ authMethods = append(authMethods, ssh.PublicKeys(signer))
+ } else if cfg.Password != "" {
+ authMethods = append(authMethods, ssh.Password(cfg.Password))
+ } else {
+ return nil, fmt.Errorf("no authentication method provided (password or keyfile)")
+ }
+
+ kh, err := knownhosts.New(filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read known_hosts: %w", err)
+ }
+
+ sshConfig := &ssh.ClientConfig{
+ User: cfg.User,
+ Auth: authMethods,
+ HostKeyCallback: kh.HostKeyCallback(),
+ }
+
+ addr := net.JoinHostPort(cfg.Host, cfg.Port)
+ conn, err := ssh.Dial("tcp", addr, sshConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to dial ssh: %w", err)
+ }
+
+ sftpClient, err := sftp.NewClient(conn)
+ if err != nil {
+ // Ensure the underlying ssh connection is closed on failure
+ conn.Close()
+ return nil, fmt.Errorf("failed to create sftp client: %w", err)
+ }
+
+ return &Medium{client: sftpClient}, nil
+}
+
+// Read retrieves the content of a file from the SFTP server.
+func (m *Medium) Read(path string) (string, error) {
+ file, err := m.client.Open(path)
+ if err != nil {
+ return "", fmt.Errorf("sftp: failed to open file %s: %w", path, err)
+ }
+ defer file.Close()
+
+ data, err := io.ReadAll(file)
+ if err != nil {
+ return "", fmt.Errorf("sftp: failed to read file %s: %w", path, err)
+ }
+
+ return string(data), nil
+}
+
+// Write saves the given content to a file on the SFTP server.
+func (m *Medium) Write(path, content string) error {
+ // Ensure the remote directory exists first.
+ dir := filepath.Dir(path)
+ if err := m.EnsureDir(dir); err != nil {
+ return err
+ }
+
+ file, err := m.client.Create(path)
+ if err != nil {
+ return fmt.Errorf("sftp: failed to create file %s: %w", path, err)
+ }
+ defer file.Close()
+
+ if _, err := file.Write([]byte(content)); err != nil {
+ return fmt.Errorf("sftp: failed to write to file %s: %w", path, err)
+ }
+
+ return nil
+}
+
+// EnsureDir makes sure a directory exists on the SFTP server.
+func (m *Medium) EnsureDir(path string) error {
+ // MkdirAll is idempotent, so it won't error if the path already exists.
+ return m.client.MkdirAll(path)
+}
+
+// IsFile checks if a path exists and is a regular file on the SFTP server.
+func (m *Medium) IsFile(path string) bool {
+ info, err := m.client.Stat(path)
+ if err != nil {
+ // If the error is "not found", it's definitely not a file.
+ // For any other error, we also conservatively say it's not a file.
+ return false
+ }
+ // Return true only if it's not a directory.
+ return !info.IsDir()
+}
+
+// FileGet is a convenience function that reads a file from the medium.
+func (m *Medium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+// FileSet is a convenience function that writes a file to the medium.
+func (m *Medium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/filesystem/sftp/sftp.go b/filesystem/sftp/sftp.go
new file mode 100644
index 0000000..cf9e2e1
--- /dev/null
+++ b/filesystem/sftp/sftp.go
@@ -0,0 +1,19 @@
+package sftp
+
+import (
+ "github.com/pkg/sftp"
+)
+
+// Medium implements the filesystem.Medium interface for the SFTP protocol.
+type Medium struct {
+ client *sftp.Client
+}
+
+// ConnectionConfig holds the necessary details to connect to an SFTP server.
+type ConnectionConfig struct {
+ Host string
+ Port string
+ User string
+ Password string // For password-based auth
+ KeyFile string // Path to a private key for key-based auth
+}
diff --git a/filesystem/webdav/client.go b/filesystem/webdav/client.go
new file mode 100644
index 0000000..7ed4f74
--- /dev/null
+++ b/filesystem/webdav/client.go
@@ -0,0 +1,16 @@
+package webdav
+
+import "net/http"
+
+// Medium implements the filesystem.Medium interface for the WebDAV protocol.
+type Medium struct {
+ client *http.Client
+ baseURL string // e.g., https://dav.example.com/remote.php/dav/files/username/
+}
+
+// ConnectionConfig holds the necessary details to connect to a WebDAV server.
+type ConnectionConfig struct {
+ URL string // The full base URL of the WebDAV share.
+ User string
+ Password string
+}
diff --git a/filesystem/webdav/webdav.go b/filesystem/webdav/webdav.go
new file mode 100644
index 0000000..db0ac66
--- /dev/null
+++ b/filesystem/webdav/webdav.go
@@ -0,0 +1,183 @@
+package webdav
+
+import (
+ "bytes"
+ _ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "path"
+ "strings"
+)
+
+// New creates a new, connected instance of the WebDAV storage medium.
+func New(cfg ConnectionConfig) (*Medium, error) {
+ transport := &authTransport{
+ Username: cfg.User,
+ Password: cfg.Password,
+ Wrapped: http.DefaultTransport,
+ }
+
+ httpClient := &http.Client{Transport: transport}
+
+ // Ping the server to ensure the connection and credentials are valid.
+ // We do a PROPFIND on the root, which is a standard WebDAV operation.
+ req, err := http.NewRequest("PROPFIND", cfg.URL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("webdav: failed to create ping request: %w", err)
+ }
+ req.Header.Set("Depth", "0")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("webdav: connection test failed: %w", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("webdav: connection test failed with status %s", resp.Status)
+ }
+
+ return &Medium{
+ client: httpClient,
+ baseURL: cfg.URL,
+ }, nil
+}
+
+// Read retrieves the content of a file from the WebDAV server.
+func (m *Medium) Read(p string) (string, error) {
+ url := m.resolveURL(p)
+ resp, err := m.client.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("webdav: GET request for %s failed: %w", p, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("webdav: failed to read %s, status: %s", p, resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("webdav: failed to read response body for %s: %w", p, err)
+ }
+
+ return string(data), nil
+}
+
+// Write saves the given content to a file on the WebDAV server.
+func (m *Medium) Write(p, content string) error {
+ // Ensure the parent directory exists first.
+ dir := path.Dir(p)
+ if dir != "." && dir != "/" {
+ if err := m.EnsureDir(dir); err != nil {
+ return err // This will be a detailed error from EnsureDir
+ }
+ }
+
+ url := m.resolveURL(p)
+ req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(content)))
+ if err != nil {
+ return fmt.Errorf("webdav: failed to create PUT request: %w", err)
+ }
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("webdav: PUT request for %s failed: %w", p, err)
+ }
+ defer resp.Body.Close()
+
+ // StatusCreated (201) or StatusNoContent (204) are success codes for PUT.
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("webdav: failed to write %s, status: %s", p, resp.Status)
+ }
+
+ return nil
+}
+
+// EnsureDir makes sure a directory exists on the WebDAV server, creating parent dirs as needed.
+func (m *Medium) EnsureDir(p string) error {
+ // To mimic MkdirAll, we create each part of the path sequentially.
+ parts := strings.Split(p, "/")
+ currentPath := ""
+ for _, part := range parts {
+ if part == "" {
+ continue
+ }
+ currentPath = path.Join(currentPath, part)
+ url := m.resolveURL(currentPath) + "/" // MKCOL needs a trailing slash
+
+ req, err := http.NewRequest("MKCOL", url, nil)
+ if err != nil {
+ return fmt.Errorf("webdav: failed to create MKCOL request for %s: %w", currentPath, err)
+ }
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("webdav: MKCOL request for %s failed: %w", currentPath, err)
+ }
+ resp.Body.Close()
+
+ // 405 Method Not Allowed means it already exists, which is fine for us.
+ // 201 Created is a success.
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusMethodNotAllowed {
+ return fmt.Errorf("webdav: failed to create directory %s, status: %s", currentPath, resp.Status)
+ }
+ }
+ return nil
+}
+
+// IsFile checks if a path exists and is a regular file on the WebDAV server.
+func (m *Medium) IsFile(p string) bool {
+ url := m.resolveURL(p)
+ req, err := http.NewRequest("PROPFIND", url, nil)
+ if err != nil {
+ return false
+ }
+ req.Header.Set("Depth", "0")
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+
+ // If we get anything other than a Multi-Status, it's probably not a file.
+ if resp.StatusCode != http.StatusMultiStatus {
+ return false
+ }
+
+ // A simple check: if the response body contains the string for a collection, it's a directory.
+ // A more robust implementation would parse the XML response.
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false
+ }
+
+ return !strings.Contains(string(body), "")
+}
+
+// resolveURL joins the base URL with a path segment, ensuring correct slashes.
+func (m *Medium) resolveURL(p string) string {
+ return strings.TrimSuffix(m.baseURL, "/") + "/" + strings.TrimPrefix(p, "/")
+}
+
+// authTransport is a custom http.RoundTripper to inject Basic Auth.
+type authTransport struct {
+ Username string
+ Password string
+ Wrapped http.RoundTripper
+}
+
+func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req.SetBasicAuth(t.Username, t.Password)
+ return t.Wrapped.RoundTrip(req)
+}
+
+// FileGet is a convenience function that reads a file from the medium.
+func (m *Medium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+// FileSet is a convenience function that writes a file to the medium.
+func (m *Medium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/workspace/local.go b/workspace/local.go
new file mode 100644
index 0000000..4eab633
--- /dev/null
+++ b/workspace/local.go
@@ -0,0 +1,41 @@
+package workspace
+
+import "core/filesystem"
+
+// localMedium implements the Medium interface for the local disk.
+type localMedium struct{}
+
+// NewLocalMedium creates a new instance of the local storage medium.
+func NewLocalMedium() filesystem.Medium {
+ return &localMedium{}
+}
+
+// FileGet reads a file from the local disk.
+func (m *localMedium) FileGet(path string) (string, error) {
+ return filesystem.Read(filesystem.Local, path)
+}
+
+// FileSet writes a file to the local disk.
+func (m *localMedium) FileSet(path, content string) error {
+ return filesystem.Write(filesystem.Local, path, content)
+}
+
+// Read reads a file from the local disk.
+func (m *localMedium) Read(path string) (string, error) {
+ return filesystem.Read(filesystem.Local, path)
+}
+
+// Write writes a file to the local disk.
+func (m *localMedium) Write(path, content string) error {
+ return filesystem.Write(filesystem.Local, path, content)
+}
+
+// EnsureDir creates a directory on the local disk.
+func (m *localMedium) EnsureDir(path string) error {
+ return filesystem.EnsureDir(filesystem.Local, path)
+}
+
+// IsFile checks if a path exists and is a file on the local disk.
+func (m *localMedium) IsFile(path string) bool {
+ return filesystem.IsFile(filesystem.Local, path)
+}
diff --git a/workspace/service.go b/workspace/service.go
new file mode 100644
index 0000000..f211f16
--- /dev/null
+++ b/workspace/service.go
@@ -0,0 +1,124 @@
+package workspace
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+
+ "core/config"
+ "core/crypt/lib/lthn"
+ "core/crypt/lib/openpgp"
+ "core/filesystem"
+)
+
+// NewService creates a new WorkspaceService.
+func NewService(cfg *config.Config, medium filesystem.Medium) *Service {
+ return &Service{
+ config: cfg,
+ workspaceList: make(map[string]string),
+ medium: medium,
+ }
+}
+
+// ServiceStartup Startup initializes the service, loading the workspace list.
+func (s *Service) ServiceStartup() error {
+ listPath := filepath.Join(s.config.WorkspacesDir, listFile)
+
+ if s.medium.IsFile(listPath) {
+ content, err := s.medium.FileGet(listPath)
+ if err != nil {
+ return fmt.Errorf("failed to read workspace list: %w", err)
+ }
+ if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil {
+ fmt.Printf("Warning: could not parse workspace list: %v\n", err)
+ s.workspaceList = make(map[string]string)
+ }
+ }
+
+ return s.SwitchWorkspace(defaultWorkspace)
+}
+
+// CreateWorkspace creates a new, obfuscated workspace on the local medium.
+func (s *Service) CreateWorkspace(identifier, password string) (string, error) {
+ realName := lthn.Hash(identifier)
+ workspaceID := lthn.Hash(fmt.Sprintf("workspace/%s", realName))
+ workspacePath := filepath.Join(s.config.WorkspacesDir, workspaceID)
+
+ if _, exists := s.workspaceList[workspaceID]; exists {
+ return "", fmt.Errorf("workspace for this identifier already exists")
+ }
+
+ dirsToCreate := []string{"config", "log", "data", "files", "keys"}
+ for _, dir := range dirsToCreate {
+ if err := s.medium.EnsureDir(filepath.Join(workspacePath, dir)); err != nil {
+ return "", fmt.Errorf("failed to create workspace directory '%s': %w", dir, err)
+ }
+ }
+
+ keyPair, err := openpgp.CreateKeyPair(workspaceID, password)
+ if err != nil {
+ return "", fmt.Errorf("failed to create workspace key pair: %w", err)
+ }
+
+ keyFiles := map[string]string{
+ filepath.Join(workspacePath, "keys", "key.pub"): keyPair.PublicKey,
+ filepath.Join(workspacePath, "keys", "key.priv"): keyPair.PrivateKey,
+ }
+ for path, content := range keyFiles {
+ if err := s.medium.FileSet(path, content); err != nil {
+ return "", fmt.Errorf("failed to write key file %s: %w", path, err)
+ }
+ }
+
+ s.workspaceList[workspaceID] = keyPair.PublicKey
+ listData, err := json.MarshalIndent(s.workspaceList, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal workspace list: %w", err)
+ }
+
+ listPath := filepath.Join(s.config.WorkspacesDir, listFile)
+ if err := s.medium.FileSet(listPath, string(listData)); err != nil {
+ return "", fmt.Errorf("failed to write workspace list file: %w", err)
+ }
+
+ return workspaceID, nil
+}
+
+// SwitchWorkspace changes the active workspace.
+func (s *Service) SwitchWorkspace(name string) error {
+ if name != defaultWorkspace {
+ if _, exists := s.workspaceList[name]; !exists {
+ return fmt.Errorf("workspace '%s' does not exist", name)
+ }
+ }
+
+ path := filepath.Join(s.config.WorkspacesDir, name)
+ if err := s.medium.EnsureDir(path); err != nil {
+ return fmt.Errorf("failed to ensure workspace directory exists: %w", err)
+ }
+
+ s.activeWorkspace = &Workspace{
+ Name: name,
+ Path: path,
+ }
+
+ return nil
+}
+
+// WorkspaceFileGet retrieves a file from the active workspace.
+func (s *Service) WorkspaceFileGet(filename string) (string, error) {
+ if s.activeWorkspace == nil {
+ return "", fmt.Errorf("no active workspace")
+ }
+ path := filepath.Join(s.activeWorkspace.Path, filename)
+ return s.medium.FileGet(path)
+}
+
+// WorkspaceFileSet writes a file to the active workspace.
+func (s *Service) WorkspaceFileSet(filename, content string) error {
+ if s.activeWorkspace == nil {
+ return fmt.Errorf("no active workspace")
+ }
+ path := filepath.Join(s.activeWorkspace.Path, filename)
+ return s.medium.FileSet(path, content)
+}
diff --git a/workspace/workspace.go b/workspace/workspace.go
new file mode 100644
index 0000000..0d8c0a1
--- /dev/null
+++ b/workspace/workspace.go
@@ -0,0 +1,25 @@
+package workspace
+
+import (
+ "core/config"
+ "core/filesystem"
+)
+
+const (
+ defaultWorkspace = "default"
+ listFile = "list.json"
+)
+
+// Workspace represents a user's workspace.
+type Workspace struct {
+ Name string
+ Path string
+}
+
+// Service manages user workspaces.
+type Service struct {
+ config *config.Config
+ activeWorkspace *Workspace
+ workspaceList map[string]string // Maps Workspace ID to Public Key
+ medium filesystem.Medium
+}
diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go
new file mode 100644
index 0000000..adacca8
--- /dev/null
+++ b/workspace/workspace_test.go
@@ -0,0 +1,157 @@
+package workspace
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "core/config"
+ "github.com/stretchr/testify/assert"
+)
+
+// MockMedium implements the Medium interface for testing purposes.
+type MockMedium struct {
+ Files map[string]string
+ Dirs map[string]bool
+}
+
+func NewMockMedium() *MockMedium {
+ return &MockMedium{
+ Files: make(map[string]string),
+ Dirs: make(map[string]bool),
+ }
+}
+
+func (m *MockMedium) FileGet(path string) (string, error) {
+ content, ok := m.Files[path]
+ if !ok {
+ return "", assert.AnError // Simulate file not found error
+ }
+ return content, nil
+}
+
+func (m *MockMedium) FileSet(path, content string) error {
+ m.Files[path] = content
+ return nil
+}
+
+func (m *MockMedium) EnsureDir(path string) error {
+ m.Dirs[path] = true
+ return nil
+}
+
+func (m *MockMedium) IsFile(path string) bool {
+ _, ok := m.Files[path]
+ return ok
+}
+
+func (m *MockMedium) Read(path string) (string, error) {
+ return m.FileGet(path)
+}
+
+func (m *MockMedium) Write(path, content string) error {
+ return m.FileSet(path, content)
+}
+
+func TestNewService(t *testing.T) {
+ mockConfig := &config.Config{} // You might want to mock this further if its behavior is critical
+ mockMedium := NewMockMedium()
+
+ service := NewService(mockConfig, mockMedium)
+
+ assert.NotNil(t, service)
+ assert.Equal(t, mockConfig, service.config)
+ assert.Equal(t, mockMedium, service.medium)
+ assert.NotNil(t, service.workspaceList)
+ assert.Nil(t, service.activeWorkspace) // Initially no active workspace
+}
+
+func TestServiceStartup(t *testing.T) {
+ mockConfig := &config.Config{
+ WorkspacesDir: "/tmp/workspaces",
+ }
+
+ // Test case 1: list.json exists and is valid
+ t.Run("existing valid list.json", func(t *testing.T) {
+ mockMedium := NewMockMedium()
+
+ // Prepare a mock workspace list
+ expectedWorkspaceList := map[string]string{
+ "workspace1": "pubkey1",
+ "workspace2": "pubkey2",
+ }
+ listContent, _ := json.MarshalIndent(expectedWorkspaceList, "", " ")
+
+ listPath := filepath.Join(mockConfig.WorkspacesDir, listFile)
+ mockMedium.FileSet(listPath, string(listContent))
+
+ service := NewService(mockConfig, mockMedium)
+ err := service.ServiceStartup()
+
+ assert.NoError(t, err)
+ assert.Equal(t, expectedWorkspaceList, service.workspaceList)
+ assert.NotNil(t, service.activeWorkspace)
+ assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
+ assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
+ })
+
+ // Test case 2: list.json does not exist
+ t.Run("no list.json", func(t *testing.T) {
+ mockMedium := NewMockMedium() // Fresh medium with no files
+
+ service := NewService(mockConfig, mockMedium)
+ err := service.ServiceStartup()
+
+ assert.NoError(t, err)
+ assert.NotNil(t, service.workspaceList)
+ assert.Empty(t, service.workspaceList) // Should be empty if no list.json
+ assert.NotNil(t, service.activeWorkspace)
+ assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
+ assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
+ })
+
+ // Test case 3: list.json exists but is invalid
+ t.Run("invalid list.json", func(t *testing.T) {
+ mockMedium := NewMockMedium()
+
+ listPath := filepath.Join(mockConfig.WorkspacesDir, listFile)
+ mockMedium.FileSet(listPath, "{invalid json") // Invalid JSON
+
+ service := NewService(mockConfig, mockMedium)
+ err := service.ServiceStartup()
+
+ assert.NoError(t, err) // Error is logged, but startup continues
+ assert.NotNil(t, service.workspaceList)
+ assert.Empty(t, service.workspaceList) // Should be empty if invalid list.json
+ assert.NotNil(t, service.activeWorkspace)
+ assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
+ assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
+ })
+}
+
+func TestCreateWorkspace(t *testing.T) {
+ mockConfig := &config.Config{
+ WorkspacesDir: "/tmp/workspaces",
+ }
+ mockMedium := NewMockMedium()
+ service := NewService(mockConfig, mockMedium)
+
+ workspaceID, err := service.CreateWorkspace("test", "password")
+ assert.NoError(t, err)
+ assert.NotEmpty(t, workspaceID)
+}
+
+func TestSwitchWorkspace(t *testing.T) {
+ mockConfig := &config.Config{
+ WorkspacesDir: "/tmp/workspaces",
+ }
+ mockMedium := NewMockMedium()
+ service := NewService(mockConfig, mockMedium)
+
+ workspaceID, err := service.CreateWorkspace("test", "password")
+ assert.NoError(t, err)
+
+ err = service.SwitchWorkspace(workspaceID)
+ assert.NoError(t, err)
+ assert.Equal(t, workspaceID, service.activeWorkspace.Name)
+}