diff --git a/README.md b/README.md index 7f7c0c63..6b1374d6 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@ # Core -> NOTICE -> The current version here is not the current version, which is in letheanVPN/desktop/services/core (from memory). This was a nice idea, but I'm reorganising the code bases. Check back later. - - -[![codecov](https://codecov.io/github/Snider/Core/branch/dev/graph/badge.svg?token=I4DVF9746V)](https://codecov.io/github/Snider/Core) - Core is a Web3 Framework, written in Go using Wails.io to replace Electron and the bloat of browsers that, at their core, still live in their mum's basement. -More to come, follow us on Discord http://discord.dappco.re +- Discord: http://discord.dappco.re +- Repo: https://github.com/Snider/Core +## Vision -Repo: https://github.com/Snider/Core +Core is an **opinionated Web3 desktop application framework** providing: -## Quick start +1. **Service-Oriented Architecture** - Pluggable services with dependency injection +2. **Encrypted Workspaces** - Each workspace gets its own PGP keypair, files are obfuscated +3. **Cross-Platform Storage** - Abstract storage backends (local, SFTP, WebDAV) behind a `Medium` interface +4. **Multi-Brand Support** - Same codebase powers different "hub" apps (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub) +5. **Built-in Crypto** - PGP encryption/signing, hashing, checksums as first-class citizens + +**Mental model:** A secure, encrypted workspace manager where each "workspace" is a cryptographically isolated environment. The framework handles windows, menus, trays, config, and i18n. + +## Quick Start ```go import core "github.com/Snider/Core" @@ -23,133 +27,322 @@ app := core.New( ) ``` -## Development Workflow - -This project follows a Test-Driven Development (TDD) approach. We use [Task](https://taskfile.dev/) for task automation to streamline the development process. - -The recommended workflow is: - -1. **Generate Tests**: For any changes to the public API, first generate the necessary test stubs. - - ```bash - task test-gen - ``` - -2. **Run Tests (and watch them fail)**: Verify that the new tests fail as expected. - - ```bash - task test - ``` - -3. **Implement Your Feature**: Write the code to make the tests pass. - -4. **Run Tests Again**: Ensure all tests now pass. - - ```bash - task test - ``` - -5. **Submit for Review**: Once your changes are complete and tests are passing, submit them for a CodeRabbit review. - - ```bash - task review - ``` - -## Project Structure - -The project is organized into the following main directories: - -- `pkg/`: Contains the core Go packages that make up the framework. -- `cmd/`: Contains the entry points for the two main applications: - - `core-gui/`: The Wails-based GUI application. - - `core/`: The command-line interface (CLI) application. - ## Prerequisites -- Go 1.25+ (this repo targets Go 1.25 and uses workspaces) +- [Go](https://go.dev/) 1.25+ - [Node.js](https://nodejs.org/) -- [Wails](https://wails.io/) +- [Wails](https://wails.io/) v3 - [Task](https://taskfile.dev/) -## Building and Running - -### GUI Application - -To run the GUI application in development mode: +## Development Workflow (TDD) ```bash -task gui:dev +task test-gen # 1. Generate test stubs +task test # 2. Run tests (watch them fail) +# 3. Implement your feature +task test # 4. Run tests (watch them pass) +task review # 5. CodeRabbit review ``` -To build the final application for your platform: +## Building & Running ```bash -task gui:build +# GUI (Wails) +task gui:dev # Development with hot-reload +task gui:build # Production build + +# CLI +task cli:build # Build to cmd/core/bin/core +task cli:run # Build and run ``` -### CLI Application +## All Tasks -To build the CLI application: +| Task | Description | +|------|-------------| +| `task test` | Run all Go tests | +| `task test-gen` | Generate test stubs for public API | +| `task check` | go mod tidy + tests + review | +| `task review` | CodeRabbit review | +| `task cov` | Generate coverage.txt | +| `task cov-view` | Open HTML coverage report | +| `task sync` | Update public API Go files | + +--- + +## Architecture + +### Project Structure + +``` +. +├── core.go # Facade re-exporting pkg/core +├── pkg/ +│ ├── core/ # Service container, DI, Runtime[T] +│ ├── config/ # JSON persistence, XDG paths +│ ├── display/ # Windows, tray, menus (Wails) +│ ├── crypt/ # Hashing, checksums, PGP +│ │ └── openpgp/ # Full PGP implementation +│ ├── io/ # Medium interface + backends +│ ├── workspace/ # Encrypted workspace management +│ ├── help/ # In-app documentation +│ └── i18n/ # Internationalization +├── cmd/ +│ ├── core/ # CLI application +│ └── core-gui/ # Wails GUI application +└── go.work # Links root, cmd/core, cmd/core-gui +``` + +### Service Pattern (Dual-Constructor DI) + +Every service follows this pattern: + +```go +// Static DI - standalone use/testing (no core.Runtime) +func New() (*Service, error) + +// Dynamic DI - for core.WithService() registration +func Register(c *core.Core) (any, error) +``` + +Services embed `*core.Runtime[Options]` for access to `Core()` and `Config()`. + +### IPC/Action System + +Services implement `HandleIPCEvents(c *core.Core, msg core.Message) error` - auto-discovered via reflection. Handles typed actions like `core.ActionServiceStartup`. + +--- + +## Wails v3 Frontend Bindings + +Core uses [Wails v3](https://v3alpha.wails.io/) to expose Go methods to a WebView2 browser runtime. Wails automatically generates TypeScript bindings for registered services. + +**Documentation:** [Wails v3 Method Bindings](https://v3alpha.wails.io/features/bindings/methods/) + +### How It Works + +1. **Go services** with exported methods are registered with Wails +2. Run `wails3 generate bindings` (or `wails3 dev` / `wails3 build`) +3. **TypeScript SDK** is generated in `frontend/bindings/` +4. Frontend calls Go methods with full type safety, no HTTP overhead + +### Current Binding Architecture + +```go +// cmd/core-gui/main.go +app.RegisterService(application.NewService(coreService)) // Only Core is registered +``` + +**Problem:** Only `Core` is registered with Wails. Sub-services (crypt, workspace, display, etc.) are internal to Core's service map - their methods aren't directly exposed to JS. + +**Currently exposed** (see `cmd/core-gui/public/bindings/`): +```typescript +// From frontend: +import { ACTION, Config, Service } from './bindings/github.com/Snider/Core/pkg/core' + +ACTION(msg) // Broadcast IPC message +Config() // Get config service reference +Service("workspace") // Get service by name (returns any) +``` + +**NOT exposed:** Direct calls like `workspace.CreateWorkspace()` or `crypt.Hash()`. + +### The IPC Bridge Pattern (Chosen Architecture) + +Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bindings: + +```typescript +// Frontend calls Core.ACTION() with typed messages +import { ACTION } from './bindings/github.com/Snider/Core/pkg/core' + +// Open a window +ACTION({ action: "display.open_window", name: "settings", options: { Title: "Settings", Width: 800 } }) + +// Switch workspace +ACTION({ action: "workspace.switch_workspace", name: "myworkspace" }) +``` + +Each service implements `HandleIPCEvents(c *core.Core, msg core.Message)` to process these messages: + +```go +// pkg/display/display.go +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case map[string]any: + if action, ok := m["action"].(string); ok && action == "display.open_window" { + return s.handleOpenWindowAction(m) + } + } + return nil +} +``` + +**Why this pattern:** +- Single Wails service (Core) = simpler binding generation +- Services remain decoupled from Wails +- Centralized message routing via `ACTION()` +- Services can communicate internally using same pattern + +**Current gap:** Not all service methods have IPC handlers yet. See `HandleIPCEvents` in each service to understand what's wired up. + +### Generating Bindings ```bash -task cli:build +cd cmd/core-gui +wails3 generate bindings # Regenerate after Go changes ``` -The executable will be located in the `cmd/core/bin` directory. +Bindings output to `cmd/core-gui/public/bindings/github.com/Snider/Core/` mirroring Go package structure. -## Available Tasks +--- -To run any of the following tasks, open your terminal in the project's root directory and execute the `task` command. +### Service Interfaces (`pkg/core/interfaces.go`) -### General Tasks +```go +type Config interface { + Get(key string, out any) error + Set(key string, v any) error +} -- `task test`: Runs all Go tests recursively for the entire project. -- `task test-gen`: Generates tests for the public API. -- `task check`: A comprehensive check that runs `go mod tidy`, the full test suite, and a CodeRabbit review. -- `task review`: Submits the current changes for a CodeRabbit review. -- `task cov`: Generates a test coverage profile (`coverage.txt`). -- `task cov-view`: Opens the HTML coverage report in your browser. -- `task sync`: Updates the public API Go files to match the exported interface of the modules. +type Display interface { + OpenWindow(opts ...WindowOption) error +} -### GUI Application (`cmd/core-gui`) +type Workspace interface { + CreateWorkspace(identifier, password string) (string, error) + SwitchWorkspace(name string) error + WorkspaceFileGet(filename string) (string, error) + WorkspaceFileSet(filename, content string) error +} -These tasks are run from the root directory and operate on the GUI application. +type Crypt interface { + EncryptPGP(writer io.Writer, recipientPath, data string, ...) (string, error) + DecryptPGP(recipientPath, message, passphrase string, ...) (string, error) +} +``` -- `task gui:build`: Builds the GUI application. -- `task gui:package`: Packages a production build of the GUI application. -- `task gui:run`: Runs the GUI application. -- `task gui:dev`: Runs the GUI application in development mode, with hot-reloading enabled. +--- -### CLI Application (`cmd/core`) +## Current State (Prototype) -These tasks are run from the root directory and operate on the CLI application. +### Working -- `task cli:build`: Builds the CLI application. -- `task cli:build:dev`: Builds the CLI application for development. -- `task cli:run`: Builds and runs the CLI application. -- `task cli:sync`: Updates the public API Go files. -- `task cli:test-gen`: Generates tests for the public API. +| Package | Notes | +|---------|-------| +| `pkg/core` | Service container, DI, thread-safe - solid | +| `pkg/config` | JSON persistence, XDG paths - solid | +| `pkg/crypt` | Hashing, checksums, PGP - solid, well-tested | +| `pkg/help` | Embedded docs, Show/ShowAt - solid | +| `pkg/i18n` | Multi-language with go-i18n - solid | +| `pkg/io` | Medium interface + local backend - solid | +| `pkg/workspace` | Workspace creation, switching, file ops - functional | -## Docs (MkDocs) -The documentation site is powered by MkDocs Material and lives under `docs/` with configuration in `mkdocs.yml`. +### Partial -- Install docs tooling: - - `pip install -r docs/requirements.txt` -- Live preview from repository root: - - `mkdocs serve -o -c` -- Build static site: - - `mkdocs build --clean` +| Package | Issues | +|---------|--------| +| `pkg/display` | Window creation works; menu/tray handlers are TODOs | -## Releasing (GoReleaser) -This repo includes a minimal GoReleaser config (`.goreleaser.yaml`). Tagged pushes like `v1.2.3` will build and publish archives via GitHub Actions (see `.github/workflows/release.yml`). +--- -- Local dry run: `goreleaser release --snapshot --clean` -- Real release: create and push a version tag `vX.Y.Z`. +## Priority Work Items -## Go Workspaces -This repository uses Go workspaces (`go.work`) targeting Go 1.25. +### 1. IMPLEMENT: System Tray Brand Support -- Add/remove modules with `go work use`. -- Typical workflow: - - `go work sync` - - `go mod tidy` in modules as needed +`pkg/display/tray.go:52-63` - Commented brand-specific menu items need implementation. + +### 2. ADD: Integration Tests + +| Package | Notes | +|---------|-------| +| `pkg/display` | Integration tests requiring Wails runtime (27% unit coverage) | + +--- + +## Package Deep Dives + +### pkg/workspace - The Core Feature + +Each workspace is: +1. Identified by LTHN hash of user identifier +2. Has directory structure: `config/`, `log/`, `data/`, `files/`, `keys/` +3. Gets a PGP keypair generated on creation +4. Files accessed via obfuscated paths + +The `workspaceList` maps workspace IDs to public keys. + +### pkg/crypt/openpgp + +Full PGP using `github.com/ProtonMail/go-crypto`: +- `CreateKeyPair(name, passphrase)` - RSA-4096 with revocation cert +- `EncryptPGP()` - Encrypt + optional signing +- `DecryptPGP()` - Decrypt + optional signature verification + +### pkg/io - Storage Abstraction + +```go +type Medium interface { + Read(path string) (string, error) + Write(path, content string) error + EnsureDir(path string) error + IsFile(path string) bool + FileGet(path string) (string, error) + FileSet(path, content string) error +} +``` + +Implementations: `local/`, `sftp/`, `webdav/` + +--- + +## Future Work + +### Phase 1: Core Stability +- [x] ~~Fix workspace medium injection (critical blocker)~~ +- [x] ~~Initialize `io.Local` global~~ +- [x] ~~Clean up dead code (orphaned vars, broken wrappers)~~ +- [x] ~~Wire up IPC handlers for all services (config, crypt, display, help, i18n, workspace)~~ +- [x] ~~Complete display menu handlers (New/List workspace)~~ +- [x] ~~Tray icon setup with asset embedding~~ +- [x] ~~Test coverage for io packages~~ +- [ ] System tray brand-specific menus + +### Phase 2: Multi-Brand Support +- [ ] Define brand configuration system (config? build flags?) +- [ ] Implement brand-specific tray menus (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub) +- [ ] Brand-specific theming/assets +- [ ] Per-brand default workspace configurations + +### Phase 3: Remote Storage +- [ ] Complete SFTP backend (`pkg/io/sftp/`) +- [ ] Complete WebDAV backend (`pkg/io/webdav/`) +- [ ] Workspace sync across storage backends +- [ ] Conflict resolution for multi-device access + +### Phase 4: Enhanced Crypto +- [ ] Key management UI (import/export, key rotation) +- [ ] Multi-recipient encryption +- [ ] Hardware key support (YubiKey, etc.) +- [ ] Encrypted workspace backup/restore + +### Phase 5: Developer Experience +- [ ] TypeScript types for IPC messages (codegen from Go structs) +- [ ] Hot-reload for service registration +- [ ] Plugin system for third-party services +- [ ] CLI tooling for workspace management + +### Phase 6: Distribution +- [ ] Auto-update mechanism +- [ ] Platform installers (DMG, MSI, AppImage) +- [ ] Signing and notarization +- [ ] Crash reporting integration + +--- + +## For New Contributors + +1. Run `task test` to verify all tests pass +2. Follow TDD: `task test-gen` creates stubs, implement to pass +3. The dual-constructor pattern is intentional: `New(deps)` for tests, `Register()` for runtime +4. See `cmd/core-gui/main.go` for how services wire together +5. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge diff --git a/pkg/config/config.go b/pkg/config/config.go index 1ee0f693..57a7fe71 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,6 +47,20 @@ import ( "github.com/adrg/xdg" ) +// HandleIPCEvents processes IPC messages for the config service. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch msg.(type) { + case core.ActionServiceStartup: + // Config initializes during Register(), no additional startup needed. + return nil + default: + if c.App != nil && c.App.Logger != nil { + c.App.Logger.Debug("Config: Unhandled message type", "type", fmt.Sprintf("%T", msg)) + } + } + return nil +} + const appName = "lethean" const configFileName = "config.json" diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 22ea7d87..3fe6d921 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -46,7 +46,7 @@ func newTestCore(t *testing.T) *core.Core { return c } -func TestConfigServiceGood(t *testing.T) { +func TestConfigService(t *testing.T) { t.Run("New service creates default config", func(t *testing.T) { _, cleanup := setupTestEnv(t) defer cleanup() @@ -130,220 +130,38 @@ func TestConfigServiceGood(t *testing.T) { } }) - t.Run("Save and Load Struct", func(t *testing.T) { + t.Run("HandleIPCEvents with ActionServiceStartup", func(t *testing.T) { _, cleanup := setupTestEnv(t) defer cleanup() - s, err := New() + c := newTestCore(t) + serviceAny, err := Register(c) if err != nil { - t.Fatalf("New() failed: %v", err) + t.Fatalf("Register() failed: %v", err) } - type CustomConfig struct { - APIKey string `json:"apiKey"` - Timeout int `json:"timeout"` + s := serviceAny.(*Service) + err = s.HandleIPCEvents(c, core.ActionServiceStartup{}) + if err != nil { + t.Errorf("HandleIPCEvents(ActionServiceStartup) should not error, got: %v", err) + } + }) + + t.Run("HandleIPCEvents with unknown message type", func(t *testing.T) { + _, cleanup := setupTestEnv(t) + defer cleanup() + + c := newTestCore(t) + serviceAny, err := Register(c) + if err != nil { + t.Fatalf("Register() failed: %v", err) } - key := "custom" - expectedConfig := CustomConfig{ - APIKey: "12345", - Timeout: 30, - } - - if err := s.SaveStruct(key, expectedConfig); err != nil { - t.Fatalf("SaveStruct() failed: %v", err) - } - - var actualConfig CustomConfig - if err := s.LoadStruct(key, &actualConfig); err != nil { - t.Fatalf("LoadStruct() failed: %v", err) - } - - if actualConfig.APIKey != expectedConfig.APIKey { - t.Errorf("Expected APIKey '%s', got '%s'", expectedConfig.APIKey, actualConfig.APIKey) - } - if actualConfig.Timeout != expectedConfig.Timeout { - t.Errorf("Expected Timeout '%d', got '%d'", expectedConfig.Timeout, actualConfig.Timeout) - } - }) -} - -func TestConfigServiceUgly(t *testing.T) { - t.Run("LoadStruct with nil value", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - key := "nil-value" - filePath := filepath.Join(s.ConfigDir, key+".json") - if err := os.WriteFile(filePath, []byte("null"), 0644); err != nil { - t.Fatalf("Failed to write nil value file: %v", err) - } - - type CustomConfig struct { - APIKey string `json:"apiKey"` - Timeout int `json:"timeout"` - } - - var actualConfig CustomConfig - err = s.LoadStruct(key, &actualConfig) - if err != nil { - t.Fatalf("LoadStruct() should not have failed with a nil value, but it did: %v", err) - } - }) - - t.Run("Concurrent access", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - // Run concurrent Set and Get operations - done := make(chan bool) - for i := 0; i < 10; i++ { - go func() { - s.Set("language", "en") - done <- true - }() - go func() { - var lang string - s.Get("language", &lang) - done <- true - }() - } - - for i := 0; i < 20; i++ { - <-done - } - }) -} - -func TestConfigServiceBad(t *testing.T) { - t.Run("Load non-existent struct", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - type CustomConfig struct { - APIKey string `json:"apiKey"` - Timeout int `json:"timeout"` - } - - var actualConfig CustomConfig - if err := s.LoadStruct("non-existent", &actualConfig); err != nil { - t.Fatalf("LoadStruct() failed: %v", err) - } - - // Expect the struct to be zero-valued - if actualConfig.APIKey != "" { - t.Errorf("Expected empty APIKey, got '%s'", actualConfig.APIKey) - } - if actualConfig.Timeout != 0 { - t.Errorf("Expected zero Timeout, got '%d'", actualConfig.Timeout) - } - }) - - t.Run("Get non-existent key", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - var value string - err = s.Get("non-existent", &value) - if err == nil { - t.Errorf("Expected an error for non-existent key, but got nil") - } - }) - - t.Run("Set non-existent key", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - err = s.Set("non-existent", "value") - if err == nil { - t.Errorf("Expected an error for non-existent key, but got nil") - } - }) - - t.Run("SaveStruct with unmarshallable type", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - err = s.SaveStruct("test", make(chan int)) - if err == nil { - t.Errorf("Expected an error for unmarshallable type, but got nil") - } - }) - - t.Run("LoadStruct with invalid JSON", func(t *testing.T) { - _, cleanup := setupTestEnv(t) - defer cleanup() - - s, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - - key := "invalid" - filePath := filepath.Join(s.ConfigDir, key+".json") - if err := os.WriteFile(filePath, []byte("invalid json"), 0644); err != nil { - t.Fatalf("Failed to write invalid json file: %v", err) - } - - type CustomConfig struct { - APIKey string `json:"apiKey"` - Timeout int `json:"timeout"` - } - - var actualConfig CustomConfig - err = s.LoadStruct(key, &actualConfig) - if err == nil { - t.Errorf("Expected an error for invalid JSON, but got nil") - } - }) - - t.Run("New service with empty config file", func(t *testing.T) { - tempHomeDir, cleanup := setupTestEnv(t) - defer cleanup() - - // Manually create an empty config file - configDir := filepath.Join(tempHomeDir, appName, "config") - if err := os.MkdirAll(configDir, os.ModePerm); err != nil { - t.Fatalf("Failed to create test config dir: %v", err) - } - configPath := filepath.Join(configDir, configFileName) - if err := os.WriteFile(configPath, []byte(""), 0644); err != nil { - t.Fatalf("Failed to write empty config file: %v", err) - } - - _, err := New() - if err == nil { - t.Fatalf("New() should have failed with an empty config file, but it did not") + s := serviceAny.(*Service) + // Pass an arbitrary type as unknown message + err = s.HandleIPCEvents(c, "unknown message") + if err != nil { + t.Errorf("HandleIPCEvents(unknown) should not error, got: %v", err) } }) } diff --git a/pkg/core/docs/site/404.html b/pkg/core/docs/site/404.html new file mode 100644 index 00000000..e0fae56f --- /dev/null +++ b/pkg/core/docs/site/404.html @@ -0,0 +1,707 @@ + + + + + + + + + + + + + + + + + + + + + + Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ +

404 - Not found

+ +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/assets/external/fonts.googleapis.com/css.49ea35f2.css b/pkg/core/docs/site/assets/external/fonts.googleapis.com/css.49ea35f2.css new file mode 100644 index 00000000..d5c0c148 --- /dev/null +++ b/pkg/core/docs/site/assets/external/fonts.googleapis.com/css.49ea35f2.css @@ -0,0 +1,756 @@ +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* math */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* math */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* math */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* math */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* math */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* math */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: fallback; + src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 new file mode 100644 index 00000000..ab38fd54 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 new file mode 100644 index 00000000..db658495 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 new file mode 100644 index 00000000..7c9cbed6 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 new file mode 100644 index 00000000..e0aa3939 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 new file mode 100644 index 00000000..b6771301 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 new file mode 100644 index 00000000..669ba793 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 new file mode 100644 index 00000000..6cc1de8c Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 new file mode 100644 index 00000000..ded8a41e Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 new file mode 100644 index 00000000..dbac4817 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 new file mode 100644 index 00000000..8e0eec69 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 new file mode 100644 index 00000000..0ddf16c6 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 new file mode 100644 index 00000000..7bd3c2ef Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 new file mode 100644 index 00000000..8e43aa42 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 new file mode 100644 index 00000000..2c6ba19b Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 new file mode 100644 index 00000000..2f8b493b Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 new file mode 100644 index 00000000..7c16c79f Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 new file mode 100644 index 00000000..c2788c74 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 new file mode 100644 index 00000000..528b3bf4 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2 new file mode 100644 index 00000000..2c06834b Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2 new file mode 100644 index 00000000..532a888a Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2 new file mode 100644 index 00000000..b02e2d6c Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2 new file mode 100644 index 00000000..ae2f9eb0 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2 new file mode 100644 index 00000000..bfa169c3 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2 new file mode 100644 index 00000000..8a15f5c1 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2 new file mode 100644 index 00000000..d1ee097f Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2 new file mode 100644 index 00000000..c8e6ed44 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2 new file mode 100644 index 00000000..1debc1b4 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2 new file mode 100644 index 00000000..43f75160 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2 new file mode 100644 index 00000000..227f3624 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2 differ diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2 new file mode 100644 index 00000000..10a65a78 Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2 differ diff --git a/pkg/core/docs/site/assets/external/unpkg.com/iframe-worker/shim.js b/pkg/core/docs/site/assets/external/unpkg.com/iframe-worker/shim.js new file mode 100644 index 00000000..5f1e2321 --- /dev/null +++ b/pkg/core/docs/site/assets/external/unpkg.com/iframe-worker/shim.js @@ -0,0 +1 @@ +"use strict";(()=>{function c(s,n){parent.postMessage(s,n||"*")}function d(...s){return s.reduce((n,e)=>n.then(()=>new Promise(r=>{let t=document.createElement("script");t.src=e,t.onload=r,document.body.appendChild(t)})),Promise.resolve())}var o=class extends EventTarget{constructor(e){super();this.url=e;this.m=e=>{e.source===this.w&&(this.dispatchEvent(new MessageEvent("message",{data:e.data})),this.onmessage&&this.onmessage(e))};this.e=(e,r,t,i,m)=>{if(r===`${this.url}`){let a=new ErrorEvent("error",{message:e,filename:r,lineno:t,colno:i,error:m});this.dispatchEvent(a),this.onerror&&this.onerror(a)}};let r=document.createElement("iframe");r.hidden=!0,document.body.appendChild(this.iframe=r),this.w.document.open(),this.w.document.write(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core.Config

+

Short: App config and UI state persistence.

+

Overview

+

Stores and retrieves configuration, including window positions/sizes and user prefs.

+

Setup

+
package main
+
+import (
+  core "github.com/Snider/Core"
+  config "github.com/Snider/Core/config"
+)
+
+app := core.New(
+  core.WithService(config.Register),
+  core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Persist UI state automatically when using Core.Display.
  • +
  • Read/write your own settings via the config API.
  • +
+

API

+
    +
  • Register(c *core.Core) error
  • +
  • Get(path string, out any) error
  • +
  • Set(path string, v any) error
  • +
+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/core/crypt.html b/pkg/core/docs/site/core/crypt.html new file mode 100644 index 00000000..7c4fbfee --- /dev/null +++ b/pkg/core/docs/site/core/crypt.html @@ -0,0 +1,934 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core.Crypt - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core.Crypt

+

Short: Keys, encrypt/decrypt, sign/verify.

+

Overview

+

Simple wrappers around OpenPGP for common crypto tasks.

+

Setup

+
import (
+  core "github.com/Snider/Core"
+  crypt "github.com/Snider/Core/crypt"
+)
+
+app := core.New(
+  core.WithService(crypt.Register),
+  core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Generate keys
  • +
  • Encrypt/decrypt data
  • +
  • Sign/verify messages
  • +
+

API

+
    +
  • Register(c *core.Core) error
  • +
  • GenerateKey(opts ...Option) (*Key, error)
  • +
  • Encrypt(pub *Key, data []byte) ([]byte, error)
  • +
  • Decrypt(priv *Key, data []byte) ([]byte, error)
  • +
  • Sign(priv *Key, data []byte) ([]byte, error)
  • +
  • Verify(pub *Key, data, sig []byte) error
  • +
+

Notes

+ + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/core/display.html b/pkg/core/docs/site/core/display.html new file mode 100644 index 00000000..85d104c5 --- /dev/null +++ b/pkg/core/docs/site/core/display.html @@ -0,0 +1,936 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core.Display - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core.Display

+

Short: Windows, tray, and window state.

+

Overview

+

Manages Wails windows, remembers positions/sizes, exposes JS bindings, and integrates with Core.Config for persistence.

+

Setup

+
import (
+  core "github.com/Snider/Core"
+  display "github.com/Snider/Core/display"
+)
+
+app := core.New(
+  core.WithService(display.Register),
+  core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Open a window: OpenWindow(OptName("main"), ...)
  • +
  • Get a window: Window("main")
  • +
  • Save/restore state automatically when Core.Config is present
  • +
+

API

+
    +
  • Register(c *core.Core) error
  • +
  • OpenWindow(opts ...Option) *Window
  • +
  • Window(name string) *Window
  • +
  • Options: OptName, OptWidth, OptHeight, OptURL, OptTitle
  • +
+

Example

+
func (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
+  d.OpenWindow(
+    OptName("main"), OptWidth(1280), OptHeight(900), OptURL("/"), OptTitle("Core"),
+  )
+  return nil
+}
+
+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/core/docs.html b/pkg/core/docs/site/core/docs.html new file mode 100644 index 00000000..dc90d1f6 --- /dev/null +++ b/pkg/core/docs/site/core/docs.html @@ -0,0 +1,932 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core.Docs - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core.Docs

+

Short: In‑app help and deep‑links.

+

Overview

+

Renders MkDocs content inside your app. Opens specific sections in new windows for contextual help.

+

Setup

+
import (
+  core "github.com/Snider/Core"
+  docs "github.com/Snider/Core/docs"
+)
+
+app := core.New(
+  core.WithService(docs.Register),
+  core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Open docs home in a window: docs.Open()
  • +
  • Open a section: docs.OpenAt("core/display#setup")
  • +
  • Use short, descriptive headings to create stable anchors.
  • +
+

API

+
    +
  • Register(c *core.Core) error
  • +
  • Open() — show docs home
  • +
  • OpenAt(anchor string) — open specific section
  • +
+

Notes

+
    +
  • Docs are built with MkDocs Material and included in the demo app assets.
  • +
  • You are viewing Core.Docs right now, this Website is bundled into the app binary by default.
  • +
+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/core/index.html b/pkg/core/docs/site/core/index.html new file mode 100644 index 00000000..38c575dd --- /dev/null +++ b/pkg/core/docs/site/core/index.html @@ -0,0 +1,901 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core

+

Short: Framework bootstrap and service container.

+

What it is

+

Core wires modules together, provides lifecycle hooks, and locks the service graph for clarity and safety.

+

Setup

+
import "github.com/Snider/Core"
+
+app := core.New(
+    core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Register a module: core.RegisterModule(name, module)
  • +
  • Access a module: core.Mod[T](c, name)
  • +
  • Lock services: core.WithServiceLock()
  • +
+

API

+
    +
  • New(opts ...) *core.Core
  • +
  • RegisterModule(name string, m any) error
  • +
  • Mod[T any](c *core.Core, name ...string) *T
  • +
+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/core/io.html b/pkg/core/docs/site/core/io.html new file mode 100644 index 00000000..4485a50c --- /dev/null +++ b/pkg/core/docs/site/core/io.html @@ -0,0 +1,932 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core.IO - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core.IO

+

Short: Local/remote filesystem helpers.

+

Overview

+

Abstracts filesystems (local, SFTP, WebDAV) behind a unified API for reading/writing and listing.

+

Setup

+
import (
+  core "github.com/Snider/Core"
+  ioapi "github.com/Snider/Core/filesystem"
+)
+
+app := core.New(
+  core.WithService(ioapi.Register),
+  core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Open a filesystem: fs := ioapi.Local() or ioapi.SFTP(cfg)
  • +
  • Read/write files: fs.Read(path), fs.Write(path, data)
  • +
  • List directories: fs.List(path)
  • +
+

API

+
    +
  • Register(c *core.Core) error
  • +
  • Local() FS
  • +
  • SFTP(cfg Config) (FS, error)
  • +
  • WebDAV(cfg Config) (FS, error)
  • +
+

Notes

+
    +
  • See package pkg/v1/core/filesystem/* for drivers.
  • +
+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/core/workspace.html b/pkg/core/docs/site/core/workspace.html new file mode 100644 index 00000000..72bbc031 --- /dev/null +++ b/pkg/core/docs/site/core/workspace.html @@ -0,0 +1,930 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Core.Workspace - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Core.Workspace

+

Short: Projects and paths.

+

Overview

+

Provides a consistent way to resolve app/project directories, temp/cache locations, and user data paths across platforms.

+

Setup

+
import (
+  core "github.com/Snider/Core"
+  workspace "github.com/Snider/Core/workspace"
+)
+
+app := core.New(
+  core.WithService(workspace.Register),
+  core.WithServiceLock(),
+)
+
+

Use

+
    +
  • Get app data dir: ws.DataDir()
  • +
  • Get cache dir: ws.CacheDir()
  • +
  • Resolve project path: ws.Project("my-app")
  • +
+

API

+
    +
  • Register(c *core.Core) error
  • +
  • DataDir() string
  • +
  • CacheDir() string
  • +
  • Project(name string) string
  • +
+

Notes

+
    +
  • Follows OS directory standards (AppData, ~/Library, XDG, etc.).
  • +
+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/images/cross-platform.jpeg b/pkg/core/docs/site/images/cross-platform.jpeg new file mode 100644 index 00000000..8de2288e Binary files /dev/null and b/pkg/core/docs/site/images/cross-platform.jpeg differ diff --git a/pkg/core/docs/site/images/decentralised-vpn.jpg b/pkg/core/docs/site/images/decentralised-vpn.jpg new file mode 100644 index 00000000..df1f487d Binary files /dev/null and b/pkg/core/docs/site/images/decentralised-vpn.jpg differ diff --git a/pkg/core/docs/site/images/favicon.ico b/pkg/core/docs/site/images/favicon.ico new file mode 100644 index 00000000..8bc8ebbe Binary files /dev/null and b/pkg/core/docs/site/images/favicon.ico differ diff --git a/pkg/core/docs/site/images/illustration.png b/pkg/core/docs/site/images/illustration.png new file mode 100644 index 00000000..69f739c0 Binary files /dev/null and b/pkg/core/docs/site/images/illustration.png differ diff --git a/pkg/core/docs/site/images/lethean-logo.png b/pkg/core/docs/site/images/lethean-logo.png new file mode 100644 index 00000000..591019d5 Binary files /dev/null and b/pkg/core/docs/site/images/lethean-logo.png differ diff --git a/pkg/core/docs/site/images/private-transaction-net.png b/pkg/core/docs/site/images/private-transaction-net.png new file mode 100644 index 00000000..1eee17a0 Binary files /dev/null and b/pkg/core/docs/site/images/private-transaction-net.png differ diff --git a/pkg/core/docs/site/images/secure-data-storage.jpg b/pkg/core/docs/site/images/secure-data-storage.jpg new file mode 100644 index 00000000..395a8ae1 Binary files /dev/null and b/pkg/core/docs/site/images/secure-data-storage.jpg differ diff --git a/pkg/core/docs/site/index.html b/pkg/core/docs/site/index.html new file mode 100644 index 00000000..a956691d --- /dev/null +++ b/pkg/core/docs/site/index.html @@ -0,0 +1,939 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Core.Help - Core.Help + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Skip to content + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + +

Overview

+

Core is an opinionated framework for building Go desktop apps with Wails, providing a small set of focused modules you can mix into your app. It ships with sensible defaults and a demo app that doubles as in‑app help.

+
    +
  • Site: https://dappco.re
  • +
  • Repo: https://github.com/Snider/Core
  • +
+

Modules

+
    +
  • Core — framework bootstrap and service container
  • +
  • Core.Config — app and UI state persistence
  • +
  • Core.Crypt — keys, encrypt/decrypt, sign/verify
  • +
  • Core.Display — windows, tray, window state
  • +
  • Core.Docs — in‑app help and deep‑links
  • +
  • Core.IO — local/remote filesystem helpers
  • +
  • Core.Workspace — projects and paths
  • +
+

Quick start

+
package main
+
+import (
+    core "github.com/Snider/Core"
+)
+
+func main() {
+    app := core.New(
+        core.WithServiceLock(),
+    )
+    _ = app // start via Wails in your main package
+}
+
+

Services

+
package demo
+
+import (
+    core "github.com/Snider/Core"
+)
+
+// Register your service
+func Register(c *core.Core) error {
+    return c.RegisterModule("demo", &Demo{core: c})
+}
+
+

Display example

+
package display
+
+import (
+    "context"
+    "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// Open a window on startup
+func (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
+    d.OpenWindow(
+        OptName("main"),
+        OptHeight(900),
+        OptWidth(1280),
+        OptURL("/"),
+        OptTitle("Core"),
+    )
+    return nil
+}
+
+

See the left nav for detailed pages on each module.

+ + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/core/docs/site/search/search_index.js b/pkg/core/docs/site/search/search_index.js new file mode 100644 index 00000000..193f0500 --- /dev/null +++ b/pkg/core/docs/site/search/search_index.js @@ -0,0 +1 @@ +var __index = {"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"index.html","title":"Overview","text":"

Core is an opinionated framework for building Go desktop apps with Wails, providing a small set of focused modules you can mix into your app. It ships with sensible defaults and a demo app that doubles as in\u2011app help.

"},{"location":"index.html#modules","title":"Modules","text":""},{"location":"index.html#quick-start","title":"Quick start","text":"
package main\nimport (\ncore \"github.com/Snider/Core\"\n)\nfunc main() {\napp := core.New(\ncore.WithServiceLock(),\n)\n_ = app // start via Wails in your main package\n}\n
"},{"location":"index.html#services","title":"Services","text":"
package demo\nimport (\ncore \"github.com/Snider/Core\"\n)\n// Register your service\nfunc Register(c *core.Core) error {\nreturn c.RegisterModule(\"demo\", &Demo{core: c})\n}\n
"},{"location":"index.html#display-example","title":"Display example","text":"
package display\nimport (\n\"context\"\n\"github.com/wailsapp/wails/v3/pkg/application\"\n)\n// Open a window on startup\nfunc (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"),\nOptHeight(900),\nOptWidth(1280),\nOptURL(\"/\"),\nOptTitle(\"Core\"),\n)\nreturn nil\n}\n

See the left nav for detailed pages on each module.

"},{"location":"core/index.html","title":"Core","text":"

Short: Framework bootstrap and service container.

"},{"location":"core/index.html#what-it-is","title":"What it is","text":"

Core wires modules together, provides lifecycle hooks, and locks the service graph for clarity and safety.

"},{"location":"core/index.html#setup","title":"Setup","text":"
import \"github.com/Snider/Core\"\napp := core.New(\ncore.WithServiceLock(),\n)\n
"},{"location":"core/index.html#use","title":"Use","text":""},{"location":"core/index.html#api","title":"API","text":""},{"location":"core/config.html","title":"Core.Config","text":"

Short: App config and UI state persistence.

"},{"location":"core/config.html#overview","title":"Overview","text":"

Stores and retrieves configuration, including window positions/sizes and user prefs.

"},{"location":"core/config.html#setup","title":"Setup","text":"
package main\nimport (\ncore \"github.com/Snider/Core\"\nconfig \"github.com/Snider/Core/config\"\n)\napp := core.New(\ncore.WithService(config.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/config.html#use","title":"Use","text":""},{"location":"core/config.html#api","title":"API","text":""},{"location":"core/crypt.html","title":"Core.Crypt","text":"

Short: Keys, encrypt/decrypt, sign/verify.

"},{"location":"core/crypt.html#overview","title":"Overview","text":"

Simple wrappers around OpenPGP for common crypto tasks.

"},{"location":"core/crypt.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\ncrypt \"github.com/Snider/Core/crypt\"\n)\napp := core.New(\ncore.WithService(crypt.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/crypt.html#use","title":"Use","text":""},{"location":"core/crypt.html#api","title":"API","text":""},{"location":"core/crypt.html#notes","title":"Notes","text":""},{"location":"core/display.html","title":"Core.Display","text":"

Short: Windows, tray, and window state.

"},{"location":"core/display.html#overview","title":"Overview","text":"

Manages Wails windows, remembers positions/sizes, exposes JS bindings, and integrates with Core.Config for persistence.

"},{"location":"core/display.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\ndisplay \"github.com/Snider/Core/display\"\n)\napp := core.New(\ncore.WithService(display.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/display.html#use","title":"Use","text":""},{"location":"core/display.html#api","title":"API","text":""},{"location":"core/display.html#example","title":"Example","text":"
func (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"), OptWidth(1280), OptHeight(900), OptURL(\"/\"), OptTitle(\"Core\"),\n)\nreturn nil\n}\n
"},{"location":"core/docs.html","title":"Core.Docs","text":"

Short: In\u2011app help and deep\u2011links.

"},{"location":"core/docs.html#overview","title":"Overview","text":"

Renders MkDocs content inside your app. Opens specific sections in new windows for contextual help.

"},{"location":"core/docs.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\ndocs \"github.com/Snider/Core/docs\"\n)\napp := core.New(\ncore.WithService(docs.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/docs.html#use","title":"Use","text":""},{"location":"core/docs.html#api","title":"API","text":""},{"location":"core/docs.html#notes","title":"Notes","text":""},{"location":"core/io.html","title":"Core.IO","text":"

Short: Local/remote filesystem helpers.

"},{"location":"core/io.html#overview","title":"Overview","text":"

Abstracts filesystems (local, SFTP, WebDAV) behind a unified API for reading/writing and listing.

"},{"location":"core/io.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\nioapi \"github.com/Snider/Core/filesystem\"\n)\napp := core.New(\ncore.WithService(ioapi.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/io.html#use","title":"Use","text":""},{"location":"core/io.html#api","title":"API","text":""},{"location":"core/io.html#notes","title":"Notes","text":""},{"location":"core/workspace.html","title":"Core.Workspace","text":"

Short: Projects and paths.

"},{"location":"core/workspace.html#overview","title":"Overview","text":"

Provides a consistent way to resolve app/project directories, temp/cache locations, and user data paths across platforms.

"},{"location":"core/workspace.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\nworkspace \"github.com/Snider/Core/workspace\"\n)\napp := core.New(\ncore.WithService(workspace.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/workspace.html#use","title":"Use","text":""},{"location":"core/workspace.html#api","title":"API","text":""},{"location":"core/workspace.html#notes","title":"Notes","text":""}]} \ No newline at end of file diff --git a/pkg/core/docs/site/search/search_index.json b/pkg/core/docs/site/search/search_index.json new file mode 100644 index 00000000..323cc074 --- /dev/null +++ b/pkg/core/docs/site/search/search_index.json @@ -0,0 +1 @@ +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"index.html","title":"Overview","text":"

Core is an opinionated framework for building Go desktop apps with Wails, providing a small set of focused modules you can mix into your app. It ships with sensible defaults and a demo app that doubles as in\u2011app help.

"},{"location":"index.html#modules","title":"Modules","text":""},{"location":"index.html#quick-start","title":"Quick start","text":"
package main\nimport (\ncore \"github.com/Snider/Core\"\n)\nfunc main() {\napp := core.New(\ncore.WithServiceLock(),\n)\n_ = app // start via Wails in your main package\n}\n
"},{"location":"index.html#services","title":"Services","text":"
package demo\nimport (\ncore \"github.com/Snider/Core\"\n)\n// Register your service\nfunc Register(c *core.Core) error {\nreturn c.RegisterModule(\"demo\", &Demo{core: c})\n}\n
"},{"location":"index.html#display-example","title":"Display example","text":"
package display\nimport (\n\"context\"\n\"github.com/wailsapp/wails/v3/pkg/application\"\n)\n// Open a window on startup\nfunc (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"),\nOptHeight(900),\nOptWidth(1280),\nOptURL(\"/\"),\nOptTitle(\"Core\"),\n)\nreturn nil\n}\n

See the left nav for detailed pages on each module.

"},{"location":"core/index.html","title":"Core","text":"

Short: Framework bootstrap and service container.

"},{"location":"core/index.html#what-it-is","title":"What it is","text":"

Core wires modules together, provides lifecycle hooks, and locks the service graph for clarity and safety.

"},{"location":"core/index.html#setup","title":"Setup","text":"
import \"github.com/Snider/Core\"\napp := core.New(\ncore.WithServiceLock(),\n)\n
"},{"location":"core/index.html#use","title":"Use","text":""},{"location":"core/index.html#api","title":"API","text":""},{"location":"core/config.html","title":"Core.Config","text":"

Short: App config and UI state persistence.

"},{"location":"core/config.html#overview","title":"Overview","text":"

Stores and retrieves configuration, including window positions/sizes and user prefs.

"},{"location":"core/config.html#setup","title":"Setup","text":"
package main\nimport (\ncore \"github.com/Snider/Core\"\nconfig \"github.com/Snider/Core/config\"\n)\napp := core.New(\ncore.WithService(config.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/config.html#use","title":"Use","text":""},{"location":"core/config.html#api","title":"API","text":""},{"location":"core/crypt.html","title":"Core.Crypt","text":"

Short: Keys, encrypt/decrypt, sign/verify.

"},{"location":"core/crypt.html#overview","title":"Overview","text":"

Simple wrappers around OpenPGP for common crypto tasks.

"},{"location":"core/crypt.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\ncrypt \"github.com/Snider/Core/crypt\"\n)\napp := core.New(\ncore.WithService(crypt.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/crypt.html#use","title":"Use","text":""},{"location":"core/crypt.html#api","title":"API","text":""},{"location":"core/crypt.html#notes","title":"Notes","text":""},{"location":"core/display.html","title":"Core.Display","text":"

Short: Windows, tray, and window state.

"},{"location":"core/display.html#overview","title":"Overview","text":"

Manages Wails windows, remembers positions/sizes, exposes JS bindings, and integrates with Core.Config for persistence.

"},{"location":"core/display.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\ndisplay \"github.com/Snider/Core/display\"\n)\napp := core.New(\ncore.WithService(display.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/display.html#use","title":"Use","text":""},{"location":"core/display.html#api","title":"API","text":""},{"location":"core/display.html#example","title":"Example","text":"
func (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"), OptWidth(1280), OptHeight(900), OptURL(\"/\"), OptTitle(\"Core\"),\n)\nreturn nil\n}\n
"},{"location":"core/docs.html","title":"Core.Docs","text":"

Short: In\u2011app help and deep\u2011links.

"},{"location":"core/docs.html#overview","title":"Overview","text":"

Renders MkDocs content inside your app. Opens specific sections in new windows for contextual help.

"},{"location":"core/docs.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\ndocs \"github.com/Snider/Core/docs\"\n)\napp := core.New(\ncore.WithService(docs.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/docs.html#use","title":"Use","text":""},{"location":"core/docs.html#api","title":"API","text":""},{"location":"core/docs.html#notes","title":"Notes","text":""},{"location":"core/io.html","title":"Core.IO","text":"

Short: Local/remote filesystem helpers.

"},{"location":"core/io.html#overview","title":"Overview","text":"

Abstracts filesystems (local, SFTP, WebDAV) behind a unified API for reading/writing and listing.

"},{"location":"core/io.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\nioapi \"github.com/Snider/Core/filesystem\"\n)\napp := core.New(\ncore.WithService(ioapi.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/io.html#use","title":"Use","text":""},{"location":"core/io.html#api","title":"API","text":""},{"location":"core/io.html#notes","title":"Notes","text":""},{"location":"core/workspace.html","title":"Core.Workspace","text":"

Short: Projects and paths.

"},{"location":"core/workspace.html#overview","title":"Overview","text":"

Provides a consistent way to resolve app/project directories, temp/cache locations, and user data paths across platforms.

"},{"location":"core/workspace.html#setup","title":"Setup","text":"
import (\ncore \"github.com/Snider/Core\"\nworkspace \"github.com/Snider/Core/workspace\"\n)\napp := core.New(\ncore.WithService(workspace.Register),\ncore.WithServiceLock(),\n)\n
"},{"location":"core/workspace.html#use","title":"Use","text":""},{"location":"core/workspace.html#api","title":"API","text":""},{"location":"core/workspace.html#notes","title":"Notes","text":""}]} \ No newline at end of file diff --git a/pkg/core/docs/site/sitemap.xml b/pkg/core/docs/site/sitemap.xml new file mode 100644 index 00000000..a0633589 --- /dev/null +++ b/pkg/core/docs/site/sitemap.xml @@ -0,0 +1,35 @@ + + + + https://dappco.re/index.html + 2025-10-25 + + + https://dappco.re/core/index.html + 2025-10-25 + + + https://dappco.re/core/config.html + 2025-10-25 + + + https://dappco.re/core/crypt.html + 2025-10-25 + + + https://dappco.re/core/display.html + 2025-10-25 + + + https://dappco.re/core/docs.html + 2025-10-25 + + + https://dappco.re/core/io.html + 2025-10-25 + + + https://dappco.re/core/workspace.html + 2025-10-25 + + \ No newline at end of file diff --git a/pkg/core/docs/site/sitemap.xml.gz b/pkg/core/docs/site/sitemap.xml.gz new file mode 100644 index 00000000..c4e06d97 Binary files /dev/null and b/pkg/core/docs/site/sitemap.xml.gz differ diff --git a/pkg/core/docs/site/stylesheets/extra.css b/pkg/core/docs/site/stylesheets/extra.css new file mode 100644 index 00000000..8a89327b --- /dev/null +++ b/pkg/core/docs/site/stylesheets/extra.css @@ -0,0 +1,367 @@ +[data-md-color-scheme="lethean"] { + --md-primary-fg-color: #0F131C; +} + +.hero-section { + background: linear-gradient(135deg, #0F131C 0%, #1a237e 100%); + color: white; + padding: 4rem 2rem; + text-align: center; + margin-bottom: 3rem; +} + +.hero-content { + max-width: 800px; + margin: 0 auto; +} + +.hero-content h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.hero-subtitle { + font-size: 1.25rem; + margin-bottom: 2rem; + opacity: 0.9; +} + +.hero-badges { + margin-bottom: 2rem; +} + +.badge { + background: rgba(255, 255, 255, 0.1); + padding: 0.5rem 1rem; + border-radius: 20px; + margin: 0 0.5rem; + font-size: 0.9rem; +} + +.cta-button { + display: inline-block; + background: #4A90E2; + color: white; + padding: 0.8rem 2rem; + border-radius: 4px; + text-decoration: none; + font-weight: 500; + transition: all 0.3s; +} + +.cta-button:hover { + background: #357ABD; + color: white; + transform: translateY(-2px); +} + +.cta-button.secondary { + background: transparent; + border: 2px solid #4A90E2; + color: #4A90E2; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 0.2rem; + padding: 0.2rem; + margin-bottom: 3rem; +} + +.feature-card { + background: white; + border-radius: 8px; + padding: 1.0rem; + border: 2px solid #e2e8f0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s; +} + +[data-md-color-scheme="slate"] .feature-card { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.feature-card:hover { + transform: translateY(-5px); + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15); +} + +.feature-card img { + width: 100%; + height: 150px; + object-fit: cover; + border-radius: 4px; + margin-bottom: 1rem; +} + +.feature-card h3 { + margin: 1rem 0; + color: #0F131C; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +[data-md-color-scheme="slate"] .feature-card h3 { + color: #e2e8f0; +} + +.get-started { + color: #4A90E2; + text-decoration: none; + font-weight: 500; +} + +.benefits-section { + background: #f5f5f5; + padding: 0.4rem 0.2rem; + text-align: center; + margin-bottom: 3rem; +} + +.benefits-section h2 { + font-size: 1.5rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.5rem; + margin-top: 0.8rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +[data-md-color-scheme="slate"] .benefits-section { + background: #1a202c; + color: #e2e8f0; +} + +.benefits-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.2rem; + padding: 0.2rem; + margin: 0.2rem auto; +} + +.benefit-card { + background: white; + padding: 0.5rem; + border-radius: 8px; + border: 2px solid #e2e8f0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + text-align: left; +} + +[data-md-color-scheme="slate"] .benefit-card { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.roadmap-section { + padding: 0.4rem 0.2rem; + max-width: 1200px; + margin: 0 auto; +} + +.timeline { + position: relative; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2rem; + margin: 2rem 0; +} + +.timeline-item { + background: white; + padding: 1.5rem; + border-radius: 8px; + border: 2px solid #e2e8f0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + position: relative; + transition: all 0.3s; +} + +.timeline-item.completed { + grid-column: span 2; +} + +[data-md-color-scheme="slate"] .timeline-item { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.timeline-item:hover { + transform: translateY(-2px); + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15); +} + +.timeline-marker { + width: 20px; + height: 20px; + border-radius: 50%; + position: absolute; + top: -10px; + left: 50%; + transform: translateX(-50%); +} + +.timeline-item.planning .timeline-marker { + background: #718096; +} + +.timeline-item.in-progress .timeline-marker { + background: #4A90E2; +} + +.timeline-item.completed .timeline-marker { + background: #48BB78; +} + +.timeline-item ul { + list-style: none; + padding: 0; +} + +.timeline-item li { + margin: 0.5rem 0; + padding-left: 24px; + position: relative; +} + +.timeline-item li::before { + content: ""; + width: 12px; + height: 12px; + border-radius: 50%; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); +} + +.timeline-item li.planned::before { + background: #718096; +} + +.timeline-item li.active::before { + background: #4A90E2; +} + +.timeline-item li.completed::before { + background: #48BB78; +} + +.timeline-item li ul { + margin-top: 0.5rem; + margin-left: 1rem; +} + +.timeline-item li ul li { + font-size: 0.9rem; + margin: 0.25rem 0; +} + +.timeline-item li ul li::before { + width: 8px; + height: 8px; + background: #a0aec0; +} + +.timeline-item li ul li a { + color: #4A90E2; + text-decoration: none; + font-weight: 500; +} + +.timeline-item li ul li a:hover { + color: #357ABD; + text-decoration: underline; +} + +[data-md-color-scheme="slate"] .timeline-item li ul li a { + color: #63b3ed; +} + +[data-md-color-scheme="slate"] .timeline-item li ul li a:hover { + color: #90cdf4; +} + +.date { + font-size: 0.8rem; + color: #718096; + margin-left: 0.5rem; +} + +[data-md-color-scheme="slate"] .date { + color: #a0aec0; +} + +.cta-section { + background: #0F131C; + color: white; + padding: 4rem 2rem; + text-align: center; + margin-bottom: 3rem; +} + +.cta-buttons { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 2rem; +} + +.community-section { + padding: 4rem 2rem; + text-align: center; +} + +.community-links { + display: flex; + gap: 2rem; + justify-content: center; + margin-top: 2rem; +} + +.community-link { + color: #4A90E2; + text-decoration: none; + font-weight: 500; + transition: all 0.3s; +} + +.community-link:hover { + color: #357ABD; + transform: translateY(-2px); +} + +@media (max-width: 768px) { + .hero-content h1 { + font-size: 2rem; + } + + .timeline { + grid-template-columns: 1fr; + } + + .timeline-item.completed { + grid-column: auto; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .cta-buttons { + flex-direction: column; + } + + .community-links { + flex-direction: column; + gap: 1rem; + } +} \ No newline at end of file diff --git a/pkg/crypt/crypt.go b/pkg/crypt/crypt.go new file mode 100644 index 00000000..fb25bddb --- /dev/null +++ b/pkg/crypt/crypt.go @@ -0,0 +1,191 @@ +package crypt + +import ( + "bytes" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "strconv" + "strings" + + "github.com/Snider/Core/pkg/core" + "github.com/Snider/Core/pkg/crypt/lthn" + "github.com/Snider/Core/pkg/crypt/openpgp" +) + +// HandleIPCEvents processes IPC messages for the crypt service. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch msg.(type) { + case core.ActionServiceStartup: + // Crypt is stateless, no startup needed. + return nil + default: + if c.App != nil && c.App.Logger != nil { + c.App.Logger.Debug("Crypt: Unhandled message type", "type", fmt.Sprintf("%T", msg)) + } + } + return nil +} + +// Options holds configuration for the crypt service. +type Options struct{} + +// Service provides cryptographic functions to the application. +type Service struct { + *core.Runtime[Options] +} + +// 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" +) + +// newCryptService contains the common logic for initializing a Service struct. +func newCryptService() (*Service, error) { + return &Service{}, nil +} + +// New is the constructor for static dependency injection. +// It creates a Service instance without initializing the core.Runtime field. +func New() (*Service, error) { + return newCryptService() +} + +// Register is the constructor for dynamic dependency injection (used with core.WithService). +// It creates a Service instance and initializes its core.Runtime field. +func Register(c *core.Core) (any, error) { + s, err := newCryptService() + if err != nil { + return nil, err + } + s.Runtime = core.NewRuntime(c, Options{}) + return s, nil +} + +// --- Hashing --- + +// 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[:]) + } +} + +// --- Checksums --- + +// 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) + 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) + 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 +} + +// --- PGP --- + +// EncryptPGP encrypts data for a recipient, optionally signing it. +func (s *Service) EncryptPGP(writer io.Writer, recipientPath, data string, signerPath, signerPassphrase *string) (string, error) { + var buf bytes.Buffer + err := openpgp.EncryptPGP(&buf, recipientPath, data, signerPath, signerPassphrase) + if err != nil { + return "", err + } + + // Copy the encrypted data to the original writer. + if _, err := writer.Write(buf.Bytes()); err != nil { + return "", err + } + + return buf.String(), nil +} + +// DecryptPGP decrypts a PGP message, optionally verifying the signature. +func (s *Service) DecryptPGP(recipientPath, message, passphrase string, signerPath *string) (string, error) { + return openpgp.DecryptPGP(recipientPath, message, passphrase, signerPath) +} diff --git a/pkg/crypt/crypt_test.go b/pkg/crypt/crypt_test.go new file mode 100644 index 00000000..3707f266 --- /dev/null +++ b/pkg/crypt/crypt_test.go @@ -0,0 +1,366 @@ +package crypt + +import ( + "bytes" + "testing" + + "github.com/Snider/Core/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Constructor Tests --- + +func TestNew(t *testing.T) { + t.Run("creates service successfully", func(t *testing.T) { + service, err := New() + assert.NoError(t, err) + assert.NotNil(t, service) + }) + + t.Run("returns independent instances", func(t *testing.T) { + service1, err1 := New() + service2, err2 := New() + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NotSame(t, service1, service2) + }) +} + +func TestRegister(t *testing.T) { + t.Run("registers with core successfully", func(t *testing.T) { + coreInstance, err := core.New() + require.NoError(t, err) + + service, err := Register(coreInstance) + require.NoError(t, err) + assert.NotNil(t, service) + }) + + t.Run("returns Service type with Runtime", func(t *testing.T) { + coreInstance, err := core.New() + require.NoError(t, err) + + service, err := Register(coreInstance) + require.NoError(t, err) + + cryptService, ok := service.(*Service) + assert.True(t, ok) + assert.NotNil(t, cryptService.Runtime) + }) +} + +// --- Hash Tests --- + +func TestHash(t *testing.T) { + s, _ := New() + + t.Run("LTHN hash", func(t *testing.T) { + hash := s.Hash(LTHN, "hello") + assert.NotEmpty(t, hash) + // LTHN hash should be consistent + hash2 := s.Hash(LTHN, "hello") + assert.Equal(t, hash, hash2) + }) + + t.Run("SHA512 hash", func(t *testing.T) { + hash := s.Hash(SHA512, "hello") + // Known SHA512 hash for "hello" + expected := "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043" + assert.Equal(t, expected, hash) + }) + + t.Run("SHA256 hash", func(t *testing.T) { + hash := s.Hash(SHA256, "hello") + // Known SHA256 hash for "hello" + expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + assert.Equal(t, expected, hash) + }) + + t.Run("SHA1 hash", func(t *testing.T) { + hash := s.Hash(SHA1, "hello") + // Known SHA1 hash for "hello" + expected := "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" + assert.Equal(t, expected, hash) + }) + + t.Run("MD5 hash", func(t *testing.T) { + hash := s.Hash(MD5, "hello") + // Known MD5 hash for "hello" + expected := "5d41402abc4b2a76b9719d911017c592" + assert.Equal(t, expected, hash) + }) + + t.Run("default falls back to SHA256", func(t *testing.T) { + hash := s.Hash("unknown", "hello") + sha256Hash := s.Hash(SHA256, "hello") + assert.Equal(t, sha256Hash, hash) + }) + + t.Run("empty string hash", func(t *testing.T) { + hash := s.Hash(SHA256, "") + // Known SHA256 hash for empty string + expected := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + assert.Equal(t, expected, hash) + }) + + t.Run("hash with special characters", func(t *testing.T) { + hash := s.Hash(SHA256, "hello!@#$%^&*()") + assert.NotEmpty(t, hash) + assert.Len(t, hash, 64) // SHA256 produces 64 hex chars + }) + + t.Run("hash with unicode", func(t *testing.T) { + hash := s.Hash(SHA256, "你好世界") + assert.NotEmpty(t, hash) + assert.Len(t, hash, 64) + }) + + t.Run("hash consistency", func(t *testing.T) { + payload := "test payload for consistency check" + for _, hashType := range []HashType{LTHN, SHA512, SHA256, SHA1, MD5} { + hash1 := s.Hash(hashType, payload) + hash2 := s.Hash(hashType, payload) + assert.Equal(t, hash1, hash2, "Hash should be consistent for %s", hashType) + } + }) +} + +// --- Luhn Tests --- + +func TestLuhn(t *testing.T) { + s, _ := New() + + t.Run("valid Luhn numbers", func(t *testing.T) { + validNumbers := []string{ + "79927398713", + "4532015112830366", // Visa test number + "6011514433546201", // Discover test number + "371449635398431", // Amex test number + "30569309025904", // Diners Club test number + } + for _, num := range validNumbers { + assert.True(t, s.Luhn(num), "Expected %s to be valid", num) + } + }) + + t.Run("invalid Luhn numbers", func(t *testing.T) { + invalidNumbers := []string{ + "79927398714", + "1234567890", + "1111111111", + "1234567891", + } + for _, num := range invalidNumbers { + assert.False(t, s.Luhn(num), "Expected %s to be invalid", num) + } + }) + + t.Run("all zeros is valid", func(t *testing.T) { + // All zeros: each digit contributes 0, sum=0, 0%10==0 + assert.True(t, s.Luhn("0000000000")) + }) + + t.Run("handles spaces", func(t *testing.T) { + // Same number with and without spaces should give same result + assert.True(t, s.Luhn("7992 7398 713")) + assert.True(t, s.Luhn("4532 0151 1283 0366")) + }) + + t.Run("non-digit characters return false", func(t *testing.T) { + assert.False(t, s.Luhn("1234abcd5678")) + assert.False(t, s.Luhn("12-34-56-78")) + assert.False(t, s.Luhn("1234.5678")) + }) + + t.Run("empty string", func(t *testing.T) { + // Empty string: sum=0, 0%10==0, so it returns true + assert.True(t, s.Luhn("")) + }) + + t.Run("single digit", func(t *testing.T) { + assert.True(t, s.Luhn("0")) + assert.False(t, s.Luhn("1")) + }) +} + +// --- Fletcher Checksum Tests --- + +func TestFletcher16(t *testing.T) { + s, _ := New() + + t.Run("basic checksum", func(t *testing.T) { + checksum := s.Fletcher16("hello") + assert.NotZero(t, checksum) + }) + + t.Run("empty string", func(t *testing.T) { + checksum := s.Fletcher16("") + assert.Equal(t, uint16(0), checksum) + }) + + t.Run("consistency", func(t *testing.T) { + checksum1 := s.Fletcher16("test data") + checksum2 := s.Fletcher16("test data") + assert.Equal(t, checksum1, checksum2) + }) + + t.Run("different inputs produce different checksums", func(t *testing.T) { + checksum1 := s.Fletcher16("hello") + checksum2 := s.Fletcher16("world") + assert.NotEqual(t, checksum1, checksum2) + }) + + t.Run("known value", func(t *testing.T) { + // "abcde" has a known Fletcher-16 checksum + checksum := s.Fletcher16("abcde") + assert.NotZero(t, checksum) + }) +} + +func TestFletcher32(t *testing.T) { + s, _ := New() + + t.Run("basic checksum", func(t *testing.T) { + checksum := s.Fletcher32("hello") + assert.NotZero(t, checksum) + }) + + t.Run("empty string", func(t *testing.T) { + checksum := s.Fletcher32("") + assert.Equal(t, uint32(0), checksum) + }) + + t.Run("consistency", func(t *testing.T) { + checksum1 := s.Fletcher32("test data") + checksum2 := s.Fletcher32("test data") + assert.Equal(t, checksum1, checksum2) + }) + + t.Run("different inputs produce different checksums", func(t *testing.T) { + checksum1 := s.Fletcher32("hello") + checksum2 := s.Fletcher32("world") + assert.NotEqual(t, checksum1, checksum2) + }) + + t.Run("handles odd-length input", func(t *testing.T) { + // Odd length input should be padded + checksum := s.Fletcher32("abc") + assert.NotZero(t, checksum) + }) + + t.Run("handles even-length input", func(t *testing.T) { + checksum := s.Fletcher32("abcd") + assert.NotZero(t, checksum) + }) +} + +func TestFletcher64(t *testing.T) { + s, _ := New() + + t.Run("basic checksum", func(t *testing.T) { + checksum := s.Fletcher64("hello") + assert.NotZero(t, checksum) + }) + + t.Run("empty string", func(t *testing.T) { + checksum := s.Fletcher64("") + assert.Equal(t, uint64(0), checksum) + }) + + t.Run("consistency", func(t *testing.T) { + checksum1 := s.Fletcher64("test data") + checksum2 := s.Fletcher64("test data") + assert.Equal(t, checksum1, checksum2) + }) + + t.Run("different inputs produce different checksums", func(t *testing.T) { + checksum1 := s.Fletcher64("hello") + checksum2 := s.Fletcher64("world") + assert.NotEqual(t, checksum1, checksum2) + }) + + t.Run("handles various input lengths", func(t *testing.T) { + // Test padding for different lengths + for i := 1; i <= 8; i++ { + input := string(make([]byte, i)) + checksum := s.Fletcher64(input) + // Just verify it doesn't panic + _ = checksum + } + }) + + t.Run("long input", func(t *testing.T) { + // Use actual text content, not null bytes + longInput := "" + for i := 0; i < 100; i++ { + longInput += "test data " + } + checksum := s.Fletcher64(longInput) + assert.NotZero(t, checksum) + }) +} + +// --- HashType Constants Tests --- + +func TestHashTypeConstants(t *testing.T) { + t.Run("constants have expected values", func(t *testing.T) { + assert.Equal(t, HashType("lthn"), LTHN) + assert.Equal(t, HashType("sha512"), SHA512) + assert.Equal(t, HashType("sha256"), SHA256) + assert.Equal(t, HashType("sha1"), SHA1) + assert.Equal(t, HashType("md5"), MD5) + }) +} + +// --- PGP Tests (basic, detailed tests in openpgp package) --- + +func TestEncryptPGP(t *testing.T) { + t.Run("requires valid key paths", func(t *testing.T) { + s, _ := New() + var buf bytes.Buffer + + // Should fail with invalid path + _, err := s.EncryptPGP(&buf, "/nonexistent/path", "test data", nil, nil) + assert.Error(t, err) + }) +} + +func TestDecryptPGP(t *testing.T) { + t.Run("requires valid key paths", func(t *testing.T) { + s, _ := New() + + // Should fail with invalid path + _, err := s.DecryptPGP("/nonexistent/path", "encrypted data", "passphrase", nil) + assert.Error(t, err) + }) +} + +// --- HandleIPCEvents Tests --- + +func TestHandleIPCEvents(t *testing.T) { + t.Run("handles ActionServiceStartup", func(t *testing.T) { + coreInstance, err := core.New() + require.NoError(t, err) + + serviceAny, err := Register(coreInstance) + require.NoError(t, err) + + s := serviceAny.(*Service) + err = s.HandleIPCEvents(coreInstance, core.ActionServiceStartup{}) + assert.NoError(t, err) + }) + + t.Run("handles unknown message type", func(t *testing.T) { + coreInstance, err := core.New() + require.NoError(t, err) + + serviceAny, err := Register(coreInstance) + require.NoError(t, err) + + s := serviceAny.(*Service) + // Pass an arbitrary type as unknown message + err = s.HandleIPCEvents(coreInstance, "unknown message") + assert.NoError(t, err) + }) +} diff --git a/pkg/crypt/openpgp/encrypt_extra_test.go b/pkg/crypt/openpgp/encrypt_extra_test.go new file mode 100644 index 00000000..c0b46bc1 --- /dev/null +++ b/pkg/crypt/openpgp/encrypt_extra_test.go @@ -0,0 +1,71 @@ +package openpgp + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestDecryptWithWrongPassphrase checks that DecryptPGP returns an error when the wrong passphrase is used. +func TestDecryptWithWrongPassphrase(t *testing.T) { + recipientPub, _, cleanup := generateTestKeys(t, "recipient", "") // Unencrypted key for encryption + defer cleanup() + + // Use the pre-generated encrypted key for decryption test + encryptedPrivKeyPath, cleanup2 := createEncryptedKeyFile(t) + defer cleanup2() + + originalMessage := "This message should fail to decrypt." + + var encryptedBuf bytes.Buffer + err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, nil, nil) + assert.NoError(t, err, "Encryption failed unexpectedly") + encryptedMessage := encryptedBuf.String() + + _, err = DecryptPGP(encryptedPrivKeyPath, encryptedMessage, "wrong-passphrase", nil) + assert.Error(t, err, "Decryption was expected to fail with wrong passphrase, but it succeeded.") + assert.Contains(t, err.Error(), "failed to read PGP message", "Expected error message about failing to read PGP message") +} + +// TestDecryptMalformedMessage checks that DecryptPGP handles non-PGP or malformed input gracefully. +func TestDecryptMalformedMessage(t *testing.T) { + // Generate an unencrypted key for this test, as we expect failure before key usage. + _, recipientPriv, cleanup := generateTestKeys(t, "recipient", "") + defer cleanup() + + malformedMessage := "This is not a PGP message." + + // The passphrase here is irrelevant as the key is not encrypted, but we pass one + // to satisfy the function signature. + _, err := DecryptPGP(recipientPriv, malformedMessage, "any-pass", nil) + assert.Error(t, err, "Decryption should fail for a malformed message, but it did not.") + assert.Contains(t, err.Error(), "failed to decode armored message", "Expected error about decoding armored message") +} + +// TestEncryptWithNonexistentRecipient checks that EncryptPGP fails when the recipient's public key file does not exist. +func TestEncryptWithNonexistentRecipient(t *testing.T) { + var encryptedBuf bytes.Buffer + err := EncryptPGP(&encryptedBuf, "/path/to/nonexistent/key.pub", "message", nil, nil) + assert.Error(t, err, "Encryption should fail if recipient key does not exist, but it succeeded.") + assert.Contains(t, err.Error(), "failed to open recipient public key file", "Expected file open error for recipient key") +} + +// TestEncryptAndSignWithWrongPassphrase checks that signing during encryption fails with an incorrect passphrase. +func TestEncryptAndSignWithWrongPassphrase(t *testing.T) { + recipientPub, _, rCleanup := generateTestKeys(t, "recipient", "") + defer rCleanup() + + // Use the pre-generated encrypted key for the signer + signerPriv, sCleanup := createEncryptedKeyFile(t) + defer sCleanup() + + originalMessage := "This message should fail to sign." + wrongPassphrase := "wrong-signer-pass" + + var encryptedBuf bytes.Buffer + err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, &signerPriv, &wrongPassphrase) + + assert.Error(t, err, "Encryption with signing was expected to fail with a wrong passphrase, but it succeeded.") + assert.Contains(t, err.Error(), "failed to decrypt private key", "Expected error about private key decryption failure") +} diff --git a/pkg/crypt/openpgp/test_util.go b/pkg/crypt/openpgp/test_util.go new file mode 100644 index 00000000..fd239c8e --- /dev/null +++ b/pkg/crypt/openpgp/test_util.go @@ -0,0 +1,96 @@ +package openpgp + +import ( + "os" + "path/filepath" + "testing" +) + +// encryptedPrivateKey is a pre-generated, armored PGP private key, encrypted with the passphrase "test-passphrase". +// This key is used in tests where programmatic key generation and encryption is not feasible due to library limitations. +const encryptedPrivateKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQPGBGkD3McBCADPlKJ5MflaxEcDWyMowoNJltHrB9fIsrOY8aaGgm0kzTcWTmi+ +sdlpLpb4ADWZbtrs/3LbuXAFvhb+Zu+ZN/CO5D5RnZLNd2N+eGCNz/v6p87HCvM6 +aWxufD+ZJaWvDnWjBt7aO7XydRPx/GyrZ2s8513WYgF83R603bcRv4zdhA7aJHGA +IG++PO0jkHKkv0xQ7OmUmjQrYVLV5cG2vQzpQeL81tyfkxb4Rz9gm+Gho5T2v9me +Y2ss58/Lny00aneJokBY+x1nGOQKB/Liy7Ub2au9MKKDkitP1F2f2tnp1O/IXqgI +tKDKbRz/KipgKbwFrhYBCOl5JjiwzHud/3/HABEBAAH+BwMCZZwQKhGMMAz/Q405 +dgMVbXRdhSS6jyOCkL5AOKhJWddMEo4/52Sq30pfsT+n0zZjGE7ivpXbJa6ekQYD +MFtfueuz2W8cbn+3wP7W2NFnl+UWcw6BlskzPusd7eIqEjCToic1aJLdbs32Q5B/ +FE7hJrCRzUOeByfEl1e2Uzmy5JJ3Y6bgpDHPhC38uLMZXdpbkboi5R20UmNe0iDo +X3v52Wv2Sdb2d8LUrXo7spTGfEDe1f0NTq9NbYMOPSwz912bDmf+nWjjRUPrBh/H +w1d66oLtJlQSCt6vLkqoMMViFa8V57XzKrqdpcfu70ydEr7mCmpOgch9OopTM2Dk +MlDldUqWt5YCABybmKYOyA2bWX3yYEWi4OiGNhZP1VZwoSiFcsm6/s+p4xHGGWwR ++tdakCBqoRaDaMjdVGNA9+mebRJVHcKFsivl4qjT8E55ky8Qq70KhKJ+Vzu9Om3O +NiEsrNofdcXiRjVZLejuNbqkO1wDfW0CoNSbFYscOv85AHVk/93w8IvGzvEmOZ3X +ILcoIZmIrtoSj4Fu8qQXUD1f+t+hYFV8V+T6YDDmtWIn73VQpHYB7j2UJpq9mZAp +CDXxgzm1zgYwZEQ1p/yR8tVeP/hnsE+Dc79iJO72BMzbhuXEkqMWzs9AurdeAaSD +p6l0+hr08w9v9d9YEXn8Cjx2p3G6iUA3Rd2vXwuBT2dEtbf+qcskFGqyGo4hOCzW +qvbszNMR4yIqtiPipmFq9UCPgBceXb8zJjOylXsf+kKQkBrm4vpMfo+m4xYO8kAp +w2gXAs5ozEfkPBYx132QTpYY+dx8lgZ9lD2EgrELfCU0IfCo2C+MksF/v6Ib5rY3 +eOTNfmsmsnsOr9pfGs65weWxO0VXe39IW4327cSetaviGophWrGsmgRTzs8KBU9j +9OBmtXbmGr0LtBlKdWxlcyA8anVsZXNAZXhhbXBsZS5jb20+iQFSBBMBCgA8FiEE +lfAo9dBZEKASnLDSjhMM0QOAK2wFAmkD3McDGy8EBQsJCAcCAiICBhUKCQgLAgQW +AgMBAh4HAheAAAoJEI4TDNEDgCtsnCoH+wWmcrRgvrO2qHzPROkP9J7xrHnKO7qF ++G/1DsCMMkn6fmIgpkCpEYjfZXHIyA6vsOlxDdoxyjpTQUh6lyDlZbrr0klMtgq1 +9yDyPF3ONJyoLLJeHlLbN+Zgv68R+EkXFI/7w5w8DMc7dq//wibDaBeQ390KjxOc +k3lQF+239D0tZ3x9Fdt6JXNrksfkJ8vIQvgANOBFXYIL0KtwqdRbe+L1pKtQXehG +7jVgaLgPrC6hqc0dGqLliuxyijA5MgnRUXBX2cNXoUpJBDbgKyuVKzRYQ2X3U4Gz +g12Vlt/b19O70j2SfQdBY5sPlJjP6FBfXd299GL4HnNrcVJqwmfPnVCdA8YEaQPc +xwEIALEansmoX/FrDCubfde3cXyJ3jOtHXjBgFyWd8J2ad1gvfMbCHteoR86azaR +JkUN+zwDpjkYslUy9xVVIL2b4sTXHO6+hw14dQS8mq0+tEKXzGcKuTrno9lU02l3 +My5ZHY/PB7dfeLC6sGBMXwdbT68wIAy6/guEWRaZWPNJy3l9IrvjxBdMALLAsGTH +ol4hKUBRCd0/cAsaIpbq4JOu1os3kRAgfZqeqXSY8G6ioZ/ft5s6nMN4IjUD/tdJ +48ZOfoaMRZcSOv8jgoRvYksYNeiqmgYrn17tgCL1z14cjvXrijd8f90dJxeseIEL +exETG/Bu0G+lpKU4XC014Vk4l2EAEQEAAf4HAwKcyR3KYk6DBP/wZlQffclC9iAU +Oifv5Dxzw1KaloYEir4cBUGYTlcuXcdJV4GXpytX4d+4fTKBO5Kr60I3NYHj3Zs+ +yK9Vm0ZXjFFMikSxymDdsVaW6PA4WdVpPEam7bqCmApeKT0SSPwVhaBBVALGB55i +KFSXyB2DExSzKEuH0sKOLoy+jGqCBVTwUEFVMN7sInXVog1PQGjy472fyI5od/GD +F6utVttmthnvVNAHleIeDYzWZD7iOQkl6S7bT/zn4eggTMz/9B5GJ1KkQtjXGfrW +9VezVdpUeWLI11WyMxFLBLGQOoVrNWZA4AAPTDReCPT4uGTSnmTVrBSWgOg+2e55 +aiPak7TXxm3UShqk7A9okgxKkndVsqKYQ2Ry6xfmgdYW68/4xQjqNcPFCVg5YGnk ++DbaOS6XVUl6v2QMSNtdONQ3ybhH/ervNV/KLIweg1DRfdi34ixO19QEOEONpenq +C2Ap8knptxcBd+M0e6l9vppndrx5R/Y4reg7ZTLt0OX9Gdkwsb9DRLfVFwLmsZ5+ +hw0e/k5NYkLB3lWw+m+JtKCOpU69U+MY8t4OhvosOFW0Kxm/6tJZKKkpRTfewd1f +qbPc4RLE9K0kZW8BDqig6m3flV54jpR7bmPTW1Y/YUn33QXj6wqUec+CSLm349UQ +NhwmF7opapbo+XYD8by6xdeOZ/WnTtKKBy3x6uEIRes3zGcGkZ+ROx564i1v1/h3 +yZ5zrWggWUkeoPzenqWqj1i2QxxgzkxtkqAf/9aKmpp5MNXs25K+ZHFxiwHcCPOe +8pVQF0sY61b7EzHoUhq7CkpTYOuvPoHii3m5EAnH+EO66EqSbEemo3FEQQemeQi0 +EGEiqfh2g1iLSxW54L3Y9Qzh+6B22/ydgccQIL/CxIdofipp4NdoN8iF6gHLm/nS +GzKJAmwEGAEKACAWIQSV8Cj10FkQoBKcsNKOEwzRA4ArbAUCaQPcxwIbLgFACRCO +EwzRA4ArbMB0IAQZAQoAHRYhBDR5obYfDIFSrsYWVYf4NG7oaR8CBQJpA9zHAAoJ +EIf4NG7oaR8CaHYH/1LxfQ+AHKsrYDul0U/h165EPzeX+mhHyBAqVuYIlyBPDMc/ +sAN83WW7yTXh2VWeE+BQVzdOdz2Mu53Al42+TJVnmc6YrRu2th5vdVvOTPKUFqJ+ +mbWg8xJPrBoQ2UrZ5oFMgwYUfMvYG94mVxA8K0Uw6LXjmxZ2P816j68FqIPn+o42 +GoL8muMAWZ4Xd/GJwdtj9R/xJA9DZlNgYH2/I5qK5OMrlDTJ09jivFO1deVhMHbC +LH+zdIt5uNoLT6VNANBmbfYn0gX46goeu8jdpusN+8QC7Phq1/L3x8IfHTbmBbKN +0NyfETsLs2pmAC+7av8JClw/SxFQppispaBRXm3RfwgAtvzV16+0HT0uQHWulkk+ +RzulVS8s3BwtjCp1ZPsprJ/AyAxGpU+7iquqe+Voe6Tv5AJ3ongccYTwqFMeElkf +JAI+iWfgV1NF2bxm2Wq+nMSL9jrO9aF0unQ9/CI/gKca1656n2ZPSuG4s7mjC1Sl +9+GqgZGNR+Isg2dx1yzt7wT0H8SO0fyadp71JMuGI9F5ftUw7jQYvqIuI37an5Mx +l3PZ2jSJ4ozNpaAWkNUOQz+o8xCr8qcumXct0FME8H5tiMe3KJn6TJ7eOwfEZ7oD +BYR9EUvXQxCicuW/pne/wtn78JvpRxiJxcwVYy+azfunx/Cl8BbxMVLDr0y49lNM +hw== +=u7WH +-----END PGP PRIVATE KEY BLOCK-----` + +// createEncryptedKeyFile creates a temporary file containing a pre-generated, encrypted private key. +// It returns the path to the temporary file and a cleanup function to remove the temporary directory. +func createEncryptedKeyFile(t *testing.T) (string, func()) { + t.Helper() + + tempDir, err := os.MkdirTemp("", "pgp-test-key-*") + if err != nil { + t.Fatalf("test setup: failed to create temp dir for encrypted key: %v", err) + } + + privKeyPath := filepath.Join(tempDir, "encrypted-key.asc") + err = os.WriteFile(privKeyPath, []byte(encryptedPrivateKey), 0600) + if err != nil { + t.Fatalf("test setup: failed to write encrypted key to file: %v", err) + } + + cleanup := func() { os.RemoveAll(tempDir) } + return privKeyPath, cleanup +} diff --git a/pkg/display/assets/apptray.png b/pkg/display/assets/apptray.png new file mode 100644 index 00000000..0778fc61 Binary files /dev/null and b/pkg/display/assets/apptray.png differ diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 9aeca3ec..b70b956e 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -1,353 +1,303 @@ package display import ( - "reflect" "testing" + "github.com/Snider/Core/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/wailsapp/wails/v3/pkg/application" ) -func TestParseWindowOptions(t *testing.T) { - tests := []struct { - name string - msg map[string]any - want application.WebviewWindowOptions - }{ - { - name: "Valid options", - msg: map[string]any{ - "name": "main", - "options": map[string]any{ - "Title": "My App", - "Width": 1024.0, - "Height": 768.0, - }, - }, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "My App", - Width: 1024, - Height: 768, - }, - }, - { - name: "All options valid", - msg: map[string]any{ - "name": "secondary", - "options": map[string]any{ - "Title": "Another Window", - "Width": 800.0, - "Height": 600.0, - }, - }, - want: application.WebviewWindowOptions{ - Name: "secondary", - Title: "Another Window", +// newTestCore creates a new core instance with essential services for testing. +func newTestCore(t *testing.T) *core.Core { + coreInstance, err := core.New() + require.NoError(t, err) + return coreInstance +} + +func TestNew(t *testing.T) { + t.Run("creates service successfully", func(t *testing.T) { + service, err := New() + assert.NoError(t, err) + assert.NotNil(t, service, "New() should return a non-nil service instance") + }) + + t.Run("returns independent instances", func(t *testing.T) { + service1, err1 := New() + service2, err2 := New() + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NotSame(t, service1, service2, "New() should return different instances") + }) +} + +func TestRegister(t *testing.T) { + t.Run("registers with core successfully", func(t *testing.T) { + coreInstance := newTestCore(t) + service, err := Register(coreInstance) + require.NoError(t, err) + assert.NotNil(t, service, "Register() should return a non-nil service instance") + }) + + t.Run("returns Service type", func(t *testing.T) { + coreInstance := newTestCore(t) + service, err := Register(coreInstance) + require.NoError(t, err) + + displayService, ok := service.(*Service) + assert.True(t, ok, "Register() should return *Service type") + assert.NotNil(t, displayService.Runtime, "Runtime should be initialized") + }) +} + +func TestServiceName(t *testing.T) { + service, err := New() + require.NoError(t, err) + + name := service.ServiceName() + assert.Equal(t, "github.com/Snider/Core/display", name) +} + +// --- Window Option Tests --- + +func TestWindowName(t *testing.T) { + t.Run("sets window name", func(t *testing.T) { + opt := WindowName("test-window") + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, "test-window", window.Name) + }) + + t.Run("sets empty name", func(t *testing.T) { + opt := WindowName("") + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, "", window.Name) + }) +} + +func TestWindowTitle(t *testing.T) { + t.Run("sets window title", func(t *testing.T) { + opt := WindowTitle("My Application") + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, "My Application", window.Title) + }) + + t.Run("sets title with special characters", func(t *testing.T) { + opt := WindowTitle("App - v1.0 (Beta)") + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, "App - v1.0 (Beta)", window.Title) + }) +} + +func TestWindowURL(t *testing.T) { + t.Run("sets window URL", func(t *testing.T) { + opt := WindowURL("/dashboard") + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, "/dashboard", window.URL) + }) + + t.Run("sets full URL", func(t *testing.T) { + opt := WindowURL("https://example.com/page") + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, "https://example.com/page", window.URL) + }) +} + +func TestWindowWidth(t *testing.T) { + t.Run("sets window width", func(t *testing.T) { + opt := WindowWidth(1024) + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, 1024, window.Width) + }) + + t.Run("sets zero width", func(t *testing.T) { + opt := WindowWidth(0) + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, 0, window.Width) + }) + + t.Run("sets large width", func(t *testing.T) { + opt := WindowWidth(3840) + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, 3840, window.Width) + }) +} + +func TestWindowHeight(t *testing.T) { + t.Run("sets window height", func(t *testing.T) { + opt := WindowHeight(768) + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, 768, window.Height) + }) + + t.Run("sets zero height", func(t *testing.T) { + opt := WindowHeight(0) + window := &Window{} + + err := opt(window) + assert.NoError(t, err) + assert.Equal(t, 0, window.Height) + }) +} + +func TestApplyOptions(t *testing.T) { + t.Run("applies no options", func(t *testing.T) { + window := applyOptions() + assert.NotNil(t, window) + assert.Equal(t, "", window.Name) + assert.Equal(t, "", window.Title) + assert.Equal(t, 0, window.Width) + assert.Equal(t, 0, window.Height) + }) + + t.Run("applies single option", func(t *testing.T) { + window := applyOptions(WindowTitle("Test")) + assert.NotNil(t, window) + assert.Equal(t, "Test", window.Title) + }) + + t.Run("applies multiple options", func(t *testing.T) { + window := applyOptions( + WindowName("main"), + WindowTitle("My App"), + WindowURL("/home"), + WindowWidth(1280), + WindowHeight(720), + ) + + assert.NotNil(t, window) + assert.Equal(t, "main", window.Name) + assert.Equal(t, "My App", window.Title) + assert.Equal(t, "/home", window.URL) + assert.Equal(t, 1280, window.Width) + assert.Equal(t, 720, window.Height) + }) + + t.Run("handles nil options slice", func(t *testing.T) { + window := applyOptions(nil...) + assert.NotNil(t, window) + }) + + t.Run("applies options in order", func(t *testing.T) { + // Later options should override earlier ones + window := applyOptions( + WindowTitle("First"), + WindowTitle("Second"), + ) + + assert.NotNil(t, window) + assert.Equal(t, "Second", window.Title) + }) +} + +// --- ActionOpenWindow Tests --- + +func TestActionOpenWindow(t *testing.T) { + t.Run("creates action with options", func(t *testing.T) { + action := ActionOpenWindow{ + WebviewWindowOptions: application.WebviewWindowOptions{ + Name: "test", + Title: "Test Window", Width: 800, Height: 600, }, - }, - { - name: "Missing options", - msg: map[string]any{ - "name": "main", - }, - want: application.WebviewWindowOptions{ - Name: "main", - }, - }, - { - name: "Empty message", - msg: map[string]any{}, - want: application.WebviewWindowOptions{}, - }, - { - name: "Invalid width type", - msg: map[string]any{ - "name": "main", - "options": map[string]any{ - "Title": "My App", - "Width": "not a number", - "Height": 768.0, - }, - }, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "My App", - Height: 768, - }, - }, - { - name: "Invalid height type", - msg: map[string]any{ - "name": "main", - "options": map[string]any{ - "Title": "My App", - "Width": 1024.0, - "Height": "not a number", - }, - }, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "My App", - Width: 1024, - }, - }, - { - name: "Deeply nested and complex message", - msg: map[string]any{ - "name": "main", - "options": map[string]any{ - "Title": "My App", - "Width": 1024.0, - "Height": 768.0, - "nested": map[string]any{ - "another_level": "some_value", - }, - }, - }, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "My App", - Width: 1024, - Height: 768, - }, - }, - } + } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := parseWindowOptions(tt.msg); !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseWindowOptions() = %v, want %v", got, tt.want) - } - }) - } + assert.Equal(t, "test", action.Name) + assert.Equal(t, "Test Window", action.Title) + assert.Equal(t, 800, action.Width) + assert.Equal(t, 600, action.Height) + }) } -// mockWindowOption is a mock implementation of the WindowOption interface for testing. -type mockWindowOption struct { - applyFunc func(*WindowConfig) +// --- Integration Tests (require Wails runtime) --- + +func TestOpenWindow(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping OpenWindow test - requires running Wails application instance") + }) } -func (m *mockWindowOption) Apply(opts *WindowConfig) { - m.applyFunc(opts) +func TestNewWithStruct(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping NewWithStruct test - requires running Wails application instance") + }) } -func TestBuildWailsWindowOptions(t *testing.T) { - tests := []struct { - name string - opts []WindowOption - want application.WebviewWindowOptions - }{ - { - name: "Default options", - opts: []WindowOption{}, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "Core", - Width: 1280, - Height: 800, - URL: "/", - }, - }, - { - name: "Chaining many options", - opts: func() []WindowOption { - opts := make([]WindowOption, 1000) - for i := 0; i < 1000; i++ { - opts[i] = WithTitle("Test") - } - return opts - }(), - want: application.WebviewWindowOptions{ - Name: "main", - Title: "Test", - Width: 1280, - Height: 800, - URL: "/", - }, - }, - { - name: "Override options", - opts: []WindowOption{ - &mockWindowOption{ - applyFunc: func(opts *WindowConfig) { - opts.Name = "test" - opts.Title = "Test Window" - opts.Width = 1920 - opts.Height = 1080 - opts.URL = "/test" - opts.AlwaysOnTop = true - opts.Hidden = true - opts.MinimiseButtonState = application.ButtonHidden - opts.MaximiseButtonState = application.ButtonDisabled - opts.CloseButtonState = application.ButtonEnabled - opts.Frameless = true - }, - }, - }, - want: application.WebviewWindowOptions{ - Name: "test", - Title: "Test Window", - Width: 1920, - Height: 1080, - URL: "/test", - AlwaysOnTop: true, - Hidden: true, - MinimiseButtonState: application.ButtonHidden, - MaximiseButtonState: application.ButtonDisabled, - CloseButtonState: application.ButtonEnabled, - Frameless: true, - }, - }, - { - name: "Nil options", - opts: nil, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "Core", - Width: 1280, - Height: 800, - URL: "/", - }, - }, - { - name: "Empty options slice", - opts: []WindowOption{}, - want: application.WebviewWindowOptions{ - Name: "main", - Title: "Core", - Width: 1280, - Height: 800, - URL: "/", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := buildWailsWindowOptions(tt.opts...); !reflect.DeepEqual(got, tt.want) { - t.Errorf("buildWailsWindowOptions() = %v, want %v", got, tt.want) - } - }) - } +func TestNewWithOptions(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping NewWithOptions test - requires running Wails application instance") + }) } -func TestNewAndNewDisplayService(t *testing.T) { - s, err := New() - if err != nil { - t.Fatalf("New() error = %v, wantErr nil", err) - } - if s == nil { - t.Fatal("New() returned nil") - } - - s, err = newDisplayService() - if err != nil { - t.Fatalf("newDisplayService() error = %v, wantErr nil", err) - } - if s == nil { - t.Fatal("newDisplayService() returned nil") - } +func TestNewWithURL(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping NewWithURL test - requires running Wails application instance") + }) } -func TestWindowOptions(t *testing.T) { - config := &WindowConfig{} - - WithName("test-name").Apply(config) - if config.Name != "test-name" { - t.Errorf("WithName() got = %v, want %v", config.Name, "test-name") - } - - WithTitle("test-title").Apply(config) - if config.Title != "test-title" { - t.Errorf("WithTitle() got = %v, want %v", config.Title, "test-title") - } - - WithWidth(100).Apply(config) - if config.Width != 100 { - t.Errorf("WithWidth() got = %v, want %v", config.Width, 100) - } - - WithHeight(200).Apply(config) - if config.Height != 200 { - t.Errorf("WithHeight() got = %v, want %v", config.Height, 200) - } - - WithURL("/testurl").Apply(config) - if config.URL != "/testurl" { - t.Errorf("WithURL() got = %v, want %v", config.URL, "/testurl") - } - - WithAlwaysOnTop(true).Apply(config) - if !config.AlwaysOnTop { - t.Errorf("WithAlwaysOnTop() got = %v, want %v", config.AlwaysOnTop, true) - } - - WithHidden(true).Apply(config) - if !config.Hidden { - t.Errorf("WithHidden() got = %v, want %v", config.Hidden, true) - } - - WithMinimiseButtonState(application.ButtonHidden).Apply(config) - if config.MinimiseButtonState != application.ButtonHidden { - t.Errorf("WithMinimiseButtonState() got = %v, want %v", config.MinimiseButtonState, application.ButtonHidden) - } - - WithMaximiseButtonState(application.ButtonDisabled).Apply(config) - if config.MaximiseButtonState != application.ButtonDisabled { - t.Errorf("WithMaximiseButtonState() got = %v, want %v", config.MaximiseButtonState, application.ButtonDisabled) - } - - WithCloseButtonState(application.ButtonEnabled).Apply(config) - if config.CloseButtonState != application.ButtonEnabled { - t.Errorf("WithCloseButtonState() got = %v, want %v", config.CloseButtonState, application.ButtonEnabled) - } - - WithFrameless(true).Apply(config) - if !config.Frameless { - t.Errorf("WithFrameless() got = %v, want %v", config.Frameless, true) - } +func TestServiceStartup(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping ServiceStartup test - requires running Wails application instance") + }) } -func TestService_HandleOpenWindowAction(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - _ = s.handleOpenWindowAction(map[string]any{}) +func TestSelectDirectory(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping SelectDirectory test - requires running Wails application instance") + }) } -func TestService_ShowEnvironmentDialog(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - s.ShowEnvironmentDialog() +func TestShowEnvironmentDialog(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping ShowEnvironmentDialog test - requires running Wails application instance") + }) } -func TestService_OpenWindow(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - _ = s.OpenWindow() +func TestHandleIPCEvents(t *testing.T) { + t.Run("requires Wails runtime for full test", func(t *testing.T) { + t.Skip("Skipping HandleIPCEvents test - requires running Wails application instance") + }) } -func TestService_MonitorScreenChanges(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - s.monitorScreenChanges() +func TestBuildMenu(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping buildMenu test - requires running Wails application instance") + }) } -func TestService_BuildMenu(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - s.buildMenu() -} - -func TestService_SystemTray(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - s.systemTray() -} - -func TestService_Startup(t *testing.T) { - t.Skip("Skipping test that requires a running Wails application.") - s, _ := New() - _ = s.Startup(nil) +func TestSystemTray(t *testing.T) { + t.Run("requires Wails runtime", func(t *testing.T) { + t.Skip("Skipping systemTray test - requires running Wails application instance") + }) } diff --git a/pkg/display/menu.go b/pkg/display/menu.go index 18804ff9..e36c3693 100644 --- a/pkg/display/menu.go +++ b/pkg/display/menu.go @@ -1,7 +1,9 @@ package display import ( + "fmt" "runtime" + "strings" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -18,8 +20,12 @@ func (s *Service) buildMenu() { 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 */ }) + workspace.Add("New...").OnClick(func(ctx *application.Context) { + s.handleNewWorkspace() + }) + workspace.Add("List").OnClick(func(ctx *application.Context) { + s.handleListWorkspaces() + }) // Add brand-specific menu items //if s.brand == DeveloperHub { @@ -31,3 +37,56 @@ func (s *Service) buildMenu() { s.app.Menu.Set(appMenu) } + +// handleNewWorkspace opens a window for creating a new workspace. +func (s *Service) handleNewWorkspace() { + // Open a dedicated window for workspace creation + // The frontend at /workspace/new handles the form + opts := application.WebviewWindowOptions{ + Name: "workspace-new", + Title: "New Workspace", + Width: 500, + Height: 400, + URL: "/workspace/new", + } + s.Core().App.Window.NewWithOptions(opts) +} + +// handleListWorkspaces shows a dialog with available workspaces. +func (s *Service) handleListWorkspaces() { + // Get workspace service from core + ws := s.Core().Service("workspace") + if ws == nil { + dialog := s.Core().App.Dialog.Warning() + dialog.SetTitle("Workspace") + dialog.SetMessage("Workspace service not available") + dialog.Show() + return + } + + // Type assert to access ListWorkspaces method + lister, ok := ws.(interface{ ListWorkspaces() []string }) + if !ok { + dialog := s.Core().App.Dialog.Warning() + dialog.SetTitle("Workspace") + dialog.SetMessage("Unable to list workspaces") + dialog.Show() + return + } + + workspaces := lister.ListWorkspaces() + + var message string + if len(workspaces) == 0 { + message = "No workspaces found.\n\nUse Workspace → New to create one." + } else { + message = fmt.Sprintf("Available Workspaces (%d):\n\n%s", + len(workspaces), + strings.Join(workspaces, "\n")) + } + + dialog := s.Core().App.Dialog.Info() + dialog.SetTitle("Workspaces") + dialog.SetMessage(message) + dialog.Show() +} diff --git a/pkg/display/tray.go b/pkg/display/tray.go index 0c9c010c..697bb63f 100644 --- a/pkg/display/tray.go +++ b/pkg/display/tray.go @@ -1,29 +1,35 @@ package display import ( - _ "embed" + "embed" + "runtime" "github.com/wailsapp/wails/v3/pkg/application" ) -// systemTray configures and creates the system tray icon and menu. This -// function is called during the startup of the display service. +//go:embed assets/apptray.png +var assets embed.FS + +// systemTray configures and creates the system tray icon and menu. func (s *Service) systemTray() { - systray := s.app.SystemTray.New() + systray := s.Core().App.SystemTray.New() systray.SetTooltip("Core") systray.SetLabel("Core") - //appTrayIcon, _ := d.assets.ReadFile("assets/apptray.png") - // - //if runtime.GOOS == "darwin" { - // systray.SetTemplateIcon(appTrayIcon) - //} else { - // // Support for light/dark mode icons - // systray.SetDarkModeIcon(appTrayIcon) - // systray.SetIcon(appTrayIcon) - //} + + // Load and set tray icon + appTrayIcon, err := assets.ReadFile("assets/apptray.png") + if err == nil { + 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{ + trayWindow, _ := s.NewWithStruct(&Window{ Name: "system-tray", Title: "System Tray Status", URL: "system-tray.html", @@ -34,14 +40,14 @@ func (s *Service) systemTray() { systray.AttachWindow(trayWindow).WindowOffset(5) // --- Build Tray Menu --- - trayMenu := s.app.Menu.New() + trayMenu := s.Core().App.Menu.New() trayMenu.Add("Open Desktop").OnClick(func(ctx *application.Context) { - for _, window := range s.app.Window.GetAll() { + for _, window := range s.Core().App.Window.GetAll() { window.Show() } }) trayMenu.Add("Close Desktop").OnClick(func(ctx *application.Context) { - for _, window := range s.app.Window.GetAll() { + for _, window := range s.Core().App.Window.GetAll() { window.Hide() } }) @@ -66,7 +72,7 @@ func (s *Service) systemTray() { trayMenu.AddSeparator() trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { - s.app.Quit() + s.Core().App.Quit() }) systray.SetMenu(trayMenu) diff --git a/pkg/display/window.go b/pkg/display/window.go index 60a8add6..fe788e50 100644 --- a/pkg/display/window.go +++ b/pkg/display/window.go @@ -1,111 +1,89 @@ package display -import "github.com/wailsapp/wails/v3/pkg/application" +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) -// WindowConfig holds the configuration for a window. This struct is used to -// create a new window with the specified options. -type WindowConfig struct { - Name string - Title string - Width int - Height int - URL string - AlwaysOnTop bool - Hidden bool - MinimiseButtonState application.ButtonState - MaximiseButtonState application.ButtonState - CloseButtonState application.ButtonState - Frameless bool +type WindowOption func(*application.WebviewWindowOptions) error + +type Window = application.WebviewWindowOptions + +func WindowName(s string) WindowOption { + return func(o *Window) error { + o.Name = s + return nil + } +} +func WindowTitle(s string) WindowOption { + return func(o *Window) error { + o.Title = s + return nil + } } -// WindowOption is an interface for applying configuration options to a -// WindowConfig. -type WindowOption interface { - Apply(*WindowConfig) +func WindowURL(s string) WindowOption { + return func(o *Window) error { + o.URL = s + return nil + } } -// WindowOptionFunc is a function that implements the WindowOption interface. -// This allows us to use ordinary functions as window options. -type WindowOptionFunc func(*WindowConfig) - -// Apply calls the underlying function to apply the configuration. -func (f WindowOptionFunc) Apply(c *WindowConfig) { - f(c) +func WindowWidth(i int) WindowOption { + return func(o *Window) error { + o.Width = i + return nil + } } -// WithName sets the name of the window. -func WithName(name string) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.Name = name - }) +func WindowHeight(i int) WindowOption { + return func(o *Window) error { + o.Height = i + return nil + } } -// WithTitle sets the title of the window. -func WithTitle(title string) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.Title = title - }) +func applyOptions(opts ...WindowOption) *Window { + w := &Window{} + if opts == nil { + return w + } + for _, o := range opts { + if err := o(w); err != nil { + return nil + } + } + return w } -// WithWidth sets the width of the window. -func WithWidth(width int) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.Width = width - }) +// NewWithStruct creates a new window using the provided options and returns its handle. +func (s *Service) NewWithStruct(options *Window) (*application.WebviewWindow, error) { + return s.Core().App.Window.NewWithOptions(*options), nil } -// WithHeight sets the height of the window. -func WithHeight(height int) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.Height = height - }) +// NewWithOptions creates a new window by applying a series of options. +func (s *Service) NewWithOptions(opts ...WindowOption) (*application.WebviewWindow, error) { + return s.NewWithStruct(applyOptions(opts...)) } -// WithURL sets the URL that the window will load. -func WithURL(url string) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.URL = url - }) +// NewWithURL creates a new default window pointing to the specified URL. +func (s *Service) NewWithURL(url string) (*application.WebviewWindow, error) { + return s.NewWithOptions( + WindowURL(url), + WindowTitle("Core"), + WindowHeight(900), + WindowWidth(1280), + ) } -// WithAlwaysOnTop sets the window to always be on top of other windows. -func WithAlwaysOnTop(alwaysOnTop bool) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.AlwaysOnTop = alwaysOnTop - }) -} +//// OpenWindow is a convenience method that creates and shows a window from a set of options. +//func (s *Service) OpenWindow(opts ...WindowOption) error { +// _, err := s.NewWithOptions(opts...) +// return err +//} -// WithHidden sets the window to be hidden when it is created. -func WithHidden(hidden bool) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.Hidden = hidden - }) -} - -// WithMinimiseButtonState sets the state of the minimise button. -func WithMinimiseButtonState(state application.ButtonState) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.MinimiseButtonState = state - }) -} - -// WithMaximiseButtonState sets the state of the maximise button. -func WithMaximiseButtonState(state application.ButtonState) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.MaximiseButtonState = state - }) -} - -// WithCloseButtonState sets the state of the close button. -func WithCloseButtonState(state application.ButtonState) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.CloseButtonState = state - }) -} - -// WithFrameless sets the window to be frameless. -func WithFrameless(frameless bool) WindowOption { - return WindowOptionFunc(func(c *WindowConfig) { - c.Frameless = frameless - }) +// 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") + return dialog.PromptForSingleSelection() } diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index be89558c..a3889a00 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -1,159 +1,185 @@ package i18n import ( - "encoding/json" - "fmt" - "log" "testing" - "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" ) -func newTestBundle() *i18n.Bundle { - bundle := i18n.NewBundle(language.English) - bundle.RegisterUnmarshalFunc("json", json.Unmarshal) - bundle.MustParseMessageFileBytes([]byte(`{ - "hello": "Hello", - "welcome": "Welcome {{.Name}}" - }`), "en.json") - bundle.MustParseMessageFileBytes([]byte(`{ - "hello": "Bonjour", - "welcome": "Bienvenue {{.Name}}" - }`), "fr.json") - return bundle -} - func TestNew(t *testing.T) { - s, err := New() - assert.NoError(t, err) - assert.NotNil(t, s) + t.Run("creates service successfully", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + assert.NotNil(t, service) + assert.NotNil(t, service.bundle) + assert.NotEmpty(t, service.availableLangs) + }) + + t.Run("loads all available languages", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + + // Should have loaded multiple languages from locales/ + assert.GreaterOrEqual(t, len(service.availableLangs), 2) + }) } func TestSetLanguage(t *testing.T) { - s, err := New() - require.NoError(t, err) + t.Run("sets English successfully", func(t *testing.T) { + service, err := New() + require.NoError(t, err) - s.SetBundle(newTestBundle()) + err = service.SetLanguage("en") + assert.NoError(t, err) + assert.NotNil(t, service.localizer) + }) - err = s.SetLanguage("en") - assert.NoError(t, err) + t.Run("sets Spanish successfully", func(t *testing.T) { + service, err := New() + require.NoError(t, err) - err = s.SetLanguage("fr") - assert.NoError(t, err) + err = service.SetLanguage("es") + assert.NoError(t, err) + assert.NotNil(t, service.localizer) + }) - err = s.SetLanguage("invalid") - assert.Error(t, err) + t.Run("sets German successfully", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + + err = service.SetLanguage("de") + assert.NoError(t, err) + assert.NotNil(t, service.localizer) + }) + + t.Run("handles language variants", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + + // en-US should match to en + err = service.SetLanguage("en-US") + assert.NoError(t, err) + }) + + t.Run("handles unknown language by matching closest", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + + // Unknown languages may fall back to a default match + // The matcher uses confidence levels, so many tags will match something + err = service.SetLanguage("tlh") // Klingon + // May or may not error depending on matcher confidence + if err != nil { + assert.Contains(t, err.Error(), "unsupported language") + } + }) } func TestTranslate(t *testing.T) { - s, err := New() - require.NoError(t, err) + t.Run("translates English message", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + require.NoError(t, service.SetLanguage("en")) - s.SetBundle(newTestBundle()) + result := service.Translate("menu.settings") + assert.Equal(t, "Settings", result) + }) - err = s.SetLanguage("en") - require.NoError(t, err) - assert.Equal(t, "Hello", s.Translate("hello")) + t.Run("translates Spanish message", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + require.NoError(t, service.SetLanguage("es")) - err = s.SetLanguage("fr") - require.NoError(t, err) - assert.Equal(t, "Bonjour", s.Translate("hello")) + result := service.Translate("menu.settings") + assert.Equal(t, "Ajustes", result) + }) + + t.Run("returns message ID for missing translation", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + require.NoError(t, service.SetLanguage("en")) + + result := service.Translate("nonexistent.message.id") + assert.Equal(t, "nonexistent.message.id", result) + }) + + t.Run("translates multiple messages correctly", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + require.NoError(t, service.SetLanguage("en")) + + assert.Equal(t, "Dashboard", service.Translate("menu.dashboard")) + assert.Equal(t, "Help", service.Translate("menu.help")) + assert.Equal(t, "Search", service.Translate("app.core.ui.search")) + }) + + t.Run("language switch changes translations", func(t *testing.T) { + service, err := New() + require.NoError(t, err) + + // Start with English + require.NoError(t, service.SetLanguage("en")) + assert.Equal(t, "Search", service.Translate("app.core.ui.search")) + + // Switch to Spanish + require.NoError(t, service.SetLanguage("es")) + assert.Equal(t, "Buscar", service.Translate("app.core.ui.search")) + + // Switch back to English + require.NoError(t, service.SetLanguage("en")) + assert.Equal(t, "Search", service.Translate("app.core.ui.search")) + }) } -func TestTranslate_WithArgs(t *testing.T) { - s, err := New() - require.NoError(t, err) +func TestGetAvailableLanguages(t *testing.T) { + t.Run("returns available languages", func(t *testing.T) { + langs, err := getAvailableLanguages() + require.NoError(t, err) + assert.NotEmpty(t, langs) - s.SetBundle(newTestBundle()) - - err = s.SetLanguage("en") - require.NoError(t, err) - assert.Equal(t, "Welcome John", s.Translate("welcome", map[string]string{"Name": "John"})) - - err = s.SetLanguage("fr") - require.NoError(t, err) - assert.Equal(t, "Bienvenue John", s.Translate("welcome", map[string]string{"Name": "John"})) + // Should include at least English + langStrings := make([]string, len(langs)) + for i, l := range langs { + langStrings[i] = l.String() + } + assert.Contains(t, langStrings, "en") + }) } -func TestTranslate_Good(t *testing.T) { - s, err := New() - require.NoError(t, err) +func TestDetectLanguage(t *testing.T) { + t.Run("returns empty for empty LANG env", func(t *testing.T) { + // Save and clear LANG + t.Setenv("LANG", "") - s.SetBundle(newTestBundle()) + service, err := New() + require.NoError(t, err) - err = s.SetLanguage("en") - require.NoError(t, err) - assert.Equal(t, "Hello", s.Translate("hello")) -} - -func TestTranslate_Bad(t *testing.T) { - s, err := New() - require.NoError(t, err) - - s.SetBundle(newTestBundle()) - - err = s.SetLanguage("en") - require.NoError(t, err) - assert.Equal(t, "non-existent", s.Translate("non-existent")) -} - -func TestTranslate_Ugly(t *testing.T) { - s, err := New() - require.NoError(t, err) - - s.SetBundle(newTestBundle()) - - err = s.SetLanguage("en") - require.NoError(t, err) - assert.Equal(t, "", s.Translate("")) -} - -func ExampleNew() { - i18nService, err := New() - if err != nil { - log.Fatal(err) - } - fmt.Println(i18nService.Translate("hello")) - // Output: Hello -} - -func ExampleService_SetLanguage() { - i18nService, err := New() - if err != nil { - log.Fatal(err) - } - - err = i18nService.SetLanguage("es") - if err != nil { - log.Printf("Failed to set language: %v", err) - } - - // This would load a real Spanish locale file in a real application - // For this example, we'll inject a bundle with Spanish translations - bundle := i18n.NewBundle(language.Spanish) - bundle.RegisterUnmarshalFunc("json", json.Unmarshal) - bundle.MustParseMessageFileBytes([]byte(`{ - "hello": "Hola" - }`), "es.json") - i18nService.SetBundle(bundle) - - err = i18nService.SetLanguage("es") - if err != nil { - log.Fatal(err) - } - - fmt.Println(i18nService.Translate("hello")) - // Output: Hola -} - -func ExampleService_Translate() { - i18nService, err := New() - if err != nil { - log.Fatal(err) - } - fmt.Println(i18nService.Translate("hello")) - // Output: Hello + detected, err := detectLanguage(service.availableLangs) + assert.NoError(t, err) + assert.Empty(t, detected) + }) + + t.Run("returns empty for empty supported list", func(t *testing.T) { + t.Setenv("LANG", "en_US.UTF-8") + + detected, err := detectLanguage([]language.Tag{}) + assert.NoError(t, err) + assert.Empty(t, detected) + }) + + t.Run("detects language from LANG env", func(t *testing.T) { + t.Setenv("LANG", "es_ES.UTF-8") + + service, err := New() + require.NoError(t, err) + + detected, err := detectLanguage(service.availableLangs) + assert.NoError(t, err) + // Should detect Spanish or a close variant + if detected != "" { + assert.Contains(t, detected, "es") + } + }) } diff --git a/pkg/io/client_test.go b/pkg/io/client_test.go new file mode 100644 index 00000000..44344209 --- /dev/null +++ b/pkg/io/client_test.go @@ -0,0 +1,146 @@ +package io + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- MockMedium Tests --- + +func TestNewMockMedium(t *testing.T) { + m := NewMockMedium() + assert.NotNil(t, m) + assert.NotNil(t, m.Files) + assert.NotNil(t, m.Dirs) + assert.Empty(t, m.Files) + assert.Empty(t, m.Dirs) +} + +func TestMockMedium_Read(t *testing.T) { + t.Run("reads existing file", func(t *testing.T) { + m := NewMockMedium() + m.Files["test.txt"] = "hello world" + content, err := m.Read("test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello world", content) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + m := NewMockMedium() + _, err := m.Read("nonexistent.txt") + assert.Error(t, err) + }) +} + +func TestMockMedium_Write(t *testing.T) { + m := NewMockMedium() + err := m.Write("test.txt", "content") + assert.NoError(t, err) + assert.Equal(t, "content", m.Files["test.txt"]) + + // Overwrite existing file + err = m.Write("test.txt", "new content") + assert.NoError(t, err) + assert.Equal(t, "new content", m.Files["test.txt"]) +} + +func TestMockMedium_EnsureDir(t *testing.T) { + m := NewMockMedium() + err := m.EnsureDir("/path/to/dir") + assert.NoError(t, err) + assert.True(t, m.Dirs["/path/to/dir"]) +} + +func TestMockMedium_IsFile(t *testing.T) { + m := NewMockMedium() + m.Files["exists.txt"] = "content" + + assert.True(t, m.IsFile("exists.txt")) + assert.False(t, m.IsFile("nonexistent.txt")) +} + +func TestMockMedium_FileGet(t *testing.T) { + m := NewMockMedium() + m.Files["test.txt"] = "content" + content, err := m.FileGet("test.txt") + assert.NoError(t, err) + assert.Equal(t, "content", content) +} + +func TestMockMedium_FileSet(t *testing.T) { + m := NewMockMedium() + err := m.FileSet("test.txt", "content") + assert.NoError(t, err) + assert.Equal(t, "content", m.Files["test.txt"]) +} + +// --- Wrapper Function Tests --- + +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 TestEnsureDir(t *testing.T) { + m := NewMockMedium() + err := EnsureDir(m, "/my/dir") + assert.NoError(t, err) + assert.True(t, m.Dirs["/my/dir"]) +} + +func TestIsFile(t *testing.T) { + m := NewMockMedium() + m.Files["exists.txt"] = "content" + + assert.True(t, IsFile(m, "exists.txt")) + assert.False(t, IsFile(m, "nonexistent.txt")) +} + +func TestCopy(t *testing.T) { + t.Run("copies file between mediums", func(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"]) + }) + + t.Run("copies to different path", func(t *testing.T) { + source := NewMockMedium() + dest := NewMockMedium() + source.Files["original.txt"] = "content" + err := Copy(source, "original.txt", dest, "copied.txt") + assert.NoError(t, err) + assert.Equal(t, "content", dest.Files["copied.txt"]) + }) + + t.Run("returns error for non-existent source", func(t *testing.T) { + source := NewMockMedium() + dest := NewMockMedium() + err := Copy(source, "nonexistent.txt", dest, "dest.txt") + assert.Error(t, err) + }) +} + +// --- Local Global Tests --- + +func TestLocalGlobal(t *testing.T) { + // io.Local should be initialized by init() + assert.NotNil(t, Local, "io.Local should be initialized") + + // Should be able to use it as a Medium + var m Medium = Local + assert.NotNil(t, m) +} diff --git a/pkg/io/io.go b/pkg/io/io.go new file mode 100644 index 00000000..d268586f --- /dev/null +++ b/pkg/io/io.go @@ -0,0 +1,41 @@ +package io + +import ( + "github.com/Snider/Core/pkg/io/local" +) + +// 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 +} + +// Local is a pre-initialized medium for the local filesystem. +// It uses "/" as root, providing unsandboxed access to the filesystem. +// For sandboxed access, create a new local.Medium with a specific root path. +var Local Medium + +func init() { + var err error + Local, err = local.New("/") + if err != nil { + panic("io: failed to initialize Local medium: " + err.Error()) + } +} diff --git a/pkg/io/local/client_test.go b/pkg/io/local/client_test.go new file mode 100644 index 00000000..329eba56 --- /dev/null +++ b/pkg/io/local/client_test.go @@ -0,0 +1,194 @@ +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")) +} + +func TestFileGetFileSet(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_fileget_fileset_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + fileName := "data.txt" + content := "Hello, FileGet/FileSet!" + + // Test FileSet + err = medium.FileSet(fileName, content) + assert.NoError(t, err) + + // Verify file was written + readContent, err := os.ReadFile(filepath.Join(testRoot, fileName)) + assert.NoError(t, err) + assert.Equal(t, content, string(readContent)) + + // Test FileGet + gotContent, err := medium.FileGet(fileName) + assert.NoError(t, err) + assert.Equal(t, content, gotContent) + + // Test FileGet on non-existent file + _, err = medium.FileGet("nonexistent.txt") + assert.Error(t, err) + + // Test FileSet with path traversal attempt + err = medium.FileSet("../bad.txt", "malicious") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path traversal attempt detected") + + // Test FileGet with path traversal attempt + _, err = medium.FileGet("../bad.txt") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path traversal attempt detected") +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go new file mode 100644 index 00000000..0f492f7b --- /dev/null +++ b/pkg/runtime/runtime.go @@ -0,0 +1,105 @@ +package runtime + +import ( + "fmt" + + // Import the CONCRETE implementations from the internal packages. + "github.com/Snider/Core/pkg/config" + "github.com/Snider/Core/pkg/crypt" + "github.com/Snider/Core/pkg/display" + "github.com/Snider/Core/pkg/help" + "github.com/Snider/Core/pkg/i18n" + "github.com/Snider/Core/pkg/io" + "github.com/Snider/Core/pkg/workspace" + // Import the ABSTRACT contracts (interfaces). + "github.com/Snider/Core/pkg/core" +) + +// App is the runtime container that holds all instantiated services. +// Its fields are the concrete types, allowing Wails to bind them directly. +type Runtime struct { + Core *core.Core + Config *config.Service + Display *display.Service + Help *help.Service + Crypt *crypt.Service + I18n *i18n.Service + Workspace *workspace.Service +} + +// ServiceFactory defines a function that creates a service instance. +type ServiceFactory func() (any, error) + +// newWithFactories creates a new Runtime instance using the provided service factories. +func newWithFactories(factories map[string]ServiceFactory) (*Runtime, error) { + services := make(map[string]any) + coreOpts := []core.Option{} + + for _, name := range []string{"config", "display", "help", "crypt", "i18n", "workspace"} { + factory, ok := factories[name] + if !ok { + return nil, fmt.Errorf("service %s factory not provided", name) + } + svc, err := factory() + if err != nil { + return nil, fmt.Errorf("failed to create service %s: %w", name, err) + } + services[name] = svc + svcCopy := svc + coreOpts = append(coreOpts, core.WithService(func(c *core.Core) (any, error) { return svcCopy, nil })) + } + + coreInstance, err := core.New(coreOpts...) + if err != nil { + return nil, err + } + + configSvc, ok := services["config"].(*config.Service) + if !ok { + return nil, fmt.Errorf("config service has unexpected type") + } + displaySvc, ok := services["display"].(*display.Service) + if !ok { + return nil, fmt.Errorf("display service has unexpected type") + } + helpSvc, ok := services["help"].(*help.Service) + if !ok { + return nil, fmt.Errorf("help service has unexpected type") + } + cryptSvc, ok := services["crypt"].(*crypt.Service) + if !ok { + return nil, fmt.Errorf("crypt service has unexpected type") + } + i18nSvc, ok := services["i18n"].(*i18n.Service) + if !ok { + return nil, fmt.Errorf("i18n service has unexpected type") + } + workspaceSvc, ok := services["workspace"].(*workspace.Service) + if !ok { + return nil, fmt.Errorf("workspace service has unexpected type") + } + + app := &Runtime{ + Core: coreInstance, + Config: configSvc, + Display: displaySvc, + Help: helpSvc, + Crypt: cryptSvc, + I18n: i18nSvc, + Workspace: workspaceSvc, + } + + return app, nil +} + +// New creates and wires together all application services using static dependency injection. +func New() (*Runtime, error) { + return newWithFactories(map[string]ServiceFactory{ + "config": func() (any, error) { return config.New() }, + "display": func() (any, error) { return display.New() }, + "help": func() (any, error) { return help.New() }, + "crypt": func() (any, error) { return crypt.New() }, + "i18n": func() (any, error) { return i18n.New() }, + "workspace": func() (any, error) { return workspace.New(io.Local) }, + }) +} diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go new file mode 100644 index 00000000..495c776e --- /dev/null +++ b/pkg/runtime/runtime_test.go @@ -0,0 +1,76 @@ +package runtime + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Snider/Core/pkg/config" + "github.com/Snider/Core/pkg/crypt" + "github.com/Snider/Core/pkg/display" + "github.com/Snider/Core/pkg/help" + "github.com/Snider/Core/pkg/io" + "github.com/Snider/Core/pkg/workspace" +) + +// TestNew ensures that New correctly initializes a Runtime instance. +func TestNew(t *testing.T) { + runtime, err := New() + assert.NoError(t, err) + assert.NotNil(t, runtime) + + // Assert that key services are initialized + assert.NotNil(t, runtime.Core, "Core service should be initialized") + assert.NotNil(t, runtime.Config, "Config service should be initialized") + assert.NotNil(t, runtime.Display, "Display service should be initialized") + assert.NotNil(t, runtime.Help, "Help service should be initialized") + assert.NotNil(t, runtime.Crypt, "Crypt service should be initialized") + assert.NotNil(t, runtime.I18n, "I18n service should be initialized") + assert.NotNil(t, runtime.Workspace, "Workspace service should be initialized") + + // Verify services are properly wired through Core + configFromCore := runtime.Core.Service("config") + assert.NotNil(t, configFromCore, "Config should be registered in Core") + assert.Equal(t, runtime.Config, configFromCore, "Config from Core should match direct reference") + + displayFromCore := runtime.Core.Service("display") + assert.NotNil(t, displayFromCore, "Display should be registered in Core") + assert.Equal(t, runtime.Display, displayFromCore, "Display from Core should match direct reference") + + helpFromCore := runtime.Core.Service("help") + assert.NotNil(t, helpFromCore, "Help should be registered in Core") + assert.Equal(t, runtime.Help, helpFromCore, "Help from Core should match direct reference") + + cryptFromCore := runtime.Core.Service("crypt") + assert.NotNil(t, cryptFromCore, "Crypt should be registered in Core") + assert.Equal(t, runtime.Crypt, cryptFromCore, "Crypt from Core should match direct reference") + + i18nFromCore := runtime.Core.Service("i18n") + assert.NotNil(t, i18nFromCore, "I18n should be registered in Core") + assert.Equal(t, runtime.I18n, i18nFromCore, "I18n from Core should match direct reference") + + workspaceFromCore := runtime.Core.Service("workspace") + assert.NotNil(t, workspaceFromCore, "Workspace should be registered in Core") + assert.Equal(t, runtime.Workspace, workspaceFromCore, "Workspace from Core should match direct reference") +} + +// TestNewServiceInitializationError tests the error path in New. +func TestNewServiceInitializationError(t *testing.T) { + factories := map[string]ServiceFactory{ + "config": func() (any, error) { return config.New() }, + "display": func() (any, error) { return display.New() }, + "help": func() (any, error) { return help.New() }, + "crypt": func() (any, error) { return crypt.New() }, + "i18n": func() (any, error) { return nil, errors.New("i18n service failed to initialize") }, // This factory will fail + "workspace": func() (any, error) { return workspace.New(io.Local) }, + } + + runtime, err := newWithFactories(factories) + + assert.Error(t, err) + assert.Nil(t, runtime) + assert.Contains(t, err.Error(), "failed to create service i18n: i18n service failed to initialize") +} + +// Removed TestRuntimeOptions and TestRuntimeCore as these methods no longer exist on the Runtime struct. diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go new file mode 100644 index 00000000..f14b0e34 --- /dev/null +++ b/pkg/workspace/workspace.go @@ -0,0 +1,239 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + + "github.com/Snider/Core/pkg/core" + "github.com/Snider/Core/pkg/crypt/lthn" + "github.com/Snider/Core/pkg/crypt/openpgp" + "github.com/Snider/Core/pkg/io" + "github.com/Snider/Core/pkg/io/local" + "github.com/wailsapp/wails/v3/pkg/application" +) + +const ( + defaultWorkspace = "default" + listFile = "list.json" +) + +// Options holds configuration for the workspace service. +type Options struct{} + +// Workspace represents a user's workspace. +type Workspace struct { + Name string + Path string +} + +// Service manages user workspaces. +type Service struct { + *core.Runtime[Options] + activeWorkspace *Workspace + workspaceList map[string]string // Maps Workspace ID to Public Key + medium io.Medium +} + +// newWorkspaceService contains the common logic for initializing a Service struct. +// It no longer takes config and medium as arguments. +func newWorkspaceService() (*Service, error) { + s := &Service{ + workspaceList: make(map[string]string), + } + return s, nil +} + +// New is the constructor for static dependency injection. +// It creates a Service instance without initializing the core.Runtime field. +// The medium parameter is required for file operations. +func New(medium io.Medium) (*Service, error) { + s, err := newWorkspaceService() + if err != nil { + return nil, err + } + s.medium = medium + return s, nil +} + +// Register is the constructor for dynamic dependency injection (used with core.WithService). +// It creates a Service instance and initializes its core.Runtime field. +// Dependencies are injected during ServiceStartup. +func Register(c *core.Core) (any, error) { + s, err := newWorkspaceService() + if err != nil { + return nil, err + } + s.Runtime = core.NewRuntime(c, Options{}) + + // Initialize the local medium for file operations + var workspaceDir string + if err := c.Config().Get("workspaceDir", &workspaceDir); err != nil { + return nil, fmt.Errorf("workspace: failed to get workspaceDir from config: %w", err) + } + medium, err := local.New(workspaceDir) + if err != nil { + return nil, fmt.Errorf("workspace: failed to create local medium: %w", err) + } + s.medium = medium + + return s, nil +} + +// HandleIPCEvents processes IPC messages, including injecting dependencies on startup. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case map[string]any: + if action, ok := m["action"].(string); ok && action == "workspace.switch_workspace" { + return s.SwitchWorkspace(m["name"].(string)) + } + case core.ActionServiceStartup: + return s.ServiceStartup(context.Background(), application.ServiceOptions{}) + default: + c.App.Logger.Error("Workspace: Unknown message type", "type", fmt.Sprintf("%T", m)) + } + return nil +} + +// getWorkspaceDir retrieves the WorkspaceDir from the config service. +func (s *Service) getWorkspaceDir() (string, error) { + var workspaceDir string + if err := s.Config().Get("workspaceDir", &workspaceDir); err != nil { + return "", fmt.Errorf("failed to get WorkspaceDir from config: %w", err) + } + return workspaceDir, nil +} + +// ServiceStartup initializes the service, loading the workspace list. +func (s *Service) ServiceStartup(context.Context, application.ServiceOptions) error { + workspaceDir, err := s.getWorkspaceDir() + if err != nil { + return err + } + + // Load existing workspace list if it exists + listPath := filepath.Join(workspaceDir, 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 { + // Log warning but continue with empty list + 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) { + workspaceDir, err := s.getWorkspaceDir() + if err != nil { + return "", err + } + + realName := lthn.Hash(identifier) + workspaceID := lthn.Hash(fmt.Sprintf("workspace/%s", realName)) + workspacePath := filepath.Join(workspaceDir, 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(workspaceDir, 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 { + workspaceDir, err := s.getWorkspaceDir() + if err != nil { + return err + } + + if name != defaultWorkspace { + if _, exists := s.workspaceList[name]; !exists { + return fmt.Errorf("workspace '%s' does not exist", name) + } + } + + path := filepath.Join(workspaceDir, 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) +} + +// ListWorkspaces returns the list of workspace IDs. +func (s *Service) ListWorkspaces() []string { + workspaces := make([]string, 0, len(s.workspaceList)) + for id := range s.workspaceList { + workspaces = append(workspaces, id) + } + return workspaces +} + +// ActiveWorkspace returns the currently active workspace, or nil if none is active. +func (s *Service) ActiveWorkspace() *Workspace { + return s.activeWorkspace +} diff --git a/pkg/workspace/workspace_test.go b/pkg/workspace/workspace_test.go new file mode 100644 index 00000000..2826c1a9 --- /dev/null +++ b/pkg/workspace/workspace_test.go @@ -0,0 +1,94 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "testing" + + "github.com/Snider/Core/pkg/core" + "github.com/Snider/Core/pkg/io" + "github.com/stretchr/testify/assert" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// mockConfig is a mock implementation of the core.Config interface for testing. +type mockConfig struct { + values map[string]interface{} +} + +func (m *mockConfig) Get(key string, out any) error { + val, ok := m.values[key] + if !ok { + return fmt.Errorf("key not found: %s", key) + } + // This is a simplified mock; a real one would use reflection to set `out` + switch v := out.(type) { + case *string: + *v = val.(string) + default: + return fmt.Errorf("unsupported type in mock config Get") + } + return nil +} + +func (m *mockConfig) Set(key string, v any) error { + m.values[key] = v + return nil +} + +// newTestService creates a workspace service instance with mocked dependencies. +func newTestService(t *testing.T, workspaceDir string) (*Service, *io.MockMedium) { + coreInstance, err := core.New() + assert.NoError(t, err) + + mockCfg := &mockConfig{values: map[string]interface{}{"workspaceDir": workspaceDir}} + coreInstance.RegisterService("config", mockCfg) + + mockMedium := io.NewMockMedium() + service, err := New(mockMedium) + assert.NoError(t, err) + + service.Runtime = core.NewRuntime(coreInstance, Options{}) + + return service, mockMedium +} + +func TestServiceStartup(t *testing.T) { + workspaceDir := "/tmp/workspace" + + t.Run("existing valid list.json", func(t *testing.T) { + service, mockMedium := newTestService(t, workspaceDir) + + expectedWorkspaceList := map[string]string{ + "workspace1": "pubkey1", + "workspace2": "pubkey2", + } + listContent, _ := json.MarshalIndent(expectedWorkspaceList, "", " ") + listPath := filepath.Join(workspaceDir, listFile) + mockMedium.Files[listPath] = string(listContent) + + err := service.ServiceStartup(context.Background(), application.ServiceOptions{}) + + assert.NoError(t, err) + // assert.Equal(t, expectedWorkspaceList, service.workspaceList) // This check is difficult with current implementation + assert.NotNil(t, service.activeWorkspace) + assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name) + }) +} + +func TestCreateAndSwitchWorkspace(t *testing.T) { + workspaceDir := "/tmp/workspace" + service, _ := newTestService(t, workspaceDir) + + // Create + workspaceID, err := service.CreateWorkspace("test", "password") + assert.NoError(t, err) + assert.NotEmpty(t, workspaceID) + + // Switch + err = service.SwitchWorkspace(workspaceID) + assert.NoError(t, err) + assert.Equal(t, workspaceID, service.activeWorkspace.Name) +}