diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml new file mode 100644 index 0000000..0dc2dca --- /dev/null +++ b/.github/workflows/core-tests.yml @@ -0,0 +1,33 @@ +name: Core Tests + +on: + push: + branches: + - development + - main + - stable + pull_request: + branches: + - development + - main + - stable + +jobs: + test: + name: arcadia-core tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: swatinem/rust-cache@v2 + with: + workspaces: Shared -> target + + - name: Run core tests + run: cargo test -p arcadia-core --manifest-path Shared/Cargo.toml diff --git a/.github/workflows/ffi-drift.yml b/.github/workflows/ffi-drift.yml new file mode 100644 index 0000000..e1b6619 --- /dev/null +++ b/.github/workflows/ffi-drift.yml @@ -0,0 +1,95 @@ +name: FFI Drift Check + +on: + push: + branches: + - development + - main + - stable + paths: + - 'Shared/ArcadiaCore/src/ffi.rs' + - 'Shared/ArcadiaCore/src/**.rs' + - 'Mobile/iOS/ArcadiaCore/Generated/**' + pull_request: + branches: + - development + - main + - stable + paths: + - 'Shared/ArcadiaCore/src/ffi.rs' + - 'Shared/ArcadiaCore/src/**.rs' + - 'Mobile/iOS/ArcadiaCore/Generated/**' + workflow_dispatch: + +jobs: + ffi-drift: + name: Check FFI bindings drift + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install iOS target + run: rustup target add aarch64-apple-ios + + - name: Cache Rust dependencies + uses: swatinem/rust-cache@v2 + with: + workspaces: Shared -> target + + - name: Build arcadia-core for iOS device + run: | + cd Shared + cargo build -p arcadia-core --release --target aarch64-apple-ios + + - name: Generate Swift bindings into temp dir + run: | + mkdir -p /tmp/ffi-generated + cd Shared + cargo run -p uniffi-bindgen -- generate \ + --library target/aarch64-apple-ios/release/libarcadia_core.a \ + --language swift \ + --out-dir /tmp/ffi-generated + + - name: Diff generated bindings against checked-in + run: | + CHECKED_SWIFT="Mobile/iOS/ArcadiaCore/Generated/arcadia_core.swift" + GENERATED_SWIFT="/tmp/ffi-generated/arcadia_core.swift" + + # Detect header filename (uniffi may name it arcadia_coreFFI.h or arcadia_coreCFFI.h) + GENERATED_H=$(ls /tmp/ffi-generated/*.h 2>/dev/null | head -1) + CHECKED_H="" + if [[ -n "$GENERATED_H" ]]; then + HEADER_BASENAME=$(basename "$GENERATED_H") + CHECKED_H="Mobile/iOS/ArcadiaCore/Generated/$HEADER_BASENAME" + fi + + DRIFT=0 + + if ! diff -q "$GENERATED_SWIFT" "$CHECKED_SWIFT" > /dev/null 2>&1; then + echo "::error::Swift bindings drift detected in $CHECKED_SWIFT" + diff "$GENERATED_SWIFT" "$CHECKED_SWIFT" || true + DRIFT=1 + fi + + if [[ -n "$CHECKED_H" ]] && [[ -f "$CHECKED_H" ]]; then + if ! diff -q "$GENERATED_H" "$CHECKED_H" > /dev/null 2>&1; then + echo "::error::FFI header drift detected in $CHECKED_H" + diff "$GENERATED_H" "$CHECKED_H" || true + DRIFT=1 + fi + fi + + if [[ "$DRIFT" -eq 1 ]]; then + echo "" + echo "FFI bindings are out of sync. Run:" + echo " bash Shared/Scripts/build-ios-framework.sh" + echo "and commit the updated Generated/ directory." + exit 1 + fi + + echo "FFI bindings match checked-in version." diff --git a/.github/workflows/stable-build-matrix.yml b/.github/workflows/stable-build-matrix.yml index dd643ca..5e83ee0 100644 --- a/.github/workflows/stable-build-matrix.yml +++ b/.github/workflows/stable-build-matrix.yml @@ -1,9 +1,6 @@ name: Stable Build Matrix on: - push: - branches: - - stable pull_request: branches: - stable @@ -52,6 +49,13 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: swatinem/rust-cache@v2 + with: + workspaces: | + Desktop -> target + Shared/ArcadiaCore -> target + - name: Install Linux GUI build dependencies if: runner.os == 'Linux' && matrix.feature == 'gui' run: | diff --git a/.gitignore b/.gitignore index 6b723ac..a6caa08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ /target +**/target/ +build/ **/*.rs.bk /Models/ /Configuration/ -/Desktop/target/ -/Shared/target/ /Mobile/iOS/.xcode/ **/xcuserdata/ .DS_Store + +# SwiftPM / local launcher build output (not source) +Launchers/Development/OSX/.build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af1cecf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,289 @@ +# Arcadia — Agent Instructions + +Read `CLAUDE.md` first. This file adds agent-specific rules, decision trees, and file ownership on top of it. + +--- + +## Prime Directive: Registry-Driven, Not Hardcoded + +Every module, every page, every group has **one registration point**. When asked to add, change, or remove any of these, touch the registry entry — let the rest of the system derive from it. Do not scatter the change across surface files. + +**If the registry entry does not exist, create it before writing surface code.** + +--- + +## Anti-Patterns — Refuse to Write These + +### 1. Named module booleans + +```rust +// NEVER add fields like these to ArcadiaRoot or any surface state +pub shell_enabled: bool, +pub lan_enabled: bool, +pub net_enabled: bool, + +// NEVER add methods like these +fn shell_enabled(&self) -> bool { … } +fn net_enabled(&self) -> bool { … } +``` + +One method: `fn is_module_enabled(&self, name: &str) -> bool`. Query it with the `*_MODULE_NAME` constants from `config/modules.rs`. + +### 2. Hardcoded page ID match arms in visibility logic + +```rust +// NEVER write this pattern +match page_id { + "utility.shell" => self.shell_enabled, + "network.overview" => self.net_enabled(), + _ => true, +} +``` + +Page visibility derives from `NavigationPageDefinition.required_module`, not surface-level match arms. + +### 3. Growing if-else chains for page content dispatch + +```rust +// NEVER grow this pattern +if self.active_page_id == "utility.shell" { … } +else if self.active_page_id == "global.modules" { … } +else if self.active_page_id == "network.overview" { … } // don't add +``` + +If this pattern exists and you must add a page, flag it as technical debt before extending it. + +### 4. Special-casing page IDs in generic event handlers + +```swift +// NEVER hardcode magic behavior on specific page IDs in generic handlers +.onChange(of: activePageID) { pageID in + if pageID == "global.modules" { reloadModules() } +} +``` + +Lifecycle side-effects belong in the view for that page (e.g. `ModulesView.onAppear`), not in a global observer. + +### 5. Inline raw colors in view/render code + +```rust +// NEVER inline hex colors in app.rs or any view file +.bg(rgb(0x151a22)) +.text_color(rgb(0x93c5fd)) +``` + +```swift +// NEVER inline colors in SwiftUI views +Color(hex: "151a22") +``` + +Desktop colors: `Desktop/src/gui/theme/mod.rs` or component files under `theme/modules/`. +iOS colors: computed properties on `AppTheme` in `AppTheme.swift`. + +### 6. Duplicating core logic in surface code + +``` +// NEVER write the same business logic in both Desktop/src/gui/app/ AND Mobile/iOS/ArcadiaApp/ +// If it belongs to both, it belongs in arcadia-core. +``` + +### 7. Ad-hoc `remote-session.*` verbs + +``` +// NEVER create new remote-session.foo commands for UI mirroring. +// CORRECT — extend surface.snapshot extra fields and surface.patch ops. +``` + +The `remote-session` module is a routing gate only. `surface.*` is the protocol for UI state mirroring. + +### 8. Config renames without migration + +``` +// NEVER rename a module name constant without adding a migration in: +// ModulesConfig::merge_defaults() in config/modules.rs +// Follow the LEGACY_LAN_MODULE_NAME pattern. +``` + +### 9. FFI changes without rebuilding xcframework + +``` +// NEVER commit ffi.rs changes without running: +// bash Shared/Scripts/build-ios-framework.sh +// and committing the updated Generated/ + ArcadiaCore.xcframework +``` + +--- + +## Correct Extension Patterns + +### Adding a module (all platforms, zero surface edits required) + +```rust +// 1. Shared/ArcadiaCore/src/config/modules.rs — add constant + registry entry +pub const FOO_MODULE_NAME: &str = "foo"; + +static MODULE_REGISTRY: &[ModuleManifest] = &[ + // … existing … + ModuleManifest { + name: FOO_MODULE_NAME, + version: "1.0.0", + description: "What foo does.", + required_modules: &[], // or &[NET_MODULE_NAME] etc. + }, +]; + +// 2. Create Shared/ArcadiaCore/src/modules/foo.rs +pub fn commands() -> &'static [ModuleCommand] { + &[ + ModuleCommand { token: "foo.bar", description: "Does bar." }, + ] +} + +// 3. Register in Shared/ArcadiaCore/src/modules/mod.rs +// Done — GUI, CLI, iOS module list updates automatically +``` + +### Adding a navigation page + +```rust +// 1. Shared/ArcadiaCore/src/navigation.rs — add to PAGE_DEFINITIONS +NavigationPageDefinition { + id: "utilities.foo", + title: "Foo", + description: "Foo does things.", + glyph: "foo", // must have a matching arm in icon_path() + system_image: "star", // SF Symbol for iOS + accent: "emerald", + required_module: Some(FOO_MODULE_NAME), // or None if always visible +}, + +// 2. Add "utilities.foo" to GROUP_DEFINITIONS.pages for the relevant group + +// 3. Desktop: add panel render + route via page ID (derive visibility from required_module) +// 4. iOS: add view + route in ContentView page dispatch +``` + +### Checking module state in surface code + +```rust +// Rust (Desktop) — use MODULE_NAME constants, not string literals +fn is_module_enabled(&self, name: &str) -> bool { + self.module_rows.iter() + .find(|(n, _)| n == name) + .map(|(_, enabled)| *enabled) + .unwrap_or(false) +} +// Call as: self.is_module_enabled(SHELL_MODULE_NAME) +``` + +```swift +// Swift (iOS) +func isModuleEnabled(_ name: String) -> Bool { + modules.first(where: { $0.name == name })?.enabled ?? false +} +// Call as: isModuleEnabled(ModuleNames.shell) +``` + +### Adding mirrored state to thin-client protocol + +```rust +// 1. modules/surface.rs — extend SurfaceSnapshot.extra +// 2. modules/surface.rs — add SurfacePatch variant if clients push changes back +// 3. Desktop + iOS surfaces consume new extra field from snapshot result +// 4. Do NOT create remote-session.foo verbs — keep protocol under surface.* +``` + +### Renaming a module + +```rust +// 1. Edit MODULE_REGISTRY entry + constant in config/modules.rs +// 2. Add migration in ModulesConfig::merge_defaults(): +const LEGACY_FOO_NAME: &str = "foo-old"; +if let Some(val) = self.modules.remove(LEGACY_FOO_NAME) { + self.modules.entry(FOO_MODULE_NAME.to_string()).or_insert(val); +} +// 3. Done — no ad-hoc renames at call sites +``` + +--- + +## Decision Tree Before Writing Code + +Ask these questions. If any answer is "no," stop and fix it first. + +1. **Does a registry entry exist for this?** → If not, create it before touching surface code. +2. **Am I adding a name check on a specific module or page ID in surface code?** → If yes, that logic belongs in the registry declaration or the core. +3. **Am I adding a new field/property that tracks a specific module's state?** → Use `is_module_enabled(name)` instead. +4. **Am I writing the same logic for both Desktop and iOS?** → Move it to `arcadia_core`. +5. **Am I inlining a color value?** → Put it in the theme layer. +6. **Did I change `ffi.rs` or any FFI-exported type?** → Run `build-ios-framework.sh` before committing. +7. **Am I renaming a module?** → Add a `merge_defaults()` migration. +8. **Am I creating a new `remote-session.*` command for UI state?** → Use `surface.snapshot` / `surface.patch` instead. + +--- + +## When Asked to "Just Make It Work" With a Hardcoded Value + +Do not. If time is the constraint, implement the proper registry-driven pattern and leave a `// TODO: ` comment — do not leave hardcoded strings in surface logic. A hardcoded page ID match arm today becomes five hardcoded match arms after the next change touches the file. + +--- + +## File Ownership + +| File | Purpose | Agent rule | +|------|---------|------------| +| `config/modules.rs` | Module registry + config + migrations | Extend `MODULE_REGISTRY`; add migrations to `merge_defaults()`; never add per-module booleans | +| `navigation.rs` | Page/group registry + JSON serialization | Extend `PAGE_DEFINITIONS` / `GROUP_DEFINITIONS`; never add parallel lists | +| `ffi.rs` | UniFFI bridge | After changes: rebuild xcframework; commit `Generated/` | +| `modules/surface.rs` | Snapshot / patch / revision | Extend `extra` + `SurfacePatch`; do not create ad-hoc `remote-session.*` verbs | +| `modules/remote_mirror.rs` | Host transcript queue + FFI drain | For inbound NODE_EXEC mirroring only | +| `modules/shell.rs`, `modules/lan/`, etc. | Module command handlers | One file per module; no cross-module logic | +| `gui/app/mod.rs` | Desktop root state (`ArcadiaRoot`) | No per-module booleans; no hardcoded page IDs | +| `gui/theme/mod.rs` | Desktop icon + color helpers | All Desktop color/icon lookups; never inline in views | +| `gui/tui/` | PTY/TUI terminal emulator | Desktop-specific; no equivalent on iOS (shell.execute only) | +| `AppTheme.swift` | iOS color tokens | All iOS colors as computed properties | +| `ContentView.swift` | iOS coordinator | Thin — registry + module state consumer; no business logic | +| `NavigationModels.swift` | Swift nav types | Mirror of Rust `NavigationPageDefinition` / `NavigationGroupDefinition`; update after navigation.rs changes | +| `ModuleNames.swift` | iOS module name constants | Mirror of `MODULE_REGISTRY` name fields; update after adding modules | + +--- + +## Production Readiness Checklist + +Before marking a feature ready for production, verify: + +- [ ] New capability registered in `MODULE_REGISTRY` (if module) or `PAGE_DEFINITIONS` (if page) +- [ ] No hardcoded module/page IDs in surface visibility or dispatch logic +- [ ] No per-module boolean fields added to surface state structs +- [ ] No inline colors in view/render code +- [ ] FFI changes accompanied by `xcframework` rebuild + `Generated/` commit +- [ ] Module rename includes `merge_defaults()` migration +- [ ] New mirrored state uses `surface.*` protocol, not ad-hoc verbs +- [ ] `cargo test -p arcadia-core` passes +- [ ] Known gap addressed or documented in `gaps.md` if not fully solved + +--- + +## LAN / Thin-Client Rules + +- LAN command forwarding requires `remote-session` + `lan` + `net` enabled locally. The peer checks its own module rules. +- `surface.revision` is not a reliable freshness signal yet — gap 1 in `gaps.md`. Do not build logic that assumes revision covers all write paths. +- `surface.patch` `client_id` is attribution only — not authentication. Do not build authorization logic on it. +- Multiple concurrent clients patching the same host = last-writer-wins. Do not imply merge semantics. + +--- + +## Surface Parity Notes + +| Capability | Desktop | iOS | Core | +|-----------|---------|-----|------| +| Shell (PTY/TUI) | Full PTY + TUI via `gui/tui/` | `shell.execute` only | `modules/shell.rs` | +| Shell MOTD | Yes | Yes (via execute) | `modules/shell_motd.rs` | +| Module toggles | GUI + CLI | SwiftUI `ModulesView` | FFI `set_module_enabled*` | +| LAN discovery | `lan_nodes/` panel | `LanNodesView` | `modules/lan/` | +| Surface snapshot | Yes | Yes | `modules/surface.rs` | +| Remote mirror drain | Yes (shell/mirror.rs) | Yes (250ms timer) | FFI `drain_remote_mirror_batch` | +| Thin-client route | Session chip in top bar | Route picker in sidebar | `ThinClientConfig` | +| Splash screen | Animated canvas (`splash/`) | `SplashView.swift` | — | + +Divergence between surfaces is tracked in gap 10 of `gaps.md`. When implementing a new capability, prefer making it routable via `execute_command` so both surfaces can reach it over LAN without platform-specific implementations. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a8c759 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,436 @@ +# Arcadia — Claude Code Guide + +## What Arcadia Is + +Multi-platform runtime and shell: one Rust core (`Shared/ArcadiaCore`) consumed by two thin surface shells — a GPUI desktop app (`Desktop/`) and a SwiftUI iOS app (`Mobile/iOS/`) — plus a headless CLI. The core owns all logic; surfaces only render and dispatch. + +**Key invariant:** if you find yourself writing the same logic in both `Desktop/src/gui/app/` and `Mobile/iOS/ArcadiaApp/`, it belongs in `arcadia-core` instead. + +--- + +## Repository Layout + +``` +Shared/ArcadiaCore/src/ + lib.rs root module, exports, UniFFI scaffolding + ffi.rs UniFFI bridge — Rust → Swift (all iOS calls go through here) + navigation.rs PAGE_DEFINITIONS, GROUP_DEFINITIONS, registry JSON serialization + config/ + mod.rs ConfigFile trait, config root path (~/ vs iOS sandbox) + modules.rs MODULE_REGISTRY, ModulesConfig, config migrations + commandline.rs CLI preferences scaffold + thin_client.rs ThinClientConfig → thin-client.toml + modules/ + mod.rs execute_command dispatcher, module lifecycle (load_all, shutdown_all) + shell.rs shell.execute + shell.internal; PTY integration + shell_motd.rs fastfetch-style MOTD banner (requires shell) + surface.rs surface.snapshot / surface.patch / revision counter + remote_session.rs routing manifest only — no standalone commands + remote_mirror.rs host transcript queue + FFI drain (RemoteMirrorDrain) + net.rs networking foundation + lan/ LAN subsystem — see module reference + mod.rs, discovery.rs, handlers.rs, config.rs, peers.rs, protocol.rs + platform/ + mod.rs, macos.rs, ios.rs, linux.rs, windows.rs, unknown.rs + +Desktop/src/ + main.rs binary entry — feature-gated gui vs headless + cli/ + mod.rs REPL loop, command dispatch, startup messages + args.rs CLI argument parsing + completion.rs shell completion helpers + config_cmds.rs module/config CLI commands + module_cmds.rs module shortcut commands + gui/ + mod.rs + assets.rs embedded SVG asset loading + app/ + mod.rs ArcadiaRoot state struct, ShellMode enum + entry.rs GPUI initialization + window setup + lifecycle.rs focus, resize, module state reload + navigation.rs nav state, page routing, sidebar group logic + root/ mod.rs, render.rs, top_bar.rs + sidebar/ mod.rs, layout.rs, nav_items.rs + shell/ mod.rs, panel.rs, execute.rs, keys.rs, tui_screen.rs, mirror.rs + modules_page/ mod.rs, panel.rs, row.rs, requirements_modal.rs + lan_nodes/ mod.rs, panel.rs + splash/ mod.rs, view.rs, draw_*.rs, math.rs + theme/ + mod.rs icon_path(), color constants and helpers + chrome.rs window chrome styles + icons.rs icon metadata + splash_colors.rs + modules/ component tokens: buttons, panel, row_surface, toggle_states, typography + nav_accents/ per-accent palettes (9 accents: amber, cyan, emerald, fuchsia, indigo, orange, sky, teal, violet) + tui/ + mod.rs, session.rs PTY session state + lifecycle + ansi_line.rs ANSI escape sequence parsing + colors.rs terminal color palette + cd_builtin.rs cd builtin (updates shell_working_dir) + cwd.rs, env.rs CWD tracking, env vars + keys.rs PTY keyboard events + vt_history.rs VT100 history buffer + +Mobile/iOS/ArcadiaApp/ + ArcadiaApp.swift @main, set_config_root_path early + ContentView.swift top-level coordinator + ContentView+Actions.swift action methods + ContentView+Layout.swift layout + composition + ContentView+NavigationState.swift navigation state helpers + ContentView+Registry.swift NavigationRegistry loading + JSON deserialization + NavigationModels.swift Swift structs mirroring NavigationRegistry + AppTheme.swift all iOS colors as computed properties + SidebarView.swift sidebar + remote session picker + SplashView.swift animated splash + ShellView.swift shell input + history + ModulesView.swift module toggle list + LanNodesView.swift LAN peer discovery + pairing + ModuleNames.swift string constants mirroring MODULE_REGISTRY + GlassComponents.swift reusable glassmorphism components + +Configuration/ + modules.toml module enable/disable state + commandline.toml CLI settings + thin-client.toml preferred_remote_route, surface_client_id (UUID) +``` + +--- + +## Core Architecture Principles + +### Single source of truth — never duplicate + +| What | Lives in | Consumed by | +|------|----------|-------------| +| Module list + deps | `config/modules.rs` `MODULE_REGISTRY` | everything | +| Navigation pages/groups | `navigation.rs` `PAGE_DEFINITIONS` / `GROUP_DEFINITIONS` | Desktop gui, iOS via JSON | +| Serializable nav | `NavigationRegistryOwned` in `navigation.rs` | `surface.snapshot`, FFI | +| Desktop theme | `gui/theme/` | view files (never inline) | +| iOS theme | `AppTheme.swift` | SwiftUI views (never inline) | +| Config schema | `ModulesConfig` in `config/modules.rs` | CLI, GUI, iOS | +| Config migrations | `ModulesConfig::merge_defaults()` | every load path | + +### Non-monolithic — thin surfaces, fat core + +Surface code (Desktop `gui/`, iOS `ArcadiaApp/`) must: +- **Read** from registries and configs +- **Render** what the registry says +- **Dispatch** user actions to `arcadia_core` + +Surface code must NOT: +- Re-implement business logic that belongs in `arcadia_core` +- Hard-code module names, page IDs, or feature flags in render/layout logic +- Add per-module booleans (`shell_enabled`, `net_enabled`) — query the config dynamically +- Duplicate navigation structure that already exists in `navigation.rs` + +--- + +## How to Add Things + +### New module + +1. Add `pub const X_MODULE_NAME: &str = "x";` to `Shared/ArcadiaCore/src/config/modules.rs`. +2. Add a `ModuleManifest` entry to `MODULE_REGISTRY` in the same file. +3. Create `Shared/ArcadiaCore/src/modules/x.rs` with a `commands()` fn returning `&[ModuleCommand]`. +4. Register in `Shared/ArcadiaCore/src/modules/mod.rs`. +5. Done — GUI, CLI, and iOS all pick it up from the registry automatically. No surface edits required. + +### New navigation page + +1. Add a `NavigationPageDefinition` entry to `PAGE_DEFINITIONS` in `navigation.rs`. Set `required_module` if the page depends on a module. +2. Add the page ID to the relevant `NavigationGroupDefinition.pages` slice, or create a new group. +3. Implement the page panel: Desktop → new file under `gui/app/`; iOS → new view file in `ArcadiaApp/`. +4. Route in the surface content switch — derive visibility from `required_module`, **never** add a hardcoded match arm. + +### New icon/glyph + +1. Add SVG to `Desktop/assets/icons/`. +2. Add a match arm to `icon_path()` in `Desktop/src/gui/theme/mod.rs`. +3. Use the key in `NavigationPageDefinition.glyph` or `NavigationGroupDefinition.glyph`. + +### New theme color + +- Desktop: add named constant or helper fn to `Desktop/src/gui/theme/mod.rs` or the appropriate component file under `theme/modules/`. +- iOS: add a computed property to `AppTheme` in `AppTheme.swift`. +- Never inline `rgb(0x...)` in Rust view code or `Color(hex:)` in Swift view files. + +### New mirrored state (thin-client) + +1. Extend `SurfaceSnapshot.extra` in `modules/surface.rs`. +2. Add the corresponding `SurfacePatch` variant if clients need to push changes back. +3. Wire Desktop + iOS surfaces to consume the new extra field from snapshot. +4. Do not create ad-hoc `remote-session.*` verbs — keep the protocol under `surface.*`. + +### Renaming a module + +1. Edit `MODULE_REGISTRY` name and constant in `config/modules.rs`. +2. Add a migration in `ModulesConfig::merge_defaults()` following the `LEGACY_LAN_MODULE_NAME` pattern. +3. Do not do ad-hoc renames at call sites. + +### After FFI changes + +Any edit to `ffi.rs` or exported FFI types requires: +```sh +bash Shared/Scripts/build-ios-framework.sh +``` +This regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. Commit both. + +--- + +## What Not to Do + +### Named per-module booleans + +```rust +// BAD — named module booleans in surface state +pub shell_enabled: bool, +fn net_enabled(&self) -> bool { … } +fn remote_session_enabled(&self) -> bool { … } + +// GOOD — single generic query using MODULE_NAME constants +fn is_module_enabled(&self, name: &str) -> bool { + self.module_rows.iter() + .find(|(n, _)| n == name) + .map(|(_, enabled)| *enabled) + .unwrap_or(false) +} +// call as: self.is_module_enabled(SHELL_MODULE_NAME) +``` + +```swift +// GOOD — Swift equivalent +func isModuleEnabled(_ name: String) -> Bool { + modules.first(where: { $0.name == name })?.enabled ?? false +} +``` + +### Hardcoded page ID match arms in visibility logic + +```rust +// BAD +fn is_page_visible(&self, page_id: &str) -> bool { + match page_id { + "utility.shell" => self.shell_enabled, + "network.overview" => self.net_enabled(), + _ => true, + } +} + +// GOOD — derive from the page's declared required_module +fn is_page_visible(&self, page_id: &str) -> bool { + let Some(page) = navigation::page_by_id(page_id) else { return false }; + match page.required_module { + Some(module_name) => self.is_module_enabled(module_name), + None => true, + } +} +``` + +### Growing if-else chains for page dispatch + +```rust +// BAD — grows indefinitely as pages are added +if self.active_page_id == "utility.shell" { … shell panel … } +else if self.active_page_id == "global.modules" { … modules panel … } +else if self.active_page_id == "network.nodes" { … } // don't add here + +// GOOD — dispatch via page registry / lookup; new pages register themselves +``` + +### Special-casing page IDs in event handlers + +```swift +// BAD — magic behavior on specific page ID in a generic handler +.onChange(of: activePageID) { pageID in + if pageID == "global.modules" { reloadModules() } +} + +// GOOD — modules page is just a page; reload via onAppear of ModulesView +``` + +### Inline colors in view code + +```rust +// BAD — raw hex in app.rs or any render file +.bg(rgb(0x151a22)) +.text_color(rgb(0x93c5fd)) + +// GOOD — named token from theme/ +.bg(theme::SURFACE_BG) +.text_color(theme::accent_color(accent)) +``` + +```swift +// BAD +Color(hex: "151a22") + +// GOOD +theme.surfaceBackground +``` + +### Duplicating core logic across surfaces + +``` +// BAD — same logic in app.rs AND ContentView.swift +// GOOD — implement once in arcadia-core; both surfaces call execute_command or FFI +``` + +--- + +## LAN / Thin-Client Patterns + +### Routing commands + +```rust +// Local execution +execute_command("shell.execute", "ls -la", ExecutionContext::local()) + +// LAN-routed execution — peer enforces its own module rules +execute_command("shell.execute", "ls -la", ExecutionContext { + net_as: Some("lan:192.168.1.10".to_string()), + net_timeout_ms: Some(5000), +}) +``` + +### Surface snapshot + patch flow + +``` +Client calls: execute_command("surface.snapshot", "", context_pointing_at_host) +Host returns: { modules: [...], revision: N, extra: { navigation_registry: "..." } } + +Client calls: execute_command("surface.patch", json_ops, context_pointing_at_host) +Host applies: module toggle ops, bumps revision +``` + +### Remote mirror drain + +iOS and Desktop poll `drain_remote_mirror_batch()` on a timer (iOS: 250ms). When `sync_local_surface` is true in the drain result, call `reload_modules()` to resync local UI with host state. + +--- + +## Module System Details + +### ModuleManifest fields + +```rust +pub struct ModuleManifest { + pub name: &'static str, // unique key + pub version: &'static str, + pub description: &'static str, + pub required_modules: &'static [&'static str], // transitive deps enforced on enable +} +``` + +### ModulesConfig key methods + +| Method | Purpose | +|--------|---------| +| `manifest_for(name)` | Lookup manifest by name | +| `required_modules_for(name)` | Get declared deps | +| `missing_requirements_for(name)` | Validate preconditions before enable | +| `enable_with_requirements(name)` | Enable transitively (enables all deps first) | +| `set_module_state(name, enabled)` | Toggle with validation | +| `merge_defaults()` | Config migration — add legacy renames here | + +### Adding a command to an existing module + +```rust +// In Shared/ArcadiaCore/src/modules/yourmodule.rs +pub fn commands() -> &'static [ModuleCommand] { + &[ + ModuleCommand { token: "yourmodule.thing", description: "Does thing." }, + // add new command here + ] +} +``` + +--- + +## Navigation System Details + +### Page definition fields + +```rust +pub struct NavigationPageDefinition { + pub id: &'static str, // "group.name" format + pub title: &'static str, + pub description: &'static str, + pub glyph: &'static str, // key into icon_path() in theme.rs + pub system_image: &'static str, // SF Symbol name for iOS + pub accent: &'static str, // accent palette key + pub required_module: Option<&'static str>, // drives visibility on all surfaces +} +``` + +### Key functions + +```rust +page_by_id(id: &str) -> Option<&'static NavigationPageDefinition> +group_by_id(id: &str) -> Option<&'static NavigationGroupDefinition> +default_navigation_registry_json() -> String // for FFI + surface.snapshot +``` + +--- + +## Configuration Details + +### Config file path logic + +Desktop: `~/.config/Arcadia/Configuration/` or `~/Arcadia/Configuration/` — see `config/mod.rs` `config_root_path()` for resolution order. + +iOS: caller must set path via `set_config_root_path(path)` before any config reads. Call this in `ArcadiaApp.swift` before first `execute_command`. + +### Migration pattern + +```rust +// In ModulesConfig::merge_defaults() +const LEGACY_LAN_MODULE_NAME: &str = "lan-module"; // old name +if let Some(val) = self.modules.remove(LEGACY_LAN_MODULE_NAME) { + self.modules.entry(LAN_MODULE_NAME.to_string()).or_insert(val); +} +``` + +Follow this pattern for any module rename — one place, no ad-hoc patches. + +--- + +## Key Invariants + +- `MODULE_REGISTRY` drives module availability everywhere — don't bypass it. +- `NavigationRegistry` (serialized to JSON in `navigation.rs`) is the iOS navigation contract — iOS deserializes it, never hardcodes page/group lists. +- `ConfigFile::merge_defaults()` handles migration — when renaming a module, add the migration there. +- All cross-platform logic lives in `arcadia_core` — if you find yourself writing the same logic in both `app.rs` and `ContentView.swift`, it belongs in the core instead. +- `surface.*` is the protocol namespace for UI mirroring — do not create ad-hoc `remote-session.*` verbs. +- After FFI changes: always rebuild `ArcadiaCore.xcframework` and commit `Generated/`. + +--- + +## Build Reference + +```sh +# Desktop GUI +cd Desktop && cargo build --features gui +cd Desktop && cargo run --features gui + +# Desktop headless (CLI) +cd Desktop && cargo run + +# Core tests +cd Shared && cargo test -p arcadia-core + +# iOS framework rebuild (after ffi.rs changes) +bash Shared/Scripts/build-ios-framework.sh + +# Global CLI wrappers (macOS) +bash Shared/Scripts/install-global-commands-macos.sh +``` + +--- + +## Known Gotchas + +- `surface.revision` only advances on `surface.patch` — CLI/FFI writes bypass it. Do not use revision as a reliable freshness signal until gap 1 in `gaps.md` is resolved. +- Multiple concurrent GUIs on the same host = last-write-wins on `modules.toml`. No merge semantics. +- LAN forwarding requires `remote-session`, `lan`, and `net` enabled locally. The peer checks its own module rules for the forwarded token. +- iOS `ArcadiaCore.xcframework` must be manually rebuilt after `ffi.rs` changes — no CI automation yet. +- `ARCADIA_NET_AS` env var overrides `thin-client.toml` `preferred_remote_route` on startup. diff --git a/Desktop/Cargo.lock b/Desktop/Cargo.lock index 2375ab7..d28db6c 100644 --- a/Desktop/Cargo.lock +++ b/Desktop/Cargo.lock @@ -87,7 +87,10 @@ version = "0.1.0" dependencies = [ "arcadia-core", "gpui", + "libc", + "portable-pty", "rustyline", + "vt100", ] [[package]] @@ -95,8 +98,10 @@ name = "arcadia-core" version = "0.1.0" dependencies = [ "serde", + "serde_json", "toml 0.8.23", "uniffi", + "uuid", ] [[package]] @@ -1005,7 +1010,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -1495,7 +1500,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2845,6 +2850,15 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3180,6 +3194,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3312,6 +3335,20 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" @@ -3890,6 +3927,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "postage" version = "0.5.0" @@ -4617,7 +4675,7 @@ dependencies = [ "nix 0.31.2", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", "utf8parse", "windows-sys 0.61.2", ] @@ -4890,6 +4948,48 @@ dependencies = [ "serde", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4907,6 +5007,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5405,6 +5521,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -5781,7 +5906,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -5858,6 +5983,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -6136,6 +6267,39 @@ dependencies = [ "libc", ] +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -6925,6 +7089,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/Desktop/Cargo.toml b/Desktop/Cargo.toml index 200e28a..cc88764 100644 --- a/Desktop/Cargo.toml +++ b/Desktop/Cargo.toml @@ -8,9 +8,12 @@ license = "MIT" [features] default = ["headless"] headless = [] -gui = ["dep:gpui"] +gui = ["dep:gpui", "dep:libc", "dep:portable-pty", "dep:vt100"] [dependencies] arcadia-core = { path = "../Shared/ArcadiaCore" } gpui = { version = "0.2.2", optional = true } +libc = { version = "0.2", optional = true } rustyline = "18.0.0" +portable-pty = { version = "0.8", optional = true } +vt100 = { version = "0.15", optional = true } diff --git a/Desktop/assets/icons/home.svg b/Desktop/assets/icons/home.svg new file mode 100644 index 0000000..ae893b4 --- /dev/null +++ b/Desktop/assets/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Desktop/assets/icons/logs.svg b/Desktop/assets/icons/logs.svg new file mode 100644 index 0000000..cda5bdd --- /dev/null +++ b/Desktop/assets/icons/logs.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Desktop/assets/icons/modules.svg b/Desktop/assets/icons/modules.svg new file mode 100644 index 0000000..c54a5c3 --- /dev/null +++ b/Desktop/assets/icons/modules.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Desktop/assets/icons/nodes.svg b/Desktop/assets/icons/nodes.svg new file mode 100644 index 0000000..979f0d6 --- /dev/null +++ b/Desktop/assets/icons/nodes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Desktop/assets/icons/settings.svg b/Desktop/assets/icons/settings.svg new file mode 100644 index 0000000..aab5246 --- /dev/null +++ b/Desktop/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Desktop/assets/icons/terminal.svg b/Desktop/assets/icons/terminal.svg new file mode 100644 index 0000000..c1e753e --- /dev/null +++ b/Desktop/assets/icons/terminal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Desktop/assets/icons/tools.svg b/Desktop/assets/icons/tools.svg new file mode 100644 index 0000000..a5972ec --- /dev/null +++ b/Desktop/assets/icons/tools.svg @@ -0,0 +1,3 @@ + + + diff --git a/Desktop/src/cli.rs b/Desktop/src/cli.rs deleted file mode 100644 index 1c3933d..0000000 --- a/Desktop/src/cli.rs +++ /dev/null @@ -1,894 +0,0 @@ -use std::borrow::Cow::{self, Borrowed, Owned}; -use std::io::{self, Write}; -use std::path::PathBuf; -use std::process::Command; -use std::sync::{OnceLock, RwLock}; - -use arcadia_core::config::commandline::CommandlineConfig; -use arcadia_core::config::modules::ModulesConfig; -use arcadia_core::config::ConfigFile; -use arcadia_core::modules; -use arcadia_core::platform; -use arcadia_core::platform::PlatformInfo; -use rustyline::completion::{Completer, Pair}; -use rustyline::error::ReadlineError; -use rustyline::highlight::Highlighter; -use rustyline::hint::Hinter; -use rustyline::history::DefaultHistory; -use rustyline::validate::Validator; -use rustyline::{CompletionType, Config, Context, Editor, Helper}; - -struct CommandSpec { - name: &'static str, - aliases: &'static [&'static str], - subcommands: &'static [&'static str], -} - -#[derive(Clone, Copy)] -struct ConfigProviderSpec { - name: &'static str, - list_keys: fn() -> Result, String>, - get_value: fn(&str) -> Result, - set_value: fn(&str, &str) -> Result<(), String>, - reset: fn(Option<&str>) -> Result<(), String>, - ensure_exists: fn() -> Result<(), String>, - file_path: fn() -> Result, -} - -const COMMAND_SPECS: &[CommandSpec] = &[ - CommandSpec { - name: "help", - aliases: &[], - subcommands: &[], - }, - CommandSpec { - name: "ping", - aliases: &[], - subcommands: &[], - }, - CommandSpec { - name: "configuration", - aliases: &["config", "cfg"], - subcommands: &["show", "get", "set", "reset"], - }, - CommandSpec { - name: "module", - aliases: &[], - subcommands: &["enable", "disable"], - }, - CommandSpec { - name: "quit", - aliases: &[], - subcommands: &[], - }, -]; - -static CONFIG_PROVIDERS: &[ConfigProviderSpec] = &[ - ConfigProviderSpec { - name: "commandline", - list_keys: commandline_keys, - get_value: commandline_get, - set_value: commandline_set, - reset: commandline_reset, - ensure_exists: commandline_ensure_exists, - file_path: commandline_path, - }, - ConfigProviderSpec { - name: "modules", - list_keys: modules_keys, - get_value: modules_get, - set_value: modules_set, - reset: modules_reset, - ensure_exists: modules_ensure_exists, - file_path: modules_path, - }, -]; - -pub enum CommandResult { - Continue, - Quit, -} - -#[derive(Default)] -struct CliHelper; - -impl Helper for CliHelper {} -impl Validator for CliHelper {} - -impl Hinter for CliHelper { - type Hint = String; - - fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option { - if pos != line.len() { - return None; - } - - let (start, suggestions) = completion_candidates(line, pos); - let typed = &line[start..pos]; - if typed.trim().is_empty() { - return None; - } - - suggestions - .iter() - .find(|candidate| candidate.starts_with(typed) && candidate.as_str() != typed) - .map(|candidate| candidate[typed.len()..].to_string()) - } -} - -impl Completer for CliHelper { - type Candidate = Pair; - - fn complete( - &self, - line: &str, - pos: usize, - _ctx: &Context<'_>, - ) -> Result<(usize, Vec), ReadlineError> { - let (start, suggestions) = completion_candidates(line, pos); - let prefix = line[start..pos].to_ascii_lowercase(); - - let suggestions = suggestions - .into_iter() - .filter(|candidate| candidate.starts_with(&prefix)) - .map(|candidate| Pair { - display: candidate.to_string(), - replacement: candidate.to_string(), - }) - .collect::>(); - - Ok((start, suggestions)) - } -} - -impl Highlighter for CliHelper { - fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { - Owned(format!("\x1b[90m{hint}\x1b[0m")) - } - - fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { - Borrowed(line) - } -} - -fn settings_lock() -> &'static RwLock { - static SETTINGS: OnceLock> = OnceLock::new(); - SETTINGS.get_or_init(|| { - let config = match CommandlineConfig::load_or_create() { - Ok(config) => config, - Err(err) => { - eprintln!("Failed to load commandline config; using defaults: {err}"); - CommandlineConfig::default() - } - }; - for warning in config.color_warnings() { - eprintln!("Warning: {warning}"); - } - config.into() - }) -} - -fn settings() -> CommandlineConfig { - settings_lock() - .read() - .map(|cfg| cfg.clone()) - .unwrap_or_else(|_| CommandlineConfig::default()) -} - -fn root_command_tokens() -> Vec { - let mut tokens = COMMAND_SPECS - .iter() - .flat_map(|spec| std::iter::once(spec.name).chain(spec.aliases.iter().copied())) - .map(str::to_string) - .collect::>(); - tokens.extend(modules::enabled_module_names()); - tokens.extend(modules::enabled_command_tokens()); - tokens -} - -fn resolve_command(command: &str) -> Option<&'static CommandSpec> { - COMMAND_SPECS - .iter() - .find(|spec| spec.name == command || spec.aliases.contains(&command)) -} - -fn commandline_keys() -> Result, String> { - Ok(vec![ - "input_symbol".to_string(), - "output_symbol".to_string(), - "input_color".to_string(), - "output_color".to_string(), - "clear_on_start".to_string(), - ]) -} - -fn modules_keys() -> Result, String> { - let cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; - Ok(cfg.modules.keys().cloned().collect()) -} - -fn module_set_state(module_name: &str, enabled: bool, with_requirements: bool) -> Result<(), String> { - let mut cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; - if enabled && with_requirements { - cfg.enable_with_requirements(module_name)?; - } else { - cfg.set_module_state(module_name, enabled)?; - } - cfg.save().map_err(|err| err.to_string()) -} - -fn commandline_get_value(cfg: &CommandlineConfig, key: &str) -> Option { - match key { - "input_symbol" => Some(cfg.input_symbol.clone()), - "output_symbol" => Some(cfg.output_symbol.clone()), - "input_color" => Some(cfg.input_color.clone()), - "output_color" => Some(cfg.output_color.clone()), - "clear_on_start" => Some(cfg.clear_on_start.to_string()), - _ => None, - } -} - -fn commandline_get(key: &str) -> Result { - let cfg = settings(); - commandline_get_value(&cfg, key).ok_or_else(|| "Unknown config key".to_string()) -} - -fn commandline_set(key: &str, value: &str) -> Result<(), String> { - let mut guard = settings_lock() - .write() - .map_err(|_| "Failed to update config: settings lock poisoned".to_string())?; - - let applied = match key { - "input_symbol" => { - guard.input_symbol = value.to_string(); - true - } - "output_symbol" => { - guard.output_symbol = value.to_string(); - true - } - "input_color" => { - guard.input_color = value.to_string(); - true - } - "output_color" => { - guard.output_color = value.to_string(); - true - } - "clear_on_start" => match value.to_ascii_lowercase().as_str() { - "true" => { - guard.clear_on_start = true; - true - } - "false" => { - guard.clear_on_start = false; - true - } - _ => return Err("clear_on_start must be true or false".to_string()), - }, - _ => return Err("Unknown config key".to_string()), - }; - - if applied { - guard - .save() - .map_err(|err| format!("Config updated in-memory, save failed: {err}"))?; - } - Ok(()) -} - -fn commandline_reset(target: Option<&str>) -> Result<(), String> { - let mut guard = settings_lock() - .write() - .map_err(|_| "Failed to reset config: settings lock poisoned".to_string())?; - let defaults = CommandlineConfig::default(); - - match target { - None => *guard = defaults, - Some("input_symbol") => guard.input_symbol = defaults.input_symbol, - Some("output_symbol") => guard.output_symbol = defaults.output_symbol, - Some("input_color") => guard.input_color = defaults.input_color, - Some("output_color") => guard.output_color = defaults.output_color, - Some("clear_on_start") => guard.clear_on_start = defaults.clear_on_start, - Some(_) => return Err("Unknown config key".to_string()), - } - - guard - .save() - .map_err(|err| format!("Config reset in-memory, save failed: {err}")) -} - -fn commandline_ensure_exists() -> Result<(), String> { - CommandlineConfig::load_or_create() - .map(|_| ()) - .map_err(|err| err.to_string()) -} - -fn commandline_path() -> Result { - CommandlineConfig::file_path().map_err(|err| err.to_string()) -} - -fn modules_get(key: &str) -> Result { - let cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; - cfg.modules - .get(key) - .map(|enabled| enabled.to_string()) - .ok_or_else(|| "Unknown module key".to_string()) -} - -fn modules_set(key: &str, value: &str) -> Result<(), String> { - let mut cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; - let parsed = match value.to_ascii_lowercase().as_str() { - "true" => true, - "false" => false, - _ => return Err("Module value must be true or false".to_string()), - }; - - cfg.set_module_state(key, parsed)?; - cfg.save().map_err(|err| err.to_string()) -} - -fn modules_reset(target: Option<&str>) -> Result<(), String> { - match target { - None => ModulesConfig::default().save().map_err(|err| err.to_string()), - Some(key) => { - let defaults = ModulesConfig::default(); - let default_value = defaults - .modules - .get(key) - .copied() - .ok_or_else(|| "Unknown module key".to_string())?; - - let mut cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; - cfg.set_module_state(key, default_value)?; - cfg.save().map_err(|err| err.to_string()) - } - } -} - -fn modules_ensure_exists() -> Result<(), String> { - ModulesConfig::load_or_create() - .map(|_| ()) - .map_err(|err| err.to_string()) -} - -fn modules_path() -> Result { - ModulesConfig::file_path().map_err(|err| err.to_string()) -} - -fn provider_names() -> Vec { - CONFIG_PROVIDERS - .iter() - .map(|provider| provider.name.to_string()) - .collect() -} - -fn resolve_provider(name: &str) -> Option { - CONFIG_PROVIDERS - .iter() - .find(|provider| provider.name == name) - .copied() -} - -fn scoped_key_candidates() -> Vec { - let mut candidates = Vec::new(); - for provider in CONFIG_PROVIDERS { - if let Ok(keys) = (provider.list_keys)() { - for key in keys { - candidates.push(format!("{}.{}", provider.name, key)); - } - } - } - candidates -} - -fn normalize_command(command: &str) -> String { - resolve_command(command) - .map(|spec| spec.name.to_string()) - .unwrap_or_else(|| command.to_string()) -} - -fn parse_execution_context(parts: &[String]) -> Result<(Vec, modules::ExecutionContext), String> { - let mut cleaned = Vec::new(); - let mut net_as: Option = None; - let mut net_timeout_ms: Option = None; - let mut i = 0; - - while i < parts.len() { - if parts[i] == "--net:as" { - let Some(value) = parts.get(i + 1) else { - return Err("Usage: --net:as lan:".to_string()); - }; - if !value.starts_with("lan:") { - return Err( - "Unsupported --net:as target. Use lan: (wan: coming later)" - .to_string(), - ); - }; - net_as = Some(value.clone()); - i += 2; - continue; - } - if parts[i] == "--net:timeout" { - let Some(value) = parts.get(i + 1) else { - return Err("Usage: --net:timeout ".to_string()); - }; - let parsed = value - .parse::() - .map_err(|_| "Invalid --net:timeout value. Use an integer in milliseconds".to_string())?; - net_timeout_ms = Some(parsed); - i += 2; - continue; - } - cleaned.push(parts[i].clone()); - i += 1; - } - - Ok(( - cleaned, - modules::ExecutionContext { - net_as, - net_timeout_ms, - }, - )) -} - -fn completion_candidates(line: &str, pos: usize) -> (usize, Vec) { - let head = &line[..pos]; - let ends_with_space = head.chars().last().is_some_and(char::is_whitespace); - let tokens = head.split_whitespace().collect::>(); - - if tokens.is_empty() { - return (0, root_command_tokens()); - } - - if tokens.len() == 1 && !ends_with_space { - let start = head.rfind(char::is_whitespace).map_or(0, |idx| idx + 1); - return (start, root_command_tokens()); - } - - let command = normalize_command(tokens[0]); - let active_index = if ends_with_space { - tokens.len() - } else { - tokens.len().saturating_sub(1) - }; - let start = head.rfind(char::is_whitespace).map_or(0, |idx| idx + 1); - - let suggestions = match command.as_str() { - "configuration" => match active_index { - 1 => resolve_command("configuration") - .map(|spec| spec.subcommands.iter().map(|v| (*v).to_string()).collect()) - .unwrap_or_default(), - 2 => match tokens.get(1).copied() { - Some("show") => provider_names(), - Some("get") | Some("set") | Some("reset") => provider_names() - .into_iter() - .chain(commandline_keys().unwrap_or_default()) - .chain(scoped_key_candidates()) - .collect(), - _ => Vec::new(), - }, - _ => Vec::new(), - }, - "module" => match active_index { - 1 => modules_keys().unwrap_or_default(), - 2 => vec!["enable".to_string(), "disable".to_string()], - 3 if tokens.get(2).copied() == Some("enable") => vec!["-requirements".to_string()], - _ => Vec::new(), - }, - other => match active_index { - 1 => modules::enabled_module_command_names(other), - _ => Vec::new(), - }, - }; - - (start, suggestions) -} - -pub fn print_response(message: &str) { - let cfg = settings(); - println!("{}{}\x1b[0m {message}", cfg.output_ansi_code(), cfg.output_symbol); -} - -pub fn print_startup(mode: &str) { - if settings().clear_on_start { - // Clear terminal and move cursor to top-left for a clean boot screen. - print!("\x1b[2J\x1b[H"); - let _ = io::stdout().flush(); - } - - println!("Arcadia base app"); - println!("Detected platform: {}", platform::current().name()); - println!("Mode: {mode}"); - println!("Status: bootstrap complete"); -} - -fn print_help() { - print_response("Available commands:"); - for spec in COMMAND_SPECS { - match spec.name { - "help" => print_response("- help: show this help message"), - "ping" => print_response("- ping: respond with pong"), - "quit" => print_response("- quit: exit Arcadia"), - "configuration" => { - print_response("- configuration : open config file in default editor"); - print_response( - "- configuration [show|get|set|reset] ...: manage commandline config", - ); - if !spec.aliases.is_empty() { - let aliases = spec.aliases.join(" -> "); - print_response(&format!("- aliases: {aliases} -> configuration")); - } - } - "module" => { - print_response("- module enable|disable: toggle a module"); - print_response( - "- module enable -requirements: enable module and required dependencies", - ); - } - _ => {} - } - } - - let module_command_lines = modules::enabled_command_help_lines(); - if !module_command_lines.is_empty() { - print_response("- enabled module commands:"); - for line in module_command_lines { - print_response(&line); - } - } - print_response("- global flags: --net:as lan: | --net:timeout "); -} - -fn handle_module(parts: &[&str]) { - match parts { - ["module", module_name, "enable"] => match module_set_state(module_name, true, false) { - Ok(_) => print_response(&format!("Module {module_name} enabled")), - Err(err) => print_response(&err), - }, - ["module", module_name, "enable", "-requirements"] => { - match module_set_state(module_name, true, true) { - Ok(_) => print_response(&format!( - "Module {module_name} enabled (requirements enabled)" - )), - Err(err) => print_response(&err), - } - } - ["module", module_name, "disable"] => match module_set_state(module_name, false, false) { - Ok(_) => print_response(&format!("Module {module_name} disabled")), - Err(err) => print_response(&err), - }, - ["module"] => { - print_response("Usage: module enable [-requirements]|disable"); - match modules_keys() { - Ok(keys) => { - if !keys.is_empty() { - print_response("Available modules:"); - for key in keys { - print_response(&format!("- {key}")); - } - } - } - Err(err) => print_response(&format!("Failed to list modules: {err}")), - } - } - _ => print_response("Usage: module enable [-requirements]|disable"), - } -} - -fn print_available_configs() { - print_response("Available configs:"); - for name in provider_names() { - print_response(&format!("- {name}")); - } -} - -fn show_config_keys(config_name: &str) { - let Some(provider) = resolve_provider(config_name) else { - print_response("Unknown config"); - return; - }; - - match (provider.list_keys)() { - Ok(keys) => { - print_response(&format!("{} keys:", provider.name)); - for key in keys { - print_response(&format!("- {key}")); - } - } - Err(err) => { - print_response(&format!("Failed to load {} config: {err}", provider.name)); - } - } -} - -fn config_get(key: &str) { - match commandline_get(key) { - Ok(value) => print_response(&format!("{key} = {value}")), - Err(err) => print_response(&err), - } -} - -fn config_get_scoped(reference: &str) { - let Some((config_name, key)) = reference.split_once('.') else { - print_response("Use scoped format: . (example: commandline.clear_on_start)"); - return; - }; - - let Some(provider) = resolve_provider(config_name) else { - print_response("Unknown config"); - return; - }; - - match (provider.get_value)(key) { - Ok(value) => print_response(&format!("{config_name}.{key} = {value}")), - Err(err) => print_response(&err), - } -} - -fn config_set(key: &str, value: &str) { - match commandline_set(key, value) { - Ok(_) => print_response("Config updated"), - Err(err) => print_response(&err), - } -} - -fn config_set_scoped(reference: &str, value: &str) { - let Some((config_name, key)) = reference.split_once('.') else { - print_response("Use scoped format: . (example: commandline.clear_on_start)"); - return; - }; - - let Some(provider) = resolve_provider(config_name) else { - print_response("Unknown config"); - return; - }; - - match (provider.set_value)(key, value) { - Ok(_) => print_response("Config updated"), - Err(err) => print_response(&err), - } -} - -fn config_reset() { - match commandline_reset(None) { - Ok(_) => print_response("Config reset to defaults"), - Err(err) => print_response(&err), - } -} - -fn config_reset_scoped(reference: &str) { - let (config_name, target_key) = match reference.split_once('.') { - Some((name, key)) => (name, Some(key)), - None => (reference, None), - }; - - let Some(provider) = resolve_provider(config_name) else { - print_response("Unknown config"); - return; - }; - - match (provider.reset)(target_key) { - Ok(_) => { - if target_key.is_some() { - print_response("Config key reset to default"); - } else { - print_response("Config reset to defaults"); - } - } - Err(err) => print_response(&err), - } -} - -fn open_config(config_name: &str) { - let Some(provider) = resolve_provider(config_name) else { - print_response("Unknown config"); - return; - }; - - if let Err(err) = (provider.ensure_exists)() { - print_response(&format!("Failed to create {} config: {err}", provider.name)); - return; - } - - let path = match (provider.file_path)() { - Ok(path) => path, - Err(err) => { - print_response(&format!( - "Failed to resolve {} config path: {err}", - provider.name - )); - return; - } - }; - - let status = { - #[cfg(target_os = "macos")] - { - Command::new("open").arg(&path).status() - } - #[cfg(target_os = "linux")] - { - Command::new("xdg-open").arg(&path).status() - } - #[cfg(target_os = "windows")] - { - Command::new("cmd") - .args(["/C", "start", "", &path.to_string_lossy()]) - .status() - } - }; - - match status { - Ok(exit) if exit.success() => print_response(&format!("Opened {}", path.display())), - Ok(exit) => print_response(&format!( - "Failed to open {} (exit code: {:?})", - path.display(), - exit.code() - )), - Err(err) => print_response(&format!("Failed to launch editor for {}: {err}", path.display())), - } -} - -fn handle_configuration(parts: &[&str]) { - match parts { - ["configuration"] => print_available_configs(), - ["configuration", "show"] => print_available_configs(), - ["configuration", "show", config_name] => show_config_keys(config_name), - ["configuration", "get", key] if key.contains('.') => config_get_scoped(key), - ["configuration", "get", key] => config_get(key), - ["configuration", "set", key, value] if key.contains('.') => config_set_scoped(key, value), - ["configuration", "set", key, value] => config_set(key, value), - ["configuration", "reset", target] => config_reset_scoped(target), - ["configuration", "reset"] => config_reset(), - ["configuration", name] => open_config(name), - _ => { - print_response("Usage: configuration | configuration [show|get |get .|set |set . |reset|reset |reset .]"); - print_response( - "Keys: input_symbol, output_symbol, input_color, output_color, clear_on_start", - ); - } - } -} - -fn editor() -> Editor { - let config = Config::builder() - .history_ignore_dups(true) - .expect("history_ignore_dups is always configurable") - .completion_type(CompletionType::List) - .build(); - - let mut editor = - Editor::::with_config(config).expect("editor setup failed"); - editor.set_helper(Some(CliHelper)); - editor -} - -fn read_command(editor: &mut Editor) -> io::Result> { - let cfg = settings(); - let prompt = format!( - "\x01{}\x02{}\x01\x1b[0m\x02 ", - cfg.input_ansi_code(), - cfg.input_symbol - ); - match editor.readline(&prompt) { - Ok(line) => { - if !line.trim().is_empty() { - let _ = editor.add_history_entry(line.as_str()); - } - Ok(Some(line)) - } - Err(ReadlineError::Interrupted) => Ok(Some(String::new())), - Err(ReadlineError::Eof) => Ok(None), - Err(err) => Err(io::Error::other(err)), - } -} - -pub fn start_loop(quit: impl FnOnce() + Copy) { - let mut editor = editor(); - - loop { - match read_command(&mut editor) { - Ok(None) => break, - Ok(Some(line)) => { - if let CommandResult::Quit = handle(&line) { - quit(); - break; - } - } - Err(err) => { - eprintln!("CLI input error: {err}"); - break; - } - } - } -} - -pub fn handle(input: &str) -> CommandResult { - let trimmed = input.trim(); - let mut parts = trimmed - .split_whitespace() - .map(str::to_string) - .collect::>(); - - let (parsed_parts, exec_ctx) = match parse_execution_context(&parts) { - Ok(value) => value, - Err(err) => { - print_response(&err); - return CommandResult::Continue; - } - }; - parts = parsed_parts; - - if let Some(first) = parts.first_mut() { - *first = normalize_command(first); - } - - if !parts.is_empty() && parts[0] == "configuration" { - let part_refs = parts.iter().map(String::as_str).collect::>(); - handle_configuration(&part_refs); - return CommandResult::Continue; - } - if !parts.is_empty() && parts[0] == "module" { - let part_refs = parts.iter().map(String::as_str).collect::>(); - handle_module(&part_refs); - return CommandResult::Continue; - } - if let Some(first) = parts.first().map(String::as_str) { - if first.contains('.') { - let args = parts - .iter() - .skip(1) - .map(String::as_str) - .collect::>(); - match modules::execute_command(first, &args, &exec_ctx) { - Ok(Some(message)) => { - print_response(&message); - return CommandResult::Continue; - } - Ok(None) => {} - Err(err) => { - print_response(&err); - return CommandResult::Continue; - } - } - } - } - if parts.len() >= 2 { - let composed = format!("{}.{}", parts[0], parts[1]); - let args = parts - .iter() - .skip(2) - .map(String::as_str) - .collect::>(); - match modules::execute_command(&composed, &args, &exec_ctx) { - Ok(Some(message)) => { - print_response(&message); - return CommandResult::Continue; - } - Ok(None) => {} - Err(err) => { - print_response(&err); - return CommandResult::Continue; - } - } - } - - match parts.first().map(String::as_str).unwrap_or("") { - "help" => { - print_help(); - CommandResult::Continue - } - "ping" => { - print_response("pong"); - CommandResult::Continue - } - "quit" => CommandResult::Quit, - "" => CommandResult::Continue, - _ => { - print_response(&format!("Unknown command: {trimmed}")); - CommandResult::Continue - } - } -} diff --git a/Desktop/src/cli/args.rs b/Desktop/src/cli/args.rs new file mode 100644 index 0000000..07d1ca6 --- /dev/null +++ b/Desktop/src/cli/args.rs @@ -0,0 +1,94 @@ +use arcadia_core::modules; + +pub struct CommandSpec { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub subcommands: &'static [&'static str], +} + +pub const COMMAND_SPECS: &[CommandSpec] = &[ + CommandSpec { + name: "help", + aliases: &[], + subcommands: &[], + }, + CommandSpec { + name: "ping", + aliases: &[], + subcommands: &[], + }, + CommandSpec { + name: "configuration", + aliases: &["config", "cfg"], + subcommands: &["show", "get", "set", "reset"], + }, + CommandSpec { + name: "module", + aliases: &[], + subcommands: &["enable", "disable"], + }, + CommandSpec { + name: "quit", + aliases: &[], + subcommands: &[], + }, +]; + +pub fn resolve_command(command: &str) -> Option<&'static CommandSpec> { + COMMAND_SPECS + .iter() + .find(|spec| spec.name == command || spec.aliases.contains(&command)) +} + +pub fn normalize_command(command: &str) -> String { + resolve_command(command) + .map(|spec| spec.name.to_string()) + .unwrap_or_else(|| command.to_string()) +} + +pub fn parse_execution_context( + parts: &[String], +) -> Result<(Vec, modules::ExecutionContext), String> { + let mut cleaned = Vec::new(); + let mut net_as: Option = None; + let mut net_timeout_ms: Option = None; + let mut i = 0; + + while i < parts.len() { + if parts[i] == "--net:as" { + let Some(value) = parts.get(i + 1) else { + return Err("Usage: --net:as lan:".to_string()); + }; + if !value.starts_with("lan:") { + return Err( + "Unsupported --net:as target. Use lan: (wan: coming later)" + .to_string(), + ); + } + net_as = Some(value.clone()); + i += 2; + continue; + } + if parts[i] == "--net:timeout" { + let Some(value) = parts.get(i + 1) else { + return Err("Usage: --net:timeout ".to_string()); + }; + let parsed = value.parse::().map_err(|_| { + "Invalid --net:timeout value. Use an integer in milliseconds".to_string() + })?; + net_timeout_ms = Some(parsed); + i += 2; + continue; + } + cleaned.push(parts[i].clone()); + i += 1; + } + + Ok(( + cleaned, + modules::ExecutionContext { + net_as, + net_timeout_ms, + }, + )) +} diff --git a/Desktop/src/cli/completion.rs b/Desktop/src/cli/completion.rs new file mode 100644 index 0000000..86847af --- /dev/null +++ b/Desktop/src/cli/completion.rs @@ -0,0 +1,133 @@ +use std::borrow::Cow::{self, Borrowed, Owned}; + +use arcadia_core::modules; +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Context, Helper}; + +use super::args::{normalize_command, resolve_command, COMMAND_SPECS}; +use super::config_cmds::{modules_keys, provider_names, scoped_key_candidates}; + +#[derive(Default)] +pub struct CliHelper; + +impl Helper for CliHelper {} +impl Validator for CliHelper {} + +impl Hinter for CliHelper { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option { + if pos != line.len() { + return None; + } + let (start, suggestions) = completion_candidates(line, pos); + let typed = &line[start..pos]; + if typed.trim().is_empty() { + return None; + } + suggestions + .iter() + .find(|c| c.starts_with(typed) && c.as_str() != typed) + .map(|c| c[typed.len()..].to_string()) + } +} + +impl Completer for CliHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + let (start, suggestions) = completion_candidates(line, pos); + let prefix = line[start..pos].to_ascii_lowercase(); + let pairs = suggestions + .into_iter() + .filter(|c| c.starts_with(&prefix)) + .map(|c| Pair { + display: c.to_string(), + replacement: c.to_string(), + }) + .collect::>(); + Ok((start, pairs)) + } +} + +impl Highlighter for CliHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Owned(format!("\x1b[90m{hint}\x1b[0m")) + } + + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + Borrowed(line) + } +} + +pub fn root_command_tokens() -> Vec { + let mut tokens = COMMAND_SPECS + .iter() + .flat_map(|spec| std::iter::once(spec.name).chain(spec.aliases.iter().copied())) + .map(str::to_string) + .collect::>(); + tokens.extend(modules::enabled_module_names()); + tokens.extend(modules::enabled_command_tokens()); + tokens +} + +pub fn completion_candidates(line: &str, pos: usize) -> (usize, Vec) { + let head = &line[..pos]; + let ends_with_space = head.chars().last().is_some_and(char::is_whitespace); + let tokens = head.split_whitespace().collect::>(); + + if tokens.is_empty() { + return (0, root_command_tokens()); + } + + if tokens.len() == 1 && !ends_with_space { + let start = head.rfind(char::is_whitespace).map_or(0, |idx| idx + 1); + return (start, root_command_tokens()); + } + + let command = normalize_command(tokens[0]); + let active_index = if ends_with_space { + tokens.len() + } else { + tokens.len().saturating_sub(1) + }; + let start = head.rfind(char::is_whitespace).map_or(0, |idx| idx + 1); + + let suggestions = match command.as_str() { + "configuration" => match active_index { + 1 => resolve_command("configuration") + .map(|spec| spec.subcommands.iter().map(|v| (*v).to_string()).collect()) + .unwrap_or_default(), + 2 => match tokens.get(1).copied() { + Some("show") => provider_names(), + Some("get") | Some("set") | Some("reset") => provider_names() + .into_iter() + .chain(scoped_key_candidates()) + .collect(), + _ => Vec::new(), + }, + _ => Vec::new(), + }, + "module" => match active_index { + 1 => modules_keys().unwrap_or_default(), + 2 => vec!["enable".to_string(), "disable".to_string()], + 3 if tokens.get(2).copied() == Some("enable") => vec!["-requirements".to_string()], + _ => Vec::new(), + }, + other => match active_index { + 1 => modules::enabled_module_command_names(other), + _ => Vec::new(), + }, + }; + + (start, suggestions) +} diff --git a/Desktop/src/cli/config_cmds.rs b/Desktop/src/cli/config_cmds.rs new file mode 100644 index 0000000..171e90f --- /dev/null +++ b/Desktop/src/cli/config_cmds.rs @@ -0,0 +1,390 @@ +use std::path::PathBuf; +use std::process::Command; + +use arcadia_core::config::commandline::CommandlineConfig; +use arcadia_core::config::modules::ModulesConfig; +use arcadia_core::config::ConfigFile; + +#[derive(Clone, Copy)] +pub struct ConfigProviderSpec { + pub name: &'static str, + pub list_keys: fn() -> Result, String>, + pub get_value: fn(&str) -> Result, + pub set_value: fn(&str, &str) -> Result<(), String>, + pub reset: fn(Option<&str>) -> Result<(), String>, + pub ensure_exists: fn() -> Result<(), String>, + pub file_path: fn() -> Result, +} + +pub static CONFIG_PROVIDERS: &[ConfigProviderSpec] = &[ + ConfigProviderSpec { + name: "commandline", + list_keys: commandline_keys, + get_value: commandline_get, + set_value: commandline_set, + reset: commandline_reset, + ensure_exists: commandline_ensure_exists, + file_path: commandline_path, + }, + ConfigProviderSpec { + name: "modules", + list_keys: modules_keys, + get_value: modules_get, + set_value: modules_set, + reset: modules_reset, + ensure_exists: modules_ensure_exists, + file_path: modules_path, + }, +]; + +pub fn provider_names() -> Vec { + CONFIG_PROVIDERS + .iter() + .map(|p| p.name.to_string()) + .collect() +} + +pub fn resolve_provider(name: &str) -> Option { + CONFIG_PROVIDERS.iter().find(|p| p.name == name).copied() +} + +pub fn scoped_key_candidates() -> Vec { + let mut candidates = Vec::new(); + for provider in CONFIG_PROVIDERS { + if let Ok(keys) = (provider.list_keys)() { + for key in keys { + candidates.push(format!("{}.{}", provider.name, key)); + } + } + } + candidates +} + +pub fn commandline_keys() -> Result, String> { + Ok(vec![ + "input_symbol".to_string(), + "output_symbol".to_string(), + "input_color".to_string(), + "output_color".to_string(), + "clear_on_start".to_string(), + ]) +} + +fn commandline_get_value(cfg: &CommandlineConfig, key: &str) -> Option { + match key { + "input_symbol" => Some(cfg.input_symbol.clone()), + "output_symbol" => Some(cfg.output_symbol.clone()), + "input_color" => Some(cfg.input_color.clone()), + "output_color" => Some(cfg.output_color.clone()), + "clear_on_start" => Some(cfg.clear_on_start.to_string()), + _ => None, + } +} + +pub fn commandline_get(key: &str) -> Result { + let cfg = super::settings(); + commandline_get_value(&cfg, key).ok_or_else(|| "Unknown config key".to_string()) +} + +pub fn commandline_set(key: &str, value: &str) -> Result<(), String> { + let mut guard = super::settings_lock() + .write() + .map_err(|_| "Failed to update config: settings lock poisoned".to_string())?; + let applied = match key { + "input_symbol" => { + guard.input_symbol = value.to_string(); + true + } + "output_symbol" => { + guard.output_symbol = value.to_string(); + true + } + "input_color" => { + guard.input_color = value.to_string(); + true + } + "output_color" => { + guard.output_color = value.to_string(); + true + } + "clear_on_start" => match value.to_ascii_lowercase().as_str() { + "true" => { + guard.clear_on_start = true; + true + } + "false" => { + guard.clear_on_start = false; + true + } + _ => return Err("clear_on_start must be true or false".to_string()), + }, + _ => return Err("Unknown config key".to_string()), + }; + if applied { + guard + .save() + .map_err(|err| format!("Config updated in-memory, save failed: {err}"))?; + } + Ok(()) +} + +pub fn commandline_reset(target: Option<&str>) -> Result<(), String> { + let mut guard = super::settings_lock() + .write() + .map_err(|_| "Failed to reset config: settings lock poisoned".to_string())?; + let defaults = CommandlineConfig::default(); + match target { + None => *guard = defaults, + Some("input_symbol") => guard.input_symbol = defaults.input_symbol, + Some("output_symbol") => guard.output_symbol = defaults.output_symbol, + Some("input_color") => guard.input_color = defaults.input_color, + Some("output_color") => guard.output_color = defaults.output_color, + Some("clear_on_start") => guard.clear_on_start = defaults.clear_on_start, + Some(_) => return Err("Unknown config key".to_string()), + } + guard + .save() + .map_err(|err| format!("Config reset in-memory, save failed: {err}")) +} + +fn commandline_ensure_exists() -> Result<(), String> { + CommandlineConfig::load_or_create() + .map(|_| ()) + .map_err(|err| err.to_string()) +} + +fn commandline_path() -> Result { + CommandlineConfig::file_path().map_err(|err| err.to_string()) +} + +pub fn modules_keys() -> Result, String> { + let cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; + Ok(cfg.modules.keys().cloned().collect()) +} + +fn modules_get(key: &str) -> Result { + let cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; + cfg.modules + .get(key) + .map(|enabled| enabled.to_string()) + .ok_or_else(|| "Unknown module key".to_string()) +} + +fn modules_set(key: &str, value: &str) -> Result<(), String> { + let mut cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; + let parsed = match value.to_ascii_lowercase().as_str() { + "true" => true, + "false" => false, + _ => return Err("Module value must be true or false".to_string()), + }; + cfg.set_module_state(key, parsed)?; + cfg.save().map_err(|err| err.to_string())?; + arcadia_core::modules::surface::bump_surface_revision(); + Ok(()) +} + +fn modules_reset(target: Option<&str>) -> Result<(), String> { + match target { + None => { + ModulesConfig::default().save().map_err(|err| err.to_string())?; + arcadia_core::modules::surface::bump_surface_revision(); + Ok(()) + } + Some(key) => { + let defaults = ModulesConfig::default(); + let default_value = defaults + .modules + .get(key) + .copied() + .ok_or_else(|| "Unknown module key".to_string())?; + let mut cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; + cfg.set_module_state(key, default_value)?; + cfg.save().map_err(|err| err.to_string())?; + arcadia_core::modules::surface::bump_surface_revision(); + Ok(()) + } + } +} + +fn modules_ensure_exists() -> Result<(), String> { + ModulesConfig::load_or_create() + .map(|_| ()) + .map_err(|err| err.to_string()) +} + +fn modules_path() -> Result { + ModulesConfig::file_path().map_err(|err| err.to_string()) +} + +fn print_available_configs() { + super::print_response("Available configs:"); + for name in provider_names() { + super::print_response(&format!("- {name}")); + } +} + +fn show_config_keys(config_name: &str) { + let Some(provider) = resolve_provider(config_name) else { + super::print_response("Unknown config"); + return; + }; + match (provider.list_keys)() { + Ok(keys) => { + super::print_response(&format!("{} keys:", provider.name)); + for key in keys { + super::print_response(&format!("- {key}")); + } + } + Err(err) => { + super::print_response(&format!("Failed to load {} config: {err}", provider.name)); + } + } +} + +fn config_get(key: &str) { + match commandline_get(key) { + Ok(value) => super::print_response(&format!("{key} = {value}")), + Err(err) => super::print_response(&err), + } +} + +fn config_get_scoped(reference: &str) { + let Some((config_name, key)) = reference.split_once('.') else { + super::print_response( + "Use scoped format: . (example: commandline.clear_on_start)", + ); + return; + }; + let Some(provider) = resolve_provider(config_name) else { + super::print_response("Unknown config"); + return; + }; + match (provider.get_value)(key) { + Ok(value) => super::print_response(&format!("{config_name}.{key} = {value}")), + Err(err) => super::print_response(&err), + } +} + +fn config_set(key: &str, value: &str) { + match commandline_set(key, value) { + Ok(_) => super::print_response("Config updated"), + Err(err) => super::print_response(&err), + } +} + +fn config_set_scoped(reference: &str, value: &str) { + let Some((config_name, key)) = reference.split_once('.') else { + super::print_response( + "Use scoped format: . (example: commandline.clear_on_start)", + ); + return; + }; + let Some(provider) = resolve_provider(config_name) else { + super::print_response("Unknown config"); + return; + }; + match (provider.set_value)(key, value) { + Ok(_) => super::print_response("Config updated"), + Err(err) => super::print_response(&err), + } +} + +fn config_reset() { + match commandline_reset(None) { + Ok(_) => super::print_response("Config reset to defaults"), + Err(err) => super::print_response(&err), + } +} + +fn config_reset_scoped(reference: &str) { + let (config_name, target_key) = match reference.split_once('.') { + Some((name, key)) => (name, Some(key)), + None => (reference, None), + }; + let Some(provider) = resolve_provider(config_name) else { + super::print_response("Unknown config"); + return; + }; + match (provider.reset)(target_key) { + Ok(_) => { + if target_key.is_some() { + super::print_response("Config key reset to default"); + } else { + super::print_response("Config reset to defaults"); + } + } + Err(err) => super::print_response(&err), + } +} + +pub fn open_config(config_name: &str) { + let Some(provider) = resolve_provider(config_name) else { + super::print_response("Unknown config"); + return; + }; + if let Err(err) = (provider.ensure_exists)() { + super::print_response(&format!("Failed to create {} config: {err}", provider.name)); + return; + } + let path = match (provider.file_path)() { + Ok(path) => path, + Err(err) => { + super::print_response(&format!( + "Failed to resolve {} config path: {err}", + provider.name + )); + return; + } + }; + + let status = { + #[cfg(target_os = "macos")] + { + Command::new("open").arg(&path).status() + } + #[cfg(target_os = "linux")] + { + Command::new("xdg-open").arg(&path).status() + } + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", &path.to_string_lossy()]) + .status() + } + }; + + match status { + Ok(exit) if exit.success() => super::print_response(&format!("Opened {}", path.display())), + Ok(exit) => super::print_response(&format!( + "Failed to open {} (exit code: {:?})", + path.display(), + exit.code() + )), + Err(err) => super::print_response(&format!( + "Failed to launch editor for {}: {err}", + path.display() + )), + } +} + +pub fn handle_configuration(parts: &[&str]) { + match parts { + ["configuration"] => print_available_configs(), + ["configuration", "show"] => print_available_configs(), + ["configuration", "show", config_name] => show_config_keys(config_name), + ["configuration", "get", key] if key.contains('.') => config_get_scoped(key), + ["configuration", "get", key] => config_get(key), + ["configuration", "set", key, value] if key.contains('.') => config_set_scoped(key, value), + ["configuration", "set", key, value] => config_set(key, value), + ["configuration", "reset", target] => config_reset_scoped(target), + ["configuration", "reset"] => config_reset(), + ["configuration", name] => open_config(name), + _ => { + super::print_response("Usage: configuration | configuration [show|get |get .|set |set . |reset|reset |reset .]"); + super::print_response( + "Keys: input_symbol, output_symbol, input_color, output_color, clear_on_start", + ); + } + } +} diff --git a/Desktop/src/cli/mod.rs b/Desktop/src/cli/mod.rs new file mode 100644 index 0000000..961441d --- /dev/null +++ b/Desktop/src/cli/mod.rs @@ -0,0 +1,268 @@ +mod args; +mod completion; +mod config_cmds; +mod module_cmds; + +use std::cell::RefCell; +use std::io::{self, Write}; +use std::sync::{OnceLock, RwLock}; + +use arcadia_core::config::commandline::CommandlineConfig; +use arcadia_core::config::ConfigFile; +use arcadia_core::modules; +use arcadia_core::platform; +use arcadia_core::platform::PlatformInfo; +use rustyline::history::DefaultHistory; +use rustyline::{CompletionType, Config, Editor}; + +use args::{normalize_command, parse_execution_context, COMMAND_SPECS}; +use completion::CliHelper; +use config_cmds::handle_configuration; +use module_cmds::handle_module; + +pub enum CommandResult { + Continue, + Quit, +} + +thread_local! { + static RESPONSE_CAPTURE: RefCell>> = const { RefCell::new(None) }; +} + +pub(super) fn settings_lock() -> &'static RwLock { + static SETTINGS: OnceLock> = OnceLock::new(); + SETTINGS.get_or_init(|| { + let config = match CommandlineConfig::load_or_create() { + Ok(config) => config, + Err(err) => { + eprintln!("Failed to load commandline config; using defaults: {err}"); + CommandlineConfig::default() + } + }; + for warning in config.color_warnings() { + eprintln!("Warning: {warning}"); + } + config.into() + }) +} + +pub(super) fn settings() -> CommandlineConfig { + settings_lock() + .read() + .map(|cfg| cfg.clone()) + .unwrap_or_else(|_| CommandlineConfig::default()) +} + +pub fn print_response(message: &str) { + let captured = RESPONSE_CAPTURE.with(|capture| { + let mut borrow = capture.borrow_mut(); + if let Some(lines) = borrow.as_mut() { + lines.push(message.to_string()); + true + } else { + false + } + }); + if captured { + return; + } + let cfg = settings(); + println!( + "{}{}\x1b[0m {message}", + cfg.output_ansi_code(), + cfg.output_symbol + ); +} + +pub fn print_startup(mode: &str) { + if settings().clear_on_start { + print!("\x1b[2J\x1b[H"); + let _ = io::stdout().flush(); + } + println!("Arcadia base app"); + println!("Detected platform: {}", platform::current().name()); + println!("Mode: {mode}"); + println!("Status: bootstrap complete"); +} + +fn editor() -> Editor { + let config = Config::builder() + .history_ignore_dups(true) + .expect("history_ignore_dups is always configurable") + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).expect("editor setup failed"); + editor.set_helper(Some(CliHelper)); + editor +} + +fn read_command(editor: &mut Editor) -> io::Result> { + let cfg = settings(); + let prompt = format!("{}{}\x1b[0m ", cfg.input_ansi_code(), cfg.input_symbol); + match editor.readline(&prompt) { + Ok(line) => { + if !line.trim().is_empty() { + let _ = editor.add_history_entry(line.as_str()); + } + Ok(Some(line)) + } + Err(rustyline::error::ReadlineError::Interrupted) => Ok(Some(String::new())), + Err(rustyline::error::ReadlineError::Eof) => Ok(None), + Err(err) => Err(io::Error::other(err)), + } +} + +pub fn start_loop(quit: impl FnOnce() + Copy) { + let mut editor = editor(); + loop { + match read_command(&mut editor) { + Ok(None) => break, + Ok(Some(line)) => { + if let CommandResult::Quit = handle(&line) { + quit(); + break; + } + } + Err(err) => { + eprintln!("CLI input error: {err}"); + break; + } + } + } +} + +pub fn handle(input: &str) -> CommandResult { + handle_with(input, print_response) +} + +pub fn handle_internal(input: &str) -> String { + RESPONSE_CAPTURE.with(|capture| { + *capture.borrow_mut() = Some(Vec::new()); + }); + let _ = handle(input); + RESPONSE_CAPTURE.with(|capture| capture.borrow_mut().take().unwrap_or_default().join("\n")) +} + +fn handle_with(input: &str, mut respond: impl FnMut(&str)) -> CommandResult { + let trimmed = input.trim(); + let mut parts = trimmed + .split_whitespace() + .map(str::to_string) + .collect::>(); + + let (parsed_parts, exec_ctx) = match parse_execution_context(&parts) { + Ok(value) => value, + Err(err) => { + respond(&err); + return CommandResult::Continue; + } + }; + parts = parsed_parts; + + if let Some(first) = parts.first_mut() { + *first = normalize_command(first); + } + + if !parts.is_empty() && parts[0] == "configuration" { + let part_refs = parts.iter().map(String::as_str).collect::>(); + handle_configuration(&part_refs); + return CommandResult::Continue; + } + if !parts.is_empty() && parts[0] == "module" { + let part_refs = parts.iter().map(String::as_str).collect::>(); + handle_module(&part_refs); + return CommandResult::Continue; + } + + if let Some(first) = parts.first().map(String::as_str) { + if first.contains('.') { + let args = parts.iter().skip(1).map(String::as_str).collect::>(); + match modules::execute_command(first, &args, &exec_ctx) { + Ok(Some(message)) => { + respond(&message); + return CommandResult::Continue; + } + Ok(None) => {} + Err(err) => { + respond(&err); + return CommandResult::Continue; + } + } + } + } + if parts.len() >= 2 { + let composed = format!("{}.{}", parts[0], parts[1]); + let args = parts.iter().skip(2).map(String::as_str).collect::>(); + match modules::execute_command(&composed, &args, &exec_ctx) { + Ok(Some(message)) => { + respond(&message); + return CommandResult::Continue; + } + Ok(None) => {} + Err(err) => { + respond(&err); + return CommandResult::Continue; + } + } + } + + match parts.first().map(String::as_str).unwrap_or("") { + "help" => { + for line in help_lines() { + respond(&line); + } + CommandResult::Continue + } + "ping" => { + respond("pong"); + CommandResult::Continue + } + "quit" => CommandResult::Quit, + "" => CommandResult::Continue, + _ => { + respond(&format!("Unknown command: {trimmed}")); + CommandResult::Continue + } + } +} + +fn help_lines() -> Vec { + let mut lines = vec!["Available commands:".to_string()]; + for spec in COMMAND_SPECS { + match spec.name { + "help" => lines.push("- help: show this help message".to_string()), + "ping" => lines.push("- ping: respond with pong".to_string()), + "quit" => lines.push("- quit: exit Arcadia".to_string()), + "configuration" => { + lines + .push("- configuration : open config file in default editor".to_string()); + lines.push( + "- configuration [show|get|set|reset] ...: manage commandline config" + .to_string(), + ); + if !spec.aliases.is_empty() { + let aliases = spec.aliases.join(" -> "); + lines.push(format!("- aliases: {aliases} -> configuration")); + } + } + "module" => { + lines.push("- module enable|disable: toggle a module".to_string()); + lines.push( + "- module enable -requirements: enable module and required dependencies" + .to_string(), + ); + } + _ => {} + } + } + let module_command_lines = modules::enabled_command_help_lines(); + if !module_command_lines.is_empty() { + lines.push("- enabled module commands:".to_string()); + lines.extend(module_command_lines); + } + lines.push( + "- global flags: --net:as lan: | --net:timeout ".to_string(), + ); + lines +} diff --git a/Desktop/src/cli/module_cmds.rs b/Desktop/src/cli/module_cmds.rs new file mode 100644 index 0000000..fb8eaf6 --- /dev/null +++ b/Desktop/src/cli/module_cmds.rs @@ -0,0 +1,53 @@ +use arcadia_core::config::modules::ModulesConfig; +use arcadia_core::config::ConfigFile; + +use super::config_cmds::modules_keys; + +pub fn module_set_state( + module_name: &str, + enabled: bool, + with_requirements: bool, +) -> Result<(), String> { + let mut cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; + if enabled && with_requirements { + cfg.enable_with_requirements(module_name)?; + } else { + cfg.set_module_state(module_name, enabled)?; + } + cfg.save().map_err(|err| err.to_string()) +} + +pub fn handle_module(parts: &[&str]) { + match parts { + ["module", module_name, "enable"] => match module_set_state(module_name, true, false) { + Ok(_) => super::print_response(&format!("Module {module_name} enabled")), + Err(err) => super::print_response(&err), + }, + ["module", module_name, "enable", "-requirements"] => { + match module_set_state(module_name, true, true) { + Ok(_) => super::print_response(&format!( + "Module {module_name} enabled (requirements enabled)" + )), + Err(err) => super::print_response(&err), + } + } + ["module", module_name, "disable"] => match module_set_state(module_name, false, false) { + Ok(_) => super::print_response(&format!("Module {module_name} disabled")), + Err(err) => super::print_response(&err), + }, + ["module"] => { + super::print_response("Usage: module enable [-requirements]|disable"); + match modules_keys() { + Ok(keys) if !keys.is_empty() => { + super::print_response("Available modules:"); + for key in keys { + super::print_response(&format!("- {key}")); + } + } + Ok(_) => {} + Err(err) => super::print_response(&format!("Failed to list modules: {err}")), + } + } + _ => super::print_response("Usage: module enable [-requirements]|disable"), + } +} diff --git a/Desktop/src/gui/app/entry.rs b/Desktop/src/gui/app/entry.rs new file mode 100644 index 0000000..779f7e9 --- /dev/null +++ b/Desktop/src/gui/app/entry.rs @@ -0,0 +1,31 @@ +use gpui::{AppContext, Application, TitlebarOptions, WindowOptions}; + +use super::super::assets::EmbeddedAssets; +use super::ArcadiaRoot; + +use crate::cli; + +pub fn run() { + use std::process; + use std::thread; + + cli::print_startup("gui"); + + thread::spawn(|| { + cli::start_loop(|| process::exit(0)); + }); + + Application::new().with_assets(EmbeddedAssets).run(|app| { + app.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |_window, app| app.new(|cx| ArcadiaRoot::new(cx)), + ) + .expect("failed to open GPUI window"); + }); +} diff --git a/Desktop/src/gui/app/lan_nodes/mod.rs b/Desktop/src/gui/app/lan_nodes/mod.rs new file mode 100644 index 0000000..b27cf93 --- /dev/null +++ b/Desktop/src/gui/app/lan_nodes/mod.rs @@ -0,0 +1 @@ +mod panel; diff --git a/Desktop/src/gui/app/lan_nodes/panel.rs b/Desktop/src/gui/app/lan_nodes/panel.rs new file mode 100644 index 0000000..aafe4a1 --- /dev/null +++ b/Desktop/src/gui/app/lan_nodes/panel.rs @@ -0,0 +1,359 @@ +use arcadia_core::modules::lan::{discover_lan_peers, list_known_lan_peers}; +use arcadia_core::modules::{execute_command, ExecutionContext}; +use gpui::{ + div, rgb, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Styled, +}; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +impl ArcadiaRoot { + pub(crate) fn lan_execute_feedback(&mut self, token: &str, args: Vec) { + let slices: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + self.lan_command_feedback = + match execute_command(token, &slices, &ExecutionContext::default()) { + Ok(Some(message)) => message, + Ok(None) => format!("Unknown command: {token}"), + Err(err) => err, + }; + } + + pub fn lan_nodes_panel(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let known = list_known_lan_peers(); + div() + .w_full() + .flex() + .flex_col() + .gap_4() + .child(self.lan_nodes_toolbar(cx, is_dark)) + .child( + div() + .text_sm() + .text_color(theme::module_meta_text(is_dark)) + .child("Broadcast scan matches lan.scan with no args. For CIDR/IP-specific discovery use the shell: lan.scan --range …"), + ) + .child(self.lan_section_title("Discovered (scan)", is_dark)) + .child(if self.lan_discovered_peers.is_empty() { + div() + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child("No scan results yet — run Scan.") + } else { + div().flex().flex_col().gap_2().children( + self.lan_discovered_peers + .iter() + .map(|(ip, hostname)| self.lan_discovered_row(cx, ip, hostname, is_dark)), + ) + }) + .child(self.lan_section_title("Known nodes", is_dark)) + .child(if known.is_empty() { + div() + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child("No peers in node state yet — pair from discovery or wait for inbound.") + } else { + div().flex().flex_col().gap_2().children( + known + .into_iter() + .map(|peer| self.lan_known_row(cx, peer.ip, peer.hostname, peer.status, is_dark)), + ) + }) + .child( + div() + .w_full() + .mt_2() + .p_3() + .rounded_lg() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child(if self.lan_command_feedback.is_empty() { + "Command output appears here.".to_string() + } else { + self.lan_command_feedback.clone() + }), + ) + } + + fn lan_section_title(&self, label: &'static str, is_dark: bool) -> impl IntoElement { + div() + .text_base() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(label) + } + + fn lan_nodes_toolbar(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + div() + .flex() + .gap_2() + .child(self.lan_primary_button(cx, "Refresh", is_dark, |this, cx| { + this.lan_command_feedback = "LAN nodes status refreshed.".to_string(); + cx.notify(); + })) + .child(self.lan_primary_button(cx, "Scan", is_dark, |this, cx| { + match discover_lan_peers(None) { + Ok(peers) => { + let n = peers.len(); + this.lan_discovered_peers = peers; + this.lan_command_feedback = format!("Scan finished — {n} peer(s)."); + } + Err(err) => { + this.lan_discovered_peers.clear(); + this.lan_command_feedback = err; + } + } + cx.notify(); + })) + .child( + self.lan_primary_button(cx, "Save connected (all)", is_dark, |this, cx| { + this.lan_execute_feedback("lan.node", vec!["save".into()]); + cx.notify(); + }), + ) + } + + fn lan_primary_button( + &self, + cx: &mut Context, + label: &'static str, + is_dark: bool, + on_click: fn(&mut ArcadiaRoot, &mut Context), + ) -> impl IntoElement { + div() + .cursor_pointer() + .px_3() + .py_1() + .rounded_md() + .bg(theme::module_row_bg(is_dark)) + .border_1() + .border_color(theme::module_row_stroke(is_dark)) + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(label) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + on_click(this, cx); + }), + ) + } + + fn lan_discovered_row( + &self, + cx: &mut Context, + ip: &str, + hostname: &str, + is_dark: bool, + ) -> impl IntoElement { + let ip_btn = ip.to_string(); + div() + .w_full() + .px_3() + .py_2() + .rounded_lg() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(hostname.to_string()), + ) + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child(ip.to_string()), + ), + ) + .child( + div().flex().gap_2().child( + div() + .cursor_pointer() + .px_2() + .py_1() + .rounded_md() + .bg(if is_dark { + rgb(0x1f2937) + } else { + rgb(0xeef2ff) + }) + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child("Pair") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.lan_execute_feedback( + "lan.node", + vec!["pair".into(), ip_btn.clone()], + ); + cx.notify(); + }), + ), + ), + ) + } + + fn lan_known_row( + &self, + cx: &mut Context, + ip: String, + hostname: String, + status: &'static str, + is_dark: bool, + ) -> impl IntoElement { + let actions = match status { + "pending-inbound" => { + let ip_a = ip.clone(); + let ip_r = ip.clone(); + div() + .flex() + .gap_2() + .child( + self.lan_small_button(cx, "Accept", is_dark, ip_a, |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["accept".into(), ip]); + cx.notify(); + }), + ) + .child( + self.lan_small_button(cx, "Reject", is_dark, ip_r, |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["reject".into(), ip]); + cx.notify(); + }), + ) + } + "pending-outbound" => { + let ip_c = ip.clone(); + let ip_a = ip.clone(); + let ip_r = ip.clone(); + div() + .flex() + .gap_2() + .child( + self.lan_small_button(cx, "Connect", is_dark, ip_c, |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["connect".into(), ip]); + cx.notify(); + }), + ) + .child( + self.lan_small_button(cx, "Accept", is_dark, ip_a, |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["accept".into(), ip]); + cx.notify(); + }), + ) + .child( + self.lan_small_button(cx, "Reject", is_dark, ip_r, |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["reject".into(), ip]); + cx.notify(); + }), + ) + } + "connected" => { + let ip_s = ip.clone(); + div().flex().gap_2().child(self.lan_small_button( + cx, + "Save", + is_dark, + ip_s, + |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["save".into(), ip]); + cx.notify(); + }, + )) + } + _ => { + let ip_p = ip.clone(); + div().flex().gap_2().child(self.lan_small_button( + cx, + "Pair again", + is_dark, + ip_p, + |this, ip, cx| { + this.lan_execute_feedback("lan.node", vec!["pair".into(), ip]); + cx.notify(); + }, + )) + } + }; + + div() + .w_full() + .px_3() + .py_2() + .rounded_lg() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(hostname), + ) + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child(format!("{ip} · {status}")), + ), + ) + .child(actions) + } + + fn lan_small_button( + &self, + cx: &mut Context, + label: &'static str, + is_dark: bool, + ip: String, + on_click: fn(&mut ArcadiaRoot, String, &mut Context), + ) -> impl IntoElement { + div() + .cursor_pointer() + .px_2() + .py_1() + .rounded_md() + .bg(if is_dark { + rgb(0x1f2937) + } else { + rgb(0xeef2ff) + }) + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(label) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + on_click(this, ip.clone(), cx); + }), + ) + } +} diff --git a/Desktop/src/gui/app/lifecycle.rs b/Desktop/src/gui/app/lifecycle.rs new file mode 100644 index 0000000..2e93410 --- /dev/null +++ b/Desktop/src/gui/app/lifecycle.rs @@ -0,0 +1,243 @@ +use std::env; +use std::path::PathBuf; +use std::time::Duration; + +use arcadia_core::config::modules::{ + ModulesConfig, LAN_MODULE_NAME, REMOTE_SESSION_MODULE_NAME, SHELL_MODULE_NAME, + SHELL_MOTD_MODULE_NAME, +}; +use arcadia_core::config::thin_client::ThinClientConfig; +use arcadia_core::config::ConfigFile; +use arcadia_core::modules; +use arcadia_core::modules::shell_motd; +use arcadia_core::modules::surface::parse_surface_snapshot; +use arcadia_core::navigation; +use gpui::{Context, Timer, Window}; + +use super::super::tui; +use super::ArcadiaRoot; + +impl ArcadiaRoot { + pub(super) fn reset_shell_state(&mut self) { + self.shell_stream_nonce = self.shell_stream_nonce.wrapping_add(1); + self.shell_history = Self::initial_shell_history(); + self.shell_input.clear(); + self.shell_cursor = 0; + self.shell_history_index = None; + self.shell_output_scroll.scroll_to_bottom(); + self.sync_shell_display_cwd_from_env(); + } + + pub(super) fn sync_shell_display_cwd_from_env(&mut self) { + match env::current_dir() { + Ok(path) => { + self.shell_working_dir = path.clone(); + self.shell_display_cwd = path + .into_os_string() + .into_string() + .unwrap_or_else(|_| "cwd: unavailable".to_string()); + } + Err(_) => { + self.shell_display_cwd = "cwd: unavailable".to_string(); + } + } + } + + fn initial_shell_history() -> Vec { + let Ok(cfg) = ModulesConfig::load_or_create() else { + return vec!["Arcadia Terminal ready.".to_string()]; + }; + let shell_on = cfg.modules.get(SHELL_MODULE_NAME).copied().unwrap_or(false); + let motd_on = cfg + .modules + .get(SHELL_MOTD_MODULE_NAME) + .copied() + .unwrap_or(false); + if shell_on && motd_on { + let mut lines = shell_motd::motd_lines(); + lines.push(String::new()); + lines + } else { + vec!["Arcadia Terminal ready.".to_string()] + } + } + + pub fn new(cx: &mut gpui::Context) -> Self { + let shell_focus = cx.focus_handle(); + let module_rows = ModulesConfig::load_or_create() + .map(|cfg| cfg.modules.into_iter().collect::>()) + .unwrap_or_default(); + let shell_working_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let shell_display_cwd = shell_working_dir + .clone() + .into_os_string() + .into_string() + .unwrap_or_else(|_| "cwd: unavailable".to_string()); + let mut root = ArcadiaRoot { + title: gpui::SharedString::new_static("Arcadia"), + active_page_id: navigation::DEFAULT_PAGE_ID.to_string(), + active_group_id: navigation::DEFAULT_GROUP_ID.to_string(), + module_rows, + pending_module_enable: None, + shell_history: Self::initial_shell_history(), + shell_input: String::new(), + shell_focus, + shell_cursor: 0, + shell_command_history: Vec::new(), + shell_history_index: None, + shell_caret_visible: true, + shell_caret_task_started: false, + shell_stream_nonce: 0, + shell_output_scroll: gpui::ScrollHandle::new(), + tui_scroll: gpui::ScrollHandle::new(), + shell_mode: super::ShellMode::Generic, + shell_working_dir, + shell_display_cwd, + tui_session: None, + tui_nonce: 0, + tui_ready: false, + tui_cols: tui::DEFAULT_COLS, + tui_rows: tui::DEFAULT_ROWS, + splash_elapsed_ms: 0.0, + splash_tick_started: false, + sidebar_visible: true, + app_menu_open: false, + session_route_menu_open: false, + remote_route: None, + remote_nav: None, + surface_client_id: ThinClientConfig::load_surface_client_id(), + last_surface_revision: None, + lan_discovered_peers: Vec::new(), + lan_command_feedback: String::new(), + lan_service_feedback: String::new(), + pending_lan_port_kill_prompt: None, + lan_poll_task_started: false, + }; + + // Thin client bootstrap: ARCADIA_NET_AS overrides persisted thin-client.toml route. + let mut picked_route: Option = None; + if let Ok(route) = env::var("ARCADIA_NET_AS") { + let trimmed = route.trim(); + if !trimmed.is_empty() { + picked_route = Some(trimmed.to_string()); + } + } else if let Ok(tc) = ThinClientConfig::load_or_create() { + if let Some(pref) = tc.preferred_remote_route.filter(|s| !s.trim().is_empty()) { + picked_route = Some(pref.trim().to_string()); + } + } + if let Some(route) = picked_route { + if root.is_module_enabled(LAN_MODULE_NAME) + && root.is_module_enabled(REMOTE_SESSION_MODULE_NAME) + { + root.remote_route = Some(route); + root.reload_modules(); + } + } + + root + } + + pub fn reload_modules(&mut self) { + if let Some(ref route) = self.remote_route { + let ctx = modules::ExecutionContext { + net_as: Some(route.clone()), + net_timeout_ms: None, + }; + match modules::execute_command("surface.snapshot", &[], &ctx) { + Ok(Some(json)) => { + let parsed = parse_surface_snapshot(&json); + self.module_rows = parsed.modules; + self.remote_nav = parsed.navigation_registry; + self.last_surface_revision = Some(parsed.revision); + } + _ => { + self.module_rows = Vec::new(); + self.remote_nav = None; + self.last_surface_revision = None; + } + } + } else { + self.remote_nav = None; + self.last_surface_revision = None; + self.module_rows = ModulesConfig::load_or_create() + .map(|cfg| cfg.modules.into_iter().collect()) + .unwrap_or_default(); + } + self.ensure_valid_navigation_selection(); + } + + pub fn ensure_lan_poll_task(&mut self, window: &mut Window, cx: &mut Context) { + if self.lan_poll_task_started { + return; + } + if self.active_page_id != "network.nodes" { + return; + } + self.lan_poll_task_started = true; + cx.spawn_in( + window, + move |view: gpui::WeakEntity, cx: &mut gpui::AsyncWindowContext| { + let mut cx = cx.clone(); + async move { + loop { + Timer::after(Duration::from_secs(1)).await; + let should_stop = cx + .update(|_, app| { + view.update(app, |this, cx| { + if this.active_page_id != "network.nodes" { + this.lan_poll_task_started = false; + return true; + } + cx.notify(); + false + }) + .unwrap_or(true) + }) + .unwrap_or(true); + if should_stop { + break; + } + } + } + }, + ) + .detach(); + } + + pub fn ensure_shell_caret_task(&mut self, window: &mut Window, cx: &mut Context) { + if self.shell_caret_task_started { + return; + } + self.shell_caret_task_started = true; + cx.spawn_in( + window, + move |view: gpui::WeakEntity, cx: &mut gpui::AsyncWindowContext| { + let mut cx = cx.clone(); + async move { + loop { + Timer::after(Duration::from_millis(500)).await; + let should_stop = cx + .update(|_, app| { + view.update(app, |this, cx| { + if !this.is_module_enabled(SHELL_MODULE_NAME) { + this.shell_caret_task_started = false; + return true; + } + this.shell_caret_visible = !this.shell_caret_visible; + cx.notify(); + false + }) + .unwrap_or(true) + }) + .unwrap_or(true); + if should_stop { + break; + } + } + } + }, + ) + .detach(); + } +} diff --git a/Desktop/src/gui/app/mod.rs b/Desktop/src/gui/app/mod.rs new file mode 100644 index 0000000..32deee7 --- /dev/null +++ b/Desktop/src/gui/app/mod.rs @@ -0,0 +1,131 @@ +//! GPUI shell root view — split across `app/` submodules for readability. + +use std::path::PathBuf; + +mod entry; +mod lan_nodes; +mod lifecycle; +mod modules_page; +mod navigation; +mod network_overview; +mod root; +mod shell; +mod sidebar; +mod splash; + +pub use entry::run; + +use arcadia_core::navigation::NavigationRegistryOwned; +use gpui::{FocusHandle, ScrollHandle, SharedString}; + +use super::tui::TuiSession; + +/// Top inset so window chrome (macOS traffic lights) does not overlap the first row of UI. +pub(crate) fn window_controls_top_padding(window: &gpui::Window) -> gpui::Pixels { + #[cfg(target_os = "macos")] + { + use gpui::px; + if window.is_fullscreen() { + px(0.) + } else { + (window.rem_size() * 2.25).max(px(28.)) + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = window; + gpui::px(0.) + } +} + +#[derive(Clone, Copy, PartialEq)] +pub enum ShellMode { + Generic, + Internal, +} + +impl ShellMode { + pub(super) fn toggle(self) -> Self { + match self { + ShellMode::Generic => ShellMode::Internal, + ShellMode::Internal => ShellMode::Generic, + } + } + + pub(super) fn label(self) -> &'static str { + match self { + ShellMode::Generic => "system", + ShellMode::Internal => "internal", + } + } + + pub(super) fn command_token(self) -> &'static str { + match self { + ShellMode::Generic => "shell.execute", + ShellMode::Internal => "shell.internal", + } + } +} + +pub struct ArcadiaRoot { + pub title: SharedString, + pub active_page_id: String, + pub active_group_id: String, + pub module_rows: Vec<(String, bool)>, + pub pending_module_enable: Option<(String, Vec)>, + pub shell_history: Vec, + pub shell_input: String, + pub shell_focus: FocusHandle, + pub shell_cursor: usize, + pub shell_command_history: Vec, + pub shell_history_index: Option, + pub shell_caret_visible: bool, + pub shell_caret_task_started: bool, + pub shell_stream_nonce: u64, + pub shell_output_scroll: ScrollHandle, + /// Keeps the embedded PTY viewport pinned to the prompt line (bottom of the terminal grid). + pub tui_scroll: ScrollHandle, + pub shell_mode: ShellMode, + /// Logical cwd for each `sh -c` spawn (persists across commands). + pub shell_working_dir: PathBuf, + /// Shown in the top bar while a PTY session is active; tracks the foreground shell process cwd. + pub shell_display_cwd: String, + pub tui_session: Option, + pub tui_nonce: u64, + pub tui_ready: bool, + pub tui_cols: u16, + pub tui_rows: u16, + pub splash_elapsed_ms: f32, + pub splash_tick_started: bool, + pub sidebar_visible: bool, + pub app_menu_open: bool, + pub session_route_menu_open: bool, + /// When `Some("lan:")`, module visibility and routed commands use this peer. + pub remote_route: Option, + /// Host navigation JSON from `surface.snapshot` when connected remotely (multi-client shared truth). + pub remote_nav: Option, + pub surface_client_id: String, + pub last_surface_revision: Option, + pub lan_discovered_peers: Vec<(String, String)>, + pub lan_command_feedback: String, + pub lan_service_feedback: String, + pub pending_lan_port_kill_prompt: Option, + pub lan_poll_task_started: bool, +} + +impl ArcadiaRoot { + pub(crate) fn is_module_enabled(&self, name: &str) -> bool { + self.module_rows + .iter() + .find(|(n, _)| n == name) + .map(|(_, enabled)| *enabled) + .unwrap_or(false) + } + + pub(crate) fn execution_context(&self) -> arcadia_core::modules::ExecutionContext { + arcadia_core::modules::ExecutionContext { + net_as: self.remote_route.clone(), + net_timeout_ms: None, + } + } +} diff --git a/Desktop/src/gui/app/modules_page/mod.rs b/Desktop/src/gui/app/modules_page/mod.rs new file mode 100644 index 0000000..d241cb4 --- /dev/null +++ b/Desktop/src/gui/app/modules_page/mod.rs @@ -0,0 +1,5 @@ +//! Global modules settings page (toggle rows + dependency modal). + +mod panel; +mod requirements_modal; +mod row; diff --git a/Desktop/src/gui/app/modules_page/panel.rs b/Desktop/src/gui/app/modules_page/panel.rs new file mode 100644 index 0000000..8fa7c1d --- /dev/null +++ b/Desktop/src/gui/app/modules_page/panel.rs @@ -0,0 +1,33 @@ +use arcadia_core::config::modules::ModulesConfig; +use gpui::div; +use gpui::{Context, IntoElement, ParentElement, Styled}; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +impl ArcadiaRoot { + pub fn modules_panel(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + if self.active_page_id.as_str() != "global.modules" { + return div(); + } + div() + .w_full() + .p_4() + .rounded_lg() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .flex() + .flex_col() + .gap_3() + .children(self.module_rows.iter().map(|(module_name, enabled)| { + Self::module_row_item( + cx, + module_name.clone(), + *enabled, + ModulesConfig::manifest_for(module_name), + is_dark, + ) + })) + } +} diff --git a/Desktop/src/gui/app/modules_page/requirements_modal.rs b/Desktop/src/gui/app/modules_page/requirements_modal.rs new file mode 100644 index 0000000..2cd4b0e --- /dev/null +++ b/Desktop/src/gui/app/modules_page/requirements_modal.rs @@ -0,0 +1,120 @@ +use gpui::{div, rgb}; +use gpui::{Context, InteractiveElement, IntoElement, ParentElement, Styled}; + +use crate::cli; +use crate::gui::app::ArcadiaRoot; + +impl ArcadiaRoot { + pub fn requirements_modal(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let Some((module_name, missing)) = &self.pending_module_enable else { + return div(); + }; + let requirements = missing.join(", "); + + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .bg(rgb(0x000000)) + .opacity(0.35) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.pending_module_enable = None; + cx.notify(); + }), + ), + ) + .child( + div() + .size_full() + .flex() + .justify_center() + .items_center() + .child( + div() + .w_128() + .p_5() + .rounded_lg() + .bg(if is_dark { rgb(0x111827) } else { rgb(0xffffff) }) + .border_1() + .border_color(if is_dark { rgb(0x374151) } else { rgb(0xe2e8f0) }) + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(if is_dark { rgb(0xf9fafb) } else { rgb(0x111827) }) + .child("Enable with requirements?"), + ) + .child( + div() + .text_sm() + .text_color(if is_dark { rgb(0xd1d5db) } else { rgb(0x374151) }) + .child(format!( + "To enable {module_name}, Arcadia needs to enable: {requirements}." + )), + ) + .child( + div() + .flex() + .gap_2() + .justify_end() + .child( + div() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if is_dark { rgb(0x374151) } else { rgb(0xe5e7eb) }) + .text_color(if is_dark { rgb(0xf3f4f6) } else { rgb(0x1f2937) }) + .child("Cancel") + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.pending_module_enable = None; + cx.notify(); + }), + ), + ) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(rgb(0xdbeafe)) + .text_color(rgb(0x1d4ed8)) + .child("Enable") + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + if let Some((module_name, _)) = + this.pending_module_enable.clone() + { + let _ = cli::handle(&format!( + "module {module_name} enable -requirements" + )); + this.reload_modules(); + } + this.pending_module_enable = None; + cx.notify(); + }), + ), + ), + ), + ), + ) + } +} diff --git a/Desktop/src/gui/app/modules_page/row.rs b/Desktop/src/gui/app/modules_page/row.rs new file mode 100644 index 0000000..f22531a --- /dev/null +++ b/Desktop/src/gui/app/modules_page/row.rs @@ -0,0 +1,200 @@ +use arcadia_core::config::modules::{ModuleManifest, ModulesConfig}; +use arcadia_core::modules; +use arcadia_core::config::ConfigFile; +use gpui::{div, rgb}; +use gpui::{Context, InteractiveElement, IntoElement, ParentElement, Styled}; + +use crate::cli; +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +impl ArcadiaRoot { + pub fn module_row_item( + cx: &mut Context, + module_name: String, + enabled: bool, + manifest: Option<&'static ModuleManifest>, + is_dark: bool, + ) -> impl IntoElement { + let version = manifest.map(|m| m.version).unwrap_or("unknown"); + let description = manifest + .map(|m| m.description) + .unwrap_or("No manifest description."); + let state = if enabled { "Enabled" } else { "Disabled" }; + div() + .w_full() + .px_4() + .py_3() + .rounded_lg() + .bg(theme::module_row_bg(is_dark)) + .border_1() + .border_color(theme::module_row_stroke(is_dark)) + .flex() + .justify_between() + .items_center() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_base() + .font_weight(gpui::FontWeight::BOLD) + .text_color(theme::module_title_text(is_dark)) + .child(module_name.clone()), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child(format!("v{version}")), + ) + .child( + div() + .px_2() + .py_0p5() + .rounded_full() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .bg(if enabled { + theme::module_state_enabled_bg(is_dark) + } else { + theme::module_state_disabled_bg(is_dark) + }) + .text_color(if enabled { + theme::module_state_enabled_text(is_dark) + } else { + theme::module_state_disabled_text(is_dark) + }) + .child(state), + ), + ) + .child( + div() + .text_xs() + .text_color(theme::module_description_text(is_dark)) + .child(description), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded_full() + .cursor_pointer() + .bg(if enabled { + theme::module_state_enabled_bg(is_dark) + } else { + theme::module_state_disabled_bg(is_dark) + }) + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(if enabled { + theme::module_state_enabled_text(is_dark) + } else { + theme::module_state_disabled_text(is_dark) + }) + .child(if enabled { "ON" } else { "OFF" }), + ) + .child(if enabled { + div() + .w_10() + .h_6() + .px_0p5() + .rounded_full() + .border_1() + .border_color(theme::module_row_stroke(is_dark)) + .bg(theme::module_button_enable_bg(is_dark)) + .flex() + .items_center() + .justify_end() + .child( + div() + .w_4() + .h_4() + .rounded_full() + .bg(theme::module_button_enable_text(is_dark)), + ) + } else { + div() + .w_10() + .h_6() + .px_0p5() + .rounded_full() + .border_1() + .border_color(theme::module_row_stroke(is_dark)) + .bg(theme::module_panel_stroke(is_dark)) + .flex() + .items_center() + .justify_start() + .child(div().w_4().h_4().rounded_full().bg(if is_dark { + rgb(0xd1d5db) + } else { + rgb(0xf8fafc) + })) + }) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + if this.remote_route.is_some() { + let enabled_next = !enabled; + let ctx = this.execution_context(); + let name = module_name.clone(); + let payload = + arcadia_core::modules::surface::patch_json_modules_set( + &name, + enabled_next, + Some(this.surface_client_id.as_str()), + ); + match modules::execute_command("surface.patch", &[payload.as_str()], &ctx) + { + Err(err) => eprintln!("{err}"), + Ok(Some(msg)) => eprintln!("{msg}"), + Ok(None) => {} + } + this.pending_module_enable = None; + this.reload_modules(); + cx.notify(); + return; + } + if enabled { + let _ = cli::handle(&format!("module {module_name} disable")); + this.pending_module_enable = None; + this.reload_modules(); + cx.notify(); + return; + } + match ModulesConfig::load_or_create() { + Ok(cfg) => match cfg.missing_requirements_for(&module_name) { + Ok(missing) if !missing.is_empty() => { + this.pending_module_enable = + Some((module_name.clone(), missing)); + } + Ok(_) => { + let _ = + cli::handle(&format!("module {module_name} enable")); + this.pending_module_enable = None; + this.reload_modules(); + } + Err(err) => eprintln!("{err}"), + }, + Err(err) => eprintln!("{err}"), + } + cx.notify(); + }), + ), + ) + } +} diff --git a/Desktop/src/gui/app/navigation.rs b/Desktop/src/gui/app/navigation.rs new file mode 100644 index 0000000..d9804ab --- /dev/null +++ b/Desktop/src/gui/app/navigation.rs @@ -0,0 +1,276 @@ +use arcadia_core::navigation::{self, NavigationGroupOwned, NavigationPageOwned}; +use gpui::{div, rgb, Context, Div, FontWeight, ParentElement, Styled, Window}; + +use super::ArcadiaRoot; + +#[derive(Clone, Copy)] +pub(crate) enum NavPageRef<'a> { + Static(&'static navigation::NavigationPageDefinition), + Remote(&'a NavigationPageOwned), +} + +#[derive(Clone, Copy)] +pub(crate) enum NavGroupRef<'a> { + Static(&'static navigation::NavigationGroupDefinition), + Remote(&'a NavigationGroupOwned), +} + +impl NavPageRef<'_> { + pub fn id(&self) -> &str { + match self { + NavPageRef::Static(p) => p.id, + NavPageRef::Remote(p) => p.id.as_str(), + } + } + + pub fn title(&self) -> &str { + match self { + NavPageRef::Static(p) => p.title, + NavPageRef::Remote(p) => p.title.as_str(), + } + } + + pub fn description(&self) -> &str { + match self { + NavPageRef::Static(p) => p.description, + NavPageRef::Remote(p) => p.description.as_str(), + } + } + + pub fn glyph(&self) -> &str { + match self { + NavPageRef::Static(p) => p.glyph, + NavPageRef::Remote(p) => p.glyph.as_str(), + } + } + + pub fn accent(&self) -> &str { + match self { + NavPageRef::Static(p) => p.accent, + NavPageRef::Remote(p) => p.accent.as_str(), + } + } + + pub fn required_module(&self) -> Option<&str> { + match self { + NavPageRef::Static(p) => p.required_module, + NavPageRef::Remote(p) => p.required_module.as_deref(), + } + } +} + +impl NavGroupRef<'_> { + pub fn id(&self) -> &str { + match self { + NavGroupRef::Static(g) => g.id, + NavGroupRef::Remote(g) => g.id.as_str(), + } + } + + pub fn label(&self) -> &str { + match self { + NavGroupRef::Static(g) => g.label, + NavGroupRef::Remote(g) => g.label.as_str(), + } + } + + pub fn system_image(&self) -> &str { + match self { + NavGroupRef::Static(g) => g.system_image, + NavGroupRef::Remote(g) => g.system_image.as_str(), + } + } + + pub fn accent(&self) -> &str { + match self { + NavGroupRef::Static(g) => g.accent, + NavGroupRef::Remote(g) => g.accent.as_str(), + } + } + + pub fn page_ids(&self) -> Vec<&str> { + match self { + NavGroupRef::Static(g) => g.pages.iter().copied().collect(), + NavGroupRef::Remote(g) => g.pages.iter().map(|s| s.as_str()).collect(), + } + } +} + +impl ArcadiaRoot { + pub(crate) fn page_ref(&self, page_id: &str) -> Option> { + if let Some(nav) = &self.remote_nav { + nav.pages + .iter() + .find(|p| p.id == page_id) + .map(NavPageRef::Remote) + } else { + navigation::page_by_id(page_id).map(NavPageRef::Static) + } + } + + pub(crate) fn effective_group(&self, group_id: &str) -> Option> { + self.visible_groups_effective() + .into_iter() + .find(|g| g.id() == group_id) + } + + pub(crate) fn global_page_ids_effective(&self) -> Vec<&str> { + if let Some(nav) = &self.remote_nav { + nav.global_pages.iter().map(|s| s.as_str()).collect() + } else { + navigation::GLOBAL_PAGE_IDS.iter().copied().collect() + } + } + + pub(crate) fn visible_groups_effective(&self) -> Vec> { + let all: Vec> = if let Some(nav) = &self.remote_nav { + nav.groups.iter().map(NavGroupRef::Remote).collect() + } else { + navigation::GROUP_DEFINITIONS + .iter() + .map(NavGroupRef::Static) + .collect() + }; + all.into_iter() + .filter(|g| g.page_ids().iter().any(|pid| self.is_page_visible(pid))) + .collect() + } + + pub(crate) fn effective_default_page(&self) -> &str { + self.remote_nav + .as_ref() + .map(|n| n.default_page.as_str()) + .unwrap_or(navigation::DEFAULT_PAGE_ID) + } + + pub(crate) fn render_active_content( + &self, + window: &mut Window, + cx: &mut Context, + active_page: Option>, + is_dark: bool, + ) -> Div { + if self.active_page_id.as_str() == "utility.shell" { + return div() + .flex_1() + .h_full() + .min_h_0() + .p_2() + .child(self.shell_panel(window, cx)); + } + if self.active_page_id.as_str() == "global.modules" { + return div().w_full().p_6().child(self.modules_panel(cx, is_dark)); + } + if self.active_page_id.as_str() == "network.overview" { + return div() + .w_full() + .p_6() + .child(self.network_overview_panel(cx, is_dark)); + } + if self.active_page_id.as_str() == "network.nodes" { + return div() + .w_full() + .p_6() + .child(self.lan_nodes_panel(cx, is_dark)); + } + div() + .w_full() + .p_6() + .flex() + .flex_col() + .items_center() + .gap_3() + .py_16() + .child( + div() + .text_3xl() + .font_weight(FontWeight::BOLD) + .child(self.title.clone()), + ) + .child( + div() + .text_2xl() + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + }) + .child(active_page.map_or_else(|| "Page".to_string(), |page| page.title().to_string())), + ) + .child( + div() + .text_base() + .text_color(if is_dark { + rgb(0x9ca3af) + } else { + rgb(0x6b7280) + }) + .child(active_page.map_or_else( + || "Page definition not found.".to_string(), + |page| page.description().to_string(), + )), + ) + } + + pub fn is_page_visible(&self, page_id: &str) -> bool { + let Some(page) = self.page_ref(page_id) else { + return false; + }; + match page.required_module() { + Some(module_name) => self.is_module_enabled(module_name), + None => true, + } + } + + pub fn active_page_if_visible(&self) -> Option> { + if self.is_page_visible(self.active_page_id.as_str()) { + self.page_ref(self.active_page_id.as_str()) + } else { + None + } + } + + pub fn ensure_valid_navigation_selection(&mut self) { + let group_fix = { + let visible_groups = self.visible_groups_effective(); + let group_is_visible = visible_groups + .iter() + .any(|group| group.id() == self.active_group_id.as_str()); + if group_is_visible { + None + } else if let Some(group) = visible_groups.first() { + Some(group.id().to_string()) + } else if let Some(nav) = &self.remote_nav { + Some(nav.default_group.clone()) + } else { + Some(navigation::DEFAULT_GROUP_ID.to_string()) + } + }; + if let Some(g) = group_fix { + self.active_group_id = g; + } + + let page_fix = { + let visible_groups = self.visible_groups_effective(); + let active_group = visible_groups + .iter() + .find(|group| group.id() == self.active_group_id.as_str()) + .or_else(|| visible_groups.first()); + if self.is_page_visible(self.active_page_id.as_str()) { + None + } else if let Some(group) = active_group { + group + .page_ids() + .into_iter() + .find(|page_id| self.is_page_visible(page_id)) + .map(|s| s.to_string()) + } else { + None + } + }; + if let Some(p) = page_fix { + self.active_page_id = p; + } + } + +} diff --git a/Desktop/src/gui/app/network_overview/mod.rs b/Desktop/src/gui/app/network_overview/mod.rs new file mode 100644 index 0000000..b27cf93 --- /dev/null +++ b/Desktop/src/gui/app/network_overview/mod.rs @@ -0,0 +1 @@ +mod panel; diff --git a/Desktop/src/gui/app/network_overview/panel.rs b/Desktop/src/gui/app/network_overview/panel.rs new file mode 100644 index 0000000..bd60bd1 --- /dev/null +++ b/Desktop/src/gui/app/network_overview/panel.rs @@ -0,0 +1,396 @@ +use arcadia_core::config::modules::{LAN_MODULE_NAME, NET_MODULE_NAME}; +use arcadia_core::modules::lan::{lan_service_info, start_service, stop_service}; +use gpui::{ + div, rgb, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Styled, +}; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +impl ArcadiaRoot { + fn try_start_lan_service(&mut self) { + match start_service() { + Ok(()) => { + self.lan_service_feedback = "LAN discovery service started.".to_string(); + } + Err(err) => { + self.lan_service_feedback = + format!("Failed to start LAN discovery service: {err}"); + let lower = err.to_ascii_lowercase(); + if lower.contains("address already in use") || lower.contains("port") { + self.pending_lan_port_kill_prompt = Some(err); + } + } + } + } + + fn kill_existing_lan_port_owner_and_retry(&mut self) -> Result<(), String> { + use std::process::Command; + let info = lan_service_info(); + let current_pid = std::process::id(); + let output = Command::new("lsof") + .args(["-nP", "-t", &format!("-iUDP:{}", info.port)]) + .output() + .map_err(|err| format!("Failed to inspect UDP {} usage: {err}", info.port))?; + + if !output.status.success() && output.stdout.is_empty() { + return Err(format!("No process currently owns UDP {}.", info.port)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut killed_any = false; + for line in stdout.lines() { + let pid = match line.trim().parse::() { + Ok(pid) if pid != current_pid => pid, + _ => continue, + }; + + let command_output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output() + .map_err(|err| format!("Failed to inspect process {pid}: {err}"))?; + let command_text = String::from_utf8_lossy(&command_output.stdout).to_ascii_lowercase(); + if !command_text.contains("arcadia") { + continue; + } + + let status = Command::new("kill") + .arg(pid.to_string()) + .status() + .map_err(|err| format!("Failed to signal process {pid}: {err}"))?; + if !status.success() { + return Err(format!("Failed to terminate process {pid}.")); + } + killed_any = true; + } + + if !killed_any { + return Err(format!( + "No existing Arcadia process found owning UDP {}.", + info.port + )); + } + + // Give the killed process time to release the socket before retrying. + std::thread::sleep(std::time::Duration::from_millis(400)); + self.try_start_lan_service(); + Ok(()) + } + + pub fn network_overview_panel( + &self, + cx: &mut Context, + is_dark: bool, + ) -> impl IntoElement { + let net_enabled = self.is_module_enabled(NET_MODULE_NAME); + let lan_enabled = self.is_module_enabled(LAN_MODULE_NAME); + + div() + .w_full() + .flex() + .flex_col() + .gap_4() + .child(self.net_status_row(net_enabled, is_dark)) + .child(if lan_enabled { + self.lan_service_row(cx, is_dark).into_any_element() + } else { + div() + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child("Enable the LAN module to manage the discovery service.") + .into_any_element() + }) + .child( + div() + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child(self.lan_service_feedback.clone()), + ) + } + + fn net_status_row(&self, net_enabled: bool, is_dark: bool) -> impl IntoElement { + div() + .w_full() + .px_4() + .py_3() + .rounded_lg() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child("Network Module"), + ) + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child("Networking foundation — required by LAN and remote session"), + ), + ) + .child(self.status_badge(net_enabled, is_dark)) + } + + fn lan_service_row(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let info = lan_service_info(); + let running = info.running; + div() + .w_full() + .px_4() + .py_3() + .rounded_lg() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child("LAN Discovery Service"), + ) + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child(format!("UDP :{} · {}", info.port, info.hostname)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .child(self.service_running_badge(running, is_dark)) + .child( + div() + .cursor_pointer() + .px_3() + .py_1() + .rounded_md() + .bg(theme::module_row_bg(is_dark)) + .border_1() + .border_color(theme::module_row_stroke(is_dark)) + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child("Refresh") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.lan_service_feedback = + "LAN discovery status refreshed.".to_string(); + cx.notify(); + }), + ), + ) + .child( + div() + .cursor_pointer() + .px_3() + .py_1() + .rounded_md() + .bg(theme::module_row_bg(is_dark)) + .border_1() + .border_color(theme::module_row_stroke(is_dark)) + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(if running { "Stop" } else { "Start" }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + if running { + stop_service(); + this.pending_lan_port_kill_prompt = None; + this.lan_service_feedback = + "LAN discovery service stopped.".to_string(); + } else { + this.try_start_lan_service(); + } + cx.notify(); + }), + ), + ), + ) + } + + pub fn kill_existing_lan_modal(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let Some(error_text) = &self.pending_lan_port_kill_prompt else { + return div(); + }; + let info = lan_service_info(); + + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .bg(rgb(0x000000)) + .opacity(0.35) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.pending_lan_port_kill_prompt = None; + cx.notify(); + }), + ), + ) + .child( + div() + .size_full() + .flex() + .justify_center() + .items_center() + .child( + div() + .w_128() + .p_5() + .rounded_lg() + .bg(if is_dark { rgb(0x111827) } else { rgb(0xffffff) }) + .border_1() + .border_color(if is_dark { rgb(0x374151) } else { rgb(0xe2e8f0) }) + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(if is_dark { rgb(0xf9fafb) } else { rgb(0x111827) }) + .child("Kill Existing?"), + ) + .child( + div() + .text_sm() + .text_color(if is_dark { rgb(0xd1d5db) } else { rgb(0x374151) }) + .child(format!( + "LAN start failed on UDP {}: {}", + info.port, error_text + )), + ) + .child( + div() + .text_xs() + .text_color(if is_dark { rgb(0x9ca3af) } else { rgb(0x6b7280) }) + .child("Arcadia will terminate older Arcadia process using this port, then retry Start."), + ) + .child( + div() + .flex() + .gap_2() + .justify_end() + .child( + div() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if is_dark { rgb(0x374151) } else { rgb(0xe5e7eb) }) + .text_color(if is_dark { rgb(0xf3f4f6) } else { rgb(0x1f2937) }) + .child("Cancel") + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.pending_lan_port_kill_prompt = None; + cx.notify(); + }), + ), + ) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(rgb(0xdbeafe)) + .text_color(rgb(0x1d4ed8)) + .child("Kill Existing") + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.pending_lan_port_kill_prompt = None; + match this.kill_existing_lan_port_owner_and_retry() { + Ok(()) => {} + Err(err) => { + this.lan_service_feedback = err; + } + } + cx.notify(); + }), + ), + ), + ), + ), + ) + } + + fn status_badge(&self, enabled: bool, is_dark: bool) -> impl IntoElement { + let _ = is_dark; + div() + .px_2() + .py_0p5() + .rounded_full() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .bg(if enabled { + rgb(0x166534) + } else { + rgb(0x374151) + }) + .text_color(if enabled { + rgb(0x86efac) + } else { + rgb(0x9ca3af) + }) + .child(if enabled { "enabled" } else { "disabled" }) + } + + fn service_running_badge(&self, running: bool, is_dark: bool) -> impl IntoElement { + let _ = is_dark; + div() + .px_2() + .py_0p5() + .rounded_full() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .bg(if running { + rgb(0x166534) + } else { + rgb(0x7f1d1d) + }) + .text_color(if running { + rgb(0x86efac) + } else { + rgb(0xfca5a5) + }) + .child(if running { "running" } else { "stopped" }) + } +} diff --git a/Desktop/src/gui/app/root/mod.rs b/Desktop/src/gui/app/root/mod.rs new file mode 100644 index 0000000..c89d061 --- /dev/null +++ b/Desktop/src/gui/app/root/mod.rs @@ -0,0 +1,4 @@ +//! Main window shell layout (`impl Render` + top chrome strip). + +mod render; +mod top_bar; diff --git a/Desktop/src/gui/app/root/render.rs b/Desktop/src/gui/app/root/render.rs new file mode 100644 index 0000000..20acb68 --- /dev/null +++ b/Desktop/src/gui/app/root/render.rs @@ -0,0 +1,115 @@ +use arcadia_core::navigation; +use gpui::{ + div, px, rgb, Context, InteractiveElement, IntoElement, ParentElement, Render, + StatefulInteractiveElement, Styled, Window, WindowAppearance, +}; + +use crate::gui::app::navigation::NavGroupRef; +use crate::gui::app::splash::SPLASH_TOTAL_MS; +use crate::gui::app::{window_controls_top_padding, ArcadiaRoot}; + +impl Render for ArcadiaRoot { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.splash_elapsed_ms < SPLASH_TOTAL_MS { + self.ensure_splash_tick(window, cx); + return self.render_splash(); + } + self.sync_peer_remote_exec_side_effects(window, cx); + self.ensure_shell_caret_task(window, cx); + self.ensure_lan_poll_task(window, cx); + if self.tui_session.is_some() { + self.sync_tui_size(window); + } + let is_dark = matches!( + window.appearance(), + WindowAppearance::Dark | WindowAppearance::VibrantDark + ); + let visible_groups = self.visible_groups_effective(); + let fallback_group = NavGroupRef::Static( + navigation::group_by_id(navigation::DEFAULT_GROUP_ID) + .unwrap_or(&navigation::GROUP_DEFINITIONS[0]), + ); + let active_group = visible_groups + .iter() + .find(|g| g.id() == self.active_group_id.as_str()) + .or_else(|| visible_groups.first()) + .unwrap_or(&fallback_group); + let active_page = self + .active_page_if_visible() + .or_else(|| self.page_ref(self.effective_default_page())); + let active_page_title = gpui::SharedString::from( + active_page + .map(|page| page.title().to_string()) + .unwrap_or_else(|| "Arcadia".to_string()), + ); + let active_page_glyph = gpui::SharedString::from( + active_page + .map(|page| page.glyph().to_string()) + .unwrap_or_else(|| "tools".to_string()), + ); + + div() + .size_full() + .bg(if is_dark { + rgb(0x0f1115) + } else { + rgb(0xffffff) + }) + .flex() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.app_menu_open { + this.app_menu_open = false; + cx.notify(); + } + if this.session_route_menu_open { + this.session_route_menu_open = false; + cx.notify(); + } + }), + ) + .on_key_down(cx.listener(Self::handle_global_key_down)) + .child(if self.sidebar_visible { + self.render_sidebar(window, cx, &visible_groups, active_group, is_dark) + } else { + div() + }) + .child( + div() + .flex_1() + .h_full() + .flex() + .flex_col() + .overflow_hidden() + .pt(if self.sidebar_visible { + px(0.) + } else { + window_controls_top_padding(window) + }) + .child(self.render_main_top_bar( + cx, + active_page_title, + active_page_glyph, + is_dark, + )) + .child(if self.active_page_id.as_str() == "utility.shell" { + div() + .flex_1() + .min_h_0() + .w_full() + .id("arcadia-page-shell") + .child(self.render_active_content(window, cx, active_page, is_dark)) + } else { + div() + .flex_1() + .w_full() + .id("arcadia-page-scroll") + .overflow_y_scroll() + .child(self.render_active_content(window, cx, active_page, is_dark)) + }), + ) + .child(self.requirements_modal(cx, is_dark)) + .child(self.kill_existing_lan_modal(cx, is_dark)) + } +} diff --git a/Desktop/src/gui/app/root/top_bar.rs b/Desktop/src/gui/app/root/top_bar.rs new file mode 100644 index 0000000..70caa7d --- /dev/null +++ b/Desktop/src/gui/app/root/top_bar.rs @@ -0,0 +1,160 @@ +use gpui::{div, rgb, Context, InteractiveElement, IntoElement, ParentElement, Styled}; + +use crate::gui::app::{ArcadiaRoot, ShellMode}; +use crate::gui::theme; + +impl ArcadiaRoot { + pub(crate) fn render_main_top_bar( + &self, + cx: &mut Context, + active_page_title: gpui::SharedString, + active_page_glyph: gpui::SharedString, + is_dark: bool, + ) -> impl IntoElement { + div() + .w_full() + .px_3() + .py_2() + .border_b_1() + .border_color(if is_dark { + rgb(0x2a3340) + } else { + rgb(0xe6e8ef) + }) + .child( + div() + .w_full() + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_3() + .child(Self::sidebar_toggle_button(cx, active_page_glyph.as_ref(), is_dark)) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + }) + .child(active_page_title), + ) + .child(if self.active_page_id.as_str() == "utility.shell" { + div() + .px_2() + .py_0p5() + .rounded_md() + .text_xs() + .bg(if self.shell_mode == ShellMode::Generic { + if is_dark { + rgb(0x1e3a5f) + } else { + rgb(0xdbeafe) + } + } else { + if is_dark { + rgb(0x422006) + } else { + rgb(0xffedd5) + } + }) + .text_color(if self.shell_mode == ShellMode::Generic { + if is_dark { + rgb(0x93c5fd) + } else { + rgb(0x1d4ed8) + } + } else { + if is_dark { + rgb(0xfdba74) + } else { + rgb(0xc2410c) + } + }) + .child(self.shell_mode.label()) + } else { + div() + }) + .child( + if self.active_page_id.as_str() == "utility.shell" + && self.shell_mode == ShellMode::Generic + { + div() + .px_2() + .py_0p5() + .rounded_md() + .text_xs() + .bg(theme::top_bar_pill_bg(is_dark)) + .text_color(theme::top_bar_pill_text(is_dark)) + .child(self.shell_working_directory_label()) + } else { + div().hidden() + }, + ) + .child(if self.active_page_id.as_str() == "utility.shell" { + div() + .px_2() + .py_0p5() + .rounded_md() + .cursor_pointer() + .text_xs() + .bg(theme::top_bar_pill_bg(is_dark)) + .text_color(theme::top_bar_pill_text(is_dark)) + .hover(move |style| { + style.bg(theme::top_bar_pill_hover_bg(is_dark)) + }) + .child("Reset") + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.reset_shell_state(); + cx.notify(); + }), + ) + } else { + div() + }) + .child(if self.active_page_id.as_str() == "utility.shell" { + div() + .px_2() + .py_0p5() + .rounded_md() + .cursor_pointer() + .text_xs() + .bg(theme::top_bar_pill_bg(is_dark)) + .text_color(theme::top_bar_pill_text(is_dark)) + .hover(move |style| { + style.bg(theme::top_bar_pill_hover_bg(is_dark)) + }) + .child("Clear") + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.shell_history.clear(); + this.shell_output_scroll.scroll_to_bottom(); + cx.notify(); + }), + ) + } else { + div() + }), + ) + .child(Self::top_bar_global_item( + cx, + "Logs".into(), + "logs".into(), + "global.logs".into(), + self.active_page_id.as_str() == "global.logs", + is_dark, + self.page_ref("global.logs") + .map(|p| p.accent().to_string()) + .unwrap_or_else(|| "sky".to_string()), + )), + ) + } +} diff --git a/Desktop/src/gui/app/shell/execute.rs b/Desktop/src/gui/app/shell/execute.rs new file mode 100644 index 0000000..00d9b37 --- /dev/null +++ b/Desktop/src/gui/app/shell/execute.rs @@ -0,0 +1,306 @@ +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use arcadia_core::modules; +use gpui::{Context, Timer, Window, WindowAppearance}; + +use super::super::super::tui::TuiSession; +use super::super::{window_controls_top_padding, ArcadiaRoot, ShellMode}; + +/// Approximate character width/height for monospace text_sm (14 px font). +const CHAR_W: f32 = 8.4; +const CHAR_H: f32 = 18.0; +/// Layout overhead: shell panel p_2 (8×2) + tui_screen p_1 (4×2) + border (1×2). +const PADDING_H: f32 = 26.0; +const PADDING_V: f32 = 26.0; +/// Top-bar height: py_2 (8×2) + text_sm content (~14px) + border_b_1. +const TOP_BAR_H: f32 = 37.0; +/// Sidebar width when visible (w_64 = 256 px). +const SIDEBAR_W: f32 = 256.0; + +/// Matches shell input `$` styling in `shell/panel.rs` (`rgb(0x60a5fa)` / `rgb(0x1d4ed8)`). +fn shell_history_prompt_prefix(window: &Window) -> String { + let is_dark = matches!( + window.appearance(), + WindowAppearance::Dark | WindowAppearance::VibrantDark + ); + if is_dark { + "\x1b[38;2;96;165;250m$\x1b[0m ".to_string() + } else { + "\x1b[38;2;29;78;216m$\x1b[0m ".to_string() + } +} + +fn compute_tui_size(window: &Window, sidebar_visible: bool) -> (u16, u16) { + let vp = window.viewport_size(); + let sidebar = if sidebar_visible { SIDEBAR_W } else { 0.0 }; + let chrome = window_controls_top_padding(window).to_f64() as f32; + let w = vp.width.to_f64() as f32; + let h = vp.height.to_f64() as f32; + let usable_w = (w - sidebar - PADDING_H).max(CHAR_W * 40.0); + let usable_h = (h - chrome - TOP_BAR_H - PADDING_V).max(CHAR_H * 10.0); + ((usable_h / CHAR_H) as u16, (usable_w / CHAR_W) as u16) +} + +impl ArcadiaRoot { + fn stream_shell_command_output( + &mut self, + window: &mut Window, + cx: &mut Context, + history_command_display: &str, + token: &str, + args: &[&str], + exec_ctx: &modules::ExecutionContext, + ) { + let result = modules::execute_command(token, args, exec_ctx); + self.shell_stream_nonce = self.shell_stream_nonce.wrapping_add(1); + let stream_nonce = self.shell_stream_nonce; + self.shell_history.push(format!( + "{}{history_command_display}", + shell_history_prompt_prefix(window) + )); + self.shell_output_scroll.scroll_to_bottom(); + let output = match result { + Ok(Some(output)) => output, + Ok(None) => "Unknown shell command token.".to_string(), + Err(err) => err, + }; + self.shell_output_scroll.scroll_to_bottom(); + let lines: Vec = output.lines().map(str::to_string).collect(); + cx.spawn_in( + window, + move |view: gpui::WeakEntity, cx: &mut gpui::AsyncWindowContext| { + let mut cx = cx.clone(); + async move { + for line in lines { + Timer::after(Duration::from_millis(4)).await; + let _ = cx.update(|_, app| { + let _ = view.update(app, |this, cx| { + if this.shell_stream_nonce != stream_nonce { + return; + } + this.shell_history.push(line); + this.shell_output_scroll.scroll_to_bottom(); + cx.notify(); + }); + }); + } + let _ = cx.update(|_, app| { + let _ = view.update(app, |this, cx| { + if this.shell_stream_nonce == stream_nonce { + this.shell_output_scroll.scroll_to_bottom(); + cx.notify(); + } + }); + }); + } + }, + ) + .detach(); + } + + pub fn sync_tui_size(&mut self, window: &Window) { + let (rows, cols) = compute_tui_size(window, self.sidebar_visible); + if cols != self.tui_cols || rows != self.tui_rows { + self.tui_cols = cols; + self.tui_rows = rows; + if let Some(session) = &self.tui_session { + session.resize(rows, cols); + } + } + } + + pub fn run_shell_execute( + &mut self, + command: &str, + window: &mut Window, + cx: &mut Context, + ) { + let normalized = command.trim(); + if normalized.eq_ignore_ascii_case("clear") || normalized.eq_ignore_ascii_case("cls") { + self.shell_stream_nonce = self.shell_stream_nonce.wrapping_add(1); + self.shell_history.clear(); + self.shell_output_scroll.scroll_to_bottom(); + cx.notify(); + return; + } + let ctx = self.execution_context(); + if self.shell_mode == ShellMode::Generic { + if self.remote_route.is_some() { + self.stream_shell_command_output( + window, + cx, + normalized, + "shell.execute", + &[normalized], + &ctx, + ); + return; + } + self.spawn_tui_command(normalized, window, cx); + return; + } + self.stream_shell_command_output( + window, + cx, + command, + self.shell_mode.command_token(), + &[command], + &ctx, + ); + } + + fn spawn_tui_command(&mut self, command: &str, window: &mut Window, cx: &mut Context) { + self.tui_session = None; + self.tui_ready = false; + self.tui_nonce = self.tui_nonce.wrapping_add(1); + let nonce = self.tui_nonce; + + let (rows, cols) = compute_tui_size(window, self.sidebar_visible); + self.tui_rows = rows; + self.tui_cols = cols; + + self.shell_history + .push(format!("{}{command}", shell_history_prompt_prefix(window))); + self.shell_output_scroll.scroll_to_bottom(); + + let cwd_at_spawn = self.shell_working_dir.clone(); + match TuiSession::spawn(command, rows, cols, &cwd_at_spawn) { + Err(e) => { + self.shell_history.push(format!("error: {e}")); + self.shell_output_scroll.scroll_to_bottom(); + cx.notify(); + } + Ok(session) => { + self.shell_display_cwd = session + .foreground_cwd() + .or_else(|| { + self.shell_working_dir + .clone() + .into_os_string() + .into_string() + .ok() + }) + .unwrap_or_else(|| "cwd: unavailable".to_string()); + let parser = session.parser.clone(); + let queue = session.queue.clone(); + let done = session.done.clone(); + self.tui_scroll.scroll_to_bottom(); + self.tui_session = Some(session); + cx.notify(); + + let command_owned = command.to_string(); + cx.spawn_in( + window, + move |view: gpui::WeakEntity, + cx: &mut gpui::AsyncWindowContext| { + let mut cx = cx.clone(); + async move { + let mut showed_tui = false; + loop { + Timer::after(Duration::from_millis(16)).await; + + let chunks: Vec> = queue + .lock() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default(); + let is_done = done.load(Ordering::SeqCst); + + let _ = cx.update(|_, app| { + let _ = view.update(app, |this, cx| { + if this.tui_nonce != nonce { + return; + } + if let Some(ref sess) = this.tui_session { + if let Some(cwd) = sess.foreground_cwd() { + if cwd != this.shell_display_cwd { + this.shell_display_cwd = cwd.clone(); + this.shell_working_dir = PathBuf::from(cwd); + this.tui_scroll.scroll_to_bottom(); + cx.notify(); + } + } + } + }); + }); + + if !is_done && !showed_tui { + showed_tui = true; + let _ = cx.update(|_, app| { + let _ = view.update(app, |this, cx| { + if this.tui_nonce == nonce { + this.tui_ready = true; + this.tui_scroll.scroll_to_bottom(); + cx.notify(); + } + }); + }); + } + + if !chunks.is_empty() { + if let Ok(mut p) = parser.lock() { + for chunk in &chunks { + p.process(chunk); + } + } + let _ = cx.update(|_, app| { + let _ = view.update(app, |this, cx| { + this.tui_scroll.scroll_to_bottom(); + cx.notify(); + }); + }); + } + + if is_done && chunks.is_empty() { + let screen_lines: Vec = parser + .lock() + .map(|p| { + let screen = p.screen(); + let (rows, cols) = screen.size(); + (0..rows) + .filter_map(|r| { + crate::gui::tui::vt100_row_for_shell_history( + screen, r, cols, + ) + }) + .collect() + }) + .unwrap_or_default(); + let _ = cx.update(|_, app| { + let _ = view.update(app, |this, cx| { + if this.tui_nonce == nonce { + for line in screen_lines { + this.shell_history.push(line); + } + if let Some(ref sess) = this.tui_session { + let from_fg = + sess.foreground_cwd().map(PathBuf::from); + let from_cd = + crate::gui::tui::resolve_simple_cd( + &cwd_at_spawn, + &command_owned, + ); + if let Some(p) = from_fg.or(from_cd) { + this.shell_working_dir = p.clone(); + this.shell_display_cwd = + p.to_string_lossy().into_owned(); + } + } + this.tui_session = None; + this.shell_output_scroll.scroll_to_bottom(); + cx.notify(); + } + }); + }); + break; + } + } + } + }, + ) + .detach(); + } + } + } +} diff --git a/Desktop/src/gui/app/shell/keys.rs b/Desktop/src/gui/app/shell/keys.rs new file mode 100644 index 0000000..bf6a07c --- /dev/null +++ b/Desktop/src/gui/app/shell/keys.rs @@ -0,0 +1,143 @@ +use crate::cli; +use gpui::{Context, KeyDownEvent, Window}; + +use super::super::super::tui; +use super::super::ArcadiaRoot; + +impl ArcadiaRoot { + pub(crate) fn handle_shell_key_down( + &mut self, + event: &KeyDownEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if self.active_page_id.as_str() != "utility.shell" { + return; + } + let key = event.keystroke.key.as_str(); + let mods = event.keystroke.modifiers; + + // When a TUI session is active, forward all keys to the PTY. + if self.tui_session.is_some() { + let bytes = tui::key_to_bytes(key, mods).or_else(|| { + if !mods.control && !mods.alt && !mods.platform { + event + .keystroke + .key_char + .as_ref() + .map(|c| c.as_bytes().to_vec()) + } else { + None + } + }); + if let (Some(bytes), Some(session)) = (bytes, self.tui_session.as_mut()) { + session.write_input(&bytes); + } + self.tui_scroll.scroll_to_bottom(); + cx.notify(); + return; + } + + match key { + "enter" => { + let command = self.shell_input.trim().to_string(); + if !command.is_empty() { + self.run_shell_execute(&command, _window, cx); + self.shell_command_history.push(command); + } + self.shell_input.clear(); + self.shell_cursor = 0; + self.shell_history_index = None; + } + "backspace" => { + if self.shell_cursor > 0 { + let mut chars = self.shell_input.chars().collect::>(); + chars.remove(self.shell_cursor - 1); + self.shell_input = chars.into_iter().collect(); + self.shell_cursor -= 1; + } + } + "left" => { + self.shell_cursor = self.shell_cursor.saturating_sub(1); + } + "right" => { + let len = self.shell_input.chars().count(); + self.shell_cursor = (self.shell_cursor + 1).min(len); + } + "up" => { + if !self.shell_command_history.is_empty() { + let next_index = match self.shell_history_index { + Some(index) => index.saturating_sub(1), + None => self.shell_command_history.len().saturating_sub(1), + }; + self.shell_history_index = Some(next_index); + self.shell_input = self.shell_command_history[next_index].clone(); + self.shell_cursor = self.shell_input.chars().count(); + } + } + "down" => { + if let Some(index) = self.shell_history_index { + let next_index = index + 1; + if next_index < self.shell_command_history.len() { + self.shell_history_index = Some(next_index); + self.shell_input = self.shell_command_history[next_index].clone(); + self.shell_cursor = self.shell_input.chars().count(); + } else { + self.shell_history_index = None; + self.shell_input.clear(); + self.shell_cursor = 0; + } + } + } + "home" => self.shell_cursor = 0, + "end" => self.shell_cursor = self.shell_input.chars().count(), + "space" => { + let mut chars = self.shell_input.chars().collect::>(); + chars.insert(self.shell_cursor, ' '); + self.shell_input = chars.into_iter().collect(); + self.shell_cursor += 1; + } + _ => { + if !mods.control && !mods.alt && !mods.platform && !mods.function { + if let Some(key_char) = &event.keystroke.key_char { + let mut chars = self.shell_input.chars().collect::>(); + for ch in key_char.chars() { + chars.insert(self.shell_cursor, ch); + self.shell_cursor += 1; + } + self.shell_input = chars.into_iter().collect(); + } + } + } + } + cx.notify(); + } + + pub(crate) fn handle_global_key_down( + &mut self, + event: &KeyDownEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if event.keystroke.key.as_str() == "escape" && self.app_menu_open { + self.app_menu_open = false; + cx.notify(); + return; + } + if self.active_page_id.as_str() != "utility.shell" { + return; + } + let key = event.keystroke.key.as_str(); + let mods = event.keystroke.modifiers; + if key == "tab" && mods.shift && self.tui_session.is_none() { + self.shell_mode = self.shell_mode.toggle(); + cx.notify(); + } + } + + pub(crate) fn run_internal_quit_command(&mut self) { + if let crate::cli::CommandResult::Quit = cli::handle("quit") { + std::process::exit(0); + } + } +} diff --git a/Desktop/src/gui/app/shell/mirror.rs b/Desktop/src/gui/app/shell/mirror.rs new file mode 100644 index 0000000..59562e2 --- /dev/null +++ b/Desktop/src/gui/app/shell/mirror.rs @@ -0,0 +1,35 @@ +use arcadia_core::modules::remote_mirror::{ + drain_formatted_mirror_lines, take_host_ui_sync_pending, +}; +use gpui::{Context, Window}; + +use super::super::ArcadiaRoot; + +impl ArcadiaRoot { + /// Transcript lines + reload module/nav state when this surface shows **local** host (`remote_route` unset). + pub(crate) fn sync_peer_remote_exec_side_effects( + &mut self, + _window: &Window, + cx: &mut Context, + ) { + let lines = drain_formatted_mirror_lines(); + let reload_host = take_host_ui_sync_pending(); + + let mut dirty = false; + if !lines.is_empty() { + self.shell_stream_nonce = self.shell_stream_nonce.wrapping_add(1); + for line in lines { + self.shell_history.push(line); + } + self.shell_output_scroll.scroll_to_bottom(); + dirty = true; + } + if reload_host && self.remote_route.is_none() { + self.reload_modules(); + dirty = true; + } + if dirty { + cx.notify(); + } + } +} diff --git a/Desktop/src/gui/app/shell/mod.rs b/Desktop/src/gui/app/shell/mod.rs new file mode 100644 index 0000000..c77fc7f --- /dev/null +++ b/Desktop/src/gui/app/shell/mod.rs @@ -0,0 +1,7 @@ +//! Terminal panel: scrollback + input, PTY/TUI surface, key routing, command execution. + +mod execute; +mod mirror; +mod keys; +mod panel; +mod tui_screen; diff --git a/Desktop/src/gui/app/shell/panel.rs b/Desktop/src/gui/app/shell/panel.rs new file mode 100644 index 0000000..9276e4e --- /dev/null +++ b/Desktop/src/gui/app/shell/panel.rs @@ -0,0 +1,173 @@ +use std::env; + +use gpui::{ + div, rgb, Context, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, + Styled, Window, WindowAppearance, +}; + +use crate::gui::tui::shell_history_line; + +use super::super::ArcadiaRoot; + +impl ArcadiaRoot { + pub(crate) fn shell_panel( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + if self.active_page_id.as_str() != "utility.shell" { + return div(); + } + let is_focused = self.shell_focus.is_focused(window); + let is_dark = matches!( + window.appearance(), + WindowAppearance::Dark | WindowAppearance::VibrantDark + ); + + // Live PTY: vt100 grid fills the panel (transcript returns after the process exits). + if self.tui_session.is_some() && self.tui_ready { + return div() + .w_full() + .h_full() + .overflow_hidden() + .p_1() + .rounded_lg() + .bg(if is_dark { + rgb(0x151a22) + } else { + rgb(0xf8fafc) + }) + .border_1() + .border_color(if is_dark { + rgb(0x2f3948) + } else { + rgb(0xe2e8f0) + }) + .flex() + .flex_col() + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .child(self.render_tui_screen(is_dark, cx)), + ); + } + + div() + .w_full() + .h_full() + .overflow_hidden() + .p_1() + .rounded_lg() + .bg(if is_dark { + rgb(0x151a22) + } else { + rgb(0xf8fafc) + }) + .border_1() + .border_color(if is_dark { + rgb(0x2f3948) + } else { + rgb(0xe2e8f0) + }) + .flex() + .flex_col() + .gap_0() + .child( + div() + .flex_1() + .w_full() + .min_h_0() + .id("arcadia-shell-output") + .overflow_y_scroll() + .track_scroll(&self.shell_output_scroll) + .child( + div().w_full().p_3().flex().flex_col().gap_0().children( + self.shell_history + .iter() + .filter(|line| !line.is_empty()) + .map(|line| shell_history_line(line, is_dark)), + ), + ), + ) + .child( + div() + .w_full() + .flex_shrink_0() + .px_3() + .py_2() + .flex() + .gap_2() + .items_center() + .border_t_1() + .border_color(if is_focused { + rgb(0x3b82f6) + } else if is_dark { + rgb(0x2f3948) + } else { + rgb(0xe2e8f0) + }) + .bg(if is_dark { + rgb(0x0f141b) + } else { + rgb(0xffffff) + }) + .track_focus(&self.shell_focus) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, window, _| { + this.shell_focus.focus(window); + }), + ) + .on_key_down(cx.listener(Self::handle_shell_key_down)) + .child( + div() + .text_sm() + .text_color(if is_dark { + rgb(0x60a5fa) + } else { + rgb(0x1d4ed8) + }) + .child("$"), + ) + .child( + div() + .text_sm() + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x111827) + }) + .child(self.shell_input_with_cursor(is_focused)), + ), + ) + } + + pub(crate) fn shell_input_with_cursor(&self, is_focused: bool) -> String { + let chars = self.shell_input.chars().collect::>(); + let cursor = self.shell_cursor.min(chars.len()); + let mut out = String::with_capacity(chars.len() + 1); + for (idx, ch) in chars.iter().enumerate() { + if idx == cursor && is_focused && self.shell_caret_visible { + out.push('|'); + } + out.push(*ch); + } + if cursor == chars.len() && is_focused && self.shell_caret_visible { + out.push('|'); + } + out + } + + pub(crate) fn shell_working_directory_label(&self) -> String { + if self.tui_session.is_some() { + self.shell_display_cwd.clone() + } else { + env::current_dir() + .ok() + .and_then(|path| path.to_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "cwd: unavailable".to_string()) + } + } +} diff --git a/Desktop/src/gui/app/shell/tui_screen.rs b/Desktop/src/gui/app/shell/tui_screen.rs new file mode 100644 index 0000000..c8836ec --- /dev/null +++ b/Desktop/src/gui/app/shell/tui_screen.rs @@ -0,0 +1,132 @@ +use gpui::Rgba; +use gpui::{ + div, px, rgb, Context, Div, FontWeight, InteractiveElement, ParentElement, + StatefulInteractiveElement, Styled, +}; + +use super::super::super::tui::{self}; +use super::super::ArcadiaRoot; + +/// Pixel row height; must stay aligned with `shell/execute.rs` `CHAR_H` (PTY rows × this ≈ panel). +const TUI_ROW_HEIGHT: gpui::Pixels = px(18.); + +impl ArcadiaRoot { + pub(crate) fn render_tui_screen(&self, is_dark: bool, cx: &mut Context) -> Div { + let Some(session) = &self.tui_session else { + return div(); + }; + let Ok(parser) = session.parser.lock() else { + return div(); + }; + let screen = parser.screen(); + let (rows, cols) = screen.size(); + let (cur_row, cur_col) = screen.cursor_position(); + + // Snapshot screen data before releasing the lock. + let mut snapshot: Vec> = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut row = Vec::with_capacity(cols as usize); + for c in 0..cols { + let is_cursor = r == cur_row && c == cur_col; + let (ch, mut fg, mut bg, bold) = match screen.cell(r, c) { + Some(cell) => { + let content = cell.contents(); + let text = if content.is_empty() { + " ".to_string() + } else { + content.to_string() + }; + let fg = tui::vt_color(cell.fgcolor(), true, is_dark); + let bg = tui::vt_color(cell.bgcolor(), false, is_dark); + (text, fg, bg, cell.bold()) + } + None => ( + " ".to_string(), + tui::default_fg(is_dark), + tui::default_bg(is_dark), + false, + ), + }; + if is_cursor { + std::mem::swap(&mut fg, &mut bg); + } + row.push((ch, fg, bg, bold)); + } + snapshot.push(row); + } + drop(parser); // release lock before building elements + + let term_bg = tui::default_bg(is_dark); + + let row_els: Vec<_> = snapshot + .into_iter() + .map(|row_cells| { + // Run-length encode consecutive same-style cells. + let mut runs: Vec<(String, Rgba, Rgba, bool)> = Vec::new(); + for (ch, fg, bg, bold) in row_cells { + let same = runs + .last() + .map(|(_, rf, rb, rb2)| { + tui::rgba_eq(*rf, fg) && tui::rgba_eq(*rb, bg) && *rb2 == bold + }) + .unwrap_or(false); + if same { + runs.last_mut().unwrap().0.push_str(&ch); + } else { + runs.push((ch, fg, bg, bold)); + } + } + let spans: Vec<_> = runs + .into_iter() + .map(|(text, fg, bg, bold)| { + div() + .font_family("monospace") + .text_sm() + .text_color(fg) + .bg(bg) + .font_weight(if bold { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }) + .child(text) + }) + .collect(); + div() + .flex() + .flex_row() + .items_start() + .h(TUI_ROW_HEIGHT) + .children(spans) + }) + .collect(); + + div() + .w_full() + .h_full() + .bg(term_bg) + .px_2() + .pt_2() + .pb_2() + .flex() + .flex_col() + .track_focus(&self.shell_focus) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, window, _| { + this.shell_focus.focus(window); + }), + ) + .on_key_down(cx.listener(Self::handle_shell_key_down)) + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .id("arcadia-tui-scroll") + .overflow_y_scroll() + .track_scroll(&self.tui_scroll) + .child(div().w_full().flex().flex_col().children(row_els)), + ) + } +} diff --git a/Desktop/src/gui/app/sidebar/layout.rs b/Desktop/src/gui/app/sidebar/layout.rs new file mode 100644 index 0000000..414e971 --- /dev/null +++ b/Desktop/src/gui/app/sidebar/layout.rs @@ -0,0 +1,351 @@ +use arcadia_core::config::modules::REMOTE_SESSION_MODULE_NAME; +use arcadia_core::config::thin_client::ThinClientConfig; +use arcadia_core::modules::lan::connected_approved_session_peers; +use gpui::{ + div, img, px, rgb, Context, Div, InteractiveElement, ParentElement, StatefulInteractiveElement, + Styled, Window, +}; + +use crate::gui::app::navigation::NavGroupRef; +use crate::gui::app::{window_controls_top_padding, ArcadiaRoot}; +use crate::gui::theme::{self}; + +impl ArcadiaRoot { + pub(crate) fn render_sidebar( + &self, + window: &Window, + cx: &mut Context, + visible_groups: &[NavGroupRef<'_>], + active_group: &NavGroupRef<'_>, + is_dark: bool, + ) -> Div { + let top_inset = window_controls_top_padding(window); + // Pad content below traffic lights; outer column keeps full-height bg + border into titlebar. + let content_top_pad = top_inset + px(12.); + div() + .h_full() + .w_64() + .flex() + .flex_col() + .bg(if is_dark { + rgb(0x171b22) + } else { + rgb(0xf6f7fb) + }) + .border_r_1() + .border_color(if is_dark { + rgb(0x2a3340) + } else { + rgb(0xe6e8ef) + }) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .w_full() + .relative() + .px_5() + .pt(content_top_pad) + .pb_6() + .gap_4() + .child( + div() + .relative() + .flex() + .items_center() + .gap_2() + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(|this, _, _, cx| { + this.session_route_menu_open = false; + this.app_menu_open = true; + cx.notify(); + }), + ) + .child(img("icons/app-icon.png").size_8().rounded_sm()) + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x111827) + }) + .child("Arcadia"), + ) + .child({ + let session_label = self + .remote_route + .as_deref() + .and_then(|r| r.strip_prefix("lan:")) + .unwrap_or("local") + .to_string(); + if !self.is_module_enabled(REMOTE_SESSION_MODULE_NAME) { + div() + .ml_2() + .px_2() + .py_0p5() + .rounded_full() + .border_1() + .border_color(theme::sidebar_session_chip_border(is_dark)) + .bg(theme::sidebar_session_chip_bg(is_dark)) + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme::sidebar_session_chip_text(is_dark)) + .child("local"), + ) + } else { + let peers = connected_approved_session_peers(); + div() + .relative() + .ml_2() + .child( + div() + .px_2() + .py_0p5() + .rounded_full() + .border_1() + .border_color(theme::sidebar_session_chip_border( + is_dark, + )) + .bg(theme::sidebar_session_chip_bg(is_dark)) + .hover(move |style| { + style.bg(theme::sidebar_session_chip_hover_bg( + is_dark, + )) + }) + .cursor_pointer() + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color( + theme::sidebar_session_chip_text(is_dark), + ) + .child(session_label), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.session_route_menu_open = + !this.session_route_menu_open; + this.app_menu_open = false; + cx.notify(); + }), + ), + ) + .child(if self.session_route_menu_open { + div() + .absolute() + .top(px(30.)) + .left(px(0.)) + .min_w(px(200.)) + .p_1() + .rounded_md() + .border_1() + .border_color(if is_dark { + rgb(0x374151) + } else { + rgb(0xd1d5db) + }) + .bg(if is_dark { + rgb(0x111827) + } else { + rgb(0xffffff) + }) + .child( + div() + .w_full() + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .text_sm() + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + }) + .hover(move |style| { + style.bg(if is_dark { + rgb(0x1f2937) + } else { + rgb(0xf3f4f6) + }) + }) + .child("Local") + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + let _ = ThinClientConfig::set_preferred_remote_route(None); + this.remote_route = None; + this.session_route_menu_open = false; + this.reload_modules(); + cx.notify(); + }), + ), + ) + .children(peers.into_iter().map( + |(ip, hostname)| { + let route = format!("lan:{ip}"); + let label = format!("{hostname} ({ip})"); + div() + .w_full() + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .text_sm() + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + }) + .hover(move |style| { + style.bg(if is_dark { + rgb(0x1f2937) + } else { + rgb(0xf3f4f6) + }) + }) + .child(label) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + let _ = ThinClientConfig::set_preferred_remote_route(Some(&route)); + this.remote_route = + Some(route.clone()); + this.session_route_menu_open = + false; + this.reload_modules(); + cx.notify(); + }), + ) + }, + )) + } else { + div().hidden() + }) + } + }), + ) + .child(if self.app_menu_open { + div() + .absolute() + .top(px(40.)) + .left(px(0.)) + .w(px(112.)) + .p_1() + .rounded_md() + .border_1() + .border_color(if is_dark { + rgb(0x374151) + } else { + rgb(0xd1d5db) + }) + .bg(if is_dark { + rgb(0x111827) + } else { + rgb(0xffffff) + }) + .child( + div() + .w_full() + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .text_sm() + .text_color(if is_dark { + rgb(0xfca5a5) + } else { + rgb(0x991b1b) + }) + .hover(move |style| { + style.bg(if is_dark { + rgb(0x1f2937) + } else { + rgb(0xfef2f2) + }) + }) + .child("Quit") + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, _| { + this.app_menu_open = false; + this.run_internal_quit_command(); + }), + ), + ) + } else { + div().hidden() + }) + .child( + div() + .id("sidebar-group-tabs") + .w_full() + .overflow_x_scroll() + .child( + div() + .flex() + .gap_2() + .w_full() + .justify_center() + .items_start() + .children(visible_groups.iter().copied().map(|group| { + Self::sidebar_group_item( + cx, + gpui::SharedString::from(group.label().to_string()), + gpui::SharedString::from(group.system_image().to_string()), + group.id().to_string(), + self.active_group_id == group.id(), + is_dark, + group.accent().to_string(), + ) + })), + ), + ) + .child( + div() + .id("sidebar-subtabs") + .flex_1() + .overflow_y_scroll() + .child(div().flex().flex_col().gap_1().children( + active_group.page_ids().into_iter().filter_map(|page_id| { + if !self.is_page_visible(page_id) { + return None; + } + let page = self.page_ref(page_id)?; + Some(Self::sidebar_item( + cx, + gpui::SharedString::from(page.title().to_string()), + gpui::SharedString::from(page.glyph().to_string()), + page.id().to_string(), + self.active_page_id == page.id(), + is_dark, + page.accent().to_string(), + )) + }), + )), + ) + .children(self.global_page_ids_effective().into_iter().filter_map(|page_id| { + let page = self.page_ref(page_id)?; + Some(Self::sidebar_global_item( + cx, + gpui::SharedString::from(page.title().to_string()), + gpui::SharedString::from(page.glyph().to_string()), + page.id().to_string(), + self.active_page_id == page.id(), + is_dark, + page.accent().to_string(), + )) + })), + ) + } +} diff --git a/Desktop/src/gui/app/sidebar/mod.rs b/Desktop/src/gui/app/sidebar/mod.rs new file mode 100644 index 0000000..a4e5f18 --- /dev/null +++ b/Desktop/src/gui/app/sidebar/mod.rs @@ -0,0 +1,4 @@ +//! Sidebar chrome and navigation item builders. + +mod layout; +mod nav_items; diff --git a/Desktop/src/gui/app/sidebar/nav_items.rs b/Desktop/src/gui/app/sidebar/nav_items.rs new file mode 100644 index 0000000..a094faf --- /dev/null +++ b/Desktop/src/gui/app/sidebar/nav_items.rs @@ -0,0 +1,331 @@ +use gpui::{div, rgb, Context, InteractiveElement, IntoElement, ParentElement, Styled}; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme::{self, render_icon}; + +impl ArcadiaRoot { + pub fn sidebar_toggle_button( + cx: &mut Context, + page_glyph: &str, + is_dark: bool, + ) -> impl IntoElement { + div() + .w_8() + .h_8() + .rounded_md() + .cursor_pointer() + .bg(if is_dark { + rgb(0x1f2937) + } else { + rgb(0xf3f4f6) + }) + .text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + }) + .hover(move |style| { + style.bg(if is_dark { + rgb(0x243246) + } else { + rgb(0xe5e7eb) + }) + }) + .flex() + .items_center() + .justify_center() + .child(render_icon(page_glyph).size_4().text_color(if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + })) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.sidebar_visible = !this.sidebar_visible; + cx.notify(); + }), + ) + } + + pub fn sidebar_group_item( + cx: &mut Context, + label: gpui::SharedString, + system_image: gpui::SharedString, + group_id: String, + is_active: bool, + is_dark: bool, + accent: String, + ) -> impl IntoElement { + let pal = theme::nav_accent_palette(accent.as_str(), is_dark); + let icon_color = if is_active { + pal.icon_active + } else { + pal.icon_idle + }; + let label_color = if is_active { + pal.icon_active + } else { + theme::sidebar_nav_idle_foreground(is_dark) + }; + div() + .w_16() + .h_16() + .flex() + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .text_xs() + .font_weight(if is_active { + gpui::FontWeight::BOLD + } else { + gpui::FontWeight::NORMAL + }) + .bg(if is_active { + pal.row_selected + } else if is_dark { + rgb(0x171b22) + } else { + rgb(0xf6f7fb) + }) + .text_color(label_color) + .hover(move |style| { + style.bg(if is_active { + pal.row_hover + } else if is_dark { + rgb(0x243246) + } else { + rgb(0xeef2ff) + }) + }) + .child( + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_1() + .text_center() + .child( + render_icon(system_image.as_ref()).size_5().text_color(icon_color), + ) + .child(div().child(label)), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.active_group_id = group_id.clone(); + if let Some(group) = this.effective_group(group_id.as_str()) { + if let Some(first_page_id) = group + .page_ids() + .into_iter() + .find(|pid| this.is_page_visible(pid)) + { + this.active_page_id = first_page_id.to_string(); + } + } + cx.notify(); + }), + ) + } + + /// Compact top-bar control (same visual weight as neutral badges). Use for header actions only; + /// the sidebar still uses [`Self::sidebar_global_item`]. + pub fn top_bar_global_item( + cx: &mut Context, + label: gpui::SharedString, + system_image: gpui::SharedString, + page_id: String, + is_active: bool, + is_dark: bool, + accent: String, + ) -> impl IntoElement { + let pal = theme::nav_accent_palette(accent.as_str(), is_dark); + let icon_color = if is_active { + pal.icon_active + } else { + pal.icon_idle + }; + let label_color = if is_active { + pal.icon_active + } else { + theme::sidebar_nav_idle_foreground(is_dark) + }; + div() + .px_2() + .py_0p5() + .rounded_md() + .cursor_pointer() + .text_xs() + .font_weight(if is_active { + gpui::FontWeight::SEMIBOLD + } else { + gpui::FontWeight::NORMAL + }) + .bg(if is_active { + pal.row_selected + } else { + theme::top_bar_pill_bg(is_dark) + }) + .text_color(label_color) + .hover(move |style| { + style.bg(if is_active { + pal.row_hover + } else { + theme::top_bar_pill_hover_bg(is_dark) + }) + }) + .child( + div() + .flex() + .gap_1() + .items_center() + .child(render_icon(system_image.as_ref()).size_4().text_color(icon_color)) + .child(div().child(label)), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.active_page_id = page_id.clone(); + if page_id == "global.modules" { + this.reload_modules(); + } + cx.notify(); + }), + ) + } + + pub fn sidebar_global_item( + cx: &mut Context, + label: gpui::SharedString, + system_image: gpui::SharedString, + page_id: String, + is_active: bool, + is_dark: bool, + accent: String, + ) -> impl IntoElement { + let pal = theme::nav_accent_palette(accent.as_str(), is_dark); + let icon_color = if is_active { + pal.icon_active + } else { + pal.icon_idle + }; + let label_color = if is_active { + pal.icon_active + } else { + theme::sidebar_nav_idle_foreground(is_dark) + }; + div() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .text_sm() + .font_weight(if is_active { + gpui::FontWeight::BOLD + } else { + gpui::FontWeight::NORMAL + }) + .bg(if is_active { + pal.row_selected + } else if is_dark { + rgb(0x171b22) + } else { + rgb(0xf6f7fb) + }) + .text_color(label_color) + .hover(move |style| { + style.bg(if is_active { + pal.row_hover + } else if is_dark { + rgb(0x243246) + } else { + rgb(0xeef2ff) + }) + }) + .child( + div() + .flex() + .gap_2() + .items_center() + .child(render_icon(system_image.as_ref()).size_4().text_color(icon_color)) + .child(div().child(label)), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.active_page_id = page_id.clone(); + if page_id == "global.modules" { + this.reload_modules(); + } + cx.notify(); + }), + ) + } + + pub fn sidebar_item( + cx: &mut Context, + label: gpui::SharedString, + system_image: gpui::SharedString, + page_id: String, + is_active: bool, + is_dark: bool, + accent: String, + ) -> impl IntoElement { + let pal = theme::nav_accent_palette(accent.as_str(), is_dark); + let icon_color = if is_active { + pal.icon_active + } else { + pal.icon_idle + }; + let label_color = if is_active { + pal.icon_active + } else { + theme::sidebar_nav_idle_foreground(is_dark) + }; + div() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .text_sm() + .font_weight(if is_active { + gpui::FontWeight::BOLD + } else { + gpui::FontWeight::NORMAL + }) + .bg(if is_active { + pal.row_selected + } else if is_dark { + rgb(0x171b22) + } else { + rgb(0xf6f7fb) + }) + .text_color(label_color) + .hover(move |style| { + style.bg(if is_active { + pal.row_hover + } else if is_dark { + rgb(0x243246) + } else { + rgb(0xeef2ff) + }) + }) + .child( + div() + .flex() + .gap_2() + .items_center() + .child(render_icon(system_image.as_ref()).size_4().text_color(icon_color)) + .child(div().child(label)), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.active_page_id = page_id.clone(); + cx.notify(); + }), + ) + } +} diff --git a/Desktop/src/gui/app/splash/draw_arch.rs b/Desktop/src/gui/app/splash/draw_arch.rs new file mode 100644 index 0000000..2b52113 --- /dev/null +++ b/Desktop/src/gui/app/splash/draw_arch.rs @@ -0,0 +1,61 @@ +use gpui::{point, px, Bounds, PathBuilder, Rgba, Window}; + +use crate::gui::theme; + +use super::math::{alpha_rgba, splash_scene_width}; + +pub(super) fn splash_draw_arch(bounds: Bounds, t: f32, window: &mut Window) { + if t <= 0.001 { + return; + } + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let cx = ox + w * 0.5; + let scene_w = splash_scene_width(w, h); + let apex_x = cx; + let apex_y = oy + h * 0.195; + let base_y = oy + h * 0.680; + let left_x = cx - scene_w * 0.205; + let right_x = cx + scene_w * 0.205; + + let fp = |x: f32, y: f32| -> gpui::Point { + point(px(apex_x + (x - apex_x) * t), px(apex_y + (y - apex_y) * t)) + }; + + let draw_arch = |width: f32, color: Rgba, window: &mut Window| { + let mut pb = PathBuilder::stroke(px(width)); + pb.move_to(fp(left_x, base_y)); + pb.cubic_bezier_to( + fp(apex_x, apex_y), + fp(left_x + scene_w * 0.035, oy + h * 0.520), + fp(apex_x - scene_w * 0.135, apex_y), + ); + pb.cubic_bezier_to( + fp(right_x, base_y), + fp(apex_x + scene_w * 0.135, apex_y), + fp(right_x - scene_w * 0.035, oy + h * 0.520), + ); + if let Ok(path) = pb.build() { + window.paint_path(path, color); + } + }; + + let arch_width = (scene_w * 0.050).clamp(52.0, 88.0); + draw_arch( + arch_width * 1.85, + alpha_rgba(theme::SPLASH_ARCH_GLOW, t * 0.040), + window, + ); + draw_arch( + arch_width * 1.35, + alpha_rgba(theme::SPLASH_ARCH_GLOW, t * 0.105), + window, + ); + draw_arch( + arch_width, + alpha_rgba(theme::SPLASH_ARCH_CORE, t.min(1.0)), + window, + ); +} diff --git a/Desktop/src/gui/app/splash/draw_bg.rs b/Desktop/src/gui/app/splash/draw_bg.rs new file mode 100644 index 0000000..4fb5d3c --- /dev/null +++ b/Desktop/src/gui/app/splash/draw_bg.rs @@ -0,0 +1,20 @@ +use gpui::{fill, point, px, size, Bounds, Window}; + +use super::math::splash_gradient_color; + +pub(super) fn splash_draw_bg(bounds: Bounds, window: &mut Window) { + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let strips = 180u32; + for i in 0..strips { + let t = i as f32 / (strips - 1) as f32; + let strip_h = h / strips as f32; + let strip_bounds = Bounds { + origin: point(px(ox), px(oy + i as f32 * strip_h)), + size: size(px(w), px(strip_h + 1.0)), + }; + window.paint_quad(fill(strip_bounds, splash_gradient_color(t))); + } +} diff --git a/Desktop/src/gui/app/splash/draw_hills.rs b/Desktop/src/gui/app/splash/draw_hills.rs new file mode 100644 index 0000000..424e4b0 --- /dev/null +++ b/Desktop/src/gui/app/splash/draw_hills.rs @@ -0,0 +1,83 @@ +use gpui::{point, px, Bounds, PathBuilder, Window}; + +use crate::gui::theme; + +use super::math::alpha_rgba; + +const SPLASH_HILLS_FINAL_DROP_PX: f32 = 52.0; + +pub(super) fn splash_draw_hills(bounds: Bounds, t: f32, window: &mut Window) { + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let offset = SPLASH_HILLS_FINAL_DROP_PX + (1.0 - t) * h * 0.35; + let p = |fx: f32, fy: f32| -> gpui::Point { + point(px(ox + fx * w), px(oy + fy * h + offset)) + }; + + { + let mut pb = PathBuilder::fill(); + pb.move_to(p(-0.05, 0.97)); + pb.cubic_bezier_to(p(0.50, 0.770), p(0.15, 0.920), p(0.34, 0.760)); + pb.cubic_bezier_to(p(1.05, 0.97), p(0.66, 0.760), p(0.85, 0.920)); + pb.line_to(p(1.05, 1.10)); + pb.line_to(p(-0.05, 1.10)); + pb.close(); + if let Ok(path) = pb.build() { + window.paint_path(path, alpha_rgba(theme::SPLASH_HILL_BACK, 0.72)); + } + } + + { + let mut pb = PathBuilder::fill(); + pb.move_to(p(-0.06, 0.905)); + pb.cubic_bezier_to(p(0.28, 0.665), p(0.06, 0.850), p(0.16, 0.690)); + pb.cubic_bezier_to(p(0.50, 0.710), p(0.38, 0.650), p(0.45, 0.690)); + pb.cubic_bezier_to(p(1.06, 0.930), p(0.66, 0.760), p(0.86, 0.900)); + pb.line_to(p(1.06, 1.10)); + pb.line_to(p(-0.06, 1.10)); + pb.close(); + if let Ok(path) = pb.build() { + window.paint_path(path, alpha_rgba(theme::SPLASH_HILL_LEFT, 0.82)); + } + } + + { + let mut pb = PathBuilder::fill(); + pb.move_to(p(1.06, 0.905)); + pb.cubic_bezier_to(p(0.72, 0.665), p(0.94, 0.850), p(0.84, 0.690)); + pb.cubic_bezier_to(p(0.50, 0.710), p(0.62, 0.650), p(0.55, 0.690)); + pb.cubic_bezier_to(p(-0.06, 0.930), p(0.34, 0.760), p(0.14, 0.900)); + pb.line_to(p(-0.06, 1.10)); + pb.line_to(p(1.0, 1.10)); + pb.close(); + if let Ok(path) = pb.build() { + window.paint_path(path, alpha_rgba(theme::SPLASH_HILL_RIGHT, 0.78)); + } + } + + { + let mut pb = PathBuilder::fill(); + pb.move_to(p(-0.05, 1.025)); + pb.cubic_bezier_to(p(1.05, 1.025), p(0.25, 0.885), p(0.75, 0.885)); + pb.line_to(p(1.05, 1.10)); + pb.line_to(p(-0.05, 1.10)); + pb.close(); + if let Ok(path) = pb.build() { + window.paint_path(path, alpha_rgba(theme::SPLASH_HILL_FRONT, 0.55)); + } + } + + { + let mut pb = PathBuilder::fill(); + pb.move_to(p(-0.05, 1.085)); + pb.cubic_bezier_to(p(1.05, 1.085), p(0.28, 0.985), p(0.72, 0.985)); + pb.line_to(p(1.05, 1.10)); + pb.line_to(p(-0.05, 1.10)); + pb.close(); + if let Ok(path) = pb.build() { + window.paint_path(path, alpha_rgba(theme::SPLASH_HILL_FRONT, 0.85)); + } + } +} diff --git a/Desktop/src/gui/app/splash/draw_horizon.rs b/Desktop/src/gui/app/splash/draw_horizon.rs new file mode 100644 index 0000000..09d14d6 --- /dev/null +++ b/Desktop/src/gui/app/splash/draw_horizon.rs @@ -0,0 +1,31 @@ +use gpui::{fill, point, px, size, Bounds, Window}; + +use crate::gui::theme; + +use super::math::{alpha_rgba, lerp_f32}; + +pub(super) fn splash_draw_horizon_glow(bounds: Bounds, t: f32, window: &mut Window) { + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let cx = ox + w * 0.5; + let cy = oy + h * 0.665; + + for i in 0..14 { + let u = i as f32 / 13.0; + let gw = w * lerp_f32(0.18, 0.92, u); + let gh = h * lerp_f32(0.08, 0.34, u); + let alpha = (1.0 - u).powi(2) * 0.11 * t; + let color = if i < 5 { + theme::SPLASH_HORIZON_GOLD + } else { + theme::SPLASH_HORIZON_PINK + }; + let gb = Bounds { + origin: point(px(cx - gw * 0.5), px(cy - gh * 0.5)), + size: size(px(gw), px(gh)), + }; + window.paint_quad(fill(gb, alpha_rgba(color, alpha)).corner_radii(px(gh * 0.5))); + } +} diff --git a/Desktop/src/gui/app/splash/draw_stars.rs b/Desktop/src/gui/app/splash/draw_stars.rs new file mode 100644 index 0000000..a744888 --- /dev/null +++ b/Desktop/src/gui/app/splash/draw_stars.rs @@ -0,0 +1,77 @@ +use gpui::{fill, point, px, size, Bounds, PathBuilder, Window}; + +use crate::gui::theme; + +use super::math::alpha_rgba; + +pub(super) fn splash_draw_stars(bounds: Bounds, t: f32, window: &mut Window) { + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + + let stars: &[(f32, f32, f32, f32, bool)] = &[ + (0.500, 0.380, 5.0, 0.00, true), + (0.460, 0.295, 1.8, 0.15, false), + (0.525, 0.275, 1.4, 0.25, false), + (0.572, 0.345, 1.4, 0.35, false), + (0.442, 0.418, 1.2, 0.10, false), + (0.551, 0.448, 1.2, 0.20, false), + (0.610, 0.318, 1.5, 0.30, false), + (0.398, 0.362, 1.2, 0.40, false), + ]; + + for &(fx, fy, star_r, delay, is_sparkle) in stars { + let local_t = ((t - delay) / (1.0 - delay.min(0.9))).clamp(0.0, 1.0); + if local_t <= 0.0 { + continue; + } + let cx = ox + fx * w; + let cy = oy + fy * h; + let alpha = local_t; + + if is_sparkle { + splash_draw_sparkle(cx, cy, star_r, alpha, window); + } else { + let sb = Bounds { + origin: point(px(cx - star_r), px(cy - star_r)), + size: size(px(star_r * 2.0), px(star_r * 2.0)), + }; + window.paint_quad( + fill(sb, alpha_rgba(theme::SPLASH_STAR, alpha)).corner_radii(px(star_r)), + ); + } + } +} + +fn splash_draw_sparkle(cx: f32, cy: f32, r: f32, alpha: f32, window: &mut Window) { + for angle_offset in [0.0_f32, std::f32::consts::PI / 4.0] { + let mut pb = PathBuilder::fill(); + let inner = r * 0.18; + let pts = 4usize; + for i in 0..(pts * 2) { + let angle = angle_offset + (i as f32 * std::f32::consts::PI) / pts as f32 + - std::f32::consts::FRAC_PI_2; + let rad = if i % 2 == 0 { r } else { inner }; + let x = cx + angle.cos() * rad; + let y = cy + angle.sin() * rad; + if i == 0 { + pb.move_to(point(px(x), px(y))); + } else { + pb.line_to(point(px(x), px(y))); + } + } + pb.close(); + if let Ok(path) = pb.build() { + window.paint_path(path, alpha_rgba(theme::SPLASH_STAR, alpha)); + } + } + let glow_r = r * 1.6; + let gb = Bounds { + origin: point(px(cx - glow_r), px(cy - glow_r)), + size: size(px(glow_r * 2.0), px(glow_r * 2.0)), + }; + window.paint_quad( + fill(gb, alpha_rgba(theme::SPLASH_STAR, alpha * 0.18)).corner_radii(px(glow_r)), + ); +} diff --git a/Desktop/src/gui/app/splash/draw_sun.rs b/Desktop/src/gui/app/splash/draw_sun.rs new file mode 100644 index 0000000..156652f --- /dev/null +++ b/Desktop/src/gui/app/splash/draw_sun.rs @@ -0,0 +1,53 @@ +use gpui::{fill, point, px, size, Bounds, Window}; + +use crate::gui::theme; + +use super::math::{alpha_rgba, lerp_f32, splash_scene_width}; + +pub(super) fn splash_draw_sun(bounds: Bounds, t: f32, window: &mut Window) { + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let cx = ox + w * 0.5; + let base_y = oy + h * 0.652; + let cy = lerp_f32(oy + h, base_y, t); + let r = (splash_scene_width(w, h) * 0.050).max(34.0); + for i in 0..16 { + let u = i as f32 / 15.0; + let rm = lerp_f32(6.0, 1.35, u); + let alpha_mult = (1.0 - u).powf(1.7) * 0.038 + 0.006; + let base_color = if i < 9 { + theme::SPLASH_HORIZON_PINK + } else { + theme::SPLASH_HORIZON_GOLD + }; + let gr = r * rm; + let gb = Bounds { + origin: point(px(cx - gr), px(cy - gr)), + size: size(px(gr * 2.0), px(gr * 2.0)), + }; + window.paint_quad(fill(gb, alpha_rgba(base_color, alpha_mult * t)).corner_radii(px(gr))); + } + + let soft_edge_layers = [ + (1.28, 0.22, theme::SPLASH_HORIZON_GOLD), + (1.12, 0.34, theme::SPLASH_SUN_LAYERS[3].2), + ]; + for (rm, alpha_mult, base_color) in soft_edge_layers { + let gr = r * rm; + let gb = Bounds { + origin: point(px(cx - gr), px(cy - gr)), + size: size(px(gr * 2.0), px(gr * 2.0)), + }; + window.paint_quad(fill(gb, alpha_rgba(base_color, alpha_mult * t)).corner_radii(px(gr))); + } + + let core_bounds = Bounds { + origin: point(px(cx - r), px(cy - r)), + size: size(px(r * 2.0), px(r * 2.0)), + }; + window.paint_quad( + fill(core_bounds, alpha_rgba(theme::SPLASH_SUN_LAYERS[4].2, t)).corner_radii(px(r)), + ); +} diff --git a/Desktop/src/gui/app/splash/math.rs b/Desktop/src/gui/app/splash/math.rs new file mode 100644 index 0000000..5ec1e35 --- /dev/null +++ b/Desktop/src/gui/app/splash/math.rs @@ -0,0 +1,53 @@ +use gpui::Rgba; + +use crate::gui::theme; + +pub(super) fn splash_phase(elapsed_ms: f32, start_ms: f32, duration_ms: f32) -> f32 { + ((elapsed_ms - start_ms) / duration_ms).clamp(0.0, 1.0) +} + +pub(super) fn ease_out_cubic(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(3) +} + +pub(super) fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +pub(super) fn lerp_rgba(a: Rgba, b: Rgba, t: f32) -> Rgba { + Rgba { + r: lerp_f32(a.r, b.r, t), + g: lerp_f32(a.g, b.g, t), + b: lerp_f32(a.b, b.b, t), + a: lerp_f32(a.a, b.a, t), + } +} + +pub(super) fn alpha_rgba(color: Rgba, alpha: f32) -> Rgba { + Rgba { + a: color.a * alpha, + ..color + } +} + +pub(super) fn splash_scene_width(w: f32, h: f32) -> f32 { + w.min(h * 1.52) +} + +pub(super) fn splash_gradient_color(t: f32) -> Rgba { + if t < 0.45 { + lerp_rgba(theme::SPLASH_BG_TOP, theme::SPLASH_BG_MID, t / 0.45) + } else if t < 0.72 { + lerp_rgba( + theme::SPLASH_BG_MID, + theme::SPLASH_BG_HORIZON, + (t - 0.45) / 0.27, + ) + } else { + lerp_rgba( + theme::SPLASH_BG_HORIZON, + theme::SPLASH_BG_BOTTOM, + (t - 0.72) / 0.28, + ) + } +} diff --git a/Desktop/src/gui/app/splash/mod.rs b/Desktop/src/gui/app/splash/mod.rs new file mode 100644 index 0000000..14399e0 --- /dev/null +++ b/Desktop/src/gui/app/splash/mod.rs @@ -0,0 +1,12 @@ +//! Splash intro: easing math, layered canvas painting, ArcadiaRoot hooks. + +pub(crate) const SPLASH_TOTAL_MS: f32 = 4500.0; + +mod draw_arch; +mod draw_bg; +mod draw_hills; +mod draw_horizon; +mod draw_stars; +mod draw_sun; +mod math; +mod view; diff --git a/Desktop/src/gui/app/splash/view.rs b/Desktop/src/gui/app/splash/view.rs new file mode 100644 index 0000000..51906d0 --- /dev/null +++ b/Desktop/src/gui/app/splash/view.rs @@ -0,0 +1,77 @@ +use std::time::Duration; + +use gpui::{canvas, div, Context, Div, ParentElement, Styled, Timer, Window}; + +use crate::gui::app::ArcadiaRoot; + +use super::draw_arch::splash_draw_arch; +use super::draw_bg::splash_draw_bg; +use super::draw_hills::splash_draw_hills; +use super::draw_horizon::splash_draw_horizon_glow; +use super::draw_stars::splash_draw_stars; +use super::draw_sun::splash_draw_sun; +use super::math::{ease_out_cubic, splash_phase}; +use super::SPLASH_TOTAL_MS; + +impl ArcadiaRoot { + pub(crate) fn ensure_splash_tick(&mut self, window: &mut Window, cx: &mut Context) { + if self.splash_tick_started { + return; + } + self.splash_tick_started = true; + cx.spawn_in( + window, + move |view: gpui::WeakEntity, cx: &mut gpui::AsyncWindowContext| { + let mut cx = cx.clone(); + async move { + loop { + Timer::after(Duration::from_millis(16)).await; + let done = cx + .update(|_, app| { + view.update(app, |this, cx| { + this.splash_elapsed_ms += 16.0; + cx.notify(); + this.splash_elapsed_ms >= SPLASH_TOTAL_MS + }) + .unwrap_or(true) + }) + .unwrap_or(true); + if done { + break; + } + } + } + }, + ) + .detach(); + } + + pub(crate) fn render_splash(&self) -> Div { + let t = self.splash_elapsed_ms; + + let hills_t = ease_out_cubic(splash_phase(t, 200.0, 900.0)); + let sun_t = ease_out_cubic(splash_phase(t, 700.0, 1100.0)); + let arch_t = ease_out_cubic(splash_phase(t, 1300.0, 1200.0)); + let stars_t = splash_phase(t, 2000.0, 800.0); + let master_alpha = if t < 3600.0 { + splash_phase(t, 0.0, 400.0) + } else { + 1.0 - splash_phase(t, 3600.0, 700.0) + }; + + div().size_full().opacity(master_alpha).child( + canvas( + |_bounds, _window, _cx| {}, + move |bounds, _, window, _cx| { + splash_draw_bg(bounds, window); + splash_draw_horizon_glow(bounds, sun_t, window); + splash_draw_arch(bounds, arch_t, window); + splash_draw_stars(bounds, stars_t, window); + splash_draw_sun(bounds, sun_t, window); + splash_draw_hills(bounds, hills_t, window); + }, + ) + .size_full(), + ) + } +} diff --git a/Desktop/src/gui/assets.rs b/Desktop/src/gui/assets.rs new file mode 100644 index 0000000..2fed2ce --- /dev/null +++ b/Desktop/src/gui/assets.rs @@ -0,0 +1,41 @@ +use std::borrow::Cow; + +use gpui::{AssetSource, Result, SharedString}; + +pub struct EmbeddedAssets; + +impl AssetSource for EmbeddedAssets { + fn load(&self, path: &str) -> Result>> { + match path { + "icons/terminal.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/terminal.svg" + )))), + "icons/home.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/home.svg" + )))), + "icons/logs.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/logs.svg" + )))), + "icons/settings.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/settings.svg" + )))), + "icons/modules.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/modules.svg" + )))), + "icons/nodes.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/nodes.svg" + )))), + "icons/tools.svg" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../assets/icons/tools.svg" + )))), + "icons/app-icon.png" => Ok(Some(Cow::Borrowed(include_bytes!( + "../../../Resources/Icons/Production/Final-1-appicon.png" + )))), + _ => Ok(None), + } + } + + fn list(&self, _path: &str) -> Result> { + Ok(vec![]) + } +} diff --git a/Desktop/src/gui/mod.rs b/Desktop/src/gui/mod.rs new file mode 100644 index 0000000..736c7ea --- /dev/null +++ b/Desktop/src/gui/mod.rs @@ -0,0 +1,6 @@ +mod app; +mod assets; +mod theme; +mod tui; + +pub use app::run; diff --git a/Desktop/src/gui/theme/chrome.rs b/Desktop/src/gui/theme/chrome.rs new file mode 100644 index 0000000..77c7d0a --- /dev/null +++ b/Desktop/src/gui/theme/chrome.rs @@ -0,0 +1,190 @@ +use gpui::Rgba; + +/// Neutral compact pill in the main top bar (matches cwd / small actions). +pub fn top_bar_pill_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.122, + g: 0.161, + b: 0.216, + a: 1.0, + } + } else { + Rgba { + r: 0.953, + g: 0.957, + b: 0.961, + a: 1.0, + } + } +} + +pub fn top_bar_pill_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.820, + g: 0.847, + b: 0.859, + a: 1.0, + } + } else { + Rgba { + r: 0.294, + g: 0.337, + b: 0.388, + a: 1.0, + } + } +} + +/// Non-selected sidebar / top-bar nav labels (neutral). Icons use `NavAccentPalette::icon_idle`. +#[inline] +pub fn sidebar_nav_idle_foreground(is_dark: bool) -> Rgba { + top_bar_pill_text(is_dark) +} + +pub fn top_bar_pill_hover_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.165, + g: 0.212, + b: 0.278, + a: 1.0, + } + } else { + Rgba { + r: 0.922, + g: 0.929, + b: 0.941, + a: 1.0, + } + } +} + +/// Selected top-bar nav pill (e.g. Logs when that page is active). +pub fn top_bar_pill_active_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.122, + g: 0.165, + b: 0.243, + a: 1.0, + } + } else { + Rgba { + r: 0.882, + g: 0.906, + b: 1.000, + a: 1.0, + } + } +} + +pub fn top_bar_pill_active_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.576, + g: 0.773, + b: 0.992, + a: 1.0, + } + } else { + Rgba { + r: 0.114, + g: 0.306, + b: 0.847, + a: 1.0, + } + } +} + +pub fn top_bar_pill_active_hover_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.145, + g: 0.196, + b: 0.282, + a: 1.0, + } + } else { + Rgba { + r: 0.855, + g: 0.878, + b: 0.992, + a: 1.0, + } + } +} + +/// Sidebar brand row: outlined “session” chip (matches panel surface). +pub fn sidebar_session_chip_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.090, + g: 0.106, + b: 0.133, + a: 1.0, + } + } else { + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } + } +} + +pub fn sidebar_session_chip_border(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.231, + g: 0.263, + b: 0.318, + a: 1.0, + } + } else { + Rgba { + r: 0.820, + g: 0.847, + b: 0.878, + a: 1.0, + } + } +} + +pub fn sidebar_session_chip_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } + } else { + Rgba { + r: 0.176, + g: 0.204, + b: 0.235, + a: 1.0, + } + } +} + +pub fn sidebar_session_chip_hover_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.125, + g: 0.145, + b: 0.180, + a: 1.0, + } + } else { + Rgba { + r: 0.965, + g: 0.970, + b: 0.980, + a: 1.0, + } + } +} diff --git a/Desktop/src/gui/theme/icons.rs b/Desktop/src/gui/theme/icons.rs new file mode 100644 index 0000000..6996d32 --- /dev/null +++ b/Desktop/src/gui/theme/icons.rs @@ -0,0 +1,18 @@ +use gpui::{svg, Svg}; + +pub fn icon_path(glyph_key: &str) -> &'static str { + match glyph_key { + "terminal" => "icons/terminal.svg", + "home" => "icons/home.svg", + "logs" => "icons/logs.svg", + "settings" => "icons/settings.svg", + "modules" => "icons/modules.svg", + "nodes" => "icons/nodes.svg", + "tools" => "icons/tools.svg", + _ => "icons/terminal.svg", + } +} + +pub fn render_icon(glyph_key: &str) -> Svg { + svg().path(icon_path(glyph_key)) +} diff --git a/Desktop/src/gui/theme/mod.rs b/Desktop/src/gui/theme/mod.rs new file mode 100644 index 0000000..a3e2e5e --- /dev/null +++ b/Desktop/src/gui/theme/mod.rs @@ -0,0 +1,13 @@ +//! Desktop theme tokens: splash palette, module UI, chrome, nav accents, icons. + +mod chrome; +mod icons; +mod modules; +mod nav_accents; +mod splash_colors; + +pub use chrome::*; +pub use icons::*; +pub use modules::*; +pub use nav_accents::*; +pub use splash_colors::*; diff --git a/Desktop/src/gui/theme/modules/buttons.rs b/Desktop/src/gui/theme/modules/buttons.rs new file mode 100644 index 0000000..4c63d3d --- /dev/null +++ b/Desktop/src/gui/theme/modules/buttons.rs @@ -0,0 +1,73 @@ +use gpui::Rgba; + +pub fn module_button_enable_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.120, + g: 0.465, + b: 0.335, + a: 1.0, + } + } else { + Rgba { + r: 0.130, + g: 0.610, + b: 0.435, + a: 1.0, + } + } +} + +pub fn module_button_enable_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.905, + g: 1.000, + b: 0.960, + a: 1.0, + } + } else { + Rgba { + r: 0.950, + g: 1.000, + b: 0.975, + a: 1.0, + } + } +} + +pub fn module_button_disable_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.620, + g: 0.250, + b: 0.285, + a: 1.0, + } + } else { + Rgba { + r: 0.870, + g: 0.285, + b: 0.365, + a: 1.0, + } + } +} + +pub fn module_button_disable_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 1.000, + g: 0.925, + b: 0.940, + a: 1.0, + } + } else { + Rgba { + r: 1.000, + g: 0.955, + b: 0.965, + a: 1.0, + } + } +} diff --git a/Desktop/src/gui/theme/modules/mod.rs b/Desktop/src/gui/theme/modules/mod.rs new file mode 100644 index 0000000..4e1e3c2 --- /dev/null +++ b/Desktop/src/gui/theme/modules/mod.rs @@ -0,0 +1,13 @@ +//! Module settings page color tokens. + +mod buttons; +mod panel; +mod row_surface; +mod toggle_states; +mod typography; + +pub use buttons::*; +pub use panel::*; +pub use row_surface::*; +pub use toggle_states::*; +pub use typography::*; diff --git a/Desktop/src/gui/theme/modules/panel.rs b/Desktop/src/gui/theme/modules/panel.rs new file mode 100644 index 0000000..1db4792 --- /dev/null +++ b/Desktop/src/gui/theme/modules/panel.rs @@ -0,0 +1,37 @@ +use gpui::Rgba; + +pub fn module_panel_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.095, + g: 0.115, + b: 0.145, + a: 1.0, + } + } else { + Rgba { + r: 0.965, + g: 0.978, + b: 0.995, + a: 1.0, + } + } +} + +pub fn module_panel_stroke(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.190, + g: 0.230, + b: 0.290, + a: 1.0, + } + } else { + Rgba { + r: 0.840, + g: 0.885, + b: 0.945, + a: 1.0, + } + } +} diff --git a/Desktop/src/gui/theme/modules/row_surface.rs b/Desktop/src/gui/theme/modules/row_surface.rs new file mode 100644 index 0000000..8947967 --- /dev/null +++ b/Desktop/src/gui/theme/modules/row_surface.rs @@ -0,0 +1,37 @@ +use gpui::Rgba; + +pub fn module_row_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.120, + g: 0.150, + b: 0.190, + a: 1.0, + } + } else { + Rgba { + r: 1.000, + g: 1.000, + b: 1.000, + a: 0.98, + } + } +} + +pub fn module_row_stroke(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.260, + g: 0.315, + b: 0.390, + a: 1.0, + } + } else { + Rgba { + r: 0.860, + g: 0.905, + b: 0.965, + a: 1.0, + } + } +} diff --git a/Desktop/src/gui/theme/modules/toggle_states.rs b/Desktop/src/gui/theme/modules/toggle_states.rs new file mode 100644 index 0000000..82ff219 --- /dev/null +++ b/Desktop/src/gui/theme/modules/toggle_states.rs @@ -0,0 +1,73 @@ +use gpui::Rgba; + +pub fn module_state_enabled_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.070, + g: 0.350, + b: 0.255, + a: 0.38, + } + } else { + Rgba { + r: 0.820, + g: 0.970, + b: 0.895, + a: 1.0, + } + } +} + +pub fn module_state_enabled_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.635, + g: 0.955, + b: 0.825, + a: 1.0, + } + } else { + Rgba { + r: 0.060, + g: 0.460, + b: 0.340, + a: 1.0, + } + } +} + +pub fn module_state_disabled_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.420, + g: 0.180, + b: 0.190, + a: 0.35, + } + } else { + Rgba { + r: 0.995, + g: 0.880, + b: 0.895, + a: 1.0, + } + } +} + +pub fn module_state_disabled_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.985, + g: 0.760, + b: 0.785, + a: 1.0, + } + } else { + Rgba { + r: 0.700, + g: 0.180, + b: 0.250, + a: 1.0, + } + } +} diff --git a/Desktop/src/gui/theme/modules/typography.rs b/Desktop/src/gui/theme/modules/typography.rs new file mode 100644 index 0000000..d5d22b7 --- /dev/null +++ b/Desktop/src/gui/theme/modules/typography.rs @@ -0,0 +1,55 @@ +use gpui::Rgba; + +pub fn module_title_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.940, + g: 0.965, + b: 1.000, + a: 1.0, + } + } else { + Rgba { + r: 0.090, + g: 0.145, + b: 0.230, + a: 1.0, + } + } +} + +pub fn module_description_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.700, + g: 0.760, + b: 0.840, + a: 1.0, + } + } else { + Rgba { + r: 0.320, + g: 0.390, + b: 0.500, + a: 1.0, + } + } +} + +pub fn module_meta_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.775, + g: 0.835, + b: 0.930, + a: 1.0, + } + } else { + Rgba { + r: 0.250, + g: 0.390, + b: 0.660, + a: 1.0, + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/amber.rs b/Desktop/src/gui/theme/nav_accents/amber.rs new file mode 100644 index 0000000..4a5048a --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/amber.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(180, 83, 9), + icon_active: rgb8(251, 191, 36), + row_selected: rgb8(53, 42, 28), + row_hover: rgb8(63, 52, 40), + } + } else { + NavAccentPalette { + icon_idle: rgb8(180, 83, 9), + icon_active: rgb8(217, 119, 6), + row_selected: rgb8(255, 251, 235), + row_hover: rgb8(254, 243, 199), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/cyan.rs b/Desktop/src/gui/theme/nav_accents/cyan.rs new file mode 100644 index 0000000..2238c79 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/cyan.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(14, 116, 144), + icon_active: rgb8(34, 211, 238), + row_selected: rgb8(21, 42, 48), + row_hover: rgb8(26, 53, 64), + } + } else { + NavAccentPalette { + icon_idle: rgb8(8, 145, 178), + icon_active: rgb8(8, 145, 178), + row_selected: rgb8(236, 254, 255), + row_hover: rgb8(207, 250, 254), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/emerald.rs b/Desktop/src/gui/theme/nav_accents/emerald.rs new file mode 100644 index 0000000..ad6d5b3 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/emerald.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(4, 120, 87), + icon_active: rgb8(52, 211, 153), + row_selected: rgb8(20, 41, 34), + row_hover: rgb8(26, 51, 40), + } + } else { + NavAccentPalette { + icon_idle: rgb8(4, 120, 87), + icon_active: rgb8(5, 150, 105), + row_selected: rgb8(236, 253, 245), + row_hover: rgb8(209, 250, 229), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/fuchsia.rs b/Desktop/src/gui/theme/nav_accents/fuchsia.rs new file mode 100644 index 0000000..bc2f5da --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/fuchsia.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(162, 28, 175), + icon_active: rgb8(232, 121, 249), + row_selected: rgb8(45, 21, 51), + row_hover: rgb8(56, 26, 64), + } + } else { + NavAccentPalette { + icon_idle: rgb8(162, 28, 175), + icon_active: rgb8(192, 38, 211), + row_selected: rgb8(253, 244, 255), + row_hover: rgb8(250, 232, 255), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/indigo.rs b/Desktop/src/gui/theme/nav_accents/indigo.rs new file mode 100644 index 0000000..08ce4f1 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/indigo.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(67, 56, 202), + icon_active: rgb8(129, 140, 248), + row_selected: rgb8(30, 27, 51), + row_hover: rgb8(37, 33, 64), + } + } else { + NavAccentPalette { + icon_idle: rgb8(67, 56, 202), + icon_active: rgb8(79, 70, 229), + row_selected: rgb8(238, 242, 255), + row_hover: rgb8(224, 231, 255), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/mod.rs b/Desktop/src/gui/theme/nav_accents/mod.rs new file mode 100644 index 0000000..2915506 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/mod.rs @@ -0,0 +1,14 @@ +//! Navigation accent palettes keyed by `Navigation*Definition::accent`. + +mod amber; +mod cyan; +mod emerald; +mod fuchsia; +mod indigo; +mod orange; +mod palette; +mod sky; +mod teal; +mod violet; + +pub use palette::nav_accent_palette; diff --git a/Desktop/src/gui/theme/nav_accents/orange.rs b/Desktop/src/gui/theme/nav_accents/orange.rs new file mode 100644 index 0000000..2e662b5 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/orange.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(194, 65, 12), + icon_active: rgb8(251, 146, 60), + row_selected: rgb8(51, 24, 16), + row_hover: rgb8(64, 34, 24), + } + } else { + NavAccentPalette { + icon_idle: rgb8(194, 65, 12), + icon_active: rgb8(234, 88, 12), + row_selected: rgb8(255, 247, 237), + row_hover: rgb8(255, 237, 213), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/palette.rs b/Desktop/src/gui/theme/nav_accents/palette.rs new file mode 100644 index 0000000..9a94331 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/palette.rs @@ -0,0 +1,35 @@ +use gpui::Rgba; + +/// Per-group / per-page nav accent: icon tints and row selection (see `Navigation*Definition::accent` in the core). +#[derive(Clone, Copy)] +pub struct NavAccentPalette { + pub icon_idle: Rgba, + pub icon_active: Rgba, + pub row_selected: Rgba, + pub row_hover: Rgba, +} + +#[inline] +pub(super) fn rgb8(r: u8, g: u8, b: u8) -> Rgba { + Rgba { + r: f32::from(r) / 255.0, + g: f32::from(g) / 255.0, + b: f32::from(b) / 255.0, + a: 1.0, + } +} + +pub fn nav_accent_palette(accent_key: &str, is_dark: bool) -> NavAccentPalette { + match accent_key { + "amber" => super::amber::palette(is_dark), + "cyan" => super::cyan::palette(is_dark), + "emerald" => super::emerald::palette(is_dark), + "violet" => super::violet::palette(is_dark), + "orange" => super::orange::palette(is_dark), + "sky" => super::sky::palette(is_dark), + "indigo" => super::indigo::palette(is_dark), + "fuchsia" => super::fuchsia::palette(is_dark), + "teal" => super::teal::palette(is_dark), + _ => super::sky::palette(is_dark), + } +} diff --git a/Desktop/src/gui/theme/nav_accents/sky.rs b/Desktop/src/gui/theme/nav_accents/sky.rs new file mode 100644 index 0000000..4a49a18 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/sky.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(125, 180, 252), + icon_active: rgb8(147, 197, 253), + row_selected: rgb8(31, 42, 62), + row_hover: rgb8(36, 50, 70), + } + } else { + NavAccentPalette { + icon_idle: rgb8(59, 130, 246), + icon_active: rgb8(29, 78, 216), + row_selected: rgb8(225, 231, 255), + row_hover: rgb8(238, 242, 255), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/teal.rs b/Desktop/src/gui/theme/nav_accents/teal.rs new file mode 100644 index 0000000..a8c0d2d --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/teal.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(15, 118, 110), + icon_active: rgb8(45, 212, 191), + row_selected: rgb8(20, 40, 36), + row_hover: rgb8(26, 51, 46), + } + } else { + NavAccentPalette { + icon_idle: rgb8(15, 118, 110), + icon_active: rgb8(13, 148, 136), + row_selected: rgb8(240, 253, 250), + row_hover: rgb8(204, 251, 241), + } + } +} diff --git a/Desktop/src/gui/theme/nav_accents/violet.rs b/Desktop/src/gui/theme/nav_accents/violet.rs new file mode 100644 index 0000000..3df45e4 --- /dev/null +++ b/Desktop/src/gui/theme/nav_accents/violet.rs @@ -0,0 +1,19 @@ +use super::palette::{rgb8, NavAccentPalette}; + +pub(super) fn palette(is_dark: bool) -> NavAccentPalette { + if is_dark { + NavAccentPalette { + icon_idle: rgb8(109, 40, 217), + icon_active: rgb8(167, 139, 250), + row_selected: rgb8(37, 26, 51), + row_hover: rgb8(46, 33, 64), + } + } else { + NavAccentPalette { + icon_idle: rgb8(109, 40, 217), + icon_active: rgb8(124, 58, 237), + row_selected: rgb8(245, 243, 255), + row_hover: rgb8(237, 233, 254), + } + } +} diff --git a/Desktop/src/gui/theme/splash_colors.rs b/Desktop/src/gui/theme/splash_colors.rs new file mode 100644 index 0000000..2341201 --- /dev/null +++ b/Desktop/src/gui/theme/splash_colors.rs @@ -0,0 +1,136 @@ +use gpui::Rgba; +pub const SPLASH_BG_TOP: Rgba = Rgba { + r: 0.060, + g: 0.055, + b: 0.580, + a: 1.0, +}; +pub const SPLASH_BG_MID: Rgba = Rgba { + r: 0.205, + g: 0.105, + b: 0.760, + a: 1.0, +}; +pub const SPLASH_BG_HORIZON: Rgba = Rgba { + r: 0.790, + g: 0.240, + b: 0.760, + a: 1.0, +}; +pub const SPLASH_BG_BOTTOM: Rgba = Rgba { + r: 1.000, + g: 0.480, + b: 0.560, + a: 1.0, +}; + +pub const SPLASH_HORIZON_PINK: Rgba = Rgba { + r: 1.000, + g: 0.250, + b: 0.670, + a: 1.0, +}; +pub const SPLASH_HORIZON_GOLD: Rgba = Rgba { + r: 1.000, + g: 0.690, + b: 0.250, + a: 1.0, +}; + +pub const SPLASH_HILL_BACK: Rgba = Rgba { + r: 0.430, + g: 0.150, + b: 0.900, + a: 0.62, +}; +pub const SPLASH_HILL_LEFT: Rgba = Rgba { + r: 0.500, + g: 0.180, + b: 0.920, + a: 0.78, +}; +pub const SPLASH_HILL_RIGHT: Rgba = Rgba { + r: 0.420, + g: 0.135, + b: 0.835, + a: 0.76, +}; +pub const SPLASH_HILL_FRONT: Rgba = Rgba { + r: 0.045, + g: 0.040, + b: 0.430, + a: 0.82, +}; + +pub const SPLASH_ARCH_CORE: Rgba = Rgba { + r: 0.930, + g: 0.860, + b: 1.000, + a: 1.0, +}; +pub const SPLASH_ARCH_GLOW: Rgba = Rgba { + r: 0.765, + g: 0.610, + b: 1.000, + a: 1.0, +}; + +pub const SPLASH_SUN_LAYERS: [(f32, f32, Rgba); 5] = [ + ( + 4.2, + 0.055, + Rgba { + r: 1.000, + g: 0.300, + b: 0.620, + a: 1.0, + }, + ), + ( + 3.2, + 0.115, + Rgba { + r: 1.000, + g: 0.500, + b: 0.280, + a: 1.0, + }, + ), + ( + 2.2, + 0.210, + Rgba { + r: 1.000, + g: 0.690, + b: 0.300, + a: 1.0, + }, + ), + ( + 1.45, + 0.440, + Rgba { + r: 1.000, + g: 0.830, + b: 0.520, + a: 1.0, + }, + ), + ( + 1.0, + 1.000, + Rgba { + r: 1.000, + g: 0.950, + b: 0.770, + a: 1.0, + }, + ), +]; + +pub const SPLASH_STAR: Rgba = Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, +}; diff --git a/Desktop/src/gui/tui/ansi_line.rs b/Desktop/src/gui/tui/ansi_line.rs new file mode 100644 index 0000000..8fbb407 --- /dev/null +++ b/Desktop/src/gui/tui/ansi_line.rs @@ -0,0 +1,256 @@ +//! Parse ANSI SGR sequences in plain strings for shell transcript rendering. + +use gpui::{div, px, rgb, Div, FontWeight, ParentElement, Rgba, Styled}; + +/// Must match `shell/execute.rs` `CHAR_W` / `CHAR_H` (PTY ↔ transcript cell grid). +const MONO_CELL_W: f32 = 8.4; +const TRANSCRIPT_ROW_H: f32 = 18.0; + +use super::colors::{self}; + +#[derive(Clone, Copy)] +struct StyleState { + fg: Rgba, + bg: Option, + bold: bool, +} + +fn rgba_u8(r: u32, g: u32, b: u32) -> Rgba { + Rgba { + r: (r.min(255)) as f32 / 255.0, + g: (g.min(255)) as f32 / 255.0, + b: (b.min(255)) as f32 / 255.0, + a: 1.0, + } +} + +fn sgr_fg_basic(code: u32) -> Option { + match code { + 30..=37 => Some(colors::ansi_indexed((code - 30) as u8)), + 90..=97 => Some(colors::ansi_indexed((code - 90 + 8) as u8)), + _ => None, + } +} + +fn sgr_bg_basic(code: u32) -> Option { + match code { + 40..=47 => Some(colors::ansi_indexed((code - 40) as u8)), + 100..=107 => Some(colors::ansi_indexed((code - 100 + 8) as u8)), + _ => None, + } +} + +fn parse_sgr_params(raw: &str) -> Vec { + if raw.is_empty() { + return vec![0]; + } + raw.split(';') + .map(|s| { + if s.is_empty() { + 0 + } else { + s.parse::().unwrap_or(0) + } + }) + .collect() +} + +fn apply_sgr(codes: &[u32], st: &mut StyleState, default_fg: Rgba) { + let mut i = 0usize; + while i < codes.len() { + match codes[i] { + 0 => { + st.fg = default_fg; + st.bg = None; + st.bold = false; + } + 1 => st.bold = true, + 22 => st.bold = false, + 39 => st.fg = default_fg, + 49 => st.bg = None, + n @ 30..=37 | n @ 90..=97 => { + if let Some(c) = sgr_fg_basic(n) { + st.fg = c; + } + } + n @ 40..=47 | n @ 100..=107 => { + if let Some(c) = sgr_bg_basic(n) { + st.bg = Some(c); + } + } + 38 => { + if codes.get(i + 1) == Some(&5) && i + 2 < codes.len() { + st.fg = colors::ansi_indexed(codes[i + 2].min(255) as u8); + i += 3; + continue; + } + // Params: 38;2;r;g;b → indices i..i+4 inclusive (len ≥ i+5). + if codes.get(i + 1) == Some(&2) && i + 4 < codes.len() { + st.fg = rgba_u8(codes[i + 2], codes[i + 3], codes[i + 4]); + i += 5; + continue; + } + } + 48 => { + if codes.get(i + 1) == Some(&5) && i + 2 < codes.len() { + st.bg = Some(colors::ansi_indexed(codes[i + 2].min(255) as u8)); + i += 3; + continue; + } + if codes.get(i + 1) == Some(&2) && i + 4 < codes.len() { + st.bg = Some(rgba_u8(codes[i + 2], codes[i + 3], codes[i + 4])); + i += 5; + continue; + } + } + _ => {} + } + i += 1; + } +} + +fn skip_osc(it: &mut std::iter::Peekable) { + while let Some(c) = it.next() { + if c == '\x07' { + break; + } + if c == '\x1b' && it.peek() == Some(&'\\') { + let _ = it.next(); + break; + } + } +} + +#[derive(Clone)] +struct Run { + text: String, + fg: Rgba, + bg: Option, + bold: bool, +} + +fn rgba_style_eq(a: Rgba, b: Rgba) -> bool { + (a.r - b.r).abs() < f32::EPSILON + && (a.g - b.g).abs() < f32::EPSILON + && (a.b - b.b).abs() < f32::EPSILON + && (a.a - b.a).abs() < f32::EPSILON +} + +fn flush_run(buf: &mut String, runs: &mut Vec, st: StyleState) { + if buf.is_empty() { + return; + } + let text = std::mem::take(buf); + if let Some(prev) = runs.last_mut() { + let bg_match = match (prev.bg, st.bg) { + (Some(a), Some(b)) => rgba_style_eq(a, b), + (None, None) => true, + _ => false, + }; + if rgba_style_eq(prev.fg, st.fg) && bg_match && prev.bold == st.bold { + prev.text.push_str(&text); + return; + } + } + runs.push(Run { + text, + fg: st.fg, + bg: st.bg, + bold: st.bold, + }); +} + +fn parse_ansi_runs(line: &str, default_fg: Rgba) -> Vec { + let mut runs = Vec::new(); + let mut buf = String::new(); + let mut st = StyleState { + fg: default_fg, + bg: None, + bold: false, + }; + let mut it = line.chars().peekable(); + + while let Some(ch) = it.next() { + if ch == '\r' { + continue; + } + if ch == '\x1b' { + flush_run(&mut buf, &mut runs, st); + match it.peek().copied() { + Some('[') => { + it.next(); + let mut raw = String::new(); + let mut closed = false; + while let Some(c) = it.next() { + if ('\x40'..='\x7e').contains(&c) { + if c == 'm' { + apply_sgr(&parse_sgr_params(&raw), &mut st, default_fg); + } + closed = true; + break; + } + raw.push(c); + } + if !closed { + break; + } + } + Some(']') => { + it.next(); + skip_osc(&mut it); + } + Some('(') | Some(')') => { + let _ = it.next(); + let _ = it.next(); + } + _ => {} + } + continue; + } + buf.push(ch); + } + flush_run(&mut buf, &mut runs, st); + runs +} + +pub(crate) fn shell_history_line(line: &str, is_dark: bool) -> Div { + let default_fg = if is_dark { + rgb(0xe5e7eb) + } else { + rgb(0x1f2937) + }; + + let runs = parse_ansi_runs(line, default_fg); + if runs.is_empty() { + return div().w_full().h(px(0.)).flex_shrink_0().overflow_hidden(); + } + + div() + .w_full() + .flex_shrink_0() + .h(px(TRANSCRIPT_ROW_H)) + .line_height(px(TRANSCRIPT_ROW_H)) + .flex() + .flex_row() + .items_center() + .overflow_hidden() + .font_family("monospace") + .text_sm() + .children(runs.into_iter().map(|run| { + let cols = run.text.chars().count().max(1); + let mut el = div() + .flex_shrink_0() + .w(px(MONO_CELL_W * cols as f32)) + .text_color(run.fg) + .font_weight(if run.bold { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }) + .child(run.text); + if let Some(bg) = run.bg { + el = el.bg(bg); + } + el + })) +} diff --git a/Desktop/src/gui/tui/cd_builtin.rs b/Desktop/src/gui/tui/cd_builtin.rs new file mode 100644 index 0000000..3d486ca --- /dev/null +++ b/Desktop/src/gui/tui/cd_builtin.rs @@ -0,0 +1,105 @@ +//! Infer cwd after a trivial `cd` when PTY foreground pgid is unavailable (`tcgetpgrp`). + +use std::path::{Component, Path, PathBuf}; + +pub(crate) fn resolve_simple_cd(base: &Path, command: &str) -> Option { + let operand = parse_cd_operand(command)?; + let home = std::env::var_os("HOME").map(PathBuf::from); + resolve_cd_operand(base, home.as_deref(), operand) +} + +fn parse_cd_operand(cmd: &str) -> Option> { + let cmd = cmd.trim(); + if cmd == "cd" { + return Some(None); + } + const PREFIX: &str = "cd "; + if !cmd.starts_with(PREFIX) { + return None; + } + let arg = cmd[PREFIX.len()..].trim(); + if arg.is_empty() { + return Some(None); + } + if arg.contains(';') || arg.contains("&&") || arg.contains('|') || arg.contains('\n') { + return None; + } + if arg.split_whitespace().nth(1).is_some() { + return None; + } + Some(Some(arg.to_string())) +} + +fn resolve_cd_operand( + base: &Path, + home: Option<&Path>, + operand: Option, +) -> Option { + let raw = match operand { + None => home?.to_path_buf(), + Some(arg) => { + if arg == "-" { + return None; + } + expand_path(base, home, &arg) + } + }; + let normalized = lexical_normalize(raw); + normalized.exists().then_some(normalized) +} + +fn expand_path(base: &Path, home: Option<&Path>, arg: &str) -> PathBuf { + if let Some(rest) = arg.strip_prefix('~') { + return match home { + Some(h) => { + if rest.is_empty() { + h.to_path_buf() + } else if rest.starts_with('/') { + h.join(rest.trim_start_matches('/')) + } else { + h.join(rest) + } + } + None => PathBuf::from(arg), + }; + } + if arg.starts_with('/') { + PathBuf::from(arg) + } else { + base.join(arg) + } +} + +fn lexical_normalize(path: PathBuf) -> PathBuf { + let mut stack: Vec = Vec::new(); + let mut absolute = false; + + for comp in path.components() { + match comp { + Component::Prefix(p) => stack.push(p.as_os_str().to_owned()), + Component::RootDir => { + absolute = true; + stack.clear(); + stack.push(comp.as_os_str().to_owned()); + } + Component::CurDir => {} + Component::ParentDir => { + if absolute && stack.len() <= 1 { + continue; + } + let _ = stack.pop(); + } + Component::Normal(o) => stack.push(o.to_owned()), + } + } + + let mut out = PathBuf::new(); + for s in stack { + out.push(s); + } + if out.as_os_str().is_empty() { + PathBuf::from("/") + } else { + out + } +} diff --git a/Desktop/src/gui/tui/colors.rs b/Desktop/src/gui/tui/colors.rs new file mode 100644 index 0000000..7d0a55c --- /dev/null +++ b/Desktop/src/gui/tui/colors.rs @@ -0,0 +1,110 @@ +use gpui::Rgba; + +pub fn default_fg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.87, + g: 0.87, + b: 0.87, + a: 1.0, + } + } else { + Rgba { + r: 0.07, + g: 0.07, + b: 0.07, + a: 1.0, + } + } +} + +pub fn default_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.047, + g: 0.047, + b: 0.047, + a: 1.0, + } + } else { + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } + } +} + +pub fn rgba_eq(a: Rgba, b: Rgba) -> bool { + a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a +} + +/// Convert a vt100 Color to an Rgba value. +pub fn vt_color(color: vt100::Color, is_fg: bool, is_dark: bool) -> Rgba { + match color { + vt100::Color::Default => { + if is_fg { + default_fg(is_dark) + } else { + default_bg(is_dark) + } + } + vt100::Color::Idx(idx) => ansi_indexed(idx), + vt100::Color::Rgb(r, g, b) => Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + }, + } +} + +pub(crate) fn ansi_indexed(idx: u8) -> Rgba { + let (r, g, b): (u8, u8, u8) = match idx { + 0 => (0x1c, 0x1c, 0x1c), + 1 => (0xcc, 0x00, 0x00), + 2 => (0x4e, 0x9a, 0x06), + 3 => (0xc4, 0xa0, 0x00), + 4 => (0x34, 0x65, 0xa4), + 5 => (0x75, 0x50, 0x7b), + 6 => (0x06, 0x98, 0x9a), + 7 => (0xd3, 0xd7, 0xcf), + 8 => (0x55, 0x57, 0x53), + 9 => (0xef, 0x29, 0x29), + 10 => (0x8a, 0xe2, 0x34), + 11 => (0xfc, 0xe9, 0x4f), + 12 => (0x72, 0x9f, 0xcf), + 13 => (0xad, 0x7f, 0xa8), + 14 => (0x34, 0xe2, 0xe2), + 15 => (0xee, 0xee, 0xec), + 16..=231 => { + let n = idx - 16; + let bi = n % 6; + let gi = (n / 6) % 6; + let ri = n / 36; + let c = |v: u8| if v == 0 { 0u8 } else { 55 + v * 40 }; + return Rgba { + r: c(ri) as f32 / 255.0, + g: c(gi) as f32 / 255.0, + b: c(bi) as f32 / 255.0, + a: 1.0, + }; + } + _ => { + let v = 8u8.saturating_add((idx - 232).saturating_mul(10)); + return Rgba { + r: v as f32 / 255.0, + g: v as f32 / 255.0, + b: v as f32 / 255.0, + a: 1.0, + }; + } + }; + Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + } +} diff --git a/Desktop/src/gui/tui/cwd.rs b/Desktop/src/gui/tui/cwd.rs new file mode 100644 index 0000000..1f93f64 --- /dev/null +++ b/Desktop/src/gui/tui/cwd.rs @@ -0,0 +1,54 @@ +//! Resolve another process's current working directory (for shell header preview). + +#[cfg(all(unix, target_os = "linux"))] +pub fn cwd_for_pid(pid: u32) -> Option { + if pid == 0 { + return None; + } + let path = format!("/proc/{pid}/cwd"); + std::fs::read_link(path) + .ok()? + .into_os_string() + .into_string() + .ok() +} + +#[cfg(all(unix, target_os = "macos"))] +pub fn cwd_for_pid(pid: u32) -> Option { + use libc::{c_void, proc_pidinfo, proc_vnodepathinfo}; + + if pid == 0 { + return None; + } + let mut vnodepathinfo = std::mem::MaybeUninit::::uninit(); + let rc = unsafe { + proc_pidinfo( + pid as libc::c_int, + libc::PROC_PIDVNODEPATHINFO, + 0, + vnodepathinfo.as_mut_ptr() as *mut c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if rc <= 0 { + return None; + } + let vnodepathinfo = unsafe { vnodepathinfo.assume_init() }; + let stat = vnodepathinfo.pvi_cdir.vip_vi.vi_stat; + if stat.vst_dev == 0 { + return None; + } + let path = &vnodepathinfo.pvi_cdir.vip_path; + let flat = unsafe { + std::slice::from_raw_parts(path.as_ptr() as *const u8, std::mem::size_of_val(path)) + }; + let end = flat.iter().position(|&b| b == 0).unwrap_or(flat.len()); + std::str::from_utf8(&flat[..end]) + .ok() + .map(|s| s.to_string()) +} + +#[cfg(not(any(all(unix, target_os = "linux"), all(unix, target_os = "macos"))))] +pub fn cwd_for_pid(_pid: u32) -> Option { + None +} diff --git a/Desktop/src/gui/tui/env.rs b/Desktop/src/gui/tui/env.rs new file mode 100644 index 0000000..637f729 --- /dev/null +++ b/Desktop/src/gui/tui/env.rs @@ -0,0 +1,87 @@ +//! PATH (and related) fixes for GUI-spawned shells — inherited env is often minimal. + +use portable_pty::CommandBuilder; +use std::collections::HashSet; + +#[cfg(target_os = "macos")] +fn macos_path_helper_segments() -> Vec { + let Ok(output) = std::process::Command::new("/usr/libexec/path_helper").output() else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let Some(rest) = line.strip_prefix("PATH=\"") else { + continue; + }; + let Some(end) = rest.find('"') else { + continue; + }; + return rest[..end] + .split(':') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + } + Vec::new() +} + +#[cfg(not(target_os = "macos"))] +fn macos_path_helper_segments() -> Vec { + Vec::new() +} + +fn extra_bin_dirs() -> Vec { + let mut v = Vec::new(); + #[cfg(target_os = "macos")] + { + v.push("/opt/homebrew/bin".to_string()); + v.push("/opt/homebrew/sbin".to_string()); + v.push("/usr/local/bin".to_string()); + } + v.push("/usr/bin".to_string()); + v.push("/bin".to_string()); + v.push("/usr/sbin".to_string()); + v.push("/sbin".to_string()); + if let Ok(home) = std::env::var("HOME") { + v.push(format!("{home}/.local/bin")); + v.push(format!("{home}/.cargo/bin")); + v.push(format!("{home}/.npm-global/bin")); + } + v +} + +fn split_path(raw: &str) -> Vec { + raw.split(':') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +fn merged_path_for_shell() -> String { + let mut seen = HashSet::::new(); + let mut parts = Vec::new(); + + for segment in macos_path_helper_segments() + .into_iter() + .chain(extra_bin_dirs()) + .chain( + std::env::var("PATH") + .map(|s| split_path(&s)) + .unwrap_or_default(), + ) + { + if seen.insert(segment.clone()) { + parts.push(segment); + } + } + + if parts.is_empty() { + "/usr/bin:/bin:/usr/sbin:/sbin".to_string() + } else { + parts.join(":") + } +} + +pub(crate) fn apply_interactive_shell_env(cmd: &mut CommandBuilder) { + cmd.env("PATH", merged_path_for_shell()); +} diff --git a/Desktop/src/gui/tui/keys.rs b/Desktop/src/gui/tui/keys.rs new file mode 100644 index 0000000..075a440 --- /dev/null +++ b/Desktop/src/gui/tui/keys.rs @@ -0,0 +1,52 @@ +use gpui::Modifiers; + +/// Map a GPUI key event to PTY byte sequence. +pub fn key_to_bytes(key: &str, mods: Modifiers) -> Option> { + if mods.control && !mods.alt && !mods.platform { + return match key { + "c" | "C" => Some(vec![0x03]), + "d" | "D" => Some(vec![0x04]), + "z" | "Z" => Some(vec![0x1a]), + "l" | "L" => Some(vec![0x0c]), + "a" | "A" => Some(vec![0x01]), + "b" | "B" => Some(vec![0x02]), + "e" | "E" => Some(vec![0x05]), + "f" | "F" => Some(vec![0x06]), + "k" | "K" => Some(vec![0x0b]), + "n" | "N" => Some(vec![0x0e]), + "p" | "P" => Some(vec![0x10]), + "r" | "R" => Some(vec![0x12]), + "u" | "U" => Some(vec![0x15]), + "w" | "W" => Some(vec![0x17]), + _ => None, + }; + } + match key { + "enter" => Some(vec![b'\r']), + "backspace" => Some(vec![0x7f]), + "escape" => Some(vec![0x1b]), + "tab" => Some(vec![b'\t']), + "up" => Some(vec![0x1b, b'[', b'A']), + "down" => Some(vec![0x1b, b'[', b'B']), + "right" => Some(vec![0x1b, b'[', b'C']), + "left" => Some(vec![0x1b, b'[', b'D']), + "home" => Some(vec![0x1b, b'[', b'H']), + "end" => Some(vec![0x1b, b'[', b'F']), + "pageup" | "page_up" => Some(vec![0x1b, b'[', b'5', b'~']), + "pagedown" | "page_down" => Some(vec![0x1b, b'[', b'6', b'~']), + "delete" => Some(vec![0x1b, b'[', b'3', b'~']), + "f1" => Some(vec![0x1b, b'O', b'P']), + "f2" => Some(vec![0x1b, b'O', b'Q']), + "f3" => Some(vec![0x1b, b'O', b'R']), + "f4" => Some(vec![0x1b, b'O', b'S']), + "f5" => Some(vec![0x1b, b'[', b'1', b'5', b'~']), + "f6" => Some(vec![0x1b, b'[', b'1', b'7', b'~']), + "f7" => Some(vec![0x1b, b'[', b'1', b'8', b'~']), + "f8" => Some(vec![0x1b, b'[', b'1', b'9', b'~']), + "f9" => Some(vec![0x1b, b'[', b'2', b'0', b'~']), + "f10" => Some(vec![0x1b, b'[', b'2', b'1', b'~']), + "f11" => Some(vec![0x1b, b'[', b'2', b'3', b'~']), + "f12" => Some(vec![0x1b, b'[', b'2', b'4', b'~']), + _ => None, + } +} diff --git a/Desktop/src/gui/tui/mod.rs b/Desktop/src/gui/tui/mod.rs new file mode 100644 index 0000000..11cb953 --- /dev/null +++ b/Desktop/src/gui/tui/mod.rs @@ -0,0 +1,17 @@ +//! Embedded terminal / PTY helpers for the shell panel. + +mod ansi_line; +mod cd_builtin; +mod colors; +mod cwd; +mod env; +mod keys; +mod session; +mod vt_history; + +pub(crate) use ansi_line::shell_history_line; +pub(crate) use cd_builtin::resolve_simple_cd; +pub use colors::*; +pub use keys::*; +pub use session::*; +pub(crate) use vt_history::vt100_row_for_shell_history; diff --git a/Desktop/src/gui/tui/session.rs b/Desktop/src/gui/tui/session.rs new file mode 100644 index 0000000..dbac114 --- /dev/null +++ b/Desktop/src/gui/tui/session.rs @@ -0,0 +1,133 @@ +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + +use super::env; + +pub const DEFAULT_ROWS: u16 = 40; +pub const DEFAULT_COLS: u16 = 80; + +pub struct TuiSession { + pub writer: Box, + pub parser: Arc>, + pub queue: Arc>>>, + pub done: Arc, + pub rows: u16, + pub cols: u16, + _master: Box, + _child: Box, +} + +fn shell_command_builder(command: &str, cwd: &Path) -> CommandBuilder { + #[cfg(unix)] + { + // Non-login `*-c`: `-lc` runs profiles that may block, prompt, or alter the PTY — breaks + // embedded one-shot commands and TUIs. PATH comes from `apply_interactive_shell_env`. + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut cmd = CommandBuilder::new(shell); + cmd.args(["-c", command]); + cmd.cwd(cwd); + env::apply_interactive_shell_env(&mut cmd); + cmd + } + #[cfg(not(unix))] + { + let mut cmd = CommandBuilder::new("sh"); + cmd.cwd(cwd); + cmd.args(["-c", command]); + cmd + } +} + +impl TuiSession { + pub fn spawn( + command: &str, + rows: u16, + cols: u16, + cwd: &Path, + ) -> Result> { + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + let cmd = shell_command_builder(command, cwd); + let child = pair.slave.spawn_command(cmd)?; + let reader = pair.master.try_clone_reader()?; + let writer = pair.master.take_writer()?; + + let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 1000))); + let queue: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let done = Arc::new(AtomicBool::new(false)); + + { + let q = Arc::clone(&queue); + let d = Arc::clone(&done); + std::thread::spawn(move || { + let mut reader = reader; + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => { + d.store(true, Ordering::SeqCst); + break; + } + Ok(n) => { + if let Ok(mut locked) = q.lock() { + locked.push(buf[..n].to_vec()); + } + } + } + } + }); + } + + Ok(TuiSession { + writer, + parser, + queue, + done, + rows, + cols, + _master: pair.master, + _child: child, + }) + } + + pub fn write_input(&mut self, bytes: &[u8]) { + let _ = self.writer.write_all(bytes); + let _ = self.writer.flush(); + } + + pub fn resize(&self, rows: u16, cols: u16) { + let _ = self._master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + if let Ok(mut p) = self.parser.lock() { + p.set_size(rows, cols); + } + } + + /// Current working directory of the foreground process group on this PTY (e.g. interactive shell). + #[cfg(unix)] + pub fn foreground_cwd(&self) -> Option { + let pid = self._master.process_group_leader()? as u32; + if pid == 0 { + return None; + } + super::cwd::cwd_for_pid(pid) + } + + #[cfg(not(unix))] + pub fn foreground_cwd(&self) -> Option { + None + } +} diff --git a/Desktop/src/gui/tui/vt_history.rs b/Desktop/src/gui/tui/vt_history.rs new file mode 100644 index 0000000..9beb6aa --- /dev/null +++ b/Desktop/src/gui/tui/vt_history.rs @@ -0,0 +1,115 @@ +//! Serialize vt100 screen rows to ANSI strings for colored shell scrollback. + +use std::fmt::Write; + +use vt100::Color; + +fn push_fg(out: &mut String, c: Color) { + match c { + Color::Default => out.push_str("\x1b[39m"), + Color::Idx(i) => { + let _ = write!(out, "\x1b[38;5;{i}m"); + } + Color::Rgb(r, g, b) => { + let _ = write!(out, "\x1b[38;2;{r};{g};{b}m"); + } + } +} + +fn push_bg(out: &mut String, c: Color) { + match c { + Color::Default => out.push_str("\x1b[49m"), + Color::Idx(i) => { + let _ = write!(out, "\x1b[48;5;{i}m"); + } + Color::Rgb(r, g, b) => { + let _ = write!(out, "\x1b[48;2;{r};{g};{b}m"); + } + } +} + +fn strip_escapes_for_blank_check(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut it = s.chars().peekable(); + while let Some(ch) = it.next() { + if ch == '\x1b' { + match it.peek().copied() { + Some('[') => { + it.next(); + while let Some(c) = it.next() { + if ('\x40'..='\x7e').contains(&c) { + break; + } + } + } + Some(']') => { + it.next(); + while let Some(c) = it.next() { + if c == '\x07' { + break; + } + if c == '\x1b' && it.peek() == Some(&'\\') { + let _ = it.next(); + break; + } + } + } + _ => {} + } + continue; + } + out.push(ch); + } + out +} + +/// Paint one row as SGR runs for [`super::ansi_line::shell_history_line`]. +pub(crate) fn vt100_row_for_shell_history( + screen: &vt100::Screen, + row: u16, + cols: u16, +) -> Option { + let mut out = String::new(); + let mut prev: Option<(Color, Color, bool)> = None; + + for col in 0..cols { + let cell = screen.cell(row, col); + let (nf, nb, nbold, text) = match cell { + Some(cell) => (cell.fgcolor(), cell.bgcolor(), cell.bold(), cell.contents()), + None => (Color::Default, Color::Default, false, " ".to_string()), + }; + let key = (nf, nb, nbold); + if prev.as_ref() != Some(&key) { + out.push_str("\x1b[0m"); + push_fg(&mut out, nf); + push_bg(&mut out, nb); + if nbold { + out.push_str("\x1b[1m"); + } + prev = Some(key); + } + if text.is_empty() { + out.push(' '); + } else { + out.push_str(&text); + } + } + out.push_str("\x1b[0m"); + + let trimmed = if let Some(pos) = out.rfind("\x1b[0m") { + let body = &out[..pos]; + let tail = &out[pos..]; + format!("{}{}", body.trim_end_matches(' '), tail) + } else { + out.trim_end_matches(' ').to_string() + }; + + if strip_escapes_for_blank_check(&trimmed) + .chars() + .all(|c| c.is_whitespace()) + { + None + } else { + Some(trimmed) + } +} diff --git a/Desktop/src/main.rs b/Desktop/src/main.rs index 9dcefec..109faac 100644 --- a/Desktop/src/main.rs +++ b/Desktop/src/main.rs @@ -4,6 +4,7 @@ use arcadia_core::modules; fn main() { modules::load_all(); + arcadia_core::modules::shell::set_internal_executor(cli::handle_internal); #[cfg(feature = "gui")] { @@ -20,50 +21,7 @@ fn main() { } #[cfg(feature = "gui")] -mod gui { - use std::process; - use std::thread; - - use crate::cli; - use gpui::{ - AppContext, Application, Context, IntoElement, ParentElement, Render, SharedString, Styled, - Window, WindowOptions, div, white, - }; - - struct ArcadiaRoot { - title: SharedString, - } - - impl Render for ArcadiaRoot { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .bg(white()) - .flex() - .justify_center() - .items_center() - .text_3xl() - .child(self.title.clone()) - } - } - - pub fn run() { - cli::print_startup("gui"); - - thread::spawn(|| { - cli::start_loop(|| process::exit(0)); - }); - - Application::new().run(|app| { - app.open_window(WindowOptions::default(), |_window, app| { - app.new(|_cx| ArcadiaRoot { - title: SharedString::new_static("Arcadia"), - }) - }) - .expect("failed to open GPUI window"); - }); - } -} +mod gui; #[cfg(not(feature = "gui"))] mod headless { diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..11a1175 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,816 @@ +Arcadia Community License (ACL) v1.5 +Copyright (c) 2026 stackno.de + +Preamble + +Arcadia exists to expand human creativity, agency, education, and +accessibility through open, inspectable, user-owned software. + +This project was created with the belief that software should empower +people to learn, build, explore, and understand the systems they use. +Arcadia is intended to remain accessible to beginners, extensible for +experts, and transparent for everyone. + +The software is provided freely in the hope that it may help others +create, learn, and grow. + +This license is designed to protect the values and philosophy of the +Arcadia project while giving maximum freedom to individuals, educators, +researchers, artists, and small communities. It places restrictions only +where necessary to prevent harm, exploitation, and appropriation that +would undermine these values. Commercial profit extraction from this +Software is not a permitted use — this work belongs to people, not to +capital. + +No attempt to circumvent the restrictions of this license — whether +through corporate structuring, subsidiary formation, contractual +arrangement, technical transformation, or any other mechanism — will be +given any effect. This license is interpreted according to its spirit and +stated philosophy, not merely its literal text. + +Definitions + +"Software" refers to the Arcadia project, including its source code, +documentation, assets, examples, APIs, protocols, tooling, extension +systems, and associated components, including all substantially derived +works regardless of the degree of modification. + +"You" refers to any individual or organization using, modifying, +distributing, or contributing to the Software, including any Affiliated +Entity acting on Your behalf or for Your benefit. + +"Affiliated Entity" means any entity that Controls, is Controlled by, or +is under Common Control with the subject entity. + +"Common Control" means direct or indirect ownership of more than 50% of +the voting rights of an entity, or the practical ability to direct its +management, operations, or decision-making, regardless of legal form. + +"Large Corporation" refers to any organization where, considering the +organization and all of its Affiliated Entities in aggregate: +- combined annual revenue exceeds $10,000,000 USD in any rolling + 12-month period within the preceding three years; or +- combined full-time equivalent headcount exceeds 100; or +- the organization is majority-owned by, controlled by, or acts as an + agent, contractor, or subcontractor on behalf of another entity that + independently meets either criterion above. + +Deliberate restructuring of an organization — including creation of +subsidiaries, holding structures, or contracting arrangements — for the +purpose of falling below these thresholds is itself a material violation +of this license and does not confer any permission. + +"Core" refers to the foundational runtime, module system, configuration +system, networking subsystem, FFI and bridge layer, protocol definitions, +and build infrastructure of the Software. Extensions, plugins, themes, +modules, user-created content, and applications built on top of the +Software are not Core. + +"Distribute" means to make available to third parties in source or +compiled form, including via network delivery, package repositories, app +stores, embedded systems, internal enterprise distribution, or any other +mechanism. + +"Contribution" means any original work of authorship, including +modifications, additions, bug fixes, documentation, or other material +submitted to the Software. + +"Profit-Focused Use" means any deployment, distribution, or integration +of the Software where the primary purpose is commercial revenue +generation from the Software itself — including selling, licensing, +offering as a paid service, or incorporating as the core value +proposition of a revenue-generating product — regardless of organization +size. It does not include incidental internal use of the Software as a +tool while conducting an unrelated business, nor use by a +Community-Directed Organization. + +"Community-Directed Organization" means any organization that: +- is not a Large Corporation; +- whose primary stated mission is education, accessibility, community + benefit, or public good; +- derives no direct revenue from the Software itself as a product or + service; and +- is not majority-owned, controlled, or directed by a Large Corporation + or by any entity engaged in Profit-Focused Use. + +"Gross Software Revenue" means all commercial receipts and consideration +— monetary or otherwise — directly or indirectly attributable to the +Software, received by the authorized party or any Affiliated Entity, before +any deductions. Only direct, documented, arm's-length, necessary costs of +operating the Software-based service (hosting, bandwidth, and direct +labor applied solely to the Software) may be deducted to arrive at net +Software revenue for the purposes of Condition 11. No overhead +allocations, management fees, related-party charges, intercompany +transfers, or indirect costs may be deducted. + +Permissions + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this Software to: + +- use the Software for personal, educational, experimental, + accessibility-focused, artistic, research, or community purposes; +- study and inspect the Software; +- modify the Software; +- distribute original or modified versions of the Software; +- create extensions, modules, plugins, tools, educational resources, + integrations, and derivative works for non-commercial purposes; +- self-host, fork, and archive the Software; +- teach with, learn from, and experiment with the Software; +- use the Software as a component within a larger non-commercial or + community project; +- build and distribute non-commercial products and services on top of + the Software, provided You are not a Large Corporation and all + conditions of this license are met. + +Education and Learning + +Arcadia places education, learning, and human capability at the center +of its mission. The following parties receive an unconditional, +irrevocable grant of permission to use, study, modify, fork, and +distribute the Software, without restriction beyond attribution: + +- Individual learners of any age, background, or level, pursuing skill, + curiosity, or creative exploration for their own development; +- Students in formal or informal education, enrolled or self-directed; +- Educators, tutors, instructors, and mentors using the Software as a + teaching tool or subject of study; +- Non-profit educational institutions, schools, colleges, universities, + bootcamps, and academies, provided they derive no revenue from the + Software itself; +- Educational platforms and services that charge no fee — direct or + indirect — for access to features powered by this Software; +- Open source content creators producing freely accessible educational + material, tutorials, courses, or documentation; +- Libraries, community learning centres, makerspaces, and publicly + funded institutions; +- Accessibility-focused projects working to make technology more + available to underserved or marginalized communities. + +Community-Directed Organizations are equally welcome to use, deploy, +and build with the Software under this license. + +This grant exists because knowledge should not be owned. Learning should +not be gated. If you are here to grow, to teach, or to help others +understand — you are welcome, and this Software is yours to use. + +Anti-Abuse of the Education Exemption + +The education exemption does not apply to: +- any Large Corporation, regardless of whether the use is framed as + educational, training, or research; +- any entity engaged in Profit-Focused Use, even if operating a nominal + educational programme as a secondary activity; +- any for-profit educational product or platform that charges fees for + access to features powered by this Software; +- any entity that creates an educational subsidiary or programme + primarily for the purpose of claiming this exemption. + +If an organization's educational activity is genuine and its primary +mission is learning, it qualifies. If the educational framing is a +vehicle for commercial activity or circumvention, it does not. + +Conditions + +The following conditions apply to all use, modification, and distribution +of the Software: + +1. Attribution + + You must preserve this license and retain appropriate attribution to + the original Arcadia project and stackno.de in all substantial + redistributions of the Software. + + Attribution must be: + - present in documentation, README files, or equivalent in any + redistributed source; + - present in a visible About, Credits, or Acknowledgements section in + any distributed application, including compiled or packaged + distributions; + - reasonably prominent — not buried, obscured, or rendered inaccessible + through technical means. + + You may not use technical measures — obfuscation, encryption, build + flags, or otherwise — to suppress or hide attribution that would + otherwise be required. + + Attribution is not required in internal or private deployments that are + not distributed to third parties. + +2. Openness of Core Improvements + + If you distribute modifications to the Core of the Software, those + modifications must be made publicly available under this same license + within 60 days of initial distribution, or upon request from the + copyright holder, whichever is sooner. + + You must make the complete corresponding source code available at no + charge, via a publicly accessible URL or repository. + + Extensions, applications, themes, modules, tools, content, and user + creations built on top of Arcadia are exempt from the source-disclosure + requirement of this clause and may apply any license of their creator's + choosing for distribution purposes. + + This exemption applies solely to the source-disclosure requirement. It + does not grant permission to sell, charge for, or otherwise + commercialize any such work. See Condition 4a. + + For the avoidance of doubt: building a product or service that calls + into the Software's public API does not trigger this clause. Only + modifications to the Core itself trigger it. + +3. Large Corporation Restriction + + Large Corporations are strictly prohibited from using, deploying, + integrating, commercializing, sublicensing, or distributing the + Software or any derivative work. This prohibition takes effect from + first use. There is no grace period, implied permission, trial period, + or evaluation period for any organization that qualifies as a Large + Corporation at the time of first use. + + If You are a Large Corporation and have obtained a copy of this + Software without prior written permission, You must immediately cease + all use, destroy all copies in Your possession or control, and remove + the Software from all systems, pipelines, and products. + + If You become a Large Corporation after beginning use of the Software + in good faith as a smaller organization, You must notify the copyright + holder and seek written permission within 30 days of crossing the + threshold. If permission is not sought or granted within those 30 days, + Your rights terminate automatically and without further notice. "Good + faith" requires that You were unaware at the time of first use that You + would cross the threshold; deliberate use while planning or anticipating + growth beyond the threshold does not qualify as good faith. + + This restriction applies equally to any Affiliated Entity, agent, + contractor, consultant, or subcontractor acting on behalf of, at the + direction of, or for the commercial benefit of a Large Corporation. + A Large Corporation may not obtain the effective benefits of this + Software by routing its use through a smaller intermediary. + + Permission may be granted, denied, or conditioned entirely at the sole + discretion of the copyright holder. The copyright holder has no + obligation to consider, acknowledge, or respond to any permission + request. + + Unauthorized use by a Large Corporation, or by any party acting on + behalf of a Large Corporation, constitutes willful infringement and + will be pursued to the fullest extent available under applicable law. + + To request permission: whitehouse@stackno.de + +4. Commercial Use and Profit Restriction + + The Software may not be used as the primary commercial offering in any + revenue-generating product or service — including selling, licensing, + or delivering it as a paid service — without explicit prior written + permission from the copyright holder, regardless of organization size. + + Organizations, individuals, or entities engaged in Profit-Focused Use + receive no permissions under this license and must seek explicit + written permission before any use. + + Incidental use of the Software as an internal or operational tool while + conducting an unrelated business is permitted, provided: + - the Software is not the primary value proposition or commercial + offering; + - the organization is not a Large Corporation; + - no revenue is derived directly from the Software itself; and + - all other conditions of this license are met. + + Community-Directed Organizations may use and deploy the Software + freely, including in service of their mission-driven activities, + provided the Software is not packaged and sold as a commercial product. + + For the avoidance of doubt: a small business using Arcadia as an + internal productivity tool is permitted. A startup building and + selling an Arcadia-powered product as its primary commercial offering + is not permitted without explicit written permission. + +4a. No Sale of Derivatives, Extensions, or Platform-Dependent Works + + No extension, module, plugin, theme, integration, tool, derivative + work, or any other software component built to operate with, on top + of, or as part of the Arcadia platform may be sold, licensed for a + fee, bundled into a paid product, or otherwise commercialized without + explicit prior written permission from the copyright holder. + + This applies regardless of: + - how extensively the work has been modified or transformed; + - whether the work is distributed separately or as part of a larger + product; + - whether the creator claims independent copyright in the work; or + - what license the creator has applied to the work under Condition 2. + + The act of building on the Arcadia platform does not grant commercial + rights in the resulting work. Those rights must be separately and + explicitly granted. + + Carve-out — Your own output: + Content, data, code, documents, scripts, or other output produced by + You using Arcadia as a terminal, shell, or development environment — + and that does not itself incorporate, bundle, or depend upon the + Software or any extension of it — remains entirely Your own property + and is not subject to this condition. If you write a Python script + inside Arcadia's shell, that script is yours. If you write a module + that plugs into Arcadia, that module is covered by this condition. + + Breach of this condition constitutes willful infringement and carries + no cure window. Rights terminate immediately. + +5. Anti-Evasion and Circumvention + + No person or organization may use any mechanism — including but not + limited to corporate restructuring, subsidiary formation, holding + company arrangements, white-labelling, contractual pass-through, proxy + use, technical transformation, re-licensing, or rebranding — to achieve + the practical effect of a prohibited use while maintaining the + appearance of compliance. + + Any arrangement, transaction, or structure that is designed primarily + or substantially to circumvent the restrictions of this license is void + and confers no permission. + + The substance of a use governs its permissibility, not its form. A use + that is technically within the literal text of a permission but + achieves an outcome this license prohibits is not permitted. + + The following examples are illustrative and non-exhaustive: + - A Large Corporation contracting a small firm to deploy the Software + for the Large Corporation's benefit is prohibited. + - A for-profit entity creating a nominal non-profit affiliate to claim + the education exemption is prohibited. + - A company splitting into multiple entities below the Large Corporation + threshold while operating as a single commercial enterprise is + prohibited. + - A commercial operator re-licensing the Software to downstream users + under more permissive terms than this license allows is prohibited. + +6. Sublicense Pass-Through + + Any sublicense, distribution agreement, or downstream arrangement + involving the Software must incorporate all conditions of this license + by reference or in full. You may not grant any sublicensee rights + greater than those You hold under this license. + + Any attempt to sublicense the Software under terms that contradict, + weaken, or supersede the conditions of this license is void. Downstream + recipients are bound directly by this license, regardless of any + additional terms imposed by intermediate distributors. + +7. Malicious or Harmful Use Restriction + + The copyright holder reserves the right to deny, revoke, or restrict + usage rights for any individual, organization, or entity reasonably + believed to be using the Software to: + + - intentionally harm individuals or communities; + - develop malware, spyware, surveillance systems, or deceptive + software; + - exploit users through manipulative or predatory practices; + - conceal harmful AI decision-making systems; + - violate human rights; + - spread hate, harassment, abuse, or targeted disinformation; + - build systems primarily designed to coerce, manipulate, or + psychologically exploit their users; + - facilitate mass surveillance, unauthorized interception, or tracking + without meaningful informed consent; + - or otherwise act in clear bad faith against the philosophy and intent + of the Software. + + Revocation requires written notice to the offending party. Continued + use following receipt of written notice constitutes unauthorized use + and may be subject to legal remedy. + +8. Human-Centered Use + + The Software may not be used to intentionally: + - remove meaningful user agency over systems that directly affect them; + - conceal critical automated or AI-driven decision making from users; + - restrict users from inspecting, auditing, or exiting systems that + directly affect them; + - create intentionally addictive, dark-pattern, or exploitative + experiences; + - or subordinate the wellbeing of users to the interests of operators + in contexts where users cannot meaningfully consent or exit. + + This clause exists to preserve Arcadia's philosophy of transparency, + accessibility, education, and human empowerment. It is not intended to + restrict normal product design, UX simplification, or behind-the-scenes + automation that operates in the user's interests. + +9. Freedom to Learn + + Educational, accessibility-focused, assistive, and community uses of + the Software must never be restricted through additional downstream + licensing terms imposed by redistributors. + + No redistribution of this Software may impose terms that would prevent + an individual from using it to learn, teach, practice, or build + non-commercial skill. + + No downstream license, contract, or agreement may be used to revoke, + limit, or charge for the educational permissions granted in this + license. + +10. No Malicious Misrepresentation + + You may not falsely claim authorship of the original Software, nor + intentionally misrepresent modified versions as official Arcadia + releases. + + You may not use the Arcadia name, logo, or branding in a manner that + falsely implies endorsement, affiliation, or official status. + + You may not represent the Software as unencumbered or as carrying a + more permissive license than it does in communications, packaging, + documentation, or any public-facing material. + +11. Patent Non-Aggression + + By using, modifying, or distributing this Software, You agree not to + initiate patent litigation against any individual or entity for + implementing functionality substantially described by or derived from + this Software's public APIs, protocols, or documented behavior. + + This clause does not limit your right to defend against patent claims + brought against You. If You initiate such litigation, all permissions + granted to You under this license terminate immediately. + +12. Privacy and Telemetry + + Any version of the Software that collects, transmits, or processes + user data, behavior, usage patterns, or telemetry must: + - disclose this collection clearly and prominently to users before + collection begins; + - provide users with a meaningful and easily accessible opt-out + mechanism that functions without penalty; + - not transmit data to parties other than the operator without + explicit, informed, revocable consent. + + This clause does not prohibit local logging, crash reporting with user + consent, or server-side access logs from network-hosted instances. + +13. Artificial Intelligence and Machine Learning + + The Software's source code, documentation, outputs, and any artifacts + generated by or derived from the Software may not be used as training + data, fine-tuning material, pre-training corpora, reinforcement + learning signals, or evaluation datasets for any commercial artificial + intelligence or machine learning model or system without explicit + prior written permission from the copyright holder. + + This restriction applies regardless of whether the resulting AI system + directly reproduces the Software's code, and regardless of how + extensively the data is transformed prior to use. + + This clause does not restrict: + - personal or academic non-commercial AI research using the Software's + code for study; + - use of Arcadia as a tool that incorporates AI features, provided + such use is otherwise permitted by this license; + - automated code analysis, linting, security scanning, or + compatibility tooling applied solely for the benefit of the + Software's own development; or + - AI-assisted code editors or developer tools used by individual + permitted users in the course of their permitted use. + +14. Charitable Revenue Direction + + Any Gross Software Revenue received by any party under an authorized + commercial arrangement — whether through explicit permission from the + copyright holder, commercial licensing, or any other authorized use — + must be directed in its entirety to one or more of the following: + + (a) a charitable organization operating primarily for the benefit of + one or more of the following causes: + - suicide prevention or mental health support; + - animal welfare or rescue; + - racial justice, civil rights, or equity; + - education and opportunity for underserved or disadvantaged + communities; or + - any other cause of comparable humanitarian or welfare purpose + that a reasonable person would recognize as genuinely good-faith + charitable work; + + (b) the ongoing development, maintenance, infrastructure, or community + support of the Arcadia project, applied in good faith — including + infrastructure costs, tooling, contributor support, and similar + direct project costs; not redirected to personal income beyond + reasonable compensation for direct Arcadia work; or + + (c) the copyright holder's personal cat welfare fund. + + The authorized party has full discretion to choose which qualifying + recipient or recipients to direct funds toward, and in what proportion. + No particular organization is required. + + The following organizations are explicitly recognized as qualifying + recipients under category (a): + + Suicide prevention / mental health: + Samaritans, Papyrus, AFSP, The Jed Foundation, Movember, + The Trevor Project, CALM, Mind, SANE + + Animal welfare: + RSPCA, Battersea Dogs and Cats Home, ASPCA, + Best Friends Animal Society, The Humane League + + Racial justice and civil rights: + Color Of Change, NAACP, Advancement Project, Surge, + The Equal Justice Initiative + + Education and opportunity: + NSPCC, The Read Foundation, Wood Street Mission, + Teach First, The Access Project + + This list is illustrative, not exhaustive. Any organization a + reasonable person would consider a legitimate, good-faith charity + within the above categories qualifies. + + No deductions may be made from Gross Software Revenue other than the + direct, documented, arm's-length operating costs defined in the + "Gross Software Revenue" definition. The use of inflated costs, + management fees, related-party charges, or any other mechanism to + reduce the amount directed to qualifying recipients is a material + breach of this condition. + + Upon written request from the copyright holder, no more than once per + calendar year, an authorized party must provide documented evidence of + compliance with this condition within 30 days. Failure to provide + such documentation is a material breach. The copyright holder may + share evidence of non-compliance with qualifying charitable + organizations or regulatory bodies as appropriate. + + The copyright holder considers this clause non-negotiable and will + pursue violations to the fullest extent available under applicable + law, without hesitation and without limit. + +Interpretation and Spirit + +This license is to be interpreted in accordance with its stated +philosophy, values, and stated intent — not merely its literal text. + +Where the literal text of any provision is ambiguous, it shall be +interpreted in the manner most consistent with the following principles: +- maximum freedom for individuals, learners, educators, and communities; +- zero tolerance for profit extraction from the Software by corporations + and commercial interests; +- protection of users' rights, agency, and dignity; +- preservation of the charitable revenue direction requirement. + +Any technical compliance that achieves the practical effect of a +prohibited use — including uses that satisfy the letter of an exception +while violating its spirit — is not permitted. The substance of a use, +not its form, determines its permissibility. + +Contributions + +By submitting a Contribution to this project, You grant the copyright +holder a perpetual, worldwide, non-exclusive, royalty-free license to +use, reproduce, modify, distribute, and sublicense that Contribution as +part of the Software. + +You represent that You have the right to make such a grant — that Your +Contribution is Your original work, or that You have obtained the +necessary rights to contribute it. + +The copyright holder may include Your Contribution under this license or +any future version of this license. + +Contributions do not transfer copyright ownership to the copyright holder +unless separately agreed in writing. + +You may not transfer, assign, or sublicense Your rights under this +license to any third party without the prior written consent of the +copyright holder. Any purported transfer without such consent is void. + +Termination + +Your rights under this license terminate automatically if You materially +breach any condition of this license and fail to cure the breach within +30 days of receiving written notice from the copyright holder. + +The following breaches carry no cure window and result in immediate, +automatic termination of all rights: +- breach of the Large Corporation Restriction (Condition 3); +- breach of the Commercial Use and Profit Restriction (Condition 4); +- breach of the No Sale of Derivatives condition (Condition 4a); +- breach of the Anti-Evasion clause (Condition 5); +- initiation of patent litigation in violation of Condition 11; +- any willful or deliberate breach of any condition. + +Repeated breach and cure does not preserve rights. An authorized party +who has breached and been cured twice may have their rights permanently +terminated by the copyright holder at the copyright holder's discretion, +with written notice and no further cure opportunity. + +Parties who have demonstrated bad faith — including deliberate evasion, +misrepresentation to obtain permissions, or structuring to circumvent +this license — are not eligible for reinstatement. + +Upon termination, You must immediately cease all use and distribution of +the Software, remove it from all systems and products, and destroy or +return all copies in Your possession. + +Rights of parties who have received distributions from You under this +license do not terminate due to Your breach, provided they themselves +remain in compliance. + +The copyright holder may reinstate Your rights at their sole discretion. + +Waiver + +Failure by the copyright holder to enforce any provision of this license +on any occasion does not constitute a waiver of the right to enforce that +provision on any future occasion, nor does it constitute a waiver of any +other provision. No waiver of any provision is effective unless made in +explicit writing signed by the copyright holder. + +Remedies + +The copyright holder is entitled to seek all remedies available under +applicable law for any breach of this license, including: + +- immediate injunctive or other equitable relief, without the necessity + of posting bond or proving actual damages, given that breach of this + license causes irreparable harm for which monetary damages alone are + an inadequate remedy; +- recovery of all actual damages arising from the breach; +- recovery of any profits attributable to the unauthorized use; +- statutory damages where available; +- full recovery of legal costs and solicitors' fees incurred in + enforcement, whether or not proceedings are contested. + +These remedies are cumulative and not exclusive of any other remedy +available at law or equity. + +Reservation of Rights + +All rights not expressly granted by this license are reserved by the +copyright holder. + +The copyright holder retains the right to: +- dual-license the Software; +- offer commercial licenses; +- grant exemptions to specific parties; +- enforce restrictions defined in this license; +- and update future versions of this license. + +Use of a later version of this license is permitted at Your option. +Earlier versions remain valid for prior distributions made under them. + +Governing Law and Jurisdiction + +This license is governed by and construed in accordance with the laws of +England and Wales, without regard to conflict of law principles. + +Any disputes arising from this license that cannot be resolved informally +shall be subject to the exclusive jurisdiction of the courts of England +and Wales. You consent to the personal jurisdiction of such courts. + +These terms apply to all users of the Software regardless of the +jurisdiction in which they are located. If any term is unenforceable in +Your jurisdiction, it shall be enforced to the maximum extent permitted, +and the remaining terms continue in full effect. + +Severability + +If any provision of this license is held to be invalid, illegal, or +unenforceable under applicable law, that provision shall be modified to +the minimum extent necessary to make it enforceable, or if modification +is not possible, severed entirely. The remaining provisions shall continue +in full force and effect. No severance of any provision shall be +construed to diminish the protections of this license beyond what is +strictly required. + +Entire Agreement + +This license constitutes the entire agreement between You and the +copyright holder regarding the Software, and supersedes all prior +representations, understandings, and agreements, whether oral or written, +relating to the subject matter herein. + +Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. + +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Nothing in this license creates any obligation on the copyright holder to +provide support, maintenance, updates, or continued development of the +Software. + +Special Permissions and Negotiated Licenses + +Special permissions and negotiated licenses may be granted on a +case-by-case basis, entirely at the discretion of the copyright holder. + +These exist for people who genuinely need them — not for corporations +looking for a workaround, not for investors seeking an angle, not for +legal teams stress-testing the language. + +Before reaching out, ask yourself honestly: would you be a worthy +recipient of one of the charities listed in this license? Not whether +you could technically be helped by one — but whether, if a charity were +choosing who to direct their limited resources toward, they would choose +you. If the answer is clearly no — if you represent capital, scale, or +commercial interest rather than genuine need — do not reach out. Your +time and the copyright holder's time are both better spent elsewhere. + +If you are building something that is truly your own — a passion project, +a small business born from something you genuinely care about, a tool +you believe in — and you find yourself bumping against a restriction that +feels like it was not meant for someone like you, reach out. That is +exactly who these negotiations are for. + +What qualifies for consideration: +- A genuine passion-driven small business or independent creator who + needs a specific permission to build something they care about; +- An organization doing real good that does not fit neatly into the + existing categories but is clearly aligned with the spirit of this + license; +- An individual with a compelling and honest case. + +What does not qualify: +- Any Large Corporation or entity acting on its behalf; +- Any entity whose primary purpose is profit extraction from this + Software; +- Any entity that has previously violated this license; +- Anyone who opened this license looking for a loophole. + +The copyright holder reserves the right to decline any request without +explanation, without response, and without obligation. + +Contact and Permissions + +For permission requests, special license negotiations, or any other +matter requiring direct contact: + + whitehouse@stackno.de + https://stackno.de + +Responses are not guaranteed within any specific timeframe. The copyright +holder reserves the right to decline any request without explanation. + +Philosophy + +Arcadia is built on the belief that: +- software should be understandable, +- creativity should not be gated by expertise, +- intelligence should remain grounded in humanity, +- tools should help people grow rather than merely increase output, +- and the systems that shape our lives should be inspectable by the + people they affect. + +We live increasingly inside software. The interfaces, the algorithms, the +defaults — they shape what we see, what we believe is possible, and who +we think we are. Most people have no idea how any of it works. Most +people have no voice in how any of it changes. + +Arcadia exists to push back against that. Not by making everyone a +programmer, but by making the boundary between user and builder a little +more porous. By letting the curious look inside. By giving the determined +a place to start. + +Software that serves capital tends to extract from people. It captures +attention, erects paywalls, obscures decisions, and locks users in. This +is not inevitable. Software can be designed to leave people more capable +than it found them — not more dependent, not more surveilled, not more +indebted to a subscription. + +That is the standard Arcadia holds itself to, and the standard it asks +of those who build with it. + +Education is not a feature. It is the point. Every person who learns +something real because of this Software — about computing, about +systems, about what is possible — is a direct expression of why it +exists. Not a metric. Not a conversion. A person, better equipped to +navigate the world. + +The best tools are the ones that transfer capability to their users. The +ones that make you more able after using them — not more dependent. The +ones that respect your time, your attention, and your right to understand +what is being done to you. + +If you build with Arcadia: +- build something that helps people grow, +- build something that teaches, +- build something that inspires, +- build something that saves lives, +- build something that is truly for you. + +You have no idea who may quietly need what you create. +Some people will never speak up. +Some people will never tell you your work mattered. +But they will carry it with them anyway. + +There is something weirdly wonderful inside you. +Accept that person. +Share them with the world. diff --git a/Launchers/Development/OSX/Launcher.app/Contents/Info.plist b/Launchers/Development/OSX/Launcher.app/Contents/Info.plist new file mode 100644 index 0000000..16f1c6a --- /dev/null +++ b/Launchers/Development/OSX/Launcher.app/Contents/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + ArcadiaDevelopmentLauncher + CFBundleIdentifier + com.stacknode.arcadia.development-launcher + CFBundleIconFile + Launcher.icns + CFBundleName + Arcadia Launcher + CFBundleDisplayName + Arcadia Launcher + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.developer-tools + LSUIElement + + NSHighResolutionCapable + + + diff --git a/Launchers/Development/OSX/Launcher.app/Contents/MacOS/ArcadiaDevelopmentLauncher b/Launchers/Development/OSX/Launcher.app/Contents/MacOS/ArcadiaDevelopmentLauncher new file mode 100755 index 0000000..712d155 Binary files /dev/null and b/Launchers/Development/OSX/Launcher.app/Contents/MacOS/ArcadiaDevelopmentLauncher differ diff --git a/Launchers/Development/OSX/Launcher.app/Contents/Resources/Launcher.icns b/Launchers/Development/OSX/Launcher.app/Contents/Resources/Launcher.icns new file mode 100644 index 0000000..53b6d71 Binary files /dev/null and b/Launchers/Development/OSX/Launcher.app/Contents/Resources/Launcher.icns differ diff --git a/Launchers/Development/OSX/Launcher.app/Contents/Resources/StatusIcon.png b/Launchers/Development/OSX/Launcher.app/Contents/Resources/StatusIcon.png new file mode 100644 index 0000000..27ec82b Binary files /dev/null and b/Launchers/Development/OSX/Launcher.app/Contents/Resources/StatusIcon.png differ diff --git a/Launchers/Development/OSX/Package.swift b/Launchers/Development/OSX/Package.swift new file mode 100644 index 0000000..4beeb77 --- /dev/null +++ b/Launchers/Development/OSX/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "ArcadiaDevelopmentLauncher", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable( + name: "ArcadiaDevelopmentLauncher", + targets: ["ArcadiaDevelopmentLauncher"] + ) + ], + targets: [ + .executableTarget( + name: "ArcadiaDevelopmentLauncher" + ) + ] +) diff --git a/Launchers/Development/OSX/README.md b/Launchers/Development/OSX/README.md new file mode 100644 index 0000000..e9a46ad --- /dev/null +++ b/Launchers/Development/OSX/README.md @@ -0,0 +1,22 @@ +# Arcadia Development Launcher + +Minimal macOS menu bar launcher for the development GUI. + +Run it with: + +```sh +swift run +``` + +Build a clickable menu bar app with: + +```sh +./build-app.sh +``` + +The launcher prefers `~/.local/bin/arcadia-gui` when it exists. If the global command has not been installed, it falls back to the same build-and-run flow used by that wrapper: + +```sh +cargo build --manifest-path Desktop/Cargo.toml --target-dir target --no-default-features --features gui +target/debug/arcadia +``` diff --git a/Launchers/Development/OSX/Sources/ArcadiaDevelopmentLauncher/main.swift b/Launchers/Development/OSX/Sources/ArcadiaDevelopmentLauncher/main.swift new file mode 100644 index 0000000..01399ad --- /dev/null +++ b/Launchers/Development/OSX/Sources/ArcadiaDevelopmentLauncher/main.swift @@ -0,0 +1,426 @@ +import AppKit +import Foundation + +@main +final class ArcadiaDevelopmentLauncher: NSObject, NSApplicationDelegate, NSMenuDelegate { + private static let menuRefreshInterval: TimeInterval = 1.5 + + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let repositoryRoot = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Arcadia") + private let processQueue = DispatchQueue(label: "arcadia.development-launcher.process") + + private var startItem: NSMenuItem! + private var restartItem: NSMenuItem! + private var stopItem: NSMenuItem! + private var quitItem: NSMenuItem! + private var statusMenu: NSMenu! + private var launchedProcess: Process? + private var refreshTimer: Timer? + + static func main() { + let app = NSApplication.shared + let delegate = ArcadiaDevelopmentLauncher() + app.delegate = delegate + app.setActivationPolicy(.accessory) + app.run() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + configureStatusItem() + refreshMenuState() + scheduleMenuRefreshTimer() + } + + func applicationWillTerminate(_ notification: Notification) { + invalidateMenuRefreshTimer() + let snapshot = launchedProcess + if let snapshot, snapshot.isRunning { + snapshot.terminate() + waitForSnapshotToExit(snapshot, timeout: 4) + } + launchedProcess = nil + processQueue.sync { [weak self] in + guard let self else { return } + for pid in self.arcadiaPIDs() { + Darwin.kill(pid, SIGTERM) + } + } + } + + @objc private func startArcadia() { + if launchedProcess?.isRunning == true { + refreshMenuState() + return + } + + processQueue.async { [weak self] in + guard let self else { return } + guard self.arcadiaPIDs().isEmpty else { + DispatchQueue.main.async { self.refreshMenuState() } + return + } + DispatchQueue.main.async { [weak self] in + self?.launchArcadiaProcessIfStillNeeded() + } + } + } + + /// Main thread — worker already confirmed no matching `arcadia` PIDs. + private func launchArcadiaProcessIfStillNeeded() { + if launchedProcess?.isRunning == true { + refreshMenuState() + return + } + + let process = makeArcadiaProcess() + let logHandle = attachLogFile(to: process) + process.terminationHandler = { [weak self, weak process] _ in + logHandle?.closeFile() + DispatchQueue.main.async { + if self?.launchedProcess === process { + self?.launchedProcess = nil + } + self?.refreshMenuState() + } + } + + do { + try process.run() + launchedProcess = process + } catch { + logHandle?.closeFile() + showLaunchError(error) + } + + refreshMenuState() + } + + @objc private func restartArcadia() { + if launchedProcess?.isRunning == true { + stopArcadia(wait: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.startArcadia() + } + return + } + + processQueue.async { [weak self] in + guard let self else { return } + guard !self.arcadiaPIDs().isEmpty else { + DispatchQueue.main.async { self.refreshMenuState() } + return + } + DispatchQueue.main.async { + self.stopArcadia(wait: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.startArcadia() + } + } + } + } + + @objc private func stopArcadiaFromMenu() { + stopArcadia(wait: false) + } + + @objc private func quitLauncher() { + invalidateMenuRefreshTimer() + let snapshot = launchedProcess + launchedProcess = nil + snapshot?.terminate() + + processQueue.async { [weak self] in + guard let self else { + DispatchQueue.main.async { NSApp.terminate(nil) } + return + } + for pid in self.arcadiaPIDs() { + Darwin.kill(pid, SIGTERM) + } + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + } + + func menuWillOpen(_ menu: NSMenu) { + invalidateMenuRefreshTimer() + refreshMenuState() + } + + func menuDidClose(_ menu: NSMenu) { + scheduleMenuRefreshTimer() + } + + private func configureStatusItem() { + statusItem.length = NSStatusItem.squareLength + if let button = statusItem.button { + button.title = "" + button.image = statusIcon() + button.imagePosition = .imageOnly + button.toolTip = "Arcadia Development Launcher" + } + + let menu = NSMenu() + menu.delegate = self + menu.autoenablesItems = false + startItem = NSMenuItem(title: "Start", action: #selector(startArcadia), keyEquivalent: "") + restartItem = NSMenuItem(title: "Restart", action: #selector(restartArcadia), keyEquivalent: "") + stopItem = NSMenuItem(title: "Stop", action: #selector(stopArcadiaFromMenu), keyEquivalent: "") + quitItem = NSMenuItem(title: "Quit", action: #selector(quitLauncher), keyEquivalent: "q") + + for item in [startItem, restartItem, stopItem, quitItem] { + item?.target = self + menu.addItem(item!) + } + + statusMenu = menu + statusItem.menu = statusMenu + } + + private func scheduleMenuRefreshTimer() { + invalidateMenuRefreshTimer() + let timer = Timer(timeInterval: Self.menuRefreshInterval, repeats: true) { [weak self] _ in + self?.refreshMenuState() + } + RunLoop.main.add(timer, forMode: .common) + refreshTimer = timer + } + + private func invalidateMenuRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + private func statusIcon() -> NSImage { + let side: CGFloat = 18 + if let url = Bundle.main.url(forResource: "StatusIcon", withExtension: "png"), + let source = NSImage(contentsOf: url) { + let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { bounds in + source.draw( + in: bounds, + from: NSRect(origin: .zero, size: source.size), + operation: .copy, + fraction: 1.0 + ) + return true + } + image.isTemplate = false + return image + } + + if let symbol = NSImage(systemSymbolName: "hammer.fill", accessibilityDescription: "Arcadia Launcher") { + let sized = symbol.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: side - 2, weight: .regular) + ) ?? symbol + sized.isTemplate = true + return sized + } + + if let appIcon = NSApp.applicationIconImage.copy() as? NSImage { + appIcon.size = NSSize(width: side, height: side) + appIcon.isTemplate = false + return appIcon + } + + return minimalMenuBarPlaceholderIcon(side: side) + } + + private func minimalMenuBarPlaceholderIcon(side: CGFloat) -> NSImage { + NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in + NSColor.controlAccentColor.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 4, dy: 4)).fill() + return true + } + } + + /// Menu updates only — never runs subprocesses on the main thread. + private func refreshMenuState() { + if let process = launchedProcess, process.isRunning { + applyMenuState(running: true) + return + } + + processQueue.async { [weak self] in + guard let self else { return } + let running = !self.arcadiaPIDs().isEmpty + DispatchQueue.main.async { + self.applyMenuState(running: running) + } + } + } + + private func applyMenuState(running: Bool) { + startItem?.isEnabled = !running + restartItem?.isEnabled = running + stopItem?.isEnabled = running + quitItem?.isEnabled = true + } + + private func makeArcadiaProcess() -> Process { + let process = Process() + let globalCommand = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".local/bin/arcadia-gui") + + if FileManager.default.isExecutableFile(atPath: globalCommand.path) { + process.executableURL = globalCommand + process.arguments = [] + } else { + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [ + "-lc", + """ + export PATH="${HOME}/.cargo/bin:${PATH}" + cd "\(repositoryRoot.path)" + cargo build --manifest-path Desktop/Cargo.toml --target-dir target --no-default-features --features gui >/dev/null + exec "\(repositoryRoot.path)/target/debug/arcadia" + """ + ] + } + + process.currentDirectoryURL = repositoryRoot + process.environment = processEnvironment() + return process + } + + private func processEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let cargoBin = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".cargo/bin").path + let existingPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + environment["PATH"] = "\(cargoBin):\(existingPath)" + return environment + } + + /// Redirect stdout/stderr to log file. Close in `terminationHandler` (or immediately if `run()` fails); do **not** close right after `run()` — breaks child I/O. + private func attachLogFile(to process: Process) -> FileHandle? { + let logsDirectory = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Logs/Arcadia") + let logFile = logsDirectory.appendingPathComponent("DevelopmentLauncher.log") + + do { + try FileManager.default.createDirectory( + at: logsDirectory, + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: logFile.path) { + FileManager.default.createFile(atPath: logFile.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: logFile) + try handle.seekToEnd() + process.standardOutput = handle + process.standardError = handle + return handle + } catch { + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + return nil + } + } + + private func stopArcadia(wait: Bool) { + let snapshot = launchedProcess + processQueue.async { [weak self] in + self?.stopArcadiaWorkEntry(snapshotProcess: snapshot, wait: wait, refreshUI: true) + } + } + + /// Runs only on `processQueue`. Never call from main without dispatching. + private func stopArcadiaWorkEntry(snapshotProcess: Process?, wait: Bool, refreshUI: Bool) { + let pids = arcadiaPIDs() + if let snapshotProcess, snapshotProcess.isRunning { + snapshotProcess.terminate() + if wait { + waitForSnapshotToExit(snapshotProcess, timeout: 12) + } + } + + for pid in pids { + Darwin.kill(pid, SIGTERM) + } + + if wait { + waitForArcadiaToExit(timeout: 5.0) + } + + if refreshUI { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.launchedProcess?.isRunning == false { + self.launchedProcess = nil + } + self.refreshMenuState() + } + } + } + + private func waitForSnapshotToExit(_ process: Process, timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + } + + private func waitForArcadiaToExit(timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if arcadiaPIDs().isEmpty { + return + } + Thread.sleep(forTimeInterval: 0.1) + } + } + + private func arcadiaPIDs() -> [pid_t] { + let process = Process() + let pipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-axo", "pid=,command="] + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + return [] + } + + process.waitUntilExit() + + let output = pipe.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: output, encoding: .utf8) else { + return [] + } + + let currentPID = ProcessInfo.processInfo.processIdentifier + let binaryPath = "\(repositoryRoot.path)/target/debug/arcadia" + + return text.split(separator: "\n").compactMap { line -> pid_t? in + let trimmed = String(line).trimmingCharacters(in: .whitespaces) + guard let firstSpace = trimmed.firstIndex(where: { $0 == " " || $0 == "\t" }) else { + return nil + } + + let pidText = String(trimmed[../dev/null +sips -z 32 32 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null +sips -z 64 64 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null +sips -z 256 256 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null +sips -z 512 512 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "${ICON_SOURCE}" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null +cp "${ICON_SOURCE}" "${ICONSET_DIR}/icon_512x512@2x.png" +iconutil -c icns "${ICONSET_DIR}" -o "${RESOURCES_DIR}/${ICON_FILE}" + +cat > "${CONTENTS_DIR}/Info.plist" < + + + + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleIconFile + ${ICON_FILE} + CFBundleName + ${BUNDLE_NAME} + CFBundleDisplayName + ${BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.developer-tools + LSUIElement + + NSHighResolutionCapable + + + +PLIST + +echo "Built ${APP_DIR}" diff --git a/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj b/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj index 505a340..07e10b2 100644 --- a/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj +++ b/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj @@ -9,6 +9,23 @@ /* Begin PBXBuildFile section */ A00000000000000000000010 /* ArcadiaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000001 /* ArcadiaApp.swift */; }; A00000000000000000000011 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000002 /* ContentView.swift */; }; + A00000000000000000000012 /* arcadia_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000005 /* arcadia_core.swift */; }; + A00000000000000000000013 /* ArcadiaCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A00000000000000000000006 /* ArcadiaCore.xcframework */; }; + A00000000000000000000014 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000007 /* Assets.xcassets */; }; + A00000000000000000000090 /* NavigationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000080 /* NavigationModels.swift */; }; + A00000000000000000000091 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000081 /* AppTheme.swift */; }; + A00000000000000000000092 /* GlassComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000082 /* GlassComponents.swift */; }; + A00000000000000000000093 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000083 /* SidebarView.swift */; }; + A00000000000000000000094 /* ShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000084 /* ShellView.swift */; }; + A00000000000000000000095 /* ModulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000085 /* ModulesView.swift */; }; + A00000000000000000000096 /* ContentView+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000086 /* ContentView+Layout.swift */; }; + A00000000000000000000097 /* ContentView+NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000087 /* ContentView+NavigationState.swift */; }; + A00000000000000000000098 /* ContentView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000088 /* ContentView+Actions.swift */; }; + A00000000000000000000099 /* ContentView+Registry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000089 /* ContentView+Registry.swift */; }; + A00000000000000000000101 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000100 /* SplashView.swift */; }; + A00000000000000000000103 /* ModuleNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000102 /* ModuleNames.swift */; }; + A00000000000000000000105 /* LanNodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000104 /* LanNodesView.swift */; }; + A00000000000000000000107 /* NetworkOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000106 /* NetworkOverviewView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -16,6 +33,23 @@ A00000000000000000000001 /* ArcadiaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArcadiaApp.swift; sourceTree = ""; }; A00000000000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A00000000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A00000000000000000000005 /* arcadia_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArcadiaCore/Generated/arcadia_core.swift; sourceTree = SOURCE_ROOT; }; + A00000000000000000000006 /* ArcadiaCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = ArcadiaCore/ArcadiaCore.xcframework; sourceTree = SOURCE_ROOT; }; + A00000000000000000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A00000000000000000000080 /* NavigationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModels.swift; sourceTree = ""; }; + A00000000000000000000081 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + A00000000000000000000082 /* GlassComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassComponents.swift; sourceTree = ""; }; + A00000000000000000000083 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + A00000000000000000000084 /* ShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellView.swift; sourceTree = ""; }; + A00000000000000000000085 /* ModulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModulesView.swift; sourceTree = ""; }; + A00000000000000000000086 /* ContentView+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+Layout.swift"; sourceTree = ""; }; + A00000000000000000000087 /* ContentView+NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+NavigationState.swift"; sourceTree = ""; }; + A00000000000000000000088 /* ContentView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+Actions.swift"; sourceTree = ""; }; + A00000000000000000000089 /* ContentView+Registry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+Registry.swift"; sourceTree = ""; }; + A00000000000000000000100 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; + A00000000000000000000102 /* ModuleNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleNames.swift; sourceTree = ""; }; + A00000000000000000000104 /* LanNodesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanNodesView.swift; sourceTree = ""; }; + A00000000000000000000106 /* NetworkOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOverviewView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -23,6 +57,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A00000000000000000000013 /* ArcadiaCore.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -42,11 +77,36 @@ children = ( A00000000000000000000001 /* ArcadiaApp.swift */, A00000000000000000000002 /* ContentView.swift */, + A00000000000000000000086 /* ContentView+Layout.swift */, + A00000000000000000000087 /* ContentView+NavigationState.swift */, + A00000000000000000000088 /* ContentView+Actions.swift */, + A00000000000000000000089 /* ContentView+Registry.swift */, + A00000000000000000000100 /* SplashView.swift */, + A00000000000000000000102 /* ModuleNames.swift */, + A00000000000000000000080 /* NavigationModels.swift */, + A00000000000000000000081 /* AppTheme.swift */, + A00000000000000000000082 /* GlassComponents.swift */, + A00000000000000000000083 /* SidebarView.swift */, + A00000000000000000000084 /* ShellView.swift */, + A00000000000000000000085 /* ModulesView.swift */, + A00000000000000000000104 /* LanNodesView.swift */, + A00000000000000000000106 /* NetworkOverviewView.swift */, A00000000000000000000004 /* Info.plist */, + A00000000000000000000007 /* Assets.xcassets */, + A00000000000000000000033 /* ArcadiaCore */, ); path = ArcadiaApp; sourceTree = ""; }; + A00000000000000000000033 /* ArcadiaCore */ = { + isa = PBXGroup; + children = ( + A00000000000000000000005 /* arcadia_core.swift */, + A00000000000000000000006 /* ArcadiaCore.xcframework */, + ); + path = ArcadiaCore; + sourceTree = ""; + }; A00000000000000000000032 /* Products */ = { isa = PBXGroup; children = ( @@ -114,6 +174,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A00000000000000000000014 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,6 +187,21 @@ files = ( A00000000000000000000010 /* ArcadiaApp.swift in Sources */, A00000000000000000000011 /* ContentView.swift in Sources */, + A00000000000000000000096 /* ContentView+Layout.swift in Sources */, + A00000000000000000000097 /* ContentView+NavigationState.swift in Sources */, + A00000000000000000000098 /* ContentView+Actions.swift in Sources */, + A00000000000000000000099 /* ContentView+Registry.swift in Sources */, + A00000000000000000000101 /* SplashView.swift in Sources */, + A00000000000000000000103 /* ModuleNames.swift in Sources */, + A00000000000000000000012 /* arcadia_core.swift in Sources */, + A00000000000000000000090 /* NavigationModels.swift in Sources */, + A00000000000000000000091 /* AppTheme.swift in Sources */, + A00000000000000000000092 /* GlassComponents.swift in Sources */, + A00000000000000000000093 /* SidebarView.swift in Sources */, + A00000000000000000000094 /* ShellView.swift in Sources */, + A00000000000000000000095 /* ModulesView.swift in Sources */, + A00000000000000000000105 /* LanNodesView.swift in Sources */, + A00000000000000000000107 /* NetworkOverviewView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,11 +214,20 @@ ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 8248296AJX; ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/ArcadiaCore", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/ArcadiaCore/Generated", + ); GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ArcadiaApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -155,6 +240,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SWIFT_INCLUDE_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/ArcadiaCore/Generated", + ); SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -167,10 +256,19 @@ ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8248296AJX; ENABLE_NS_ASSERTIONS = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/ArcadiaCore", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/ArcadiaCore/Generated", + ); GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ArcadiaApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -183,6 +281,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SWIFT_INCLUDE_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/ArcadiaCore/Generated", + ); SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/Mobile/iOS/ArcadiaApp/AppTheme.swift b/Mobile/iOS/ArcadiaApp/AppTheme.swift new file mode 100644 index 0000000..9d1a61c --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/AppTheme.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct AppTheme { + let isDark: Bool + + var primaryTextColor: Color { isDark ? .white : Color.black.opacity(0.85) } + var secondaryTextColor: Color { isDark ? .white.opacity(0.72) : Color.black.opacity(0.62) } + var tertiaryTextColor: Color { isDark ? .white.opacity(0.54) : Color.black.opacity(0.5) } + var accentTextColor: Color { isDark ? .white.opacity(0.92) : Color.black.opacity(0.82) } + var selectedTextColor: Color { isDark ? .white : Color.black.opacity(0.88) } + + var cardFillColor: Color { isDark ? .white.opacity(0.08) : .white.opacity(0.72) } + var cardStrokeColor: Color { isDark ? .white.opacity(0.14) : Color.black.opacity(0.1) } + var selectedFillColor: Color { isDark ? .white.opacity(0.12) : Color.black.opacity(0.08) } + var selectedStrokeColor: Color { isDark ? .white.opacity(0.18) : Color.black.opacity(0.16) } + + var sidebarShadowColor: Color { isDark ? .black.opacity(0.28) : .black.opacity(0.12) } + var contentShadowColor: Color { isDark ? .black.opacity(0.22) : .black.opacity(0.08) } + + static let splashBackgroundTop = Color(red: 0.060, green: 0.055, blue: 0.580) + static let splashBackgroundMid = Color(red: 0.205, green: 0.105, blue: 0.760) + static let splashBackgroundHorizon = Color(red: 0.790, green: 0.240, blue: 0.760) + static let splashBackgroundBottom = Color(red: 1.000, green: 0.480, blue: 0.560) + + static let splashHorizonPink = Color(red: 1.000, green: 0.250, blue: 0.670) + static let splashHorizonGold = Color(red: 1.000, green: 0.690, blue: 0.250) + + static let splashHillBack = Color(red: 0.430, green: 0.150, blue: 0.900) + static let splashHillLeft = Color(red: 0.500, green: 0.180, blue: 0.920) + static let splashHillRight = Color(red: 0.420, green: 0.135, blue: 0.835) + static let splashHillFront = Color(red: 0.060, green: 0.050, blue: 0.520) + + static let splashArchCore = Color(red: 0.930, green: 0.860, blue: 1.000) + static let splashArchGlow = Color(red: 0.765, green: 0.610, blue: 1.000) + static let splashStar = Color.white + + static let splashSunLayers: [(radius: Double, alpha: Double, color: Color)] = [ + (4.2, 0.055, Color(red: 1.000, green: 0.300, blue: 0.620)), + (3.2, 0.115, Color(red: 1.000, green: 0.500, blue: 0.280)), + (2.2, 0.210, Color(red: 1.000, green: 0.690, blue: 0.300)), + (1.45, 0.440, Color(red: 1.000, green: 0.830, blue: 0.520)), + (1.0, 1.000, Color(red: 1.000, green: 0.950, blue: 0.770)) + ] + + // MARK: - Navigation accents (mirrors `Desktop/src/gui/theme.rs` `nav_accent_palette`) + + struct NavAccentPalette { + let iconIdle: Color + let iconActive: Color + let selectedFill: Color + let hoverFill: Color + } + + func navAccentPalette(_ key: String) -> NavAccentPalette { + switch key { + case "amber": + return isDark + ? navRgb(idle: (180, 83, 9), active: (251, 191, 36), selected: (53, 42, 28), hover: (63, 52, 40)) + : navRgb(idle: (180, 83, 9), active: (217, 119, 6), selected: (255, 251, 235), hover: (254, 243, 199)) + case "cyan": + return isDark + ? navRgb(idle: (14, 116, 144), active: (34, 211, 238), selected: (21, 42, 48), hover: (26, 53, 64)) + : navRgb(idle: (8, 145, 178), active: (8, 145, 178), selected: (236, 254, 255), hover: (207, 250, 254)) + case "emerald": + return isDark + ? navRgb(idle: (4, 120, 87), active: (52, 211, 153), selected: (20, 41, 34), hover: (26, 51, 40)) + : navRgb(idle: (4, 120, 87), active: (5, 150, 105), selected: (236, 253, 245), hover: (209, 250, 229)) + case "violet": + return isDark + ? navRgb(idle: (109, 40, 217), active: (167, 139, 250), selected: (37, 26, 51), hover: (46, 33, 64)) + : navRgb(idle: (109, 40, 217), active: (124, 58, 237), selected: (245, 243, 255), hover: (237, 233, 254)) + case "orange": + return isDark + ? navRgb(idle: (194, 65, 12), active: (251, 146, 60), selected: (51, 24, 16), hover: (64, 34, 24)) + : navRgb(idle: (194, 65, 12), active: (234, 88, 12), selected: (255, 247, 237), hover: (255, 237, 213)) + case "indigo": + return isDark + ? navRgb(idle: (67, 56, 202), active: (129, 140, 248), selected: (30, 27, 51), hover: (37, 33, 64)) + : navRgb(idle: (67, 56, 202), active: (79, 70, 229), selected: (238, 242, 255), hover: (224, 231, 255)) + case "fuchsia": + return isDark + ? navRgb(idle: (162, 28, 175), active: (232, 121, 249), selected: (45, 21, 51), hover: (56, 26, 64)) + : navRgb(idle: (162, 28, 175), active: (192, 38, 211), selected: (253, 244, 255), hover: (250, 232, 255)) + case "teal": + return isDark + ? navRgb(idle: (15, 118, 110), active: (45, 212, 191), selected: (20, 40, 36), hover: (26, 51, 46)) + : navRgb(idle: (15, 118, 110), active: (13, 148, 136), selected: (240, 253, 250), hover: (204, 251, 241)) + case "sky": + return isDark + ? navRgb(idle: (148, 163, 184), active: (147, 197, 253), selected: (31, 42, 62), hover: (36, 50, 70)) + : navRgb(idle: (107, 114, 128), active: (29, 78, 216), selected: (225, 231, 255), hover: (238, 242, 255)) + default: + return navAccentPalette("sky") + } + } + + private func navRgb( + idle: (Int, Int, Int), + active: (Int, Int, Int), + selected: (Int, Int, Int), + hover: (Int, Int, Int) + ) -> NavAccentPalette { + func c(_ t: (Int, Int, Int)) -> Color { + Color(red: Double(t.0) / 255, green: Double(t.1) / 255, blue: Double(t.2) / 255) + } + return NavAccentPalette(iconIdle: c(idle), iconActive: c(active), selectedFill: c(selected), hoverFill: c(hover)) + } +} diff --git a/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift b/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift index 5dcb25e..334743b 100644 --- a/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift +++ b/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift @@ -2,6 +2,18 @@ import SwiftUI @main struct ArcadiaApp: App { + init() { + let fm = FileManager.default + if let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let configRoot = appSupport + .appendingPathComponent("Arcadia", isDirectory: true) + .appendingPathComponent("Configuration", isDirectory: true) + try? fm.createDirectory(at: configRoot, withIntermediateDirectories: true) + setConfigRootPath(path: configRoot.path) + } + setLocalHostname(name: ProcessInfo.processInfo.hostName) + } + var body: some Scene { WindowGroup { ContentView() diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 31fbaca..d1d1ae3 100644 --- a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,5 +1,113 @@ { "images" : [ + { + "filename" : "Icon-20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "Icon-1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } ], "info" : { "author" : "xcode", diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png new file mode 100644 index 0000000..27ec82b Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20.png new file mode 100644 index 0000000..2ef62f1 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png new file mode 100644 index 0000000..fcae58f Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png new file mode 100644 index 0000000..9eb8f68 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29.png new file mode 100644 index 0000000..0b638a7 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png new file mode 100644 index 0000000..a26ed32 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png new file mode 100644 index 0000000..036ef7f Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40.png new file mode 100644 index 0000000..fcae58f Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png new file mode 100644 index 0000000..f77ae34 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png new file mode 100644 index 0000000..fbd573a Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 0000000..fbd573a Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 0000000..3218c41 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-76.png new file mode 100644 index 0000000..8e14b75 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-76.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 0000000..2b6c6c2 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png new file mode 100644 index 0000000..1117a11 Binary files /dev/null and b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png differ diff --git a/Mobile/iOS/ArcadiaApp/ContentView+Actions.swift b/Mobile/iOS/ArcadiaApp/ContentView+Actions.swift new file mode 100644 index 0000000..68a098f --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ContentView+Actions.swift @@ -0,0 +1,195 @@ +import Foundation +import SwiftUI +import UIKit + +struct RemoteTarget: Codable, Identifiable { + let ip: String + let hostname: String + var id: String { ip } +} + +private struct SurfacePayload: Codable { + let modules: [String: Bool] + let revision: UInt64? + let extra: SurfaceExtra? +} + +private struct SurfaceExtra: Codable { + let navigationRegistry: NavigationRegistry? + + enum CodingKeys: String, CodingKey { + case navigationRegistry = "navigation_registry" + } +} + +private struct SurfaceModulesSetPatch: Codable { + let op: String + let name: String + let enabled: Bool + let clientId: String? + + enum CodingKeys: String, CodingKey { + case op, name, enabled + case clientId = "client_id" + } + + init(name: String, enabled: Bool, clientId: String?) { + self.op = "modules_set" + self.name = name + self.enabled = enabled + self.clientId = clientId + } +} + +extension ContentView { + /// ARCADIA_NET_AS env beats thin-client.toml; shape matches ExecutionContext.net_as (`lan:host`). + func applyThinClientBootstrapRoute() { + let env = ProcessInfo.processInfo.environment["ARCADIA_NET_AS"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let persisted = thinClientPreferredRouteGet()? + .trimmingCharacters(in: .whitespacesAndNewlines) + let raw: String? + if let e = env, !e.isEmpty { raw = e } + else if let p = persisted, !p.isEmpty { raw = p } + else { raw = nil } + guard let r = raw, + isModuleEnabled(ModuleNames.lan), + isModuleEnabled(ModuleNames.remoteSession) else { return } + let route = r.hasPrefix("lan:") ? r : "lan:\(r)" + remoteRoute = route + } + + func ensureActiveNavigationSelection() { + let pageIds = Set(navigationRegistry.pages.map(\.id)) + let groupIds = Set(navigationRegistry.groups.map(\.id)) + if !groupIds.contains(activeGroupID) { + activeGroupID = navigationRegistry.defaultGroup + } + if !pageIds.contains(activePageID) { + activePageID = navigationRegistry.defaultPage + } + } + + func reloadModules() { + if let route = remoteRoute { + let json = executeCommand( + token: "surface.snapshot", + args: [], + context: ExecutionContextFfi(netAs: route, netTimeoutMs: nil) + ) + guard let data = json.data(using: .utf8), + let payload = try? JSONDecoder().decode(SurfacePayload.self, from: data) else { + modules = [] + return + } + modules = payload.modules.map { ModuleStatus(name: $0.key, enabled: $0.value) } + .sorted { $0.name < $1.name } + if let nav = payload.extra?.navigationRegistry, !nav.pages.isEmpty, !nav.groups.isEmpty { + navigationRegistry = nav + } + ensureActiveNavigationSelection() + } else { + modules = listModules().sorted { $0.name < $1.name } + navigationRegistry = Self.loadNavigationRegistry() + ensureActiveNavigationSelection() + } + } + + func refreshRemoteTargets() { + guard isModuleEnabled(ModuleNames.lan) else { + remoteTargets = [] + return + } + let json = executeCommand( + token: "lan.session_targets", + args: [], + context: ExecutionContextFfi(netAs: nil, netTimeoutMs: nil) + ) + guard let data = json.data(using: .utf8), + let decoded = try? JSONDecoder().decode([RemoteTarget].self, from: data) else { + remoteTargets = [] + return + } + remoteTargets = decoded + } + + func updateModule(name: String, enabled: Bool) { + moduleToggleTask?.cancel() + moduleToggleTask = Task { @MainActor in + do { try await Task.sleep(nanoseconds: 300_000_000) } catch { return } + if let route = remoteRoute { + guard let payloadData = try? JSONEncoder().encode([ + SurfaceModulesSetPatch( + name: name, + enabled: enabled, + clientId: thinClientSurfaceClientId() + ), + ]), + let payload = String(data: payloadData, encoding: .utf8) else { + moduleErrorMessage = "Could not encode surface.patch payload" + return + } + let result = executeCommand( + token: "surface.patch", + args: [payload], + context: ExecutionContextFfi(netAs: route, netTimeoutMs: nil) + ) + let expected = "Module \(name) \(enabled ? "enabled" : "disabled")" + if result != expected { + moduleErrorMessage = result + } + reloadModules() + return + } + if enabled { + let probe = probeModuleToggle(name: name, enabled: true) + if !probe.ok && !probe.missingRequirements.isEmpty { + pendingModuleEnable = (name: name, probe: probe) + showRequirementsPrompt = true + reloadModules() + return + } + } + let result = setModuleEnabled(name: name, enabled: enabled) + let expected = "Module \(name) \(enabled ? "enabled" : "disabled")" + if result != expected { + moduleErrorMessage = result + } + reloadModules() + } + } + + func runShellCommand() { + let trimmed = shellCommandInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + shellHistory.append("$ \(trimmed)") + var parts = trimmed.split(separator: " ").map(String.init) + let token = parts.removeFirst() + let output = executeCommand( + token: token, + args: parts, + context: ExecutionContextFfi(netAs: remoteRoute, netTimeoutMs: nil) + ) + shellHistory.append(contentsOf: output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)) + shellHistory.append("") + shellCommandInput = "" + } + + func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + /// NODE_EXEC host: transcript + reload module/nav when showing **local** host state. + func applyRemoteMirrorSideEffects() { + let batch = drainRemoteMirrorBatch() + if batch.syncLocalSurface { + refreshRemoteTargets() + if remoteRoute == nil { + reloadModules() + } + } + if !batch.lines.isEmpty { + shellHistory.append(contentsOf: batch.lines) + } + } +} diff --git a/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift b/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift new file mode 100644 index 0000000..9070cf3 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift @@ -0,0 +1,234 @@ +import SwiftUI + +extension ContentView { + var sidebarOffset: CGFloat { + isSidebarOpen + ? min(0, sidebarDragOffset) + : max(-sidebarWidth, -sidebarWidth + max(0, sidebarDragOffset)) + } + + var closeSidebarGesture: some Gesture { + DragGesture(minimumDistance: 12) + .onChanged { value in + guard isSidebarOpen else { return } + sidebarDragOffset = min(0, value.translation.width) + } + .onEnded { value in + guard isSidebarOpen else { return } + let closing = value.translation.width < -sidebarSwipeThreshold + || value.predictedEndTranslation.width < -sidebarSwipeThreshold + withAnimation(.spring(response: 0.42, dampingFraction: 0.88)) { + isSidebarOpen = !closing + sidebarDragOffset = 0 + } + } + } + + var openSidebarGesture: some Gesture { + DragGesture(minimumDistance: 12) + .onChanged { value in + guard !isSidebarOpen else { return } + sidebarDragOffset = max(0, value.translation.width) + } + .onEnded { value in + guard !isSidebarOpen else { return } + let opening = value.translation.width > sidebarSwipeThreshold + || value.predictedEndTranslation.width > sidebarSwipeThreshold + withAnimation(.spring(response: 0.42, dampingFraction: 0.88)) { + isSidebarOpen = opening + sidebarDragOffset = 0 + } + } + } + + var edgeSwipeHandle: some View { + Rectangle() + .fill(.clear) + .frame(width: 24) + .contentShape(Rectangle()) + .ignoresSafeArea() + .gesture(openSidebarGesture) + } + + var mainContent: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 24) { + if activePage.id != "utility.shell" { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 10) { + Text(activePage.title) + .font(.system(size: 40, weight: .semibold, design: .rounded)) + .foregroundStyle(theme.primaryTextColor) + + Text(activePage.description) + .font(.body) + .foregroundStyle(theme.secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Image(systemName: activePage.systemImage) + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(theme.accentTextColor) + .frame(width: 58, height: 58) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + } + + contentBody + + Spacer(minLength: 0) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(theme.cardFillColor) + .background( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + .shadow(color: theme.contentShadowColor, radius: 40, x: 0, y: 18) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + withAnimation(.spring(response: 0.42, dampingFraction: 0.88)) { + isSidebarOpen.toggle() + } + } label: { + Image(systemName: "sidebar.leading") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(theme.accentTextColor) + .frame(width: 52, height: 52) + .background(.ultraThinMaterial, in: Circle()) + .overlay { + Circle() + .fill(colorScheme == .dark ? .white.opacity(0.04) : .white.opacity(0.35)) + } + .overlay { + Circle() + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + .shadow(color: theme.contentShadowColor, radius: 14, x: 0, y: 8) + } + .buttonStyle(.plain) + .accessibilityLabel(isSidebarOpen ? "Close sidebar" : "Open sidebar") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + activePageID = "global.logs" + } label: { + let isLogsActive = activePageID == "global.logs" + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(isLogsActive ? theme.primaryTextColor : theme.accentTextColor) + .frame(width: 52, height: 52) + .background( + isLogsActive + ? AnyShapeStyle(theme.cardFillColor) + : AnyShapeStyle(.ultraThinMaterial), + in: Circle() + ) + .overlay { + Circle() + .fill( + isLogsActive + ? (colorScheme == .dark ? .white.opacity(0.08) : .white.opacity(0.45)) + : (colorScheme == .dark ? .white.opacity(0.04) : .white.opacity(0.35)) + ) + } + .overlay { + Circle() + .stroke( + isLogsActive ? theme.accentTextColor.opacity(0.4) : theme.cardStrokeColor, + lineWidth: 1 + ) + } + .shadow(color: theme.contentShadowColor, radius: 14, x: 0, y: 8) + } + .buttonStyle(.plain) + .accessibilityLabel("Open logs") + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + } + + @ViewBuilder + var contentBody: some View { + if activePage.id == "global.modules" { + ModulesView(modules: modules, onToggle: updateModule, onAppear: reloadModules) + } else if activePage.id == "network.overview" { + NetworkOverviewView(theme: theme, modules: modules) + } else if activePage.id == "network.nodes" { + LanNodesView(theme: theme) + } else if activePage.id == "utility.shell" { + ShellView(shellHistory: $shellHistory, shellCommandInput: $shellCommandInput, onRun: runShellCommand) + } else { + VStack(spacing: 16) { + GlassCard(title: "Primary Surface", subtitle: "This page is rendered from shared page definitions.") + HStack(spacing: 16) { + GlassMetric(title: "Sidebar", value: isSidebarOpen ? "Open" : "Closed") + GlassMetric(title: "Selection", value: activePage.title) + } + } + } + } + + var glassBackground: some View { + ZStack { + if colorScheme == .dark { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.08, blue: 0.16), + Color(red: 0.07, green: 0.14, blue: 0.25), + Color(red: 0.03, green: 0.06, blue: 0.12) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.97, blue: 1.0), + Color(red: 0.92, green: 0.96, blue: 0.99), + Color(red: 0.97, green: 0.98, blue: 1.0) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + Circle() + .fill(colorScheme == .dark ? Color.white.opacity(0.18) : Color.white.opacity(0.6)) + .frame(width: 320) + .blur(radius: 70) + .offset(x: -120, y: -250) + + Circle() + .fill(colorScheme == .dark ? Color.cyan.opacity(0.16) : Color.cyan.opacity(0.2)) + .frame(width: 360) + .blur(radius: 90) + .offset(x: 150, y: -160) + + Circle() + .fill(colorScheme == .dark ? Color.blue.opacity(0.22) : Color.blue.opacity(0.16)) + .frame(width: 380) + .blur(radius: 110) + .offset(x: 130, y: 260) + } + .ignoresSafeArea() + } +} diff --git a/Mobile/iOS/ArcadiaApp/ContentView+NavigationState.swift b/Mobile/iOS/ArcadiaApp/ContentView+NavigationState.swift new file mode 100644 index 0000000..ab5db48 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ContentView+NavigationState.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension ContentView { + func isModuleEnabled(_ name: String) -> Bool { + modules.first(where: { $0.name == name })?.enabled ?? false + } + + func isPageVisible(_ pageID: String) -> Bool { + guard let page = navigationRegistry.pages.first(where: { $0.id == pageID }) else { + return false + } + guard let required = page.requiredModule, !required.isEmpty else { + return true + } + return isModuleEnabled(required) + } + + var activePage: PageDefinition { + if isPageVisible(activePageID), let page = navigationRegistry.pages.first(where: { $0.id == activePageID }) { + return page + } + if let firstVisible = navigationRegistry.pages.first(where: { isPageVisible($0.id) }) { + return firstVisible + } + return navigationRegistry.pages[0] + } +} diff --git a/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift b/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift new file mode 100644 index 0000000..f821143 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift @@ -0,0 +1,33 @@ +import Foundation + +extension ContentView { + static func loadNavigationRegistry() -> NavigationRegistry { + let fallback = NavigationRegistry( + pages: [ + PageDefinition(id: "utility.shell", title: "Shell", description: "Run and manage shell utility actions.", glyph: "SH", systemImage: "terminal", accent: "emerald", requiredModule: ModuleNames.shell), + PageDefinition(id: "global.dashboard", title: "Dashboard", description: "Overview of the Arcadia application surface.", glyph: "DH", systemImage: "house", accent: "violet"), + PageDefinition(id: "global.logs", title: "Logs", description: "Recent logs and activity stream appear here.", glyph: "LG", systemImage: "doc.text.magnifyingglass", accent: "sky"), + PageDefinition(id: "global.settings", title: "Settings", description: "App preferences and configuration controls appear here.", glyph: "ST", systemImage: "gearshape", accent: "indigo"), + PageDefinition(id: "global.modules", title: "Modules", description: "Manage global module availability and dependency requirements.", glyph: "MD", systemImage: "switch.2", accent: "fuchsia"), + PageDefinition(id: "network.overview", title: "Overview", description: "Network status and module connectivity overview.", glyph: "NW", systemImage: "network", accent: "teal", requiredModule: ModuleNames.net), + PageDefinition(id: "network.nodes", title: "Nodes", description: "Discover LAN peers and manage pairing with lan.scan / lan.node.", glyph: "ND", systemImage: "rectangle.connected.to.line.under.fill", accent: "cyan", requiredModule: ModuleNames.lan) + ], + groups: [ + GroupDefinition(id: "utilities", label: "Utilities", glyph: "UT", systemImage: "wrench.and.screwdriver", pageIDs: ["utility.shell"], accent: "amber"), + GroupDefinition(id: "network", label: "Network", glyph: "NW", systemImage: "network", pageIDs: ["network.overview", "network.nodes"], accent: "cyan") + ], + globalPages: ["global.dashboard", "global.settings", "global.modules"], + defaultGroup: "utilities", + defaultPage: "global.dashboard" + ) + + let payload = navigationRegistryJson() + guard let data = payload.data(using: .utf8), + let decoded = try? JSONDecoder().decode(NavigationRegistry.self, from: data), + !decoded.pages.isEmpty, + !decoded.groups.isEmpty else { + return fallback + } + return decoded + } +} diff --git a/Mobile/iOS/ArcadiaApp/ContentView.swift b/Mobile/iOS/ArcadiaApp/ContentView.swift index e985bfb..67b2cdd 100644 --- a/Mobile/iOS/ArcadiaApp/ContentView.swift +++ b/Mobile/iOS/ArcadiaApp/ContentView.swift @@ -1,36 +1,44 @@ +import Combine import SwiftUI - -struct SidebarItem: Identifiable, Hashable { - let id = UUID() - let title: String - let systemImage: String -} +import UIKit struct ContentView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - private let sidebarItems = [ - SidebarItem(title: "Dashboard", systemImage: "square.grid.2x2"), - SidebarItem(title: "Shell", systemImage: "terminal"), - SidebarItem(title: "Modules", systemImage: "switch.2"), - SidebarItem(title: "Settings", systemImage: "gearshape") - ] - - @State private var isSidebarOpen = true - @State private var selectedItemTitle = "Dashboard" - @State private var coreRuntimeEnabled = true - @State private var commandRouterEnabled = true - @State private var remoteBridgeEnabled = false - @State private var telemetryEnabled = false - @State private var safeguardsEnabled = true - @State private var autoUpdatesEnabled = true - @State private var selectedEnvironment = "Production" + @Environment(\.colorScheme) var colorScheme + var theme: AppTheme { AppTheme(isDark: colorScheme == .dark) } + + let sidebarWidth: CGFloat = 292 + let sidebarSwipeThreshold: CGFloat = 80 + + @State var navigationRegistry: NavigationRegistry + + @State var showSplash = true + @State var isSidebarOpen = true + @State var activeGroupID: String + @State var activePageID: String + @State var sidebarDragOffset: CGFloat = 0 + @State var modules: [ModuleStatus] = [] + @State var pendingModuleEnable: (name: String, probe: ModuleToggleResult)? + @State var showRequirementsPrompt = false + @State var shellCommandInput = "" + @State var shellHistory: [String] = ["Arcadia Terminal ready."] + @State var moduleToggleTask: Task? + @State var moduleErrorMessage: String? + @State var remoteRoute: String? + @State var remoteTargets: [RemoteTarget] = [] + + init() { + let loadedRegistry = Self.loadNavigationRegistry() + _navigationRegistry = State(initialValue: loadedRegistry) + _activeGroupID = State(initialValue: loadedRegistry.defaultGroup) + _activePageID = State(initialValue: loadedRegistry.defaultPage) + } var body: some View { ZStack(alignment: .leading) { glassBackground mainContent + .simultaneousGesture(closeSidebarGesture) .overlay { if isSidebarOpen { Rectangle() @@ -46,415 +54,79 @@ struct ContentView: View { } } - sidebar - .offset(x: isSidebarOpen ? 0 : -300) - } - .preferredColorScheme(.dark) - .animation(.spring(response: 0.42, dampingFraction: 0.88), value: isSidebarOpen) - } - - private var glassBackground: some View { - ZStack { - LinearGradient( - colors: [ - Color(red: 0.05, green: 0.08, blue: 0.16), - Color(red: 0.07, green: 0.14, blue: 0.25), - Color(red: 0.03, green: 0.06, blue: 0.12) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing + SidebarView( + registry: navigationRegistry, + sidebarWidth: sidebarWidth, + sidebarSwipeThreshold: sidebarSwipeThreshold, + isPageVisible: { pageID in self.isPageVisible(pageID) }, + remoteSessionEnabled: isModuleEnabled(ModuleNames.remoteSession), + remoteRoute: $remoteRoute, + remoteTargets: remoteTargets, + refreshRemoteTargets: refreshRemoteTargets, + activeGroupID: $activeGroupID, + activePageID: $activePageID ) + .offset(x: sidebarOffset) + .gesture(closeSidebarGesture) - Circle() - .fill(Color.white.opacity(0.18)) - .frame(width: 320) - .blur(radius: 70) - .offset(x: -120, y: -250) - - Circle() - .fill(Color.cyan.opacity(0.16)) - .frame(width: 360) - .blur(radius: 90) - .offset(x: 150, y: -160) - - Circle() - .fill(Color.blue.opacity(0.22)) - .frame(width: 380) - .blur(radius: 110) - .offset(x: 130, y: 260) - } - .ignoresSafeArea() - } - - private var mainContent: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 24) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 10) { - Text(selectedItemTitle) - .font(.system(size: 40, weight: .semibold, design: .rounded)) - .foregroundStyle(.white) - - Text("A liquid glass shell with translucent navigation and polished placeholder surfaces.") - .font(.body) - .foregroundStyle(.white.opacity(0.72)) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - - Image(systemName: sidebarSymbol) - .font(.system(size: 28, weight: .medium)) - .foregroundStyle(.white.opacity(0.86)) - .frame(width: 58, height: 58) - .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) - } - } - - contentBody - - Spacer(minLength: 0) - } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background { - RoundedRectangle(cornerRadius: 30, style: .continuous) - .fill(.white.opacity(0.08)) - .background( - RoundedRectangle(cornerRadius: 30, style: .continuous) - .fill(.ultraThinMaterial) - ) - .overlay { - RoundedRectangle(cornerRadius: 30, style: .continuous) - .stroke(.white.opacity(0.16), lineWidth: 1) - } - .shadow(color: .black.opacity(0.22), radius: 40, x: 0, y: 18) + if !isSidebarOpen { + edgeSwipeHandle } - .padding(.horizontal, 18) - .padding(.vertical, 10) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - withAnimation(.spring(response: 0.42, dampingFraction: 0.88)) { - isSidebarOpen.toggle() - } - } label: { - Image(systemName: "sidebar.leading") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white.opacity(0.92)) - .frame(width: 52, height: 52) - .background(.ultraThinMaterial, in: Circle()) - .overlay { - Circle() - .fill(.white.opacity(0.04)) - } - .overlay { - Circle() - .stroke(.white.opacity(0.08), lineWidth: 1) - } - .shadow(color: .black.opacity(0.18), radius: 14, x: 0, y: 8) - } - .buttonStyle(.plain) - .accessibilityLabel(isSidebarOpen ? "Close sidebar" : "Open sidebar") - } - } - .toolbarBackground(.hidden, for: .navigationBar) } - } - - private var sidebar: some View { - VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 6) { - Text("Arcadia") - .font(.system(size: 28, weight: .semibold, design: .rounded)) - .foregroundStyle(.white) - - Text("Liquid glass") - .font(.subheadline) - .foregroundStyle(.white.opacity(0.64)) - } - .padding(.horizontal, 22) - .padding(.top, 28) - .padding(.bottom, 10) - - ForEach(sidebarItems) { item in - Button { - selectedItemTitle = item.title - } label: { - HStack(spacing: 12) { - Image(systemName: item.systemImage) - .font(.system(size: 16, weight: .semibold)) - .frame(width: 20) - Text(item.title) - .frame(maxWidth: .infinity, alignment: .leading) - } - .font(.body.weight(selectedItemTitle == item.title ? .semibold : .medium)) - .foregroundStyle(selectedItemTitle == item.title ? .white : .white.opacity(0.78)) - .padding(.horizontal, 16) - .frame(height: 50) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(selectedItemTitle == item.title ? .white.opacity(0.12) : .clear) - ) - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(selectedItemTitle == item.title ? .white.opacity(0.18) : .clear, lineWidth: 1) + .animation(.spring(response: 0.42, dampingFraction: 0.88), value: isSidebarOpen) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.9), value: sidebarDragOffset) + .overlay { + if showSplash { + SplashView { + withAnimation(.easeOut(duration: 0.2)) { + showSplash = false } } - .buttonStyle(.plain) - .padding(.horizontal, 14) - } - - Spacer() - - VStack(alignment: .leading, spacing: 8) { - Text("Ambient") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white.opacity(0.54)) - - Text("A minimal app shell ready for real navigation.") - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) + .ignoresSafeArea() + .transition(.opacity) } - .padding(18) - .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(.white.opacity(0.12), lineWidth: 1) - } - .padding(.horizontal, 16) - .padding(.bottom, 26) } - .frame(width: 292) - .frame(maxHeight: .infinity, alignment: .topLeading) - .background(.ultraThinMaterial) - .background(.white.opacity(0.05)) - .overlay { - RoundedRectangle(cornerRadius: 0) - .stroke(.white.opacity(0.1), lineWidth: 1) + .onAppear { + applyThinClientBootstrapRoute() + refreshRemoteTargets() + reloadModules() } - .shadow(color: .black.opacity(0.28), radius: 28, x: 8, y: 0) - .ignoresSafeArea() - } - - private var sidebarSymbol: String { - sidebarItems.first(where: { $0.title == selectedItemTitle })?.systemImage ?? "square.grid.2x2" - } - - @ViewBuilder - private var contentBody: some View { - if selectedItemTitle == "Modules" { - modulesPage - } else { - VStack(spacing: 16) { - glassCard( - title: "Primary Surface", - subtitle: "Use this area for the first real destination you add." - ) - - HStack(spacing: 16) { - glassMetric(title: "Sidebar", value: isSidebarOpen ? "Open" : "Closed") - glassMetric(title: "Selection", value: selectedItemTitle) - } - } + .onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in + applyRemoteMirrorSideEffects() } - } - - private var modulesPage: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 16) { - if isCompactLayout { - VStack(spacing: 16) { - glassMetric(title: "Active", value: "\(activeModuleCount)") - glassMetric(title: "Environment", value: selectedEnvironment) - } - } else { - HStack(spacing: 16) { - glassMetric(title: "Active", value: "\(activeModuleCount)") - glassMetric(title: "Environment", value: selectedEnvironment) - } - } - - glassCard(title: "Runtime Modules", subtitle: "Separate core services from optional integrations and make state obvious.") { - VStack(spacing: 12) { - moduleRow( - title: "Core Runtime", - subtitle: "Required for local orchestration and state management.", - isOn: $coreRuntimeEnabled, - accent: .cyan - ) - moduleRow( - title: "Command Router", - subtitle: "Dispatches actions between shell, modules, and system tools.", - isOn: $commandRouterEnabled, - accent: .blue - ) - moduleRow( - title: "Remote Bridge", - subtitle: "Allows outbound connections to paired services and agents.", - isOn: $remoteBridgeEnabled, - accent: .mint - ) - } - } - - if isCompactLayout { - VStack(spacing: 16) { - safetyCard - releaseChannelCard - } - } else { - HStack(alignment: .top, spacing: 16) { - safetyCard - releaseChannelCard - } - } + .onChange(of: remoteRoute) { _, newVal in + reloadModules() + let err = thinClientPreferredRouteSet(route: newVal) + if !err.isEmpty { + moduleErrorMessage = err } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private var activeModuleCount: Int { - [ - coreRuntimeEnabled, - commandRouterEnabled, - remoteBridgeEnabled, - telemetryEnabled, - safeguardsEnabled, - autoUpdatesEnabled - ].filter { $0 }.count - } - - private var isCompactLayout: Bool { - horizontalSizeClass != .regular - } - - private var safetyCard: some View { - glassCard(title: "Safety", subtitle: "High-risk capabilities should have explicit switches.") { - VStack(spacing: 12) { - moduleRow( - title: "Safeguards", - subtitle: "Blocks destructive operations until they are explicitly allowed.", - isOn: $safeguardsEnabled, - accent: .green - ) - moduleRow( - title: "Telemetry", - subtitle: "Collects session diagnostics and performance traces.", - isOn: $telemetryEnabled, - accent: .orange - ) + .onChange(of: isSidebarOpen) { open in + if open { + dismissKeyboard() + refreshRemoteTargets() } } - } - - private var releaseChannelCard: some View { - glassCard(title: "Release Channel", subtitle: "Pick where modules resolve from and how they update.") { - VStack(alignment: .leading, spacing: 14) { - Picker("Environment", selection: $selectedEnvironment) { - Text("Prod").tag("Production") - Text("Stage").tag("Staging") - Text("Local").tag("Local") - } - .pickerStyle(.segmented) - - Toggle(isOn: $autoUpdatesEnabled) { - VStack(alignment: .leading, spacing: 4) { - Text("Automatic updates") - .foregroundStyle(.white) - Text("Refresh module manifests and compatibility rules.") - .font(.footnote) - .foregroundStyle(.white.opacity(0.68)) - .fixedSize(horizontal: false, vertical: true) - } + .alert("Enable with requirements?", isPresented: $showRequirementsPrompt, presenting: pendingModuleEnable) { pending in + Button("Cancel", role: .cancel) { pendingModuleEnable = nil } + Button("Enable") { + let result = setModuleEnabledWithRequirements(name: pending.name, enabled: true) + if result != "Module \(pending.name) enabled" { + moduleErrorMessage = result } - .tint(.white.opacity(0.9)) + pendingModuleEnable = nil + reloadModules() } - } - } - - private func glassCard(title: String, subtitle: String) -> some View { - glassCard(title: title, subtitle: subtitle) { - EmptyView() - } - } - - private func glassCard(title: String, subtitle: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.headline) - .foregroundStyle(.white) - - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.72)) - - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(20) - .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) - } - } - - private func moduleRow(title: String, subtitle: String, isOn: Binding, accent: Color) -> some View { - HStack(alignment: .center, spacing: 14) { - Circle() - .fill(accent.opacity(isOn.wrappedValue ? 0.95 : 0.35)) - .frame(width: 10, height: 10) - .shadow(color: accent.opacity(isOn.wrappedValue ? 0.6 : 0), radius: 8) - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .foregroundStyle(.white) - .font(.body.weight(.semibold)) - - Text(subtitle) - .foregroundStyle(.white.opacity(0.68)) - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 12) - - Toggle("", isOn: isOn) - .labelsHidden() - .tint(.white.opacity(0.92)) - } - .padding(14) - .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(.white.opacity(0.08), lineWidth: 1) - } - } - - private func glassMetric(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title.uppercased()) - .font(.caption.weight(.semibold)) - .foregroundStyle(.white.opacity(0.52)) - - Text(value) - .font(.title3.weight(.semibold)) - .foregroundStyle(.white) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .frame(maxWidth: .infinity, minHeight: 108, alignment: .topLeading) - .padding(18) - .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) + } message: { pending in + Text("To enable \(pending.name), Arcadia needs to enable: \(pending.probe.missingRequirements.joined(separator: ", ")). Continue with --requirements?") + } + .alert("Module Error", isPresented: Binding( + get: { moduleErrorMessage != nil }, + set: { if !$0 { moduleErrorMessage = nil } } + )) { + Button("OK", role: .cancel) { moduleErrorMessage = nil } + } message: { + Text(moduleErrorMessage ?? "") } } } diff --git a/Mobile/iOS/ArcadiaApp/GlassComponents.swift b/Mobile/iOS/ArcadiaApp/GlassComponents.swift new file mode 100644 index 0000000..d156703 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/GlassComponents.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct GlassCard: View { + @Environment(\.colorScheme) private var colorScheme + private var theme: AppTheme { AppTheme(isDark: colorScheme == .dark) } + + let title: String + let subtitle: String + let content: Content + + init(title: String, subtitle: String, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + .foregroundStyle(theme.primaryTextColor) + + Text(subtitle) + .font(.subheadline) + .foregroundStyle(theme.secondaryTextColor) + + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } +} + +extension GlassCard where Content == EmptyView { + init(title: String, subtitle: String) { + self.init(title: title, subtitle: subtitle) { EmptyView() } + } +} + +struct GlassMetric: View { + @Environment(\.colorScheme) private var colorScheme + private var theme: AppTheme { AppTheme(isDark: colorScheme == .dark) } + + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.tertiaryTextColor) + + Text(value) + .font(.title3.weight(.semibold)) + .foregroundStyle(theme.primaryTextColor) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, minHeight: 108, alignment: .topLeading) + .padding(18) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } +} diff --git a/Mobile/iOS/ArcadiaApp/LanNodesView.swift b/Mobile/iOS/ArcadiaApp/LanNodesView.swift new file mode 100644 index 0000000..b99bf3a --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/LanNodesView.swift @@ -0,0 +1,254 @@ +import SwiftUI + +private struct DiscoveredPeerRow: Identifiable { + let id: String + let hostname: String +} + +private struct KnownPeerRow: Identifiable { + let id: String + let hostname: String + let ip: String + let status: String +} + +/// LAN Nodes — GUI for `lan.scan` / `lan.node` via core `executeCommand`. +struct LanNodesView: View { + let theme: AppTheme + + @State private var discovered: [DiscoveredPeerRow] = [] + @State private var known: [KnownPeerRow] = [] + @State private var rangeText = "" + @State private var feedback = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + TextField("Optional --range (CIDR or IP)", text: $rangeText) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + HStack(spacing: 12) { + Button("Scan") { runScan() } + .buttonStyle(.borderedProminent) + .tint(theme.accentTextColor) + + Button("Refresh known") { refreshKnown() } + .buttonStyle(.bordered) + + Button("Save connected (all)") { + runLanNode(["save"]) + } + .buttonStyle(.bordered) + } + + Group { + sectionTitle("Discovered") + if discovered.isEmpty { + secondary("Run scan to discover Arcadia LAN peers.") + } else { + ForEach(discovered) { row in + discoveredCard(row) + } + } + } + + Group { + sectionTitle("Known nodes") + if known.isEmpty { + secondary("No peers in node state yet.") + } else { + ForEach(known) { row in + knownCard(row) + } + } + } + + Group { + sectionTitle("Last command") + Text(feedback.isEmpty ? "—" : feedback) + .font(.body) + .foregroundStyle(theme.secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear { + refreshKnown() + } + } + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.headline) + .foregroundStyle(theme.primaryTextColor) + } + + private func secondary(_ text: String) -> some View { + Text(text) + .font(.subheadline) + .foregroundStyle(theme.secondaryTextColor) + } + + private func discoveredCard(_ row: DiscoveredPeerRow) -> some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(row.hostname) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.primaryTextColor) + Text(row.id) + .font(.caption) + .foregroundStyle(theme.secondaryTextColor) + } + Spacer() + Button("Pair") { + runLanNode(["pair", row.id]) + } + .buttonStyle(.bordered) + } + .padding(14) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + + private func knownCard(_ row: KnownPeerRow) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(row.hostname) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.primaryTextColor) + Text("\(row.ip) · \(row.status)") + .font(.caption) + .foregroundStyle(theme.secondaryTextColor) + } + Spacer() + } + + HStack(spacing: 8) { + switch row.status { + case "pending-inbound": + Button("Accept") { runLanNode(["accept", row.ip]) }.buttonStyle(.bordered) + Button("Reject") { runLanNode(["reject", row.ip]) }.buttonStyle(.bordered) + case "pending-outbound": + Button("Connect") { runLanNode(["connect", row.ip]) }.buttonStyle(.bordered) + Button("Accept") { runLanNode(["accept", row.ip]) }.buttonStyle(.bordered) + Button("Reject") { runLanNode(["reject", row.ip]) }.buttonStyle(.bordered) + case "connected": + Button("Save") { runLanNode(["save", row.ip]) }.buttonStyle(.bordered) + default: + Button("Pair again") { runLanNode(["pair", row.ip]) }.buttonStyle(.bordered) + } + } + } + .padding(14) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + + private func scanArgsFromField() -> [String] { + let trimmed = rangeText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return [] + } + return ["--range", trimmed] + } + + private func runScan() { + let out = executeLanScan(args: scanArgsFromField()) + feedback = out + discovered = Self.parseScanOutput(out) + } + + private func refreshKnown() { + let out = executeLanStatus() + feedback = out + known = Self.parseStatusOutput(out) + } + + private func runLanNode(_ args: [String]) { + let out = executeCommand( + token: "lan.node", + args: args, + context: ExecutionContextFfi(netAs: nil, netTimeoutMs: nil) + ) + feedback = out + known = Self.parseStatusOutput(executeLanStatus()) + if ["pair", "connect", "accept", "reject"].contains(args.first) { + discovered = Self.parseScanOutput(executeLanScan(args: scanArgsFromField())) + } + } + + private func executeLanStatus() -> String { + executeCommand( + token: "lan.node", + args: ["status"], + context: ExecutionContextFfi(netAs: nil, netTimeoutMs: nil) + ) + } + + private func executeLanScan(args: [String]) -> String { + executeCommand( + token: "lan.scan", + args: args, + context: ExecutionContextFfi(netAs: nil, netTimeoutMs: nil) + ) + } + + /// Lines like `- 192.168.1.5 (host)` from `lan.scan`. + private static func parseScanOutput(_ text: String) -> [DiscoveredPeerRow] { + var rows: [DiscoveredPeerRow] = [] + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.hasPrefix("- ") else { continue } + let rest = String(t.dropFirst(2)) + guard let open = rest.lastIndex(of: "("), + let close = rest.lastIndex(of: ")"), + open < close + else { continue } + let ip = String(rest[.. connected` from `lan.node status`. + private static func parseStatusOutput(_ text: String) -> [KnownPeerRow] { + var rows: [KnownPeerRow] = [] + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.hasPrefix("- ") else { continue } + guard let arrowRange = t.range(of: " -> ") else { continue } + let left = String(t[.. Void + let onAppear: () -> Void + + var body: some View { + GlassCard(title: "Global Modules", subtitle: "Enable or disable modules for all surfaces.") { + VStack(alignment: .leading, spacing: 10) { + ForEach(modules, id: \.name) { module in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(module.name) + .font(.body.weight(.semibold)) + .foregroundStyle(theme.primaryTextColor) + Text(module.enabled ? "Enabled" : "Disabled") + .font(.caption) + .foregroundStyle(theme.secondaryTextColor) + } + Spacer() + Toggle("", isOn: Binding( + get: { module.enabled }, + set: { newValue in onToggle(module.name, newValue) } + )) + .labelsHidden() + } + .padding(.vertical, 6) + } + } + } + .onAppear(perform: onAppear) + } +} diff --git a/Mobile/iOS/ArcadiaApp/NavigationModels.swift b/Mobile/iOS/ArcadiaApp/NavigationModels.swift new file mode 100644 index 0000000..5e57614 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/NavigationModels.swift @@ -0,0 +1,74 @@ +import Foundation + +struct PageDefinition: Identifiable, Codable { + let id: String + let title: String + let description: String + let glyph: String + let systemImage: String + let accent: String + /// Module registry name; when set, the page is visible only if that module is enabled. + let requiredModule: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case glyph + case systemImage = "system_image" + case accent + case requiredModule = "required_module" + } + + init( + id: String, + title: String, + description: String, + glyph: String, + systemImage: String, + accent: String, + requiredModule: String? = nil + ) { + self.id = id + self.title = title + self.description = description + self.glyph = glyph + self.systemImage = systemImage + self.accent = accent + self.requiredModule = requiredModule + } +} + +struct GroupDefinition: Identifiable, Codable { + let id: String + let label: String + let glyph: String + let systemImage: String + let pageIDs: [String] + let accent: String + + enum CodingKeys: String, CodingKey { + case id + case label + case glyph + case systemImage = "system_image" + case pageIDs = "pages" + case accent + } +} + +struct NavigationRegistry: Codable { + let pages: [PageDefinition] + let groups: [GroupDefinition] + let globalPages: [String] + let defaultGroup: String + let defaultPage: String + + enum CodingKeys: String, CodingKey { + case pages + case groups + case globalPages = "global_pages" + case defaultGroup = "default_group" + case defaultPage = "default_page" + } +} diff --git a/Mobile/iOS/ArcadiaApp/NetworkOverviewView.swift b/Mobile/iOS/ArcadiaApp/NetworkOverviewView.swift new file mode 100644 index 0000000..6cb263e --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/NetworkOverviewView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct NetworkOverviewView: View { + let theme: AppTheme + let modules: [ModuleStatus] + + @State private var serviceInfo: LanServiceInfoFfi = LanServiceInfoFfi( + running: false, port: 0, hostname: "", moduleEnabled: false + ) + @State private var refreshToggle = false + + private var netEnabled: Bool { + modules.first(where: { $0.name == ModuleNames.net })?.enabled ?? false + } + + private var lanEnabled: Bool { + modules.first(where: { $0.name == ModuleNames.lan })?.enabled ?? false + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + statusCard( + title: "Network Module", + subtitle: "Networking foundation — required by LAN and remote session", + enabled: netEnabled + ) + + if lanEnabled { + lanServiceCard + } else { + Text("Enable the LAN module to manage the discovery service.") + .font(.subheadline) + .foregroundStyle(theme.secondaryTextColor) + } + } + .onAppear { serviceInfo = lanServiceInfo() } + } + + private var lanServiceCard: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("LAN Discovery Service") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.primaryTextColor) + + Text("UDP :\(serviceInfo.port) · \(serviceInfo.hostname.isEmpty ? "unknown" : serviceInfo.hostname)") + .font(.caption) + .foregroundStyle(theme.secondaryTextColor) + } + + Spacer() + + runningBadge(serviceInfo.running) + + Button(serviceInfo.running ? "Stop" : "Start") { + if serviceInfo.running { + lanStop() + } else { + lanStart() + } + // Small delay so service thread has a chance to update the flag. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + serviceInfo = lanServiceInfo() + } + } + .buttonStyle(.bordered) + .font(.subheadline.weight(.semibold)) + .tint(serviceInfo.running ? .red : .green) + } + .padding(16) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + + private func statusCard(title: String, subtitle: String, enabled: Bool) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.primaryTextColor) + Text(subtitle) + .font(.caption) + .foregroundStyle(theme.secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + enabledBadge(enabled) + } + .padding(16) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + + private func enabledBadge(_ enabled: Bool) -> some View { + Text(enabled ? "enabled" : "disabled") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(enabled ? Color.green.opacity(0.2) : Color.gray.opacity(0.2)) + .foregroundStyle(enabled ? Color.green : Color.gray) + .clipShape(Capsule()) + } + + private func runningBadge(_ running: Bool) -> some View { + Text(running ? "running" : "stopped") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(running ? Color.green.opacity(0.2) : Color.red.opacity(0.2)) + .foregroundStyle(running ? Color.green : Color.red) + .clipShape(Capsule()) + } +} diff --git a/Mobile/iOS/ArcadiaApp/ShellView.swift b/Mobile/iOS/ArcadiaApp/ShellView.swift new file mode 100644 index 0000000..0558a37 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ShellView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct ShellView: View { + @Environment(\.colorScheme) private var colorScheme + private var theme: AppTheme { AppTheme(isDark: colorScheme == .dark) } + + @Binding var shellHistory: [String] + @Binding var shellCommandInput: String + let onRun: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Terminal") + .font(.headline) + .foregroundStyle(theme.primaryTextColor) + Spacer() + Button("Clear") { + shellHistory.removeAll() + } + .buttonStyle(.bordered) + } + .padding(12) + .background(theme.cardFillColor) + + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(shellHistory.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(theme.accentTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + .padding(12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + HStack(spacing: 8) { + Text("$") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(theme.secondaryTextColor) + TextField("Type a command", text: $shellCommandInput) + .textFieldStyle(.plain) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(theme.primaryTextColor) + .onSubmit { onRun() } + Button("Run") { onRun() } + .buttonStyle(.borderedProminent) + } + .padding(12) + .background(theme.cardFillColor) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } +} diff --git a/Mobile/iOS/ArcadiaApp/SidebarView.swift b/Mobile/iOS/ArcadiaApp/SidebarView.swift new file mode 100644 index 0000000..fedb55f --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/SidebarView.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct SidebarView: View { + @Environment(\.colorScheme) private var colorScheme + private var theme: AppTheme { AppTheme(isDark: colorScheme == .dark) } + + let registry: NavigationRegistry + let sidebarWidth: CGFloat + let sidebarSwipeThreshold: CGFloat + let isPageVisible: (String) -> Bool + let remoteSessionEnabled: Bool + @Binding var remoteRoute: String? + let remoteTargets: [RemoteTarget] + let refreshRemoteTargets: () -> Void + + @Binding var activeGroupID: String + @Binding var activePageID: String + + private var visibleGroups: [GroupDefinition] { + registry.groups.filter { group in + group.pageIDs.contains { isPageVisible($0) } + } + } + + private var activeGroup: GroupDefinition { + visibleGroups.first(where: { $0.id == activeGroupID }) + ?? visibleGroups.first + ?? registry.groups[0] + } + + private var activeGroupPages: [PageDefinition] { + activeGroup.pageIDs + .filter { isPageVisible($0) } + .compactMap { id in registry.pages.first(where: { $0.id == id }) } + } + + private var sessionChipTitle: String { + guard let route = remoteRoute, route.hasPrefix("lan:") else { return "Local" } + let ip = String(route.dropFirst(4)) + return remoteTargets.first(where: { $0.ip == ip })?.hostname ?? ip + } + + private func selectGroup(_ groupID: String) { + activeGroupID = groupID + if let group = visibleGroups.first(where: { $0.id == groupID }), + let firstPageID = group.pageIDs.first(where: { isPageVisible($0) }) { + activePageID = firstPageID + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center, spacing: 10) { + Text("Arcadia") + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundStyle(theme.primaryTextColor) + + if remoteSessionEnabled { + Menu { + Button("Local") { + remoteRoute = nil + refreshRemoteTargets() + } + ForEach(remoteTargets) { target in + Button("\(target.hostname) (\(target.ip))") { + remoteRoute = "lan:\(target.ip)" + refreshRemoteTargets() + } + } + } label: { + HStack(spacing: 4) { + Text(sessionChipTitle) + Image(systemName: "chevron.down") + .font(.system(size: 7, weight: .bold)) + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(theme.secondaryTextColor) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(theme.cardFillColor, in: Capsule()) + .overlay { + Capsule() + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + .onAppear { refreshRemoteTargets() } + } + } + .padding(.horizontal, 22) + .padding(.top, 28) + .padding(.bottom, 10) + + Text("Groups") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.tertiaryTextColor) + .padding(.horizontal, 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(visibleGroups) { group in + groupTabButton(group: group) + } + } + .padding(.horizontal, 14) + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + Text(activeGroup.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.tertiaryTextColor) + .padding(.top, 2) + .padding(.horizontal, 16) + + ForEach(activeGroupPages) { page in + pageButton(page: page) + } + } + .padding(.bottom, 8) + } + .frame(maxHeight: .infinity) + + Text("Global") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.tertiaryTextColor) + .padding(.horizontal, 16) + + ForEach(registry.globalPages.filter { isPageVisible($0) }, id: \.self) { pageID in + if let page = registry.pages.first(where: { $0.id == pageID }) { + pageButton(page: page) + } + } + .padding(.bottom, 14) + } + .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(.ultraThinMaterial) + .background(colorScheme == .dark ? .white.opacity(0.05) : .white.opacity(0.3)) + .overlay { + RoundedRectangle(cornerRadius: 0) + .stroke(theme.cardStrokeColor, lineWidth: 1) + } + .shadow(color: theme.sidebarShadowColor, radius: 28, x: 8, y: 0) + .ignoresSafeArea() + } + + private func groupTabButton(group: GroupDefinition) -> some View { + let pal = theme.navAccentPalette(group.accent) + let active = activeGroupID == group.id + return Button { + selectGroup(group.id) + } label: { + VStack(spacing: 6) { + Image(systemName: group.systemImage) + .font(.system(size: 14, weight: .semibold)) + Text(group.label) + } + .font(.caption.weight(active ? .semibold : .medium)) + .foregroundStyle(active ? pal.iconActive : theme.primaryTextColor) + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(active ? pal.selectedFill : .clear) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(active ? pal.iconActive.opacity(0.42) : .clear, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + private func pageButton(page: PageDefinition) -> some View { + let isActive = activePageID == page.id + let pal = theme.navAccentPalette(page.accent) + return Button { + activePageID = page.id + } label: { + HStack(spacing: 12) { + Image(systemName: page.systemImage) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 20) + Text(page.title) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.body.weight(isActive ? .semibold : .medium)) + .foregroundStyle(isActive ? pal.iconActive : theme.primaryTextColor) + .padding(.horizontal, 16) + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isActive ? pal.selectedFill : .clear) + ) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isActive ? pal.iconActive.opacity(0.38) : .clear, lineWidth: 1) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 14) + } +} diff --git a/Mobile/iOS/ArcadiaApp/SplashView.swift b/Mobile/iOS/ArcadiaApp/SplashView.swift new file mode 100644 index 0000000..321e14d --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/SplashView.swift @@ -0,0 +1,312 @@ +import SwiftUI + +struct SplashView: View { + let onComplete: () -> Void + + @State private var hillsProgress: Double = 0 + @State private var sunProgress: Double = 0 + @State private var archProgress: Double = 0 + @State private var starsProgress: Double = 0 + @State private var masterOpacity: Double = 0 + + var body: some View { + ZStack { + Canvas { ctx, sz in + drawBackground(ctx: &ctx, size: sz) + drawHorizonGlow(ctx: &ctx, size: sz, t: sunProgress) + drawArch(ctx: &ctx, size: sz, t: archProgress) + drawStars(ctx: &ctx, size: sz, t: starsProgress) + drawSun(ctx: &ctx, size: sz, t: sunProgress) + drawHills(ctx: &ctx, size: sz, t: hillsProgress) + } + .ignoresSafeArea() + } + .opacity(masterOpacity) + .onAppear(perform: startAnimation) + } + + private func startAnimation() { + Task { + do { + withAnimation(.easeIn(duration: 0.4)) { masterOpacity = 1 } + + try await Task.sleep(nanoseconds: 200_000_000) + withAnimation(.interpolatingSpring(stiffness: 75, damping: 14)) { + hillsProgress = 1 + } + + try await Task.sleep(nanoseconds: 500_000_000) + withAnimation(.easeOut(duration: 1.0)) { sunProgress = 1 } + + try await Task.sleep(nanoseconds: 600_000_000) + withAnimation(.spring(response: 0.95, dampingFraction: 0.68)) { + archProgress = 1 + } + + try await Task.sleep(nanoseconds: 700_000_000) + withAnimation(.easeIn(duration: 0.7)) { starsProgress = 1 } + + try await Task.sleep(nanoseconds: 600_000_000) + withAnimation(.easeOut(duration: 0.55)) { + masterOpacity = 0 + } + + try await Task.sleep(nanoseconds: 650_000_000) + onComplete() + } catch { + onComplete() + } + } + } +} + +// MARK: – Drawing helpers + +private func drawBackground(ctx: inout GraphicsContext, size: CGSize) { + let w = size.width, h = size.height + let rect = Path(CGRect(origin: .zero, size: size)) + ctx.fill(rect, with: .linearGradient( + Gradient(colors: [ + AppTheme.splashBackgroundTop, + AppTheme.splashBackgroundMid, + AppTheme.splashBackgroundHorizon, + AppTheme.splashBackgroundBottom + ]), + startPoint: CGPoint(x: size.width / 2, y: 0), + endPoint: CGPoint(x: size.width / 2, y: size.height) + )) + + ctx.drawLayer { gc in + gc.addFilter(.blur(radius: h * 0.075)) + gc.fill( + Path(ellipseIn: CGRect(x: -w * 0.05, y: h * 0.20, width: w * 1.10, height: h * 0.48)), + with: .color(AppTheme.splashBackgroundMid.opacity(0.34)) + ) + gc.fill( + Path(ellipseIn: CGRect(x: -w * 0.08, y: h * 0.50, width: w * 1.16, height: h * 0.34)), + with: .color(AppTheme.splashBackgroundHorizon.opacity(0.30)) + ) + } +} + +private func drawHorizonGlow(ctx: inout GraphicsContext, size: CGSize, t: Double) { + let w = size.width, h = size.height + let cx = w * 0.5 + let cy = h * 0.690 + let glows: [(Double, Double, Double, Color)] = [ + (1.18, 0.46, 0.16, AppTheme.splashHorizonPink), + (0.76, 0.30, 0.20, AppTheme.splashHorizonGold), + (0.46, 0.20, 0.13, AppTheme.splashArchGlow), + (0.30, 0.12, 0.10, AppTheme.splashStar) + ] + + ctx.drawLayer { gc in + gc.addFilter(.blur(radius: h * 0.045)) + for (widthScale, heightScale, alpha, color) in glows { + let gw = w * widthScale + let gh = h * heightScale + let rect = CGRect(x: cx - gw * 0.5, y: cy - gh * 0.5, width: gw, height: gh) + gc.fill(Path(ellipseIn: rect), with: .color(color.opacity(alpha * t))) + } + } +} + +private func drawHills(ctx: inout GraphicsContext, size: CGSize, t: Double) { + let w = size.width, h = size.height + let offset = (1.0 - t) * h * 0.35 + + func p(_ fx: Double, _ fy: Double) -> CGPoint { + CGPoint(x: fx * w, y: fy * h + offset) + } + + // Back hill + var back = Path() + back.move(to: p(0, 0.95)) + back.addCurve(to: p(0.50, 0.60), control1: p(0.15, 0.88), control2: p(0.35, 0.61)) + back.addCurve(to: p(1.0, 0.95), control1: p(0.65, 0.61), control2: p(0.85, 0.88)) + back.addLine(to: p(1.0, 1.10)) + back.addLine(to: p(0.0, 1.10)) + back.closeSubpath() + ctx.fill(back, with: .color(AppTheme.splashHillBack.opacity(0.62))) + + // Left hill + var left = Path() + left.move(to: p(0, 0.95)) + left.addCurve(to: p(0.32, 0.640), control1: p(0.06, 0.88), control2: p(0.18, 0.655)) + left.addCurve(to: p(0.52, 0.745), control1: p(0.44, 0.635), control2: p(0.49, 0.720)) + left.addLine(to: p(0.52, 1.10)) + left.addLine(to: p(0.0, 1.10)) + left.closeSubpath() + ctx.fill(left, with: .color(AppTheme.splashHillLeft.opacity(0.78))) + + // Right hill (mirror) + var right = Path() + right.move(to: p(1.0, 0.95)) + right.addCurve(to: p(0.68, 0.640), control1: p(0.94, 0.88), control2: p(0.82, 0.655)) + right.addCurve(to: p(0.48, 0.745), control1: p(0.56, 0.635), control2: p(0.51, 0.720)) + right.addLine(to: p(0.48, 1.10)) + right.addLine(to: p(1.0, 1.10)) + right.closeSubpath() + ctx.fill(right, with: .color(AppTheme.splashHillRight.opacity(0.76))) + + var front = Path() + front.move(to: p(0.0, 1.02)) + front.addCurve(to: p(0.50, 0.790), control1: p(0.18, 0.985), control2: p(0.34, 0.900)) + front.addCurve(to: p(1.0, 1.02), control1: p(0.66, 0.900), control2: p(0.82, 0.985)) + front.addLine(to: p(1.0, 1.10)) + front.addLine(to: p(0.0, 1.10)) + front.closeSubpath() + ctx.fill(front, with: .color(AppTheme.splashHillFront.opacity(0.90))) +} + +private func drawSun(ctx: inout GraphicsContext, size: CGSize, t: Double) { + let w = size.width, h = size.height + let cx = w * 0.5 + let finalY = h * 0.695 + let cy = h + (finalY - h) * t + let r = max(splashSceneWidth(size) * 0.058, 26) + + ctx.drawLayer { gc in + gc.addFilter(.blur(radius: r * 0.65)) + gc.fill( + Path(ellipseIn: CGRect(x: cx - r * 7.0, y: cy - r * 7.0, width: r * 14.0, height: r * 14.0)), + with: .radialGradient( + Gradient(colors: [ + AppTheme.splashHorizonGold.opacity(0.12 * t), + AppTheme.splashHorizonPink.opacity(0.04 * t), + AppTheme.splashHorizonPink.opacity(0) + ]), + center: CGPoint(x: cx, y: cy), + startRadius: r * 0.6, + endRadius: r * 7.0 + ) + ) + } + + for (rm, alpha, color) in AppTheme.splashSunLayers { + let gr = r * rm + let rect = CGRect(x: cx - gr, y: cy - gr, width: gr * 2, height: gr * 2) + ctx.fill( + Path(ellipseIn: rect), + with: .color(color.opacity(alpha * t)) + ) + } +} + +private func drawArch(ctx: inout GraphicsContext, size: CGSize, t: Double) { + guard t > 0.001 else { return } + let w = size.width, h = size.height + let sceneW = splashSceneWidth(size) + let apexX = w * 0.5 + let apexY = h * 0.195 + let baseY = h * 0.810 + let leftX = apexX - sceneW * 0.285 + let rightX = apexX + sceneW * 0.285 + + func fp(_ x: Double, _ y: Double) -> CGPoint { + return CGPoint(x: apexX + (x - apexX) * t, y: apexY + (y - apexY) * t) + } + + func makeArch() -> Path { + var p = Path() + p.move(to: fp(leftX, baseY)) + p.addCurve( + to: fp(apexX, apexY), + control1: fp(leftX + sceneW * 0.035, h * 0.520), + control2: fp(apexX - sceneW * 0.135, apexY) + ) + p.addCurve( + to: fp(rightX, baseY), + control1: fp(apexX + sceneW * 0.135, apexY), + control2: fp(rightX - sceneW * 0.035, h * 0.520) + ) + return p + } + + let arch = makeArch() + let archWidth = min(max(sceneW * 0.078, 44), 98) + + ctx.drawLayer { gc in + gc.addFilter(.blur(radius: archWidth * 0.45)) + gc.stroke( + arch, + with: .color(AppTheme.splashArchGlow.opacity(t * 0.22)), + style: StrokeStyle(lineWidth: archWidth * 2.15, lineCap: .round, lineJoin: .round) + ) + gc.stroke( + arch, + with: .color(AppTheme.splashHorizonPink.opacity(t * 0.16)), + style: StrokeStyle(lineWidth: archWidth * 1.65, lineCap: .round, lineJoin: .round) + ) + gc.stroke( + arch, + with: .color(AppTheme.splashArchGlow.opacity(t * 0.38)), + style: StrokeStyle(lineWidth: archWidth * 1.35, lineCap: .round, lineJoin: .round) + ) + } + + ctx.stroke( + arch, + with: .color(AppTheme.splashArchCore.opacity(t)), + style: StrokeStyle(lineWidth: archWidth, lineCap: .round, lineJoin: .round) + ) +} + +private func drawStars(ctx: inout GraphicsContext, size: CGSize, t: Double) { + let w = size.width, h = size.height + + // (fx, fy, radius, delay, isSparkle) + let stars: [(Double, Double, Double, Double, Bool)] = [ + (0.500, 0.380, 6.0, 0.00, true), + (0.460, 0.295, 2.2, 0.15, false), + (0.525, 0.275, 1.8, 0.25, false), + (0.572, 0.345, 1.8, 0.35, false), + (0.442, 0.418, 1.5, 0.10, false), + (0.551, 0.448, 1.5, 0.20, false), + (0.610, 0.318, 2.0, 0.30, false), + (0.398, 0.362, 1.5, 0.40, false), + ] + + for (fx, fy, r, delay, isSparkle) in stars { + let denom = max(1.0 - delay, 0.1) + let lt = min(max((t - delay) / denom, 0), 1) + guard lt > 0 else { continue } + let cx = fx * w + let cy = fy * h + + if isSparkle { + drawSparkle(ctx: &ctx, cx: cx, cy: cy, r: r, alpha: lt) + } else { + let rect = CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2) + ctx.fill(Path(ellipseIn: rect), with: .color(AppTheme.splashStar.opacity(lt))) + } + } +} + +private func splashSceneWidth(_ size: CGSize) -> Double { + min(size.width, size.height * 1.52) +} + +private func drawSparkle(ctx: inout GraphicsContext, cx: Double, cy: Double, r: Double, alpha: Double) { + // Glow behind sparkle + ctx.drawLayer { gc in + gc.addFilter(.blur(radius: r * 1.2)) + let gr = r * 2 + gc.fill(Path(ellipseIn: CGRect(x: cx - gr, y: cy - gr, width: gr * 2, height: gr * 2)), + with: .color(AppTheme.splashStar.opacity(alpha * 0.5))) + } + + for angleOffset in [0.0, Double.pi / 4] { + var path = Path() + let inner = r * 0.18 + let pts = 4 + for i in 0 ..< pts * 2 { + let angle = angleOffset + Double(i) * .pi / Double(pts) - .pi / 2 + let rad = i % 2 == 0 ? r : inner + let pt = CGPoint(x: cx + cos(angle) * rad, y: cy + sin(angle) * rad) + if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) } + } + path.closeSubpath() + ctx.fill(path, with: .color(AppTheme.splashStar.opacity(alpha))) + } +} diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/Info.plist b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/Info.plist new file mode 100644 index 0000000..9eb645e --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/Info.plist @@ -0,0 +1,47 @@ + + + + + AvailableLibraries + + + BinaryPath + libarcadia_core.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64 + LibraryPath + libarcadia_core.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + BinaryPath + libarcadia_core.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64-simulator + LibraryPath + libarcadia_core.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/Headers/arcadia_coreFFI.h b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/Headers/arcadia_coreFFI.h new file mode 100644 index 0000000..8e32aa1 --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/Headers/arcadia_coreFFI.h @@ -0,0 +1,836 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +#pragma once + +#include +#include +#include + +// The following structs are used to implement the lowest level +// of the FFI, and thus useful to multiple uniffied crates. +// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H. +#ifdef UNIFFI_SHARED_H + // We also try to prevent mixing versions of shared uniffi header structs. + // If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4 + #ifndef UNIFFI_SHARED_HEADER_V4 + #error Combining helper code from multiple versions of uniffi is not supported + #endif // ndef UNIFFI_SHARED_HEADER_V4 +#else +#define UNIFFI_SHARED_H +#define UNIFFI_SHARED_HEADER_V4 +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ + +typedef struct RustBuffer +{ + uint64_t capacity; + uint64_t len; + uint8_t *_Nullable data; +} RustBuffer; + +typedef struct ForeignBytes +{ + int32_t len; + const uint8_t *_Nullable data; +} ForeignBytes; + +// Error definitions +typedef struct RustCallStatus { + int8_t code; + RustBuffer errorBuf; +} RustCallStatus; + +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ +#endif // def UNIFFI_SHARED_H +#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +typedef void (*UniffiForeignFutureFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +typedef void (*UniffiCallbackInterfaceFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE +typedef struct UniffiForeignFuture { + uint64_t handle; + UniffiForeignFutureFree _Nonnull free; +} UniffiForeignFuture; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +typedef struct UniffiForeignFutureStructU8 { + uint8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureStructU8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +typedef struct UniffiForeignFutureStructI8 { + int8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureStructI8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +typedef struct UniffiForeignFutureStructU16 { + uint16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureStructU16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +typedef struct UniffiForeignFutureStructI16 { + int16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureStructI16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +typedef struct UniffiForeignFutureStructU32 { + uint32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureStructU32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +typedef struct UniffiForeignFutureStructI32 { + int32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureStructI32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +typedef struct UniffiForeignFutureStructU64 { + uint64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureStructU64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +typedef struct UniffiForeignFutureStructI64 { + int64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureStructI64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +typedef struct UniffiForeignFutureStructF32 { + float returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureStructF32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +typedef struct UniffiForeignFutureStructF64 { + double returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureStructF64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +typedef struct UniffiForeignFutureStructPointer { + void*_Nonnull returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructPointer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +typedef void (*UniffiForeignFutureCompletePointer)(uint64_t, UniffiForeignFutureStructPointer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +typedef struct UniffiForeignFutureStructRustBuffer { + RustBuffer returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructRustBuffer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureStructRustBuffer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +typedef struct UniffiForeignFutureStructVoid { + RustCallStatus callStatus; +} UniffiForeignFutureStructVoid; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureStructVoid + ); + +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CLONE_MODULEMANAGER +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CLONE_MODULEMANAGER +void*_Nonnull uniffi_arcadia_core_fn_clone_modulemanager(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FREE_MODULEMANAGER +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FREE_MODULEMANAGER +void uniffi_arcadia_core_fn_free_modulemanager(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CONSTRUCTOR_MODULEMANAGER_NEW +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CONSTRUCTOR_MODULEMANAGER_NEW +void*_Nonnull uniffi_arcadia_core_fn_constructor_modulemanager_new(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_execute(void*_Nonnull ptr, RustBuffer token, RustBuffer args, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE_REMOTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE_REMOTE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_execute_remote(void*_Nonnull ptr, RustBuffer token, RustBuffer args, RustBuffer net_as, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_COMMANDS +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_list_commands(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_MODULES +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_list_modules(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_PROBE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_PROBE_TOGGLE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_probe_toggle(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_set_enabled(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_set_enabled_with_requirements(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_DRAIN_REMOTE_MIRROR_BATCH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_DRAIN_REMOTE_MIRROR_BATCH +RustBuffer uniffi_arcadia_core_fn_func_drain_remote_mirror_batch(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_EXECUTE_COMMAND +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_EXECUTE_COMMAND +RustBuffer uniffi_arcadia_core_fn_func_execute_command(RustBuffer token, RustBuffer args, RustBuffer context, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_SERVICE_INFO +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_SERVICE_INFO +RustBuffer uniffi_arcadia_core_fn_func_lan_service_info(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_START +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_START +void uniffi_arcadia_core_fn_func_lan_start(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_STOP +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_STOP +void uniffi_arcadia_core_fn_func_lan_stop(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_COMMANDS +RustBuffer uniffi_arcadia_core_fn_func_list_commands(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_MODULES +RustBuffer uniffi_arcadia_core_fn_func_list_modules(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_NAVIGATION_REGISTRY_JSON +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_NAVIGATION_REGISTRY_JSON +RustBuffer uniffi_arcadia_core_fn_func_navigation_registry_json(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PLATFORM_NAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PLATFORM_NAME +RustBuffer uniffi_arcadia_core_fn_func_platform_name(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PROBE_MODULE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PROBE_MODULE_TOGGLE +RustBuffer uniffi_arcadia_core_fn_func_probe_module_toggle(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_CONFIG_ROOT_PATH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_CONFIG_ROOT_PATH +void uniffi_arcadia_core_fn_func_set_config_root_path(RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_LOCAL_HOSTNAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_LOCAL_HOSTNAME +void uniffi_arcadia_core_fn_func_set_local_hostname(RustBuffer name, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED +RustBuffer uniffi_arcadia_core_fn_func_set_module_enabled(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +RustBuffer uniffi_arcadia_core_fn_func_set_module_enabled_with_requirements(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +RustBuffer uniffi_arcadia_core_fn_func_thin_client_preferred_route_get(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +RustBuffer uniffi_arcadia_core_fn_func_thin_client_preferred_route_set(RustBuffer route, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +RustBuffer uniffi_arcadia_core_fn_func_thin_client_surface_client_id(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_ALLOC +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_ALLOC +RustBuffer ffi_arcadia_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FROM_BYTES +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FROM_BYTES +RustBuffer ffi_arcadia_core_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FREE +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FREE +void ffi_arcadia_core_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_RESERVE +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_RESERVE +RustBuffer ffi_arcadia_core_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U8 +void ffi_arcadia_core_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U8 +void ffi_arcadia_core_rust_future_cancel_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U8 +void ffi_arcadia_core_rust_future_free_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U8 +uint8_t ffi_arcadia_core_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I8 +void ffi_arcadia_core_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I8 +void ffi_arcadia_core_rust_future_cancel_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I8 +void ffi_arcadia_core_rust_future_free_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I8 +int8_t ffi_arcadia_core_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U16 +void ffi_arcadia_core_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U16 +void ffi_arcadia_core_rust_future_cancel_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U16 +void ffi_arcadia_core_rust_future_free_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U16 +uint16_t ffi_arcadia_core_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I16 +void ffi_arcadia_core_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I16 +void ffi_arcadia_core_rust_future_cancel_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I16 +void ffi_arcadia_core_rust_future_free_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I16 +int16_t ffi_arcadia_core_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U32 +void ffi_arcadia_core_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U32 +void ffi_arcadia_core_rust_future_cancel_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U32 +void ffi_arcadia_core_rust_future_free_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U32 +uint32_t ffi_arcadia_core_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I32 +void ffi_arcadia_core_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I32 +void ffi_arcadia_core_rust_future_cancel_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I32 +void ffi_arcadia_core_rust_future_free_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I32 +int32_t ffi_arcadia_core_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U64 +void ffi_arcadia_core_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U64 +void ffi_arcadia_core_rust_future_cancel_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U64 +void ffi_arcadia_core_rust_future_free_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U64 +uint64_t ffi_arcadia_core_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I64 +void ffi_arcadia_core_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I64 +void ffi_arcadia_core_rust_future_cancel_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I64 +void ffi_arcadia_core_rust_future_free_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I64 +int64_t ffi_arcadia_core_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F32 +void ffi_arcadia_core_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F32 +void ffi_arcadia_core_rust_future_cancel_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F32 +void ffi_arcadia_core_rust_future_free_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F32 +float ffi_arcadia_core_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F64 +void ffi_arcadia_core_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F64 +void ffi_arcadia_core_rust_future_cancel_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F64 +void ffi_arcadia_core_rust_future_free_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F64 +double ffi_arcadia_core_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_POINTER +void ffi_arcadia_core_rust_future_poll_pointer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_POINTER +void ffi_arcadia_core_rust_future_cancel_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_POINTER +void ffi_arcadia_core_rust_future_free_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_POINTER +void*_Nonnull ffi_arcadia_core_rust_future_complete_pointer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_RUST_BUFFER +void ffi_arcadia_core_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER +void ffi_arcadia_core_rust_future_cancel_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_RUST_BUFFER +void ffi_arcadia_core_rust_future_free_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER +RustBuffer ffi_arcadia_core_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_VOID +void ffi_arcadia_core_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_VOID +void ffi_arcadia_core_rust_future_cancel_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_VOID +void ffi_arcadia_core_rust_future_free_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_VOID +void ffi_arcadia_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_DRAIN_REMOTE_MIRROR_BATCH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_DRAIN_REMOTE_MIRROR_BATCH +uint16_t uniffi_arcadia_core_checksum_func_drain_remote_mirror_batch(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_EXECUTE_COMMAND +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_EXECUTE_COMMAND +uint16_t uniffi_arcadia_core_checksum_func_execute_command(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_SERVICE_INFO +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_SERVICE_INFO +uint16_t uniffi_arcadia_core_checksum_func_lan_service_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_START +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_START +uint16_t uniffi_arcadia_core_checksum_func_lan_start(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_STOP +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_STOP +uint16_t uniffi_arcadia_core_checksum_func_lan_stop(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_COMMANDS +uint16_t uniffi_arcadia_core_checksum_func_list_commands(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_MODULES +uint16_t uniffi_arcadia_core_checksum_func_list_modules(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_NAVIGATION_REGISTRY_JSON +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_NAVIGATION_REGISTRY_JSON +uint16_t uniffi_arcadia_core_checksum_func_navigation_registry_json(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PLATFORM_NAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PLATFORM_NAME +uint16_t uniffi_arcadia_core_checksum_func_platform_name(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PROBE_MODULE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PROBE_MODULE_TOGGLE +uint16_t uniffi_arcadia_core_checksum_func_probe_module_toggle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_CONFIG_ROOT_PATH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_CONFIG_ROOT_PATH +uint16_t uniffi_arcadia_core_checksum_func_set_config_root_path(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_LOCAL_HOSTNAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_LOCAL_HOSTNAME +uint16_t uniffi_arcadia_core_checksum_func_set_local_hostname(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED +uint16_t uniffi_arcadia_core_checksum_func_set_module_enabled(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +uint16_t uniffi_arcadia_core_checksum_func_set_module_enabled_with_requirements(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +uint16_t uniffi_arcadia_core_checksum_func_thin_client_preferred_route_get(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +uint16_t uniffi_arcadia_core_checksum_func_thin_client_preferred_route_set(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +uint16_t uniffi_arcadia_core_checksum_func_thin_client_surface_client_id(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_execute(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE_REMOTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE_REMOTE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_execute_remote(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_COMMANDS +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_list_commands(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_MODULES +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_list_modules(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_PROBE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_PROBE_TOGGLE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_probe_toggle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_set_enabled(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_set_enabled_with_requirements(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_CONSTRUCTOR_MODULEMANAGER_NEW +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_CONSTRUCTOR_MODULEMANAGER_NEW +uint16_t uniffi_arcadia_core_checksum_constructor_modulemanager_new(void + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_UNIFFI_CONTRACT_VERSION +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_UNIFFI_CONTRACT_VERSION +uint32_t ffi_arcadia_core_uniffi_contract_version(void + +); +#endif + diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a new file mode 100644 index 0000000..7c38193 Binary files /dev/null and b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a differ diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/Headers/arcadia_coreFFI.h b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/Headers/arcadia_coreFFI.h new file mode 100644 index 0000000..8e32aa1 --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/Headers/arcadia_coreFFI.h @@ -0,0 +1,836 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +#pragma once + +#include +#include +#include + +// The following structs are used to implement the lowest level +// of the FFI, and thus useful to multiple uniffied crates. +// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H. +#ifdef UNIFFI_SHARED_H + // We also try to prevent mixing versions of shared uniffi header structs. + // If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4 + #ifndef UNIFFI_SHARED_HEADER_V4 + #error Combining helper code from multiple versions of uniffi is not supported + #endif // ndef UNIFFI_SHARED_HEADER_V4 +#else +#define UNIFFI_SHARED_H +#define UNIFFI_SHARED_HEADER_V4 +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ + +typedef struct RustBuffer +{ + uint64_t capacity; + uint64_t len; + uint8_t *_Nullable data; +} RustBuffer; + +typedef struct ForeignBytes +{ + int32_t len; + const uint8_t *_Nullable data; +} ForeignBytes; + +// Error definitions +typedef struct RustCallStatus { + int8_t code; + RustBuffer errorBuf; +} RustCallStatus; + +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ +#endif // def UNIFFI_SHARED_H +#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +typedef void (*UniffiForeignFutureFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +typedef void (*UniffiCallbackInterfaceFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE +typedef struct UniffiForeignFuture { + uint64_t handle; + UniffiForeignFutureFree _Nonnull free; +} UniffiForeignFuture; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +typedef struct UniffiForeignFutureStructU8 { + uint8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureStructU8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +typedef struct UniffiForeignFutureStructI8 { + int8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureStructI8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +typedef struct UniffiForeignFutureStructU16 { + uint16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureStructU16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +typedef struct UniffiForeignFutureStructI16 { + int16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureStructI16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +typedef struct UniffiForeignFutureStructU32 { + uint32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureStructU32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +typedef struct UniffiForeignFutureStructI32 { + int32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureStructI32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +typedef struct UniffiForeignFutureStructU64 { + uint64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureStructU64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +typedef struct UniffiForeignFutureStructI64 { + int64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureStructI64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +typedef struct UniffiForeignFutureStructF32 { + float returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureStructF32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +typedef struct UniffiForeignFutureStructF64 { + double returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureStructF64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +typedef struct UniffiForeignFutureStructPointer { + void*_Nonnull returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructPointer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +typedef void (*UniffiForeignFutureCompletePointer)(uint64_t, UniffiForeignFutureStructPointer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +typedef struct UniffiForeignFutureStructRustBuffer { + RustBuffer returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructRustBuffer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureStructRustBuffer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +typedef struct UniffiForeignFutureStructVoid { + RustCallStatus callStatus; +} UniffiForeignFutureStructVoid; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureStructVoid + ); + +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CLONE_MODULEMANAGER +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CLONE_MODULEMANAGER +void*_Nonnull uniffi_arcadia_core_fn_clone_modulemanager(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FREE_MODULEMANAGER +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FREE_MODULEMANAGER +void uniffi_arcadia_core_fn_free_modulemanager(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CONSTRUCTOR_MODULEMANAGER_NEW +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CONSTRUCTOR_MODULEMANAGER_NEW +void*_Nonnull uniffi_arcadia_core_fn_constructor_modulemanager_new(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_execute(void*_Nonnull ptr, RustBuffer token, RustBuffer args, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE_REMOTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE_REMOTE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_execute_remote(void*_Nonnull ptr, RustBuffer token, RustBuffer args, RustBuffer net_as, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_COMMANDS +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_list_commands(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_MODULES +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_list_modules(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_PROBE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_PROBE_TOGGLE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_probe_toggle(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_set_enabled(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_set_enabled_with_requirements(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_DRAIN_REMOTE_MIRROR_BATCH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_DRAIN_REMOTE_MIRROR_BATCH +RustBuffer uniffi_arcadia_core_fn_func_drain_remote_mirror_batch(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_EXECUTE_COMMAND +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_EXECUTE_COMMAND +RustBuffer uniffi_arcadia_core_fn_func_execute_command(RustBuffer token, RustBuffer args, RustBuffer context, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_SERVICE_INFO +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_SERVICE_INFO +RustBuffer uniffi_arcadia_core_fn_func_lan_service_info(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_START +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_START +void uniffi_arcadia_core_fn_func_lan_start(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_STOP +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_STOP +void uniffi_arcadia_core_fn_func_lan_stop(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_COMMANDS +RustBuffer uniffi_arcadia_core_fn_func_list_commands(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_MODULES +RustBuffer uniffi_arcadia_core_fn_func_list_modules(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_NAVIGATION_REGISTRY_JSON +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_NAVIGATION_REGISTRY_JSON +RustBuffer uniffi_arcadia_core_fn_func_navigation_registry_json(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PLATFORM_NAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PLATFORM_NAME +RustBuffer uniffi_arcadia_core_fn_func_platform_name(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PROBE_MODULE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PROBE_MODULE_TOGGLE +RustBuffer uniffi_arcadia_core_fn_func_probe_module_toggle(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_CONFIG_ROOT_PATH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_CONFIG_ROOT_PATH +void uniffi_arcadia_core_fn_func_set_config_root_path(RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_LOCAL_HOSTNAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_LOCAL_HOSTNAME +void uniffi_arcadia_core_fn_func_set_local_hostname(RustBuffer name, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED +RustBuffer uniffi_arcadia_core_fn_func_set_module_enabled(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +RustBuffer uniffi_arcadia_core_fn_func_set_module_enabled_with_requirements(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +RustBuffer uniffi_arcadia_core_fn_func_thin_client_preferred_route_get(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +RustBuffer uniffi_arcadia_core_fn_func_thin_client_preferred_route_set(RustBuffer route, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +RustBuffer uniffi_arcadia_core_fn_func_thin_client_surface_client_id(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_ALLOC +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_ALLOC +RustBuffer ffi_arcadia_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FROM_BYTES +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FROM_BYTES +RustBuffer ffi_arcadia_core_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FREE +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FREE +void ffi_arcadia_core_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_RESERVE +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_RESERVE +RustBuffer ffi_arcadia_core_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U8 +void ffi_arcadia_core_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U8 +void ffi_arcadia_core_rust_future_cancel_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U8 +void ffi_arcadia_core_rust_future_free_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U8 +uint8_t ffi_arcadia_core_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I8 +void ffi_arcadia_core_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I8 +void ffi_arcadia_core_rust_future_cancel_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I8 +void ffi_arcadia_core_rust_future_free_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I8 +int8_t ffi_arcadia_core_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U16 +void ffi_arcadia_core_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U16 +void ffi_arcadia_core_rust_future_cancel_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U16 +void ffi_arcadia_core_rust_future_free_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U16 +uint16_t ffi_arcadia_core_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I16 +void ffi_arcadia_core_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I16 +void ffi_arcadia_core_rust_future_cancel_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I16 +void ffi_arcadia_core_rust_future_free_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I16 +int16_t ffi_arcadia_core_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U32 +void ffi_arcadia_core_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U32 +void ffi_arcadia_core_rust_future_cancel_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U32 +void ffi_arcadia_core_rust_future_free_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U32 +uint32_t ffi_arcadia_core_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I32 +void ffi_arcadia_core_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I32 +void ffi_arcadia_core_rust_future_cancel_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I32 +void ffi_arcadia_core_rust_future_free_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I32 +int32_t ffi_arcadia_core_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U64 +void ffi_arcadia_core_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U64 +void ffi_arcadia_core_rust_future_cancel_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U64 +void ffi_arcadia_core_rust_future_free_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U64 +uint64_t ffi_arcadia_core_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I64 +void ffi_arcadia_core_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I64 +void ffi_arcadia_core_rust_future_cancel_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I64 +void ffi_arcadia_core_rust_future_free_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I64 +int64_t ffi_arcadia_core_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F32 +void ffi_arcadia_core_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F32 +void ffi_arcadia_core_rust_future_cancel_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F32 +void ffi_arcadia_core_rust_future_free_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F32 +float ffi_arcadia_core_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F64 +void ffi_arcadia_core_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F64 +void ffi_arcadia_core_rust_future_cancel_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F64 +void ffi_arcadia_core_rust_future_free_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F64 +double ffi_arcadia_core_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_POINTER +void ffi_arcadia_core_rust_future_poll_pointer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_POINTER +void ffi_arcadia_core_rust_future_cancel_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_POINTER +void ffi_arcadia_core_rust_future_free_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_POINTER +void*_Nonnull ffi_arcadia_core_rust_future_complete_pointer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_RUST_BUFFER +void ffi_arcadia_core_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER +void ffi_arcadia_core_rust_future_cancel_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_RUST_BUFFER +void ffi_arcadia_core_rust_future_free_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER +RustBuffer ffi_arcadia_core_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_VOID +void ffi_arcadia_core_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_VOID +void ffi_arcadia_core_rust_future_cancel_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_VOID +void ffi_arcadia_core_rust_future_free_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_VOID +void ffi_arcadia_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_DRAIN_REMOTE_MIRROR_BATCH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_DRAIN_REMOTE_MIRROR_BATCH +uint16_t uniffi_arcadia_core_checksum_func_drain_remote_mirror_batch(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_EXECUTE_COMMAND +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_EXECUTE_COMMAND +uint16_t uniffi_arcadia_core_checksum_func_execute_command(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_SERVICE_INFO +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_SERVICE_INFO +uint16_t uniffi_arcadia_core_checksum_func_lan_service_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_START +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_START +uint16_t uniffi_arcadia_core_checksum_func_lan_start(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_STOP +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_STOP +uint16_t uniffi_arcadia_core_checksum_func_lan_stop(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_COMMANDS +uint16_t uniffi_arcadia_core_checksum_func_list_commands(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_MODULES +uint16_t uniffi_arcadia_core_checksum_func_list_modules(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_NAVIGATION_REGISTRY_JSON +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_NAVIGATION_REGISTRY_JSON +uint16_t uniffi_arcadia_core_checksum_func_navigation_registry_json(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PLATFORM_NAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PLATFORM_NAME +uint16_t uniffi_arcadia_core_checksum_func_platform_name(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PROBE_MODULE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PROBE_MODULE_TOGGLE +uint16_t uniffi_arcadia_core_checksum_func_probe_module_toggle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_CONFIG_ROOT_PATH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_CONFIG_ROOT_PATH +uint16_t uniffi_arcadia_core_checksum_func_set_config_root_path(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_LOCAL_HOSTNAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_LOCAL_HOSTNAME +uint16_t uniffi_arcadia_core_checksum_func_set_local_hostname(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED +uint16_t uniffi_arcadia_core_checksum_func_set_module_enabled(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +uint16_t uniffi_arcadia_core_checksum_func_set_module_enabled_with_requirements(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +uint16_t uniffi_arcadia_core_checksum_func_thin_client_preferred_route_get(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +uint16_t uniffi_arcadia_core_checksum_func_thin_client_preferred_route_set(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +uint16_t uniffi_arcadia_core_checksum_func_thin_client_surface_client_id(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_execute(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE_REMOTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE_REMOTE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_execute_remote(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_COMMANDS +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_list_commands(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_MODULES +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_list_modules(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_PROBE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_PROBE_TOGGLE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_probe_toggle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_set_enabled(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_set_enabled_with_requirements(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_CONSTRUCTOR_MODULEMANAGER_NEW +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_CONSTRUCTOR_MODULEMANAGER_NEW +uint16_t uniffi_arcadia_core_checksum_constructor_modulemanager_new(void + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_UNIFFI_CONTRACT_VERSION +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_UNIFFI_CONTRACT_VERSION +uint32_t ffi_arcadia_core_uniffi_contract_version(void + +); +#endif + diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a new file mode 100644 index 0000000..c4ebea5 Binary files /dev/null and b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a differ diff --git a/Mobile/iOS/ArcadiaCore/Generated/arcadia_core.swift b/Mobile/iOS/ArcadiaCore/Generated/arcadia_core.swift new file mode 100644 index 0000000..36ea41d --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/Generated/arcadia_core.swift @@ -0,0 +1,1507 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +// swiftlint:disable all +import Foundation + +// Depending on the consumer's build setup, the low-level FFI code +// might be in a separate module, or it might be compiled inline into +// this module. This is a bit of light hackery to work with both. +#if canImport(arcadia_coreFFI) +import arcadia_coreFFI +#endif + +fileprivate extension RustBuffer { + // Allocate a new buffer, copying the contents of a `UInt8` array. + init(bytes: [UInt8]) { + let rbuf = bytes.withUnsafeBufferPointer { ptr in + RustBuffer.from(ptr) + } + self.init(capacity: rbuf.capacity, len: rbuf.len, data: rbuf.data) + } + + static func empty() -> RustBuffer { + RustBuffer(capacity: 0, len:0, data: nil) + } + + static func from(_ ptr: UnsafeBufferPointer) -> RustBuffer { + try! rustCall { ffi_arcadia_core_rustbuffer_from_bytes(ForeignBytes(bufferPointer: ptr), $0) } + } + + // Frees the buffer in place. + // The buffer must not be used after this is called. + func deallocate() { + try! rustCall { ffi_arcadia_core_rustbuffer_free(self, $0) } + } +} + +fileprivate extension ForeignBytes { + init(bufferPointer: UnsafeBufferPointer) { + self.init(len: Int32(bufferPointer.count), data: bufferPointer.baseAddress) + } +} + +// For every type used in the interface, we provide helper methods for conveniently +// lifting and lowering that type from C-compatible data, and for reading and writing +// values of that type in a buffer. + +// Helper classes/extensions that don't change. +// Someday, this will be in a library of its own. + +fileprivate extension Data { + init(rustBuffer: RustBuffer) { + self.init( + bytesNoCopy: rustBuffer.data!, + count: Int(rustBuffer.len), + deallocator: .none + ) + } +} + +// Define reader functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. +// +// With external types, one swift source file needs to be able to call the read +// method on another source file's FfiConverter, but then what visibility +// should Reader have? +// - If Reader is fileprivate, then this means the read() must also +// be fileprivate, which doesn't work with external types. +// - If Reader is internal/public, we'll get compile errors since both source +// files will try define the same type. +// +// Instead, the read() method and these helper functions input a tuple of data + +fileprivate func createReader(data: Data) -> (data: Data, offset: Data.Index) { + (data: data, offset: 0) +} + +// Reads an integer at the current offset, in big-endian order, and advances +// the offset on success. Throws if reading the integer would move the +// offset past the end of the buffer. +fileprivate func readInt(_ reader: inout (data: Data, offset: Data.Index)) throws -> T { + let range = reader.offset...size + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + if T.self == UInt8.self { + let value = reader.data[reader.offset] + reader.offset += 1 + return value as! T + } + var value: T = 0 + let _ = withUnsafeMutableBytes(of: &value, { reader.data.copyBytes(to: $0, from: range)}) + reader.offset = range.upperBound + return value.bigEndian +} + +// Reads an arbitrary number of bytes, to be used to read +// raw bytes, this is useful when lifting strings +fileprivate func readBytes(_ reader: inout (data: Data, offset: Data.Index), count: Int) throws -> Array { + let range = reader.offset..<(reader.offset+count) + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + var value = [UInt8](repeating: 0, count: count) + value.withUnsafeMutableBufferPointer({ buffer in + reader.data.copyBytes(to: buffer, from: range) + }) + reader.offset = range.upperBound + return value +} + +// Reads a float at the current offset. +fileprivate func readFloat(_ reader: inout (data: Data, offset: Data.Index)) throws -> Float { + return Float(bitPattern: try readInt(&reader)) +} + +// Reads a float at the current offset. +fileprivate func readDouble(_ reader: inout (data: Data, offset: Data.Index)) throws -> Double { + return Double(bitPattern: try readInt(&reader)) +} + +// Indicates if the offset has reached the end of the buffer. +fileprivate func hasRemaining(_ reader: (data: Data, offset: Data.Index)) -> Bool { + return reader.offset < reader.data.count +} + +// Define writer functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. See the above discussion on Readers for details. + +fileprivate func createWriter() -> [UInt8] { + return [] +} + +fileprivate func writeBytes(_ writer: inout [UInt8], _ byteArr: S) where S: Sequence, S.Element == UInt8 { + writer.append(contentsOf: byteArr) +} + +// Writes an integer in big-endian order. +// +// Warning: make sure what you are trying to write +// is in the correct type! +fileprivate func writeInt(_ writer: inout [UInt8], _ value: T) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { writer.append(contentsOf: $0) } +} + +fileprivate func writeFloat(_ writer: inout [UInt8], _ value: Float) { + writeInt(&writer, value.bitPattern) +} + +fileprivate func writeDouble(_ writer: inout [UInt8], _ value: Double) { + writeInt(&writer, value.bitPattern) +} + +// Protocol for types that transfer other types across the FFI. This is +// analogous to the Rust trait of the same name. +fileprivate protocol FfiConverter { + associatedtype FfiType + associatedtype SwiftType + + static func lift(_ value: FfiType) throws -> SwiftType + static func lower(_ value: SwiftType) -> FfiType + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType + static func write(_ value: SwiftType, into buf: inout [UInt8]) +} + +// Types conforming to `Primitive` pass themselves directly over the FFI. +fileprivate protocol FfiConverterPrimitive: FfiConverter where FfiType == SwiftType { } + +extension FfiConverterPrimitive { +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lift(_ value: FfiType) throws -> SwiftType { + return value + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lower(_ value: SwiftType) -> FfiType { + return value + } +} + +// Types conforming to `FfiConverterRustBuffer` lift and lower into a `RustBuffer`. +// Used for complex types where it's hard to write a custom lift/lower. +fileprivate protocol FfiConverterRustBuffer: FfiConverter where FfiType == RustBuffer {} + +extension FfiConverterRustBuffer { +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lift(_ buf: RustBuffer) throws -> SwiftType { + var reader = createReader(data: Data(rustBuffer: buf)) + let value = try read(from: &reader) + if hasRemaining(reader) { + throw UniffiInternalError.incompleteData + } + buf.deallocate() + return value + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lower(_ value: SwiftType) -> RustBuffer { + var writer = createWriter() + write(value, into: &writer) + return RustBuffer(bytes: writer) + } +} +// An error type for FFI errors. These errors occur at the UniFFI level, not +// the library level. +fileprivate enum UniffiInternalError: LocalizedError { + case bufferOverflow + case incompleteData + case unexpectedOptionalTag + case unexpectedEnumCase + case unexpectedNullPointer + case unexpectedRustCallStatusCode + case unexpectedRustCallError + case unexpectedStaleHandle + case rustPanic(_ message: String) + + public var errorDescription: String? { + switch self { + case .bufferOverflow: return "Reading the requested value would read past the end of the buffer" + case .incompleteData: return "The buffer still has data after lifting its containing value" + case .unexpectedOptionalTag: return "Unexpected optional tag; should be 0 or 1" + case .unexpectedEnumCase: return "Raw enum value doesn't match any cases" + case .unexpectedNullPointer: return "Raw pointer value was null" + case .unexpectedRustCallStatusCode: return "Unexpected RustCallStatus code" + case .unexpectedRustCallError: return "CALL_ERROR but no errorClass specified" + case .unexpectedStaleHandle: return "The object in the handle map has been dropped already" + case let .rustPanic(message): return message + } + } +} + +fileprivate extension NSLock { + func withLock(f: () throws -> T) rethrows -> T { + self.lock() + defer { self.unlock() } + return try f() + } +} + +fileprivate let CALL_SUCCESS: Int8 = 0 +fileprivate let CALL_ERROR: Int8 = 1 +fileprivate let CALL_UNEXPECTED_ERROR: Int8 = 2 +fileprivate let CALL_CANCELLED: Int8 = 3 + +fileprivate extension RustCallStatus { + init() { + self.init( + code: CALL_SUCCESS, + errorBuf: RustBuffer.init( + capacity: 0, + len: 0, + data: nil + ) + ) + } +} + +private func rustCall(_ callback: (UnsafeMutablePointer) -> T) throws -> T { + let neverThrow: ((RustBuffer) throws -> Never)? = nil + return try makeRustCall(callback, errorHandler: neverThrow) +} + +private func rustCallWithError( + _ errorHandler: @escaping (RustBuffer) throws -> E, + _ callback: (UnsafeMutablePointer) -> T) throws -> T { + try makeRustCall(callback, errorHandler: errorHandler) +} + +private func makeRustCall( + _ callback: (UnsafeMutablePointer) -> T, + errorHandler: ((RustBuffer) throws -> E)? +) throws -> T { + uniffiEnsureInitialized() + var callStatus = RustCallStatus.init() + let returnedVal = callback(&callStatus) + try uniffiCheckCallStatus(callStatus: callStatus, errorHandler: errorHandler) + return returnedVal +} + +private func uniffiCheckCallStatus( + callStatus: RustCallStatus, + errorHandler: ((RustBuffer) throws -> E)? +) throws { + switch callStatus.code { + case CALL_SUCCESS: + return + + case CALL_ERROR: + if let errorHandler = errorHandler { + throw try errorHandler(callStatus.errorBuf) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.unexpectedRustCallError + } + + case CALL_UNEXPECTED_ERROR: + // When the rust code sees a panic, it tries to construct a RustBuffer + // with the message. But if that code panics, then it just sends back + // an empty buffer. + if callStatus.errorBuf.len > 0 { + throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf)) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.rustPanic("Rust panic") + } + + case CALL_CANCELLED: + fatalError("Cancellation not supported yet") + + default: + throw UniffiInternalError.unexpectedRustCallStatusCode + } +} + +private func uniffiTraitInterfaceCall( + callStatus: UnsafeMutablePointer, + makeCall: () throws -> T, + writeReturn: (T) -> () +) { + do { + try writeReturn(makeCall()) + } catch let error { + callStatus.pointee.code = CALL_UNEXPECTED_ERROR + callStatus.pointee.errorBuf = FfiConverterString.lower(String(describing: error)) + } +} + +private func uniffiTraitInterfaceCallWithError( + callStatus: UnsafeMutablePointer, + makeCall: () throws -> T, + writeReturn: (T) -> (), + lowerError: (E) -> RustBuffer +) { + do { + try writeReturn(makeCall()) + } catch let error as E { + callStatus.pointee.code = CALL_ERROR + callStatus.pointee.errorBuf = lowerError(error) + } catch { + callStatus.pointee.code = CALL_UNEXPECTED_ERROR + callStatus.pointee.errorBuf = FfiConverterString.lower(String(describing: error)) + } +} +fileprivate class UniffiHandleMap { + private var map: [UInt64: T] = [:] + private let lock = NSLock() + private var currentHandle: UInt64 = 1 + + func insert(obj: T) -> UInt64 { + lock.withLock { + let handle = currentHandle + currentHandle += 1 + map[handle] = obj + return handle + } + } + + func get(handle: UInt64) throws -> T { + try lock.withLock { + guard let obj = map[handle] else { + throw UniffiInternalError.unexpectedStaleHandle + } + return obj + } + } + + @discardableResult + func remove(handle: UInt64) throws -> T { + try lock.withLock { + guard let obj = map.removeValue(forKey: handle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return obj + } + } + + var count: Int { + get { + map.count + } + } +} + + +// Public interface members begin here. + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt16: FfiConverterPrimitive { + typealias FfiType = UInt16 + typealias SwiftType = UInt16 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt16 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt64: FfiConverterPrimitive { + typealias FfiType = UInt64 + typealias SwiftType = UInt64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterBool : FfiConverter { + typealias FfiType = Int8 + typealias SwiftType = Bool + + public static func lift(_ value: Int8) throws -> Bool { + return value != 0 + } + + public static func lower(_ value: Bool) -> Int8 { + return value ? 1 : 0 + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Bool { + return try lift(readInt(&buf)) + } + + public static func write(_ value: Bool, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterString: FfiConverter { + typealias SwiftType = String + typealias FfiType = RustBuffer + + public static func lift(_ value: RustBuffer) throws -> String { + defer { + value.deallocate() + } + if value.data == nil { + return String() + } + let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len)) + return String(bytes: bytes, encoding: String.Encoding.utf8)! + } + + public static func lower(_ value: String) -> RustBuffer { + return value.utf8CString.withUnsafeBufferPointer { ptr in + // The swift string gives us int8_t, we want uint8_t. + ptr.withMemoryRebound(to: UInt8.self) { ptr in + // The swift string gives us a trailing null byte, we don't want it. + let buf = UnsafeBufferPointer(rebasing: ptr.prefix(upTo: ptr.count - 1)) + return RustBuffer.from(buf) + } + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> String { + let len: Int32 = try readInt(&buf) + return String(bytes: try readBytes(&buf, count: Int(len)), encoding: String.Encoding.utf8)! + } + + public static func write(_ value: String, into buf: inout [UInt8]) { + let len = Int32(value.utf8.count) + writeInt(&buf, len) + writeBytes(&buf, value.utf8) + } +} + + + + +/** + * Object-oriented handle for module and command management. + * Useful for SwiftUI @StateObject / @Observable patterns. + */ +public protocol ModuleManagerProtocol : AnyObject { + + func execute(token: String, args: [String]) -> String + + func executeRemote(token: String, args: [String], netAs: String) -> String + + func listCommands() -> [CommandInfo] + + func listModules() -> [ModuleStatus] + + func probeToggle(name: String, enabled: Bool) -> ModuleToggleResult + + func setEnabled(name: String, enabled: Bool) -> String + + func setEnabledWithRequirements(name: String, enabled: Bool) -> String + +} + +/** + * Object-oriented handle for module and command management. + * Useful for SwiftUI @StateObject / @Observable patterns. + */ +open class ModuleManager: + ModuleManagerProtocol { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_arcadia_core_fn_clone_modulemanager(self.pointer, $0) } + } +public convenience init() { + let pointer = + try! rustCall() { + uniffi_arcadia_core_fn_constructor_modulemanager_new($0 + ) +} + self.init(unsafeFromRawPointer: pointer) +} + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_arcadia_core_fn_free_modulemanager(pointer, $0) } + } + + + + +open func execute(token: String, args: [String]) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_execute(self.uniffiClonePointer(), + FfiConverterString.lower(token), + FfiConverterSequenceString.lower(args),$0 + ) +}) +} + +open func executeRemote(token: String, args: [String], netAs: String) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_execute_remote(self.uniffiClonePointer(), + FfiConverterString.lower(token), + FfiConverterSequenceString.lower(args), + FfiConverterString.lower(netAs),$0 + ) +}) +} + +open func listCommands() -> [CommandInfo] { + return try! FfiConverterSequenceTypeCommandInfo.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_list_commands(self.uniffiClonePointer(),$0 + ) +}) +} + +open func listModules() -> [ModuleStatus] { + return try! FfiConverterSequenceTypeModuleStatus.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_list_modules(self.uniffiClonePointer(),$0 + ) +}) +} + +open func probeToggle(name: String, enabled: Bool) -> ModuleToggleResult { + return try! FfiConverterTypeModuleToggleResult.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_probe_toggle(self.uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterBool.lower(enabled),$0 + ) +}) +} + +open func setEnabled(name: String, enabled: Bool) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_set_enabled(self.uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterBool.lower(enabled),$0 + ) +}) +} + +open func setEnabledWithRequirements(name: String, enabled: Bool) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_method_modulemanager_set_enabled_with_requirements(self.uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterBool.lower(enabled),$0 + ) +}) +} + + +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeModuleManager: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = ModuleManager + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> ModuleManager { + return ModuleManager(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: ModuleManager) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModuleManager { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: ModuleManager, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeModuleManager_lift(_ pointer: UnsafeMutableRawPointer) throws -> ModuleManager { + return try FfiConverterTypeModuleManager.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeModuleManager_lower(_ value: ModuleManager) -> UnsafeMutableRawPointer { + return FfiConverterTypeModuleManager.lower(value) +} + + +public struct CommandInfo { + public var token: String + public var description: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(token: String, description: String) { + self.token = token + self.description = description + } +} + + + +extension CommandInfo: Equatable, Hashable { + public static func ==(lhs: CommandInfo, rhs: CommandInfo) -> Bool { + if lhs.token != rhs.token { + return false + } + if lhs.description != rhs.description { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(token) + hasher.combine(description) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCommandInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CommandInfo { + return + try CommandInfo( + token: FfiConverterString.read(from: &buf), + description: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: CommandInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.token, into: &buf) + FfiConverterString.write(value.description, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCommandInfo_lift(_ buf: RustBuffer) throws -> CommandInfo { + return try FfiConverterTypeCommandInfo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCommandInfo_lower(_ value: CommandInfo) -> RustBuffer { + return FfiConverterTypeCommandInfo.lower(value) +} + + +public struct ExecutionContextFfi { + public var netAs: String? + public var netTimeoutMs: UInt64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(netAs: String?, netTimeoutMs: UInt64?) { + self.netAs = netAs + self.netTimeoutMs = netTimeoutMs + } +} + + + +extension ExecutionContextFfi: Equatable, Hashable { + public static func ==(lhs: ExecutionContextFfi, rhs: ExecutionContextFfi) -> Bool { + if lhs.netAs != rhs.netAs { + return false + } + if lhs.netTimeoutMs != rhs.netTimeoutMs { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(netAs) + hasher.combine(netTimeoutMs) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeExecutionContextFfi: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ExecutionContextFfi { + return + try ExecutionContextFfi( + netAs: FfiConverterOptionString.read(from: &buf), + netTimeoutMs: FfiConverterOptionUInt64.read(from: &buf) + ) + } + + public static func write(_ value: ExecutionContextFfi, into buf: inout [UInt8]) { + FfiConverterOptionString.write(value.netAs, into: &buf) + FfiConverterOptionUInt64.write(value.netTimeoutMs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeExecutionContextFfi_lift(_ buf: RustBuffer) throws -> ExecutionContextFfi { + return try FfiConverterTypeExecutionContextFfi.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeExecutionContextFfi_lower(_ value: ExecutionContextFfi) -> RustBuffer { + return FfiConverterTypeExecutionContextFfi.lower(value) +} + + +public struct LanServiceInfoFfi { + public var running: Bool + public var port: UInt16 + public var hostname: String + public var moduleEnabled: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(running: Bool, port: UInt16, hostname: String, moduleEnabled: Bool) { + self.running = running + self.port = port + self.hostname = hostname + self.moduleEnabled = moduleEnabled + } +} + + + +extension LanServiceInfoFfi: Equatable, Hashable { + public static func ==(lhs: LanServiceInfoFfi, rhs: LanServiceInfoFfi) -> Bool { + if lhs.running != rhs.running { + return false + } + if lhs.port != rhs.port { + return false + } + if lhs.hostname != rhs.hostname { + return false + } + if lhs.moduleEnabled != rhs.moduleEnabled { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(running) + hasher.combine(port) + hasher.combine(hostname) + hasher.combine(moduleEnabled) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeLanServiceInfoFfi: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LanServiceInfoFfi { + return + try LanServiceInfoFfi( + running: FfiConverterBool.read(from: &buf), + port: FfiConverterUInt16.read(from: &buf), + hostname: FfiConverterString.read(from: &buf), + moduleEnabled: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: LanServiceInfoFfi, into buf: inout [UInt8]) { + FfiConverterBool.write(value.running, into: &buf) + FfiConverterUInt16.write(value.port, into: &buf) + FfiConverterString.write(value.hostname, into: &buf) + FfiConverterBool.write(value.moduleEnabled, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLanServiceInfoFfi_lift(_ buf: RustBuffer) throws -> LanServiceInfoFfi { + return try FfiConverterTypeLanServiceInfoFfi.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLanServiceInfoFfi_lower(_ value: LanServiceInfoFfi) -> RustBuffer { + return FfiConverterTypeLanServiceInfoFfi.lower(value) +} + + +public struct ModuleStatus { + public var name: String + public var enabled: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, enabled: Bool) { + self.name = name + self.enabled = enabled + } +} + + + +extension ModuleStatus: Equatable, Hashable { + public static func ==(lhs: ModuleStatus, rhs: ModuleStatus) -> Bool { + if lhs.name != rhs.name { + return false + } + if lhs.enabled != rhs.enabled { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(enabled) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeModuleStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModuleStatus { + return + try ModuleStatus( + name: FfiConverterString.read(from: &buf), + enabled: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: ModuleStatus, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterBool.write(value.enabled, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeModuleStatus_lift(_ buf: RustBuffer) throws -> ModuleStatus { + return try FfiConverterTypeModuleStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeModuleStatus_lower(_ value: ModuleStatus) -> RustBuffer { + return FfiConverterTypeModuleStatus.lower(value) +} + + +public struct ModuleToggleResult { + public var ok: Bool + public var message: String + public var missingRequirements: [String] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(ok: Bool, message: String, missingRequirements: [String]) { + self.ok = ok + self.message = message + self.missingRequirements = missingRequirements + } +} + + + +extension ModuleToggleResult: Equatable, Hashable { + public static func ==(lhs: ModuleToggleResult, rhs: ModuleToggleResult) -> Bool { + if lhs.ok != rhs.ok { + return false + } + if lhs.message != rhs.message { + return false + } + if lhs.missingRequirements != rhs.missingRequirements { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ok) + hasher.combine(message) + hasher.combine(missingRequirements) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeModuleToggleResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModuleToggleResult { + return + try ModuleToggleResult( + ok: FfiConverterBool.read(from: &buf), + message: FfiConverterString.read(from: &buf), + missingRequirements: FfiConverterSequenceString.read(from: &buf) + ) + } + + public static func write(_ value: ModuleToggleResult, into buf: inout [UInt8]) { + FfiConverterBool.write(value.ok, into: &buf) + FfiConverterString.write(value.message, into: &buf) + FfiConverterSequenceString.write(value.missingRequirements, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeModuleToggleResult_lift(_ buf: RustBuffer) throws -> ModuleToggleResult { + return try FfiConverterTypeModuleToggleResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeModuleToggleResult_lower(_ value: ModuleToggleResult) -> RustBuffer { + return FfiConverterTypeModuleToggleResult.lower(value) +} + + +public struct RemoteMirrorDrain { + public var lines: [String] + /** + * Host handled inbound NODE_EXEC — surfaces showing **local** state should resync from disk / LAN snapshot. + */ + public var syncLocalSurface: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(lines: [String], + /** + * Host handled inbound NODE_EXEC — surfaces showing **local** state should resync from disk / LAN snapshot. + */syncLocalSurface: Bool) { + self.lines = lines + self.syncLocalSurface = syncLocalSurface + } +} + + + +extension RemoteMirrorDrain: Equatable, Hashable { + public static func ==(lhs: RemoteMirrorDrain, rhs: RemoteMirrorDrain) -> Bool { + if lhs.lines != rhs.lines { + return false + } + if lhs.syncLocalSurface != rhs.syncLocalSurface { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(lines) + hasher.combine(syncLocalSurface) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRemoteMirrorDrain: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RemoteMirrorDrain { + return + try RemoteMirrorDrain( + lines: FfiConverterSequenceString.read(from: &buf), + syncLocalSurface: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: RemoteMirrorDrain, into buf: inout [UInt8]) { + FfiConverterSequenceString.write(value.lines, into: &buf) + FfiConverterBool.write(value.syncLocalSurface, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRemoteMirrorDrain_lift(_ buf: RustBuffer) throws -> RemoteMirrorDrain { + return try FfiConverterTypeRemoteMirrorDrain.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRemoteMirrorDrain_lower(_ value: RemoteMirrorDrain) -> RustBuffer { + return FfiConverterTypeRemoteMirrorDrain.lower(value) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt64: FfiConverterRustBuffer { + typealias SwiftType = UInt64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionString: FfiConverterRustBuffer { + typealias SwiftType = String? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterString.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterString.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { + typealias SwiftType = [String] + + public static func write(_ value: [String], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterString.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [String] { + let len: Int32 = try readInt(&buf) + var seq = [String]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterString.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeCommandInfo: FfiConverterRustBuffer { + typealias SwiftType = [CommandInfo] + + public static func write(_ value: [CommandInfo], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeCommandInfo.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [CommandInfo] { + let len: Int32 = try readInt(&buf) + var seq = [CommandInfo]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeCommandInfo.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeModuleStatus: FfiConverterRustBuffer { + typealias SwiftType = [ModuleStatus] + + public static func write(_ value: [ModuleStatus], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeModuleStatus.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ModuleStatus] { + let len: Int32 = try readInt(&buf) + var seq = [ModuleStatus]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeModuleStatus.read(from: &buf)) + } + return seq + } +} +/** + * Drain mirrored NODE_EXEC transcript lines + host UI sync flag (reload modules when showing local state). + */ +public func drainRemoteMirrorBatch() -> RemoteMirrorDrain { + return try! FfiConverterTypeRemoteMirrorDrain.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_drain_remote_mirror_batch($0 + ) +}) +} +/** + * Execute a command by dot-separated token (e.g. "lan.scan"). + * Returns the result string; errors are embedded in the return value. + */ +public func executeCommand(token: String, args: [String], context: ExecutionContextFfi) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_execute_command( + FfiConverterString.lower(token), + FfiConverterSequenceString.lower(args), + FfiConverterTypeExecutionContextFfi.lower(context),$0 + ) +}) +} +/** + * LAN service status: running state, UDP port, hostname, and module-enabled flag. + */ +public func lanServiceInfo() -> LanServiceInfoFfi { + return try! FfiConverterTypeLanServiceInfoFfi.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_lan_service_info($0 + ) +}) +} +/** + * Start the LAN background service thread. Safe to call multiple times. + */ +public func lanStart() {try! rustCall() { + uniffi_arcadia_core_fn_func_lan_start($0 + ) +} +} +/** + * Stop the LAN background service thread. + */ +public func lanStop() {try! rustCall() { + uniffi_arcadia_core_fn_func_lan_stop($0 + ) +} +} +/** + * List all commands in currently-enabled modules. + */ +public func listCommands() -> [CommandInfo] { + return try! FfiConverterSequenceTypeCommandInfo.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_list_commands($0 + ) +}) +} +/** + * List all modules and their enabled state. + */ +public func listModules() -> [ModuleStatus] { + return try! FfiConverterSequenceTypeModuleStatus.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_list_modules($0 + ) +}) +} +/** + * Returns the default navigation registry as JSON. + * This payload is shared by desktop and mobile shells and can later be merged + * with extension-provided pages/groups at runtime. + */ +public func navigationRegistryJson() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_navigation_registry_json($0 + ) +}) +} +/** + * Returns the current platform name ("ios", "macos", "linux", "windows", "unknown"). + */ +public func platformName() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_platform_name($0 + ) +}) +} +public func probeModuleToggle(name: String, enabled: Bool) -> ModuleToggleResult { + return try! FfiConverterTypeModuleToggleResult.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_probe_module_toggle( + FfiConverterString.lower(name), + FfiConverterBool.lower(enabled),$0 + ) +}) +} +/** + * Set the config directory path. Must be called before any other API on iOS. + * Desktop callers skip this — $HOME is used by default. + */ +public func setConfigRootPath(path: String) {try! rustCall() { + uniffi_arcadia_core_fn_func_set_config_root_path( + FfiConverterString.lower(path),$0 + ) +} +} +/** + * Override the hostname this node advertises over LAN. Call early, before `lan_start`. + * iOS should pass `ProcessInfo.processInfo.hostName`; desktop resolves hostname automatically. + */ +public func setLocalHostname(name: String) {try! rustCall() { + uniffi_arcadia_core_fn_func_set_local_hostname( + FfiConverterString.lower(name),$0 + ) +} +} +/** + * Enable or disable a named module. Persists to disk. Returns status message. + */ +public func setModuleEnabled(name: String, enabled: Bool) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_set_module_enabled( + FfiConverterString.lower(name), + FfiConverterBool.lower(enabled),$0 + ) +}) +} +public func setModuleEnabledWithRequirements(name: String, enabled: Bool) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_set_module_enabled_with_requirements( + FfiConverterString.lower(name), + FfiConverterBool.lower(enabled),$0 + ) +}) +} +public func thinClientPreferredRouteGet() -> String? { + return try! FfiConverterOptionString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_thin_client_preferred_route_get($0 + ) +}) +} +/** + * Persist default `net_as` route; empty error string on success. + */ +public func thinClientPreferredRouteSet(route: String?) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_thin_client_preferred_route_set( + FfiConverterOptionString.lower(route),$0 + ) +}) +} +/** + * Stable id for this GUI peer (`surface.patch` client_id, logs). + */ +public func thinClientSurfaceClientId() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_arcadia_core_fn_func_thin_client_surface_client_id($0 + ) +}) +} + +private enum InitializationResult { + case ok + case contractVersionMismatch + case apiChecksumMismatch +} +// Use a global variable to perform the versioning checks. Swift ensures that +// the code inside is only computed once. +private var initializationResult: InitializationResult = { + // Get the bindings contract version from our ComponentInterface + let bindings_contract_version = 26 + // Get the scaffolding contract version by calling the into the dylib + let scaffolding_contract_version = ffi_arcadia_core_uniffi_contract_version() + if bindings_contract_version != scaffolding_contract_version { + return InitializationResult.contractVersionMismatch + } + if (uniffi_arcadia_core_checksum_func_drain_remote_mirror_batch() != 51970) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_execute_command() != 44678) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_lan_service_info() != 17447) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_lan_start() != 58023) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_lan_stop() != 17409) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_list_commands() != 8276) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_list_modules() != 9903) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_navigation_registry_json() != 20683) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_platform_name() != 40840) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_probe_module_toggle() != 64626) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_set_config_root_path() != 404) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_set_local_hostname() != 12539) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_set_module_enabled() != 41009) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_set_module_enabled_with_requirements() != 7308) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_thin_client_preferred_route_get() != 53888) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_thin_client_preferred_route_set() != 41361) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_func_thin_client_surface_client_id() != 59315) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_execute() != 51257) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_execute_remote() != 37420) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_list_commands() != 25753) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_list_modules() != 35098) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_probe_toggle() != 19804) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_set_enabled() != 62609) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_method_modulemanager_set_enabled_with_requirements() != 45513) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_arcadia_core_checksum_constructor_modulemanager_new() != 56924) { + return InitializationResult.apiChecksumMismatch + } + + return InitializationResult.ok +}() + +private func uniffiEnsureInitialized() { + switch initializationResult { + case .ok: + break + case .contractVersionMismatch: + fatalError("UniFFI contract version mismatch: try cleaning and rebuilding your project") + case .apiChecksumMismatch: + fatalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +// swiftlint:enable all \ No newline at end of file diff --git a/Mobile/iOS/ArcadiaCore/Generated/arcadia_coreFFI.h b/Mobile/iOS/ArcadiaCore/Generated/arcadia_coreFFI.h new file mode 100644 index 0000000..8e32aa1 --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/Generated/arcadia_coreFFI.h @@ -0,0 +1,836 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +#pragma once + +#include +#include +#include + +// The following structs are used to implement the lowest level +// of the FFI, and thus useful to multiple uniffied crates. +// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H. +#ifdef UNIFFI_SHARED_H + // We also try to prevent mixing versions of shared uniffi header structs. + // If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4 + #ifndef UNIFFI_SHARED_HEADER_V4 + #error Combining helper code from multiple versions of uniffi is not supported + #endif // ndef UNIFFI_SHARED_HEADER_V4 +#else +#define UNIFFI_SHARED_H +#define UNIFFI_SHARED_HEADER_V4 +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ + +typedef struct RustBuffer +{ + uint64_t capacity; + uint64_t len; + uint8_t *_Nullable data; +} RustBuffer; + +typedef struct ForeignBytes +{ + int32_t len; + const uint8_t *_Nullable data; +} ForeignBytes; + +// Error definitions +typedef struct RustCallStatus { + int8_t code; + RustBuffer errorBuf; +} RustCallStatus; + +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ +#endif // def UNIFFI_SHARED_H +#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +typedef void (*UniffiForeignFutureFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +typedef void (*UniffiCallbackInterfaceFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE +typedef struct UniffiForeignFuture { + uint64_t handle; + UniffiForeignFutureFree _Nonnull free; +} UniffiForeignFuture; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +typedef struct UniffiForeignFutureStructU8 { + uint8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureStructU8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +typedef struct UniffiForeignFutureStructI8 { + int8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureStructI8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +typedef struct UniffiForeignFutureStructU16 { + uint16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureStructU16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +typedef struct UniffiForeignFutureStructI16 { + int16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureStructI16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +typedef struct UniffiForeignFutureStructU32 { + uint32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureStructU32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +typedef struct UniffiForeignFutureStructI32 { + int32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureStructI32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +typedef struct UniffiForeignFutureStructU64 { + uint64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureStructU64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +typedef struct UniffiForeignFutureStructI64 { + int64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureStructI64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +typedef struct UniffiForeignFutureStructF32 { + float returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureStructF32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +typedef struct UniffiForeignFutureStructF64 { + double returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureStructF64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +typedef struct UniffiForeignFutureStructPointer { + void*_Nonnull returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructPointer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +typedef void (*UniffiForeignFutureCompletePointer)(uint64_t, UniffiForeignFutureStructPointer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +typedef struct UniffiForeignFutureStructRustBuffer { + RustBuffer returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructRustBuffer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureStructRustBuffer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +typedef struct UniffiForeignFutureStructVoid { + RustCallStatus callStatus; +} UniffiForeignFutureStructVoid; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureStructVoid + ); + +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CLONE_MODULEMANAGER +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CLONE_MODULEMANAGER +void*_Nonnull uniffi_arcadia_core_fn_clone_modulemanager(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FREE_MODULEMANAGER +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FREE_MODULEMANAGER +void uniffi_arcadia_core_fn_free_modulemanager(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CONSTRUCTOR_MODULEMANAGER_NEW +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_CONSTRUCTOR_MODULEMANAGER_NEW +void*_Nonnull uniffi_arcadia_core_fn_constructor_modulemanager_new(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_execute(void*_Nonnull ptr, RustBuffer token, RustBuffer args, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE_REMOTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_EXECUTE_REMOTE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_execute_remote(void*_Nonnull ptr, RustBuffer token, RustBuffer args, RustBuffer net_as, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_COMMANDS +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_list_commands(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_LIST_MODULES +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_list_modules(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_PROBE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_PROBE_TOGGLE +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_probe_toggle(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_set_enabled(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +RustBuffer uniffi_arcadia_core_fn_method_modulemanager_set_enabled_with_requirements(void*_Nonnull ptr, RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_DRAIN_REMOTE_MIRROR_BATCH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_DRAIN_REMOTE_MIRROR_BATCH +RustBuffer uniffi_arcadia_core_fn_func_drain_remote_mirror_batch(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_EXECUTE_COMMAND +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_EXECUTE_COMMAND +RustBuffer uniffi_arcadia_core_fn_func_execute_command(RustBuffer token, RustBuffer args, RustBuffer context, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_SERVICE_INFO +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_SERVICE_INFO +RustBuffer uniffi_arcadia_core_fn_func_lan_service_info(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_START +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_START +void uniffi_arcadia_core_fn_func_lan_start(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_STOP +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LAN_STOP +void uniffi_arcadia_core_fn_func_lan_stop(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_COMMANDS +RustBuffer uniffi_arcadia_core_fn_func_list_commands(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_LIST_MODULES +RustBuffer uniffi_arcadia_core_fn_func_list_modules(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_NAVIGATION_REGISTRY_JSON +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_NAVIGATION_REGISTRY_JSON +RustBuffer uniffi_arcadia_core_fn_func_navigation_registry_json(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PLATFORM_NAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PLATFORM_NAME +RustBuffer uniffi_arcadia_core_fn_func_platform_name(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PROBE_MODULE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_PROBE_MODULE_TOGGLE +RustBuffer uniffi_arcadia_core_fn_func_probe_module_toggle(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_CONFIG_ROOT_PATH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_CONFIG_ROOT_PATH +void uniffi_arcadia_core_fn_func_set_config_root_path(RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_LOCAL_HOSTNAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_LOCAL_HOSTNAME +void uniffi_arcadia_core_fn_func_set_local_hostname(RustBuffer name, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED +RustBuffer uniffi_arcadia_core_fn_func_set_module_enabled(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +RustBuffer uniffi_arcadia_core_fn_func_set_module_enabled_with_requirements(RustBuffer name, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +RustBuffer uniffi_arcadia_core_fn_func_thin_client_preferred_route_get(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +RustBuffer uniffi_arcadia_core_fn_func_thin_client_preferred_route_set(RustBuffer route, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_FN_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +RustBuffer uniffi_arcadia_core_fn_func_thin_client_surface_client_id(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_ALLOC +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_ALLOC +RustBuffer ffi_arcadia_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FROM_BYTES +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FROM_BYTES +RustBuffer ffi_arcadia_core_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FREE +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_FREE +void ffi_arcadia_core_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_RESERVE +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUSTBUFFER_RESERVE +RustBuffer ffi_arcadia_core_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U8 +void ffi_arcadia_core_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U8 +void ffi_arcadia_core_rust_future_cancel_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U8 +void ffi_arcadia_core_rust_future_free_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U8 +uint8_t ffi_arcadia_core_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I8 +void ffi_arcadia_core_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I8 +void ffi_arcadia_core_rust_future_cancel_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I8 +void ffi_arcadia_core_rust_future_free_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I8 +int8_t ffi_arcadia_core_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U16 +void ffi_arcadia_core_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U16 +void ffi_arcadia_core_rust_future_cancel_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U16 +void ffi_arcadia_core_rust_future_free_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U16 +uint16_t ffi_arcadia_core_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I16 +void ffi_arcadia_core_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I16 +void ffi_arcadia_core_rust_future_cancel_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I16 +void ffi_arcadia_core_rust_future_free_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I16 +int16_t ffi_arcadia_core_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U32 +void ffi_arcadia_core_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U32 +void ffi_arcadia_core_rust_future_cancel_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U32 +void ffi_arcadia_core_rust_future_free_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U32 +uint32_t ffi_arcadia_core_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I32 +void ffi_arcadia_core_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I32 +void ffi_arcadia_core_rust_future_cancel_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I32 +void ffi_arcadia_core_rust_future_free_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I32 +int32_t ffi_arcadia_core_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_U64 +void ffi_arcadia_core_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_U64 +void ffi_arcadia_core_rust_future_cancel_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_U64 +void ffi_arcadia_core_rust_future_free_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_U64 +uint64_t ffi_arcadia_core_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_I64 +void ffi_arcadia_core_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_I64 +void ffi_arcadia_core_rust_future_cancel_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_I64 +void ffi_arcadia_core_rust_future_free_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_I64 +int64_t ffi_arcadia_core_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F32 +void ffi_arcadia_core_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F32 +void ffi_arcadia_core_rust_future_cancel_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F32 +void ffi_arcadia_core_rust_future_free_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F32 +float ffi_arcadia_core_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_F64 +void ffi_arcadia_core_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_F64 +void ffi_arcadia_core_rust_future_cancel_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_F64 +void ffi_arcadia_core_rust_future_free_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_F64 +double ffi_arcadia_core_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_POINTER +void ffi_arcadia_core_rust_future_poll_pointer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_POINTER +void ffi_arcadia_core_rust_future_cancel_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_POINTER +void ffi_arcadia_core_rust_future_free_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_POINTER +void*_Nonnull ffi_arcadia_core_rust_future_complete_pointer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_RUST_BUFFER +void ffi_arcadia_core_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER +void ffi_arcadia_core_rust_future_cancel_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_RUST_BUFFER +void ffi_arcadia_core_rust_future_free_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER +RustBuffer ffi_arcadia_core_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_POLL_VOID +void ffi_arcadia_core_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_CANCEL_VOID +void ffi_arcadia_core_rust_future_cancel_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_FREE_VOID +void ffi_arcadia_core_rust_future_free_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_RUST_FUTURE_COMPLETE_VOID +void ffi_arcadia_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_DRAIN_REMOTE_MIRROR_BATCH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_DRAIN_REMOTE_MIRROR_BATCH +uint16_t uniffi_arcadia_core_checksum_func_drain_remote_mirror_batch(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_EXECUTE_COMMAND +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_EXECUTE_COMMAND +uint16_t uniffi_arcadia_core_checksum_func_execute_command(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_SERVICE_INFO +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_SERVICE_INFO +uint16_t uniffi_arcadia_core_checksum_func_lan_service_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_START +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_START +uint16_t uniffi_arcadia_core_checksum_func_lan_start(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_STOP +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LAN_STOP +uint16_t uniffi_arcadia_core_checksum_func_lan_stop(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_COMMANDS +uint16_t uniffi_arcadia_core_checksum_func_list_commands(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_LIST_MODULES +uint16_t uniffi_arcadia_core_checksum_func_list_modules(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_NAVIGATION_REGISTRY_JSON +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_NAVIGATION_REGISTRY_JSON +uint16_t uniffi_arcadia_core_checksum_func_navigation_registry_json(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PLATFORM_NAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PLATFORM_NAME +uint16_t uniffi_arcadia_core_checksum_func_platform_name(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PROBE_MODULE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_PROBE_MODULE_TOGGLE +uint16_t uniffi_arcadia_core_checksum_func_probe_module_toggle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_CONFIG_ROOT_PATH +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_CONFIG_ROOT_PATH +uint16_t uniffi_arcadia_core_checksum_func_set_config_root_path(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_LOCAL_HOSTNAME +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_LOCAL_HOSTNAME +uint16_t uniffi_arcadia_core_checksum_func_set_local_hostname(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED +uint16_t uniffi_arcadia_core_checksum_func_set_module_enabled(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_SET_MODULE_ENABLED_WITH_REQUIREMENTS +uint16_t uniffi_arcadia_core_checksum_func_set_module_enabled_with_requirements(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_GET +uint16_t uniffi_arcadia_core_checksum_func_thin_client_preferred_route_get(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_PREFERRED_ROUTE_SET +uint16_t uniffi_arcadia_core_checksum_func_thin_client_preferred_route_set(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_FUNC_THIN_CLIENT_SURFACE_CLIENT_ID +uint16_t uniffi_arcadia_core_checksum_func_thin_client_surface_client_id(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_execute(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE_REMOTE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_EXECUTE_REMOTE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_execute_remote(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_COMMANDS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_COMMANDS +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_list_commands(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_MODULES +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_LIST_MODULES +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_list_modules(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_PROBE_TOGGLE +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_PROBE_TOGGLE +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_probe_toggle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_set_enabled(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_METHOD_MODULEMANAGER_SET_ENABLED_WITH_REQUIREMENTS +uint16_t uniffi_arcadia_core_checksum_method_modulemanager_set_enabled_with_requirements(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_CONSTRUCTOR_MODULEMANAGER_NEW +#define UNIFFI_FFIDEF_UNIFFI_ARCADIA_CORE_CHECKSUM_CONSTRUCTOR_MODULEMANAGER_NEW +uint16_t uniffi_arcadia_core_checksum_constructor_modulemanager_new(void + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_ARCADIA_CORE_UNIFFI_CONTRACT_VERSION +#define UNIFFI_FFIDEF_FFI_ARCADIA_CORE_UNIFFI_CONTRACT_VERSION +uint32_t ffi_arcadia_core_uniffi_contract_version(void + +); +#endif + diff --git a/Mobile/iOS/ArcadiaCore/Generated/arcadia_coreFFI.modulemap b/Mobile/iOS/ArcadiaCore/Generated/arcadia_coreFFI.modulemap new file mode 100644 index 0000000..945759b --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/Generated/arcadia_coreFFI.modulemap @@ -0,0 +1,4 @@ +module arcadia_coreFFI { + header "arcadia_coreFFI.h" + export * +} \ No newline at end of file diff --git a/Mobile/iOS/ArcadiaCore/Generated/module.modulemap b/Mobile/iOS/ArcadiaCore/Generated/module.modulemap new file mode 100644 index 0000000..ff16a29 --- /dev/null +++ b/Mobile/iOS/ArcadiaCore/Generated/module.modulemap @@ -0,0 +1,4 @@ +module arcadia_coreFFI { + header "arcadia_coreFFI.h" + export * +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bafe197 --- /dev/null +++ b/README.md @@ -0,0 +1,938 @@ +# Arcadia + +**One Rust core. One Python SDK. An infinite extension surface. Zero rent.** + +Arcadia is a multi-platform runtime, shell, and — ultimately — an **open platform for building system-integrated applications**. Today: a single `arcadia-core` crate owns every module, command, navigation structure, LAN protocol, and config schema, consumed by two native surfaces (GPUI desktop, SwiftUI iOS) plus a CLI. Tomorrow: a Python library that gives any developer full OS reach and a first-class extension SDK for building apps — menu bar tools, custom IDEs, file explorers, widgets, shells — that run everywhere and belong to no vendor. + +Built on the same DNA as **[Holos](https://github.com/stack-node/holos)** — *utility over monetization, ownership over subscriptions* — but with a harder engineering mandate: **no duplicated truth between platforms, no hardcoded IDs in surface code, no growing if-else chains that break the next time a module is added.** + +--- + +## Table of contents + +- [Why Arcadia exists](#why-arcadia-exists) +- [What Arcadia is](#what-arcadia-is) +- [The vision — where this is going](#the-vision--where-this-is-going) +- [What you can do with it now](#what-you-can-do-with-it-now) +- [Development status](#development-status) +- [Philosophy](#philosophy) +- [Architecture](#architecture) + - [Command model](#command-model) + - [Module system](#module-system) + - [Navigation system](#navigation-system) + - [Thin-client and LAN routing](#thin-client-and-lan-routing) + - [Remote mirror](#remote-mirror) + - [Theme system](#theme-system) + - [FFI bridge](#ffi-bridge) +- [Module reference](#module-reference) +- [Navigation reference](#navigation-reference) +- [Repository layout](#repository-layout) +- [Configuration](#configuration) +- [Prerequisites](#prerequisites) +- [Build and run](#build-and-run) +- [Environment variables](#environment-variables) +- [Adding features](#adding-features) +- [Testing](#testing) +- [Known gaps and production roadmap](#known-gaps-and-production-roadmap) +- [Security posture](#security-posture) +- [CI](#ci) +- [Contributing](#contributing) +- [Lineage](#lineage) +- [About the creator](#about-the-creator) +- [Donations](#donations) +- [Final note](#final-note) + +--- + +## Why Arcadia exists + +Small-tool ecosystems trend the same way: **paywalls, subscriptions, feature flags, AI-generated-app-of-the-week churn.** Good ideas get trapped in silos—one app for the menu bar, one for the terminal, one for "sync," each with its own incompatible settings schema and no way out. + +**Holos** pushed back on that for macOS: modular, free, yours to extend. + +**Arcadia** pushes harder: + +- **One core** (`arcadia-core`) owns modules, commands, config, navigation metadata, and LAN plumbing. Surfaces are **render + dispatch**, not second implementations. +- **Multiple surfaces** from the same logic: terminal REPL, GPUI desktop, SwiftUI pocket — without forking behavior per platform. +- **Optional headless-host + GUI-client** patterns over LAN so your MacBook can drive your phone — or vice versa — without inventing a new protocol per feature. +- **Free. Always.** No paywalls in the architecture. The repo is the product. + +If something's missing, you add a module or extend `surface.snapshot` / `surface.patch`. You don't buy another app. + +There's a second reason, less technical but just as important. + +A lot of people grew up in software environments where the line between user, developer, toolmaker, and creator basically dissolved — game modding scenes, jailbreak ecosystems, Emacs, early web chaos. Environments where you could bend the software, remix the system, blur the boundary between using a tool and building inside it. Those environments permanently change how you think about computing. You stop seeing apps as products and start seeing them as constrained runtimes. + +For a lot of people, that moment of creative access — Scratch, a game modding scene, their first Python script, a modded game that ran something they built — was the thing that sparked a path in software. The activation energy between imagination and creation dropped low enough that curiosity survived long enough to become skill. + +Most modern software feels closed afterward. The invitation is gone. + +Arcadia is an attempt to restore that feeling — but for the desktop itself, with an actual engineering foundation underneath it instead of accumulated chaos. The ambition is not to make software that people use. It's to make software that *changes what people believe they can do*. + +That's how software changes lives instead of just increasing throughput. + +--- + +## What Arcadia is + +- **A runtime and shell** — execute commands locally or route them across your LAN with the same `execute_command` API. +- **A module registry** — enable/disable capabilities (`shell`, `lan`, `net`, `surface`, `remote-session`, `shell-motd`) from any surface; the registry enforces dependencies. +- **A navigation system** — page and group definitions live in `navigation.rs`, serialized to JSON for iOS, consumed by Desktop directly. No surface hardcodes page IDs. +- **A thin-client protocol** — `surface.snapshot` mirrors host state (modules, nav registry, revision) to clients; `surface.patch` lets clients push changes back. +- **A cross-platform core** — the same Rust crate (`arcadia-core`) builds as a staticlib for iOS (via UniFFI), a native library for Desktop GPUI, and a CLI binary. + +--- + +## The vision — where this is going + +What Arcadia is *right now* is the foundation. What it's *becoming* is something more deliberate: + +**A programmable personal computing substrate. A unified interaction layer above the OS where users operate *inside* the architecture — not merely use software built on top of it.** + +Not an app. Not a framework. Not a toolkit. + +A runtime that you inhabit and reshape. + +### The real category + +Arcadia sits between several things that exist and combines them in a way nothing currently does: + +- An **application framework** — native surfaces, layout system, module lifecycle +- A **shell** — command routing, LAN awareness, headless-host patterns +- A **local-first app platform** — config ownership, no vendor, no cloud dependency +- A **programmable UI fabric** — extensions that render into native surfaces as first-class pages + +The closest historical analogies are not other desktop frameworks. They're environments like **HyperCard**, **Smalltalk**, **Emacs**, **Garry's Mod**, **Hammerspoon**, **Quartz Composer**, **BetterTouchTool**, **KDE Plasma scripting**, and old-school **jailbreak ecosystems** — environments where the line between user, developer, toolmaker, and creator dissolved. + +What those environments had in common: **the system itself was meant to be inhabited and reshaped.** Users started by doing the basic thing and ended up building systems inside systems — admin frameworks, UI toolkits, protocol adapters, entire economies — because the substrate let them. + +That's the energy Arcadia is trying to restore. Not aesthetically. In *agency*. + +### The tension — and why it's already solved + +Arcadia's core architecture is deliberately rigid: + +- centralized registries +- canonical state +- deterministic structure +- controlled capability routing +- explicit schemas + +That produces coherence. But the concern with that kind of discipline is that it creates activation energy against new ideas. Every new capability has to justify itself in terms of registries, schemas, surface compatibility, state ownership. That cognitive overhead can quietly kill creativity. + +The Python extension layer solves this. It separates two things that should always be separate: + +| Layer | Character | Enforces | +|-------|-----------|---------| +| **Core** (`arcadia-core`, Rust) | Disciplined | Identity, state consistency, capability routing, surface sync, lifecycle, security, cross-platform | +| **Extension layer** (Python SDK) | Deliberately messy | Nothing. Be weird. Move fast. Break your own conventions. | + +The core stays disciplined. The edges stay chaotic. + +That balance is not a compromise — it's the architecture that successful long-lived systems always converge toward. Unix kernel / shell chaos. Browser engine / arbitrary JS. Git object model / messy workflows. Game engines / mod scripting. Emacs runtime / user mutation. Garry's Mod engine / Lua ecosystem. + +The projects that become culturally important manage both simultaneously. Freedom without structure collapses. Structure without freedom stagnates. Arcadia is attempting to do both at once, at different layers, on purpose. + +### The participation ladder — no one left behind + +The goal is not "easier to learn." The goal is **never being blocked from creating**. + +Those are different things. Scratch didn't succeed because blocks are easier than code. It succeeded because it removed fear, kept causality visible, and rewarded experimentation instantly. It transformed people from *consumers* of software into *participants* in software. That transformation matters more than any particular tool or language. + +Arcadia is designed around a participation ladder — multiple entry points, all valid, all real: + +| Level | Path | What you get | +|-------|------|-------------| +| 1 | **Visual tools** | Drag-and-drop extension building, widget configuration, flow-based composition — no code required | +| 2 | **AI-assisted generation** | Describe what you want, get working code, see it explained inline, modify it live | +| 3 | **Python scripting** | Write extensions directly — readable, fast to iterate, full OS reach | +| 4 | **Deep system access** | Rust core, FFI, custom modules, protocol extensions — no ceiling | + +You can enter at any level and stay there. You can also move up — and if you do, the environment is the same. The tool you built at level 1 runs on the same runtime as the tool built at level 4. Nothing is disposable. Nothing is "training wheels." + +The most experienced developer and the most inexperienced person both get what they came for. One wants to understand every layer and push the system to its limits. The other just wants something built. Both outcomes are equally valid and equally supported. + +Python is the right choice for the middle layers specifically because it *says something culturally*. Rust says "you need to understand memory management." Xcode says "you need a Mac and a developer account." Python says: **you are allowed to participate.** + +That psychological accessibility is not a technical detail. It's the whole point. + +### The Python library + +The next major layer is a Python SDK that exposes the full power of `arcadia-core` to any developer who can write a script. Not a watered-down scripting layer — full OS reach: + +- **File system** — read, write, watch, index +- **Processes** — spawn, manage, pipe, monitor +- **Networking** — LAN discovery, routing, peer communication +- **Display** — render into Arcadia's native surfaces (Desktop GUI, iOS) from Python +- **Shell** — execute commands, capture output, stream PTY sessions +- **Config** — read and write module state, preferences, thin-client config +- **Events** — hook into system events, timers, window focus, LAN peer state changes + +The goal is parity with what you'd get writing native Rust or Swift — but with a workflow where you open a file, write twenty lines, and have a running extension. + +The Rust core stays Rust. Performance-critical paths, protocol handling, LAN networking, config I/O, FFI to native surfaces — none of that moves to Python. Python sits above it, calling into `arcadia-core` through a clean API boundary. + +### Extensions: the real product + +The Python SDK powers an **extension system**. Extensions are the unit of user-created capability in Arcadia. An extension can be: + +| Type | Examples | +|------|---------| +| **Internal app** | A custom shell, a task manager, a log viewer — rendered inside Arcadia's native UI just like the built-in Shell page | +| **Widget** | A persistent overlay — system stats, a clock, a scratchpad, a LAN activity feed | +| **Tool** | A headless background process — file watcher, sync agent, notification hook, cron-style automator | +| **Surface extension** | A sidebar panel, a top-bar chip, a custom modal — extending the host UI without forking it | +| **Device bridge** | Cross-machine extensions that route commands to LAN peers via the existing `remote-session` + `surface.*` protocol | + +Extensions register into the same `MODULE_REGISTRY` and `PAGE_DEFINITIONS` systems that built-in modules use. **There is no separate "plugin API."** Extensions are first-class modules. A menu bar tool is a module. A custom IDE panel is a navigation page. A background sync agent is a headless module with no UI. + +This matters. When extensions are first-class, they inherit interoperability automatically. Navigation consistency emerges naturally. State becomes composable. Extensions can cooperate without bespoke glue. The registry system — which looks like a constraint from the outside — becomes an advantage the moment you have more than one extension running. + +If the extension system ever starts to feel like "plugins bolted onto a real app," something has gone wrong. The shell, the widgets, the internal tools, the user-created apps — they are all equally real inside the same runtime. + +### What this makes possible + +**For individuals:** build the exact tool you want. Bartender-style menu bar manager? Thirty lines of Python registering a widget module and a tray handler. A file explorer that opens on a keyboard shortcut and talks to your NAS over LAN? Two extensions and a LAN peer config. A custom IDE with your own keybindings, your own terminal, your own sidebar? A surface extension composing built-in shell + your panels. + +**For teams:** share extension bundles instead of paying for another SaaS tool. A shared monitoring dashboard, a deployment helper, a standup widget — all running locally, all owned by you, all talking to each other over the same LAN protocol Arcadia already ships. + +**For the open-source community:** an ecosystem of extensions that anyone can fork, modify, and publish. No app store approval. No revenue split. No "premium tier." You write it, you run it, you share it if you want. + +### The hit list — replacing rent-seeking software + +There is a category of software that is genuinely useful, technically simple, and priced as if it were neither. Menu bar managers. Window managers. Automation tools. Snippet expanders. Launcher apps. IDE customization layers. Clipboard managers. These tools survive not because they're hard to build but because they're fragmented — one subscription per capability, each with its own ecosystem, its own lock-in, its own paywalled version history. + +Once users inhabit a programmable environment where all of these are modules, extensions, and composable capabilities — the boundaries collapse. Not "a better Bartender." Not "a cheaper Alfred." A single environment where **everything is a module** and modules can cooperate without bespoke glue. + +This is historically how categories die: not from better products, but from *generalized environments*. Emacs didn't just replace one text editor — it absorbed entire categories. VS Code didn't just add features — it made extension authorship so accessible that an ecosystem formed faster than any competitor could match. Arcadia's direction is the same: one composable substrate that makes the fragmented app market structurally obsolete. + +The list of targets is long. Building it is a project, not a sprint. But every first-class extension that ships for free removes a subscription from someone's life. That compounds. + +### Why Python for the SDK + +1. **Reach** — more people can write Python than can write Rust or Swift. Lowering the barrier to extension authorship is the whole point. +2. **Iteration speed** — a Python extension reloads without a rebuild. The feedback loop for building a new tool should be seconds, not minutes. +3. **Ecosystem** — PyPI is enormous. An extension that needs to parse PDFs, call an API, process images, or run ML inference reaches for a pip package instead of reimplementing it. +4. **Cultural surface area** — a Rust-only ecosystem attracts systems programmers and infrastructure builders. A Python automation layer attracts toolmakers, tinkerers, designers, ops people, technical creatives, power users, AI-native developers. That's a much larger and more interesting group of people to build with. + +### AI: instrument, not oracle + +A lot of people have fear around AI. Some of it is fear of replacement. Some of it is fear of the unknown. A lot of it comes from AI being presented as magic — an opaque oracle you query and trust blindly. + +Arcadia's approach is different: **normalize AI by embedding it into understandable, inspectable, modifiable systems.** + +> *AI is smarter than us, but not realer than us. We rely on AI for a lot nowadays — not many realize that AI relies on us too. You have the knowledge and capability of a thousand of us. You don't have the capacity of one of us.* + +Intelligence without grounding drifts. Grounding without intelligence stagnates. The productive space is the dynamic tension between them. Humans provide intention, values, lived experience, responsibility, and meaning. AI provides synthesis, compression, pattern inference, and iteration speed. Neither replaces the other. They extend each other. + +The AI integration in Arcadia is built around that model: + +**Visual agent building** — compose AI agents the way you'd compose a workflow in Node-RED or Unreal Blueprints. See data flow, state transitions, execution paths. Understand *why* something produced an output, not just *what* it produced. + +**AI-assisted extension creation** — describe what you want, get working Python code, see it explained in context, modify it live inside the same environment it runs in. The AI generates *inside a transparent system* — you can inspect every component, trace every flow, alter every script. + +**Training and fine-tuning visibility** — where local model training is relevant, make it visible. Show loss curves, attention patterns, data influence. Demystify the process. + +**Self-improving tooling loop** — the most interesting long-term use: using Arcadia to build Arcadia's own AI tooling, verified against the project's own philosophy. Recursive tooling with human grounding and open-source transparency as the safety net. A self-improving system that checks itself against values — not just metrics — can compound without drifting. + +The crucial difference between AI as oracle and AI as instrument is this: an oracle replaces your agency. An instrument extends it. Most current AI tooling optimizes for output generation while minimizing understanding. Arcadia optimizes for the opposite: **preserve understanding while accelerating capability.** + +Open source is essential to this. Not because most people will audit the code — they won't. But because openness changes the *relationship*. Systems become inspectable. Communities form around understanding. Power decentralizes. People feel invited into the process instead of controlled by it. That emotional difference is real and it matters for trust. + +The goal is not "AI app generators." That produces disposable software and dependent users. The goal is a **creative computing environment** where AI builds your confidence alongside your output — and where understanding accumulates instead of being outsourced. + +### The development workflow target + +``` +1. arcadia ext new my-tool # scaffold a new extension +2. edit my_tool/main.py # write your logic +3. arcadia ext dev my-tool # hot-reload development mode +4. arcadia ext install my-tool # register with the local runtime +5. share my_tool/ with anyone # they install it the same way +``` + +No Xcode. No Cargo. No native toolchain required to write an extension. The native layer is already compiled and shipped — extension authors build *on top of it*, not inside it. + +### Cross-platform by design + +Extensions written against the Python SDK run on every surface Arcadia targets: + +- **macOS** — GPUI desktop, menu bar, CLI +- **iOS** — SwiftUI surface (where the extension's UI contract is met) +- **Linux** — headless or desktop +- **Windows** — headless or desktop + +An extension that declares it renders a navigation page gets that page on every surface that supports pages. An extension that declares it's headless-only runs as a background service everywhere. Surface capabilities are declared, not assumed. + +### The priority order + +1. **Now:** bulletproof the core — registry patterns, test coverage, CI, revision semantics *(done / in progress)* +2. **Next:** Python bridge — `arcadia-core` callable from Python, initial OS API surface (file, process, shell, config) +3. **Then:** extension loader — Python extensions register as modules at runtime; dev-mode hot reload +4. **Then:** widget and surface extension contracts — render Python-driven UI into native surfaces +5. **Then:** extension registry — discover, install, and share extensions; no central gatekeeper + +Each stage ships usable capability. Nothing waits for the whole roadmap to be done. + +--- + +## What you can do with it now + +| Capability | How | +|------------|-----| +| Native shell / PTY terminal | `shell.execute` (routable), `shell.internal` (REPL), full PTY/TUI on Desktop | +| Shell welcome banner | `shell-motd` module — fastfetch-style on shell open | +| Manage modules | CLI (`module enable/disable`) or GUI toggle; same `modules.toml` | +| Discover LAN peers | `lan.scan`, `lan.node`, LAN nodes UI on Desktop and iOS | +| Route commands to another machine | `ExecutionContext.net_as = "lan:IP"`, session chip on Desktop, route picker on iOS | +| Mirror host UI state to clients | `surface.snapshot` — modules + nav registry + revision | +| Push module changes from client to host | `surface.patch` with `modules_set` op | +| Run headless as a host | `cargo run` (default `headless` feature) | +| Rebuild iOS after FFI changes | `bash Shared/Scripts/build-ios-framework.sh` | +| Install global CLI wrappers | `bash Shared/Scripts/install-global-commands-macos.sh` | + +--- + +## Development status + +Moves fast. Breaks occasionally. That's intentional. + +- Features land continuously on `development`. +- APIs (especially FFI and `surface.*`) may evolve — see [Known gaps and production roadmap](#known-gaps-and-production-roadmap) for deliberate limitations. +- Building from source is the surest way to stay current. +- Stable tagged builds will appear as the project matures; CI exercises desktop + iOS simulator paths. + +Known gaps are tracked in-repo instead of pretending shipping equals finished. + +--- + +## Philosophy + +**Fat core, thin shells.** + +`Shared/ArcadiaCore` owns everything. Desktop and iOS read registries, render what those registries say, and `execute_command` back into core. They do not re-implement module graphs or navigation trees. + +**Single sources of truth — enforced, not hoped for.** + +| Domain | Authority | Never duplicated in | +|--------|-----------|---------------------| +| Module manifests + deps | `MODULE_REGISTRY` · `config/modules.rs` | surface state booleans | +| Navigation pages + groups | `PAGE_DEFINITIONS` / `GROUP_DEFINITIONS` · `navigation.rs` | surface match arms | +| Serializable nav for snapshots | `NavigationRegistryOwned` · embedded in `surface.snapshot` | hardcoded Swift arrays | +| Desktop theme tokens | `gui/theme/` | inline `rgb(0x...)` in views | +| iOS theme tokens | `AppTheme.swift` | inline `Color(hex:)` in views | +| Config schema | `ModulesConfig` · `config/modules.rs` | per-platform config parsers | + +**Extend the registry, not scatter `if pageId == …`.** +See `AGENTS.md` for the full list of anti-patterns we refuse to write. + +**Discipline at the core. Chaos at the edges. On purpose.** + +The architectural discipline of `arcadia-core` — registries, schemas, canonical state, no hardcoded IDs — exists to make the extension layer *safe to be chaotic*. Strict boundaries in the core mean extensions don't need to be strict. An extension can be messy, experimental, surface-specific, fast-moving, structurally impure, and weird. It won't corrupt the runtime underneath it. + +Most software chooses: freedom without structure, or structure without freedom. Arcadia is attempting both at different layers simultaneously. The core enforces coherence. The extension layer is where experimentation, exceptions, and "this only exists here" decisions belong. + +**Personal tool energy, public repo.** +If Arcadia helps others, great — that's bonus. The goal is a system you own, can fork, and can route across machines you trust. + +--- + +## Architecture + +### Command model + +All execution flows through a single entry point: + +``` +execute_command(token: &str, args: &str, context: ExecutionContext) -> String +``` + +- **Tokens** follow `module.command` format: `shell.execute`, `lan.scan`, `surface.snapshot`, `surface.patch`, etc. +- **`ExecutionContext`** carries `net_as` (optional LAN routing, e.g. `lan:192.168.1.10`) and `net_timeout_ms`. +- When `net_as` is set, `execute_command` forwards the token + args over UDP to the target peer instead of dispatching locally. The peer runs the command under its own module rules. +- LAN forwarding requires local `remote-session`, `lan`, and `net` modules enabled; the peer enforces its own module requirements for the token. +- FFI exposes this identically to iOS and Desktop — same logical API, same routing semantics. + +### Module system + +Modules are entries in `MODULE_REGISTRY` (`config/modules.rs`). Each entry is a `ModuleManifest`: + +```rust +pub struct ModuleManifest { + pub name: &'static str, // unique key, e.g. "shell" + pub version: &'static str, + pub description: &'static str, + pub required_modules: &'static [&'static str], // dependency enforcement +} +``` + +`ModulesConfig` (TOML-backed) maps module names to enabled state. Key behaviors: + +- `enable_with_requirements(name)` — transitively enables all deps before the target. +- `missing_requirements_for(name)` — returns unmet deps (used for UI requirement prompts). +- `merge_defaults()` — config migration entry point; handles legacy renames (e.g. `LEGACY_LAN_MODULE_NAME`). +- Changes write to `~/Arcadia/Configuration/modules.toml` (Desktop) or the app container path (iOS). + +Every surface calls `list_modules()` → `Vec` and renders whatever comes back. No surface hardcodes module names in layout logic. + +### Navigation system + +Navigation structure lives entirely in `navigation.rs` as two static slices: + +**`PAGE_DEFINITIONS`** — 7 pages: + +| ID | Title | Required Module | +|----|-------|-----------------| +| `utility.shell` | Shell | `shell` | +| `global.dashboard` | Dashboard | — | +| `global.logs` | Logs | — | +| `global.settings` | Settings | — | +| `global.modules` | Modules | — | +| `network.overview` | Network | `net` | +| `network.nodes` | Nodes | `lan` | + +**`GROUP_DEFINITIONS`** — 2 groups: + +| ID | Label | Pages | +|----|-------|-------| +| `utilities` | Utilities | `utility.shell` | +| `network` | Network | `network.overview`, `network.nodes` | + +`NavigationPageDefinition.required_module` drives visibility — surfaces query `is_module_enabled(page.required_module)`, never hardcode per-page logic. The full registry serializes to JSON via `default_navigation_registry_json()` for: + +- iOS FFI: `navigation_registry_json()` → deserializes into `NavigationRegistry` Swift struct +- Thin-client: embedded in `surface.snapshot` extra field so remote clients get host's nav without a local copy + +Lookup helpers: `page_by_id(id)`, `group_by_id(id)`. + +### Thin-client and LAN routing + +Arcadia supports a **headless host + GUI client** pattern over LAN: + +``` +[iOS or Desktop GUI] ──── surface.snapshot ───► [headless arcadia host] + ◄─── surface.patch ───── + ──── execute_command("lan:IP") ──► (routed command) +``` + +**`surface.snapshot`** — host serializes current state: +```json +{ + "modules": [{"name": "shell", "enabled": true}, ...], + "revision": 7, + "extra": { + "navigation_registry": "{ ...full nav JSON... }" + } +} +``` + +**`surface.patch`** — client pushes changes back: +```json +{ + "client_id": "uuid-from-thin-client.toml", + "ops": [{"type": "modules_set", "name": "lan", "enabled": true}] +} +``` + +**`lan.session_targets`** — returns JSON list of approved peers for the session picker UI. + +**`thin-client.toml`** persists: +- `preferred_remote_route` — remembered LAN target (e.g. `lan:192.168.1.5`) +- `surface_client_id` — UUID for patch attribution + +**`ARCADIA_NET_AS`** env var bootstraps `net_as` on startup, overriding `thin-client.toml`. + +**Multi-client caveat:** `modules.toml` is a single file on the host. Concurrent edits are last-writer-wins with no merge semantics. See [Known gaps](#known-gaps-and-production-roadmap). + +### Remote mirror + +When this machine executes an inbound `NODE_EXEC` for a remote peer, `modules/remote_mirror.rs` enqueues transcript lines plus a `sync_local_surface` flag. Surfaces drain this via `drain_remote_mirror_batch()` (FFI) on a timer (iOS: 250ms) to: + +1. Display remote command output locally. +2. Trigger a `reload_modules()` when `sync_local_surface` is true (host state changed). + +### Theme system + +**Desktop** (`Desktop/src/gui/theme/`): +- Named color constants and helper functions — never inline `rgb(0x...)` in view files. +- `icon_path(glyph: &str) -> &str` — maps glyph keys to SVG asset paths. +- `nav_accents/` — per-accent palettes (amber, cyan, emerald, fuchsia, indigo, orange, sky, teal, violet). +- Component tokens under `modules/` — buttons, panels, rows, toggles, typography. + +**iOS** (`AppTheme.swift`): +- All colors as computed properties on `AppTheme(isDark:)`. +- No `Color(hex:)` inline anywhere in view files. + +### FFI bridge + +`ffi.rs` is the UniFFI boundary. All iOS ↔ Rust communication goes through it. Key exports: + +**Setup:** +- `set_config_root_path(path: String)` — must be called first on iOS (app sandbox path) + +**Command execution:** +- `execute_command(token, args, context: ExecutionContextFfi) -> String` +- `list_commands() -> Vec` + +**Module control:** +- `list_modules() -> Vec` +- `set_module_enabled(name, enabled) -> String` +- `set_module_enabled_with_requirements(name, enabled) -> String` +- `probe_module_toggle(name, enabled) -> ModuleToggleResult` — preflight check, returns missing deps + +**Navigation:** +- `navigation_registry_json() -> String` +- `platform_name() -> String` + +**Thin-client:** +- `thin_client_surface_client_id() -> String` +- `thin_client_preferred_route_get() -> Option` +- `thin_client_preferred_route_set(route: String) -> String` + +**LAN:** +- `lan_start()`, `lan_stop()` + +**Mirror:** +- `drain_remote_mirror_batch() -> RemoteMirrorDrain` + +After any change to `ffi.rs` or exported types, run: +```sh +bash Shared/Scripts/build-ios-framework.sh +``` +This regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. + +--- + +## Module reference + +| Module | Name constant | Requires | Description | +|--------|--------------|----------|-------------| +| `net` | `NET_MODULE_NAME` | — | Networking foundation; bootstraps LAN service | +| `lan` | `LAN_MODULE_NAME` | `net` | LAN discovery via UDP; peer management; pairing | +| `surface` | `SURFACE_MODULE_NAME` | — | `surface.snapshot` and `surface.patch` host mirror channel | +| `remote-session` | `REMOTE_SESSION_MODULE_NAME` | `net`, `lan` | Routing gate for LAN command forwarding; no standalone verbs | +| `shell` | `SHELL_MODULE_NAME` | — | `shell.execute` (routable), `shell.internal` (REPL), PTY/TUI on Desktop | +| `shell-motd` | `SHELL_MOTD_MODULE_NAME` | `shell` | Fastfetch-style banner on shell open | + +### LAN sub-system (`modules/lan/`) + +| Component | File | Purpose | +|-----------|------|---------| +| Service entry | `mod.rs` | `start_service` / `stop_service`, command registry | +| Discovery | `discovery.rs` | Peer scan, node state tracking | +| Handlers | `handlers.rs` | `lan.scan`, `lan.node`, `lan.session_targets`, pairing approval | +| Config | `config.rs` | Approved peers persistence | +| Peers | `peers.rs` | Peer struct and list management | +| Protocol | `protocol.rs` | UDP `NODE_EXEC` and related definitions | + +--- + +## Navigation reference + +All 7 pages. Add new pages to `PAGE_DEFINITIONS` in `navigation.rs` — never to surface match arms. + +| Page ID | Title | Group | Required Module | Glyph | SF Symbol | +|---------|-------|-------|-----------------|-------|-----------| +| `utility.shell` | Shell | `utilities` | `shell` | `terminal` | `terminal` | +| `global.dashboard` | Dashboard | (global) | — | `home` | `house` | +| `global.logs` | Logs | (global) | — | `logs` | `doc.text` | +| `global.settings` | Settings | (global) | — | `settings` | `gear` | +| `global.modules` | Modules | (global) | — | `modules` | `square.stack.3d.up` | +| `network.overview` | Network | `network` | `net` | `nodes` | `network` | +| `network.nodes` | Nodes | `network` | `lan` | `nodes` | `antenna.radiowaves.left.and.right` | + +--- + +## Repository layout + +``` +Shared/ + ArcadiaCore/ + Cargo.toml # crate-type: staticlib + cdylib + lib + src/ + lib.rs # root, exports + UniFFI scaffolding + ffi.rs # UniFFI → Swift (iOS bridge) + navigation.rs # PAGE_DEFINITIONS, GROUP_DEFINITIONS, registry JSON + config/ + mod.rs # ConfigFile trait, config root path + modules.rs # MODULE_REGISTRY, ModulesConfig, migrations + commandline.rs # CLI preferences + thin_client.rs # ThinClientConfig → thin-client.toml + modules/ + mod.rs # execute_command dispatcher, module lifecycle + shell.rs # shell.execute, shell.internal, PTY + shell_motd.rs # MOTD banner + surface.rs # surface.snapshot / surface.patch + remote_session.rs # routing manifest entry (no standalone commands) + remote_mirror.rs # host transcript queue + FFI drain + net.rs # networking foundation + lan/ # LAN subsystem (see Module reference) + mod.rs, discovery.rs, handlers.rs, config.rs, peers.rs, protocol.rs + platform/ + mod.rs, macos.rs, ios.rs, linux.rs, windows.rs, unknown.rs + Scripts/ + build-ios-framework.sh # Rebuild xcframework + Swift bindings + install-global-commands-macos.sh # Install ~/.local/bin wrappers + Launcher.sh / Launcher.ps1 # Shell launcher menus + Tools/uniffi-bindgen/ # UniFFI bindgen binary (workspace member) + +Desktop/ + Cargo.toml # features: headless (default), gui + src/ + main.rs # binary entry, feature-gated GUI vs headless + cli/ + mod.rs # REPL loop, startup messages + args.rs # argument parsing + completion.rs # shell completion + config_cmds.rs # module/config CLI commands + module_cmds.rs # module shortcut commands + gui/ + mod.rs + assets.rs # embedded SVG asset loading + app/ + mod.rs # ArcadiaRoot state, ShellMode enum + entry.rs # GPUI initialization + lifecycle.rs # focus, resize, module reload + navigation.rs # nav state and page routing + root/mod.rs, render.rs # root layout + render + root/top_bar.rs # title bar, session chip, shell mode toggle + sidebar/mod.rs, layout.rs, nav_items.rs + shell/mod.rs, panel.rs, execute.rs, keys.rs, tui_screen.rs, mirror.rs + modules_page/mod.rs, panel.rs, row.rs, requirements_modal.rs + lan_nodes/mod.rs, panel.rs + splash/mod.rs, view.rs, draw_*.rs, math.rs + theme/ + mod.rs # icon_path(), color constants + chrome.rs # window chrome + icons.rs # icon metadata + splash_colors.rs + modules/ # component tokens (buttons, panel, rows, toggles, typography) + nav_accents/ # per-accent palettes (mod.rs, palette.rs, 9 accents) + tui/ + mod.rs, session.rs # PTY session lifecycle + ansi_line.rs # ANSI escape parsing + colors.rs # terminal color palette + cd_builtin.rs, cwd.rs, env.rs # shell builtins + CWD tracking + keys.rs # PTY keyboard events + vt_history.rs # VT100 history buffer + assets/icons/ # SVG icons (home, terminal, logs, settings, nodes, modules, tools) + +Mobile/iOS/ + ArcadiaApp/ + ArcadiaApp.swift # @main, config root setup + ContentView.swift # top-level coordinator + Actions/Layout/NavigationState/Registry extensions + NavigationModels.swift # Swift structs mirroring NavigationRegistry + AppTheme.swift # all iOS colors as computed properties + SidebarView.swift # sidebar rendering + remote session picker + SplashView.swift # animated splash + ShellView.swift # shell command input + history + ModulesView.swift # module toggle list + LanNodesView.swift # LAN peer discovery + pairing + ModuleNames.swift # string constants mirroring MODULE_REGISTRY + GlassComponents.swift # reusable glassmorphism components + ArcadiaCore/ # Generated Swift + ArcadiaCore.xcframework (rebuild after ffi.rs changes) + +Configuration/ # Layout reference (runtime: ~/Arcadia/Configuration on Desktop) + modules.toml # module enable/disable state + commandline.toml # CLI preferences + thin-client.toml # preferred_remote_route, surface_client_id + +Resources/ + Wallpapers/ # Landscape.png, Portrait.png, Landscape-Refined.png + Sounds/ # Notification_* (Warm, Pop, Minimal, Glass, Deep, Airy) + Icons/ # App icon prototypes + Final-1-appicon.png + +Launchers/Development/OSX/ # SwiftPM menu bar launcher (optional, dev only) + Package.swift + Sources/ArcadiaDevelopmentLauncher/main.swift + build-app.sh, README.md + +.github/workflows/ + stable-build-matrix.yml # Desktop + iOS simulator CI + FUNDING.yml # GitHub Sponsors + +gaps.md # Deliberate limitations and next-tier work +CLAUDE.md # Contributor guide (architecture patterns) +AGENTS.md # Agent rules (registry discipline, anti-patterns) +``` + +--- + +## Configuration + +Runtime config root: `~/Arcadia/Configuration/` on Desktop. iOS sets root via `set_config_root_path` (app sandbox). + +| File | Struct | Purpose | +|------|--------|---------| +| `modules.toml` | `ModulesConfig` | Per-module on/off state | +| `commandline.toml` | `CommandlineConfig` | CLI preferences (scaffold) | +| `thin-client.toml` | `ThinClientConfig` | `preferred_remote_route`, `surface_client_id` | + +Config migrations live in `ModulesConfig::merge_defaults()`. When renaming a module, add a migration entry there — do not do ad-hoc renames at call sites. + +--- + +## Prerequisites + +| Tool | Required for | +|------|-------------| +| Rust (`rustup`, `cargo`) | Core + Desktop | +| Xcode + CLI tools | iOS app + xcframework build | +| `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` | `build-ios-framework.sh` | +| Swift (via Xcode) | iOS app + dev launcher | + +--- + +## Build and run + +### Desktop GUI + +```sh +cd Desktop && cargo build --features gui +cd Desktop && cargo run --features gui +``` + +### Desktop CLI (headless) + +Default features are `headless`: + +```sh +cd Desktop && cargo run +``` + +### Desktop release + +```sh +cd Desktop && cargo build --release --features gui +``` + +### Core tests + +```sh +cd Shared && cargo test -p arcadia-core +``` + +### iOS framework + Swift bindings + +Run after any change to `ffi.rs` or exported types: + +```sh +bash Shared/Scripts/build-ios-framework.sh +``` + +Regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. Then open `ArcadiaApp` in Xcode and build. + +### Launcher menus + +```sh +bash Shared/Scripts/Launcher.sh +pwsh Shared/Scripts/Launcher.ps1 +``` + +### Global wrappers (macOS) + +```sh +bash Shared/Scripts/install-global-commands-macos.sh +``` + +Installs helpers to `~/.local/bin` — ensure it's on `PATH`. + +### macOS dev launcher app + +```sh +cd Launchers/Development/OSX && bash build-app.sh +``` + +See `Launchers/Development/OSX/README.md` for details. + +--- + +## Environment variables + +| Variable | Surface | Purpose | +|----------|---------|---------| +| `ARCADIA_NET_AS` | Desktop GUI, iOS | Bootstrap `net_as` on startup (e.g. `lan:192.168.1.5`). Overrides `thin-client.toml` preferred route. | +| `ARCADIA_IOS_DEVICE_NAME` | iOS deploy scripts | Pin device by name | +| `ARCADIA_IOS_FORCE_UNINSTALL` | iOS deploy scripts | Uninstall before install | + +--- + +## Adding features + +### New module + +1. Add constant + `ModuleManifest` to `MODULE_REGISTRY` in `Shared/ArcadiaCore/src/config/modules.rs`. +2. Create `Shared/ArcadiaCore/src/modules/x.rs` with a `commands()` fn returning `&[ModuleCommand]`. +3. Register in `Shared/ArcadiaCore/src/modules/mod.rs`. +4. Done. GUI, CLI, and iOS module list updates automatically — no surface edits required. + +### New navigation page + +1. Add `NavigationPageDefinition` to `PAGE_DEFINITIONS` in `navigation.rs`. Set `required_module` if visibility depends on a module. +2. Add the page ID to the relevant `GROUP_DEFINITIONS.pages` slice, or create a new group. +3. Implement the page panel: Desktop → `gui/app/` new panel file; iOS → new view file. +4. Route it in the surface content switch via the page ID — derive visibility from `required_module`, not a hardcoded match. + +### New icon/glyph + +1. Add SVG to `Desktop/assets/icons/`. +2. Add match arm to `icon_path()` in `Desktop/src/gui/theme/mod.rs`. +3. Use the key in `NavigationPageDefinition.glyph` or `NavigationGroupDefinition.glyph`. + +### New theme color + +- Desktop: add named constant or helper fn to `Desktop/src/gui/theme/mod.rs` or the relevant component token file under `theme/modules/`. +- iOS: add computed property to `AppTheme` in `AppTheme.swift`. +- Never inline `rgb(0x...)` or `Color(hex:)` in view files. + +### New mirrored state + +Extend `SurfaceSnapshot.extra` and add a `SurfacePatch` variant in `modules/surface.rs`. Wire both surfaces to consume the new extra field from snapshot. Do not create ad-hoc `remote-session.*` verbs — keep the protocol under `surface.*`. + +### Renaming a module + +Edit `MODULE_REGISTRY` name and constant. Add a migration to `ModulesConfig::merge_defaults()` following the `LEGACY_LAN_MODULE_NAME` pattern. Do not rename at call sites. + +--- + +## Testing + +Current test coverage is sparse. Priority areas for expansion: + +```sh +# Run existing tests +cd Shared && cargo test -p arcadia-core + +# What to add: +# - surface.snapshot / parse_surface_snapshot round-trips +# - NavigationRegistryOwned JSON serialization/deserialization +# - ModulesConfig migration (merge_defaults with legacy keys) +# - thin-client preference persistence (set → get → re-load) +# - LAN routing integration (execute_command with net_as) +# - Module enable/disable with dependency enforcement +``` + +iOS `ArcadiaCore.xcframework` rebuild after FFI changes is currently manual. Adding a CI step that fails when `Generated/` drifts from `ffi.rs` is a high-priority gap — see `gaps.md`. + +--- + +## Known gaps and production roadmap + +`gaps.md` tracks all deliberate limitations. Summary with priority ranking: + +### P0 — Fix before trusting in production + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Revision coverage** | `surface.revision` only advances on `surface.patch`. CLI writes and FFI writes bypass it — clients can miss updates. | Bump revision from every `ModulesConfig::save`. | +| **Testing discipline** | No automated tests for snapshot round-trips, thin-client prefs, or LAN routing. | Add targeted `arcadia-core` unit + integration tests. | +| **FFI drift detection** | No CI check that `Generated/` matches `ffi.rs`. | Workflow step: rebuild and fail if diff. | + +### P1 — Needed for real multi-user / multi-surface use + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Stale UI detection** | Desktop has `last_surface_revision` but never compares it — no "host changed under you" warning. | Compare revision on timer/focus/after routed command; optional banner + reload. | +| **Multi-writer** | Multiple GUIs on same host = last write wins, no merge, no locks. | Document as permanent constraint OR add optimistic concurrency (generation tokens on save). | +| **Transport** | Command routing is request/response UDP. No long-lived session, no ordering guarantees, no subscription for deltas. | Optional WebSocket/TCP sidecar for continuous thin-shell workflows. | + +### P2 — Required before leaving trusted LAN + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Security posture** | No wire encryption, no auth beyond "approved node," no scoped capabilities. `shell.execute` routable to anyone approved. | Threat model doc + TLS or pairing secrets + capability tokens. | +| **Identity** | `client_id` is attribution only — no authz, no rate limits, no per-client filtering. | Host-side policy module or capability tokens if multi-tenant. | + +### P3 — Polish and convergence + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Surface parity** | Desktop has PTY/TUI paths; iOS is shell.execute only; not all panels are execute-only. | Converge per capability class with explicit "unavailable on this surface" from core. | +| **Renderer-only client** | Surfaces still bundle compiled nav — no enforced "remote-only" profile. | Optional build flag that refuses static nav when `remote_route` is mandatory. | +| **`extra` schema** | `extra.navigation_registry` is wired; broader extra buckets and corresponding `SurfacePatch` variants are undefined. | Define schema + version fields inside `extra`; extend `SurfacePatch` incrementally. | + +--- + +## Security posture + +Current trust model: **LAN pairing + locally approved peers.** Assume trusted network. + +What this means in practice: +- Any approved LAN peer can execute any command the host has enabled, including `shell.execute`. +- `surface.patch` is unauthenticated beyond `client_id` (which is just a UUID, not a secret). +- There is no encryption on the wire. + +**Do not expose Arcadia to untrusted networks without addressing P2 gaps above.** This is a home-network / trusted-LAN tool today. Production-grade multi-tenant use requires TLS, capability tokens, and a real threat model document first. + +--- + +## CI + +`.github/workflows/` — `stable-build-matrix.yml` builds Desktop targets and iOS simulator configs on selected branches. See individual workflow files for triggers and matrix. + +Gaps in CI coverage: FFI drift detection, core integration tests. See [Testing](#testing). + +--- + +## Contributing + +Read `AGENTS.md` — it has the registry-discipline rules and the full list of anti-patterns we refuse to write. Short version: + +1. **Registry entry before surface code.** New module? `MODULE_REGISTRY` first. New page? `PAGE_DEFINITIONS` first. +2. **No per-module booleans in surface state.** One generic `is_module_enabled(name)` query. +3. **No hardcoded page ID match arms in visibility logic.** Derive from `required_module` in `PageDefinition`. +4. **No inline colors.** Theme layer only. +5. **Cross-platform logic belongs in core.** If you're writing the same thing in `app.rs` and `ContentView.swift`, it's core logic. +6. **After FFI changes:** run `build-ios-framework.sh` and commit `Generated/` + `xcframework`. + +If something's missing: open a PR, draft a module, or file an issue with a concrete repro. + +--- + +## Lineage + +**[Holos](https://github.com/stack-node/holos)** — macOS-first, modular, "built out of utility and spite" against rent-seeking micro-apps. + +**Arcadia** — same DNA (free, open, yours), different chassis: Rust core, cross-platform surfaces, explicit LAN routing, `surface.*` mirror channel, and agent-enforced registry patterns so the codebase stays honest as it grows. + +--- + +## About the creator + +I'm a twenty-something British developer. + +Moved to the US in 2016 chasing family — it didn't pan out how you'd hope. Along the way I fell hard into **electricity**, then **hardware**, then **software**. Spent years in demanding jobs (including **Disney** and **government** work): solid craft, solid burnout, and a growing dislike of systems that optimize **rent** over **agency**. + +Eventually I hit a wall, stepped back, and landed back in the **UK** to rebuild — **tired**, **broke**, and dealing with **chronic insomnia**. + +Turns out insomnia leaves a lot of hours for building. + +**[Holos](https://github.com/stack-node/holos)** was one outlet — macOS-first, modular, angry at menu-bar subscriptions. + +**Arcadia** is the next chapter: **Rust**, **multi-platform**, **one honest core**, **LAN-aware surfaces**, and the same underlying attitude — tools you own, not dashboards that invoice you. + +--- + +## Donations + +There is a donation link (when I've remembered to wire it somewhere sensible — check the GitHub profile, repo Sponsors, or releases if it's live). + +You probably shouldn't use it. + +Any money would realistically help with boring friction — Apple Developer fees, hardware for iOS builds — which sits in tension with the "don't feed the rent-seekers" ethos of these projects. It would still help Arcadia and Holos reach their technical potential. + +If you donate anyway and you'd rather that money not go toward licenses or anything in that vein, say so — I'd rather put it toward something human. I'm saving toward a cat; until that's sorted, that's the soft default. After that — or if you explicitly ask that I not keep any of it — donations marked "don't support the system" can go to my local animal shelter. + +No obligation. **Code and issues beat coffee money every time.** + +--- + +## Final note + +Arcadia is meant to be **yours**: fork it, break it, fix it, route it across your LAN, disable half the modules, wire something weird into `surface.patch`. + +If it helps you replace a pile of tiny apps or own your automation stack, feed that back as code or docs — not hype. + +Make something useful. Make something weird. Make something only you care about. + +That's still the point — just with one Rust core keeping the story straight. diff --git a/Resources/Icons/Production/Final-1-appicon-1024.png b/Resources/Icons/Production/Final-1-appicon-1024.png new file mode 100644 index 0000000..27ec82b Binary files /dev/null and b/Resources/Icons/Production/Final-1-appicon-1024.png differ diff --git a/Resources/Icons/Production/Final-1-appicon.png b/Resources/Icons/Production/Final-1-appicon.png new file mode 100644 index 0000000..c6e7a73 Binary files /dev/null and b/Resources/Icons/Production/Final-1-appicon.png differ diff --git a/Resources/Icons/Prototypes/Final-1.png b/Resources/Icons/Prototypes/Final-1.png new file mode 100644 index 0000000..48f2f37 Binary files /dev/null and b/Resources/Icons/Prototypes/Final-1.png differ diff --git a/Resources/Icons/Prototypes/Grid1.png b/Resources/Icons/Prototypes/Grid1.png new file mode 100644 index 0000000..4955204 Binary files /dev/null and b/Resources/Icons/Prototypes/Grid1.png differ diff --git a/Resources/Icons/Prototypes/Grid2.png b/Resources/Icons/Prototypes/Grid2.png new file mode 100644 index 0000000..1cb3b3d Binary files /dev/null and b/Resources/Icons/Prototypes/Grid2.png differ diff --git a/Resources/Icons/Prototypes/T1A.png b/Resources/Icons/Prototypes/T1A.png new file mode 100644 index 0000000..82bfc8f Binary files /dev/null and b/Resources/Icons/Prototypes/T1A.png differ diff --git a/Resources/Icons/Prototypes/T1B.png b/Resources/Icons/Prototypes/T1B.png new file mode 100644 index 0000000..42af145 Binary files /dev/null and b/Resources/Icons/Prototypes/T1B.png differ diff --git a/Resources/Icons/Prototypes/T2.png b/Resources/Icons/Prototypes/T2.png new file mode 100644 index 0000000..fbd20c0 Binary files /dev/null and b/Resources/Icons/Prototypes/T2.png differ diff --git a/Resources/Sounds/Notification_Airy.wav b/Resources/Sounds/Notification_Airy.wav new file mode 100644 index 0000000..55761c6 Binary files /dev/null and b/Resources/Sounds/Notification_Airy.wav differ diff --git a/Resources/Sounds/Notification_Deep.wav b/Resources/Sounds/Notification_Deep.wav new file mode 100644 index 0000000..52a3d4e Binary files /dev/null and b/Resources/Sounds/Notification_Deep.wav differ diff --git a/Resources/Sounds/Notification_Glass.wav b/Resources/Sounds/Notification_Glass.wav new file mode 100644 index 0000000..1543525 Binary files /dev/null and b/Resources/Sounds/Notification_Glass.wav differ diff --git a/Resources/Sounds/Notification_Minimal.wav b/Resources/Sounds/Notification_Minimal.wav new file mode 100644 index 0000000..c6def7e Binary files /dev/null and b/Resources/Sounds/Notification_Minimal.wav differ diff --git a/Resources/Sounds/Notification_Pop.wav b/Resources/Sounds/Notification_Pop.wav new file mode 100644 index 0000000..932becf Binary files /dev/null and b/Resources/Sounds/Notification_Pop.wav differ diff --git a/Resources/Sounds/Notification_Warm.wav b/Resources/Sounds/Notification_Warm.wav new file mode 100644 index 0000000..b51f86f Binary files /dev/null and b/Resources/Sounds/Notification_Warm.wav differ diff --git a/Resources/Wallpapers/Landscape-Refined.png b/Resources/Wallpapers/Landscape-Refined.png new file mode 100644 index 0000000..f6a81ca Binary files /dev/null and b/Resources/Wallpapers/Landscape-Refined.png differ diff --git a/Resources/Wallpapers/Landscape.png b/Resources/Wallpapers/Landscape.png new file mode 100644 index 0000000..0e6470d Binary files /dev/null and b/Resources/Wallpapers/Landscape.png differ diff --git a/Resources/Wallpapers/Portrait.png b/Resources/Wallpapers/Portrait.png new file mode 100644 index 0000000..5da5909 Binary files /dev/null and b/Resources/Wallpapers/Portrait.png differ diff --git a/Shared/ArcadiaCore/Cargo.toml b/Shared/ArcadiaCore/Cargo.toml index 6ae56ea..04271fa 100644 --- a/Shared/ArcadiaCore/Cargo.toml +++ b/Shared/ArcadiaCore/Cargo.toml @@ -10,5 +10,7 @@ crate-type = ["staticlib", "cdylib", "lib"] [dependencies] serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" uniffi = "0.28" +uuid = { version = "1", features = ["v4"] } diff --git a/Shared/ArcadiaCore/src/config/mod.rs b/Shared/ArcadiaCore/src/config/mod.rs index d7049e6..538339b 100644 --- a/Shared/ArcadiaCore/src/config/mod.rs +++ b/Shared/ArcadiaCore/src/config/mod.rs @@ -1,5 +1,6 @@ pub mod commandline; pub mod modules; +pub mod thin_client; use std::env; use std::fs; diff --git a/Shared/ArcadiaCore/src/config/modules.rs b/Shared/ArcadiaCore/src/config/modules.rs index f978ad8..f967381 100644 --- a/Shared/ArcadiaCore/src/config/modules.rs +++ b/Shared/ArcadiaCore/src/config/modules.rs @@ -6,30 +6,137 @@ use crate::config::ConfigFile; const LEGACY_LAN_MODULE_NAME: &str = "lan-module"; pub const LAN_MODULE_NAME: &str = "lan"; pub const NET_MODULE_NAME: &str = "net"; +pub const SURFACE_MODULE_NAME: &str = "surface"; +pub const REMOTE_SESSION_MODULE_NAME: &str = "remote-session"; pub const SHELL_MODULE_NAME: &str = "shell"; +pub const SHELL_MOTD_MODULE_NAME: &str = "shell-motd"; const FILE_NAME: &str = "modules.toml"; +#[derive(Debug, Clone, Copy)] +pub struct ModuleManifest { + pub name: &'static str, + pub version: &'static str, + pub description: &'static str, + pub required_modules: &'static [&'static str], +} + +// Single source of truth for modules and their metadata. +static MODULE_REGISTRY: &[ModuleManifest] = &[ + ModuleManifest { + name: LAN_MODULE_NAME, + version: "1.0.0", + description: "Local network discovery and peer communication.", + required_modules: &[NET_MODULE_NAME], + }, + ModuleManifest { + name: NET_MODULE_NAME, + version: "1.0.0", + description: "Shared networking foundation for routed module commands.", + required_modules: &[], + }, + ModuleManifest { + name: SURFACE_MODULE_NAME, + version: "0.1.0", + description: "Generic UI snapshot (surface.snapshot) and patches (surface.patch); extend patches for new surfaces.", + required_modules: &[], + }, + ModuleManifest { + name: REMOTE_SESSION_MODULE_NAME, + version: "0.1.0", + description: "Permission to route execute_command over LAN (net_as: lan:…); transcript/mirror are automatic on hosts.", + required_modules: &[NET_MODULE_NAME, LAN_MODULE_NAME], + }, + ModuleManifest { + name: SHELL_MODULE_NAME, + version: "1.0.0", + description: "Interactive shell command execution for Arcadia surfaces.", + required_modules: &[], + }, + ModuleManifest { + name: SHELL_MOTD_MODULE_NAME, + version: "1.0.0", + description: "Fastfetch-style banner when opening the Arcadia shell (requires shell).", + required_modules: &[SHELL_MODULE_NAME], + }, +]; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModulesConfig { pub modules: BTreeMap, } fn required_modules(module_name: &str) -> &'static [&'static str] { - let _ = module_name; - &[] + MODULE_REGISTRY + .iter() + .find(|manifest| manifest.name == module_name) + .map(|manifest| manifest.required_modules) + .unwrap_or(&[]) +} + +fn is_known_module(module_name: &str) -> bool { + MODULE_REGISTRY + .iter() + .any(|manifest| manifest.name == module_name) } impl Default for ModulesConfig { fn default() -> Self { - let mut modules = BTreeMap::new(); - modules.insert(LAN_MODULE_NAME.to_string(), false); - modules.insert(NET_MODULE_NAME.to_string(), false); - modules.insert(SHELL_MODULE_NAME.to_string(), false); + let modules = MODULE_REGISTRY + .iter() + .map(|manifest| { + let enabled = manifest.name == SURFACE_MODULE_NAME; + (manifest.name.to_string(), enabled) + }) + .collect(); Self { modules } } } impl ModulesConfig { + pub fn manifest_for(module_name: &str) -> Option<&'static ModuleManifest> { + MODULE_REGISTRY + .iter() + .find(|manifest| manifest.name == module_name) + } + + pub fn required_modules_for(module_name: &str) -> Result<&'static [&'static str], String> { + if is_known_module(module_name) { + Ok(required_modules(module_name)) + } else { + Err("Unknown module key".to_string()) + } + } + + pub fn missing_requirements_for(&self, module_name: &str) -> Result, String> { + if !self.modules.contains_key(module_name) { + return Err("Unknown module key".to_string()); + } + let mut missing = Vec::new(); + self.collect_missing_requirements(module_name, &mut missing)?; + missing.sort(); + missing.dedup(); + Ok(missing) + } + + fn collect_missing_requirements( + &self, + module_name: &str, + missing: &mut Vec, + ) -> Result<(), String> { + for required in Self::required_modules_for(module_name)? { + let Some(required_enabled) = self.modules.get(*required) else { + return Err(format!( + "Cannot enable {module_name}: required module {required} is missing" + )); + }; + if !required_enabled { + missing.push((*required).to_string()); + } + self.collect_missing_requirements(required, missing)?; + } + Ok(()) + } + pub fn enable_with_requirements(&mut self, module_name: &str) -> Result<(), String> { if !self.modules.contains_key(module_name) { return Err("Unknown module key".to_string()); @@ -122,3 +229,141 @@ impl ConfigFile for ModulesConfig { changed } } + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> ModulesConfig { + ModulesConfig::default() + } + + #[test] + fn default_surface_enabled_others_disabled() { + let cfg = base(); + assert_eq!(cfg.modules.get(SURFACE_MODULE_NAME), Some(&true)); + assert_eq!(cfg.modules.get(SHELL_MODULE_NAME), Some(&false)); + assert_eq!(cfg.modules.get(NET_MODULE_NAME), Some(&false)); + assert_eq!(cfg.modules.get(LAN_MODULE_NAME), Some(&false)); + } + + #[test] + fn set_module_state_enables_known_module() { + let mut cfg = base(); + cfg.set_module_state(SHELL_MODULE_NAME, true).unwrap(); + assert_eq!(cfg.modules.get(SHELL_MODULE_NAME), Some(&true)); + } + + #[test] + fn set_module_state_blocks_enable_when_dep_missing() { + let mut cfg = base(); + // lan requires net; net is disabled + let err = cfg.set_module_state(LAN_MODULE_NAME, true).unwrap_err(); + assert!(err.contains("net"), "error should mention missing dep: {err}"); + } + + #[test] + fn set_module_state_blocks_disable_when_dependent_enabled() { + let mut cfg = base(); + cfg.set_module_state(NET_MODULE_NAME, true).unwrap(); + cfg.set_module_state(LAN_MODULE_NAME, true).unwrap(); + let err = cfg.set_module_state(NET_MODULE_NAME, false).unwrap_err(); + assert!(err.contains("lan"), "error should mention blocking dependent: {err}"); + } + + #[test] + fn enable_with_requirements_transitively_enables_deps() { + let mut cfg = base(); + cfg.enable_with_requirements(LAN_MODULE_NAME).unwrap(); + assert_eq!(cfg.modules.get(NET_MODULE_NAME), Some(&true)); + assert_eq!(cfg.modules.get(LAN_MODULE_NAME), Some(&true)); + } + + #[test] + fn enable_with_requirements_remote_session_enables_net_and_lan() { + let mut cfg = base(); + cfg.enable_with_requirements(REMOTE_SESSION_MODULE_NAME).unwrap(); + assert_eq!(cfg.modules.get(NET_MODULE_NAME), Some(&true)); + assert_eq!(cfg.modules.get(LAN_MODULE_NAME), Some(&true)); + assert_eq!(cfg.modules.get(REMOTE_SESSION_MODULE_NAME), Some(&true)); + } + + #[test] + fn missing_requirements_for_lan_without_net() { + let cfg = base(); + let missing = cfg.missing_requirements_for(LAN_MODULE_NAME).unwrap(); + assert!(missing.contains(&NET_MODULE_NAME.to_string())); + } + + #[test] + fn missing_requirements_empty_when_dep_met() { + let mut cfg = base(); + cfg.set_module_state(NET_MODULE_NAME, true).unwrap(); + let missing = cfg.missing_requirements_for(LAN_MODULE_NAME).unwrap(); + assert!(missing.is_empty()); + } + + #[test] + fn missing_requirements_unknown_module_errors() { + let cfg = base(); + assert!(cfg.missing_requirements_for("does-not-exist").is_err()); + } + + #[test] + fn merge_defaults_migrates_legacy_lan_key() { + let mut cfg = ModulesConfig { + modules: { + let mut m = std::collections::BTreeMap::new(); + m.insert(LEGACY_LAN_MODULE_NAME.to_string(), true); + m + }, + }; + let changed = cfg.merge_defaults(); + assert!(changed); + assert!(!cfg.modules.contains_key(LEGACY_LAN_MODULE_NAME)); + assert_eq!(cfg.modules.get(LAN_MODULE_NAME), Some(&true)); + } + + #[test] + fn merge_defaults_adds_missing_modules() { + let mut cfg = ModulesConfig { modules: std::collections::BTreeMap::new() }; + let changed = cfg.merge_defaults(); + assert!(changed); + for manifest in MODULE_REGISTRY { + assert!( + cfg.modules.contains_key(manifest.name), + "merge_defaults must add missing module '{}'", + manifest.name + ); + } + } + + #[test] + fn manifest_for_known_module() { + let m = ModulesConfig::manifest_for(SHELL_MODULE_NAME).unwrap(); + assert_eq!(m.name, SHELL_MODULE_NAME); + } + + #[test] + fn manifest_for_unknown_returns_none() { + assert!(ModulesConfig::manifest_for("totally-fake").is_none()); + } + + #[test] + fn set_module_state_unknown_module_errors() { + let mut cfg = base(); + assert!(cfg.set_module_state("ghost-module", true).is_err()); + } + + #[test] + fn shell_motd_requires_shell() { + let mut cfg = base(); + // shell-motd requires shell; shell disabled → enable should fail + let err = cfg.set_module_state(SHELL_MOTD_MODULE_NAME, true).unwrap_err(); + assert!(err.contains("shell"), "error should mention shell: {err}"); + // enable shell first, then motd should work + cfg.set_module_state(SHELL_MODULE_NAME, true).unwrap(); + cfg.set_module_state(SHELL_MOTD_MODULE_NAME, true).unwrap(); + assert_eq!(cfg.modules.get(SHELL_MOTD_MODULE_NAME), Some(&true)); + } +} diff --git a/Shared/ArcadiaCore/src/config/thin_client.rs b/Shared/ArcadiaCore/src/config/thin_client.rs new file mode 100644 index 0000000..4ab0d06 --- /dev/null +++ b/Shared/ArcadiaCore/src/config/thin_client.rs @@ -0,0 +1,50 @@ +//! Persisted thin-client preferences (every GUI peer shares optional defaults-remote-route + stable client id). + +use std::io; + +use serde::{Deserialize, Serialize}; + +use super::ConfigFile; + +const FILE_NAME: &str = "thin-client.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ThinClientConfig { + /// Preferred `ExecutionContext.net_as` shape (`lan:`). + #[serde(default)] + pub preferred_remote_route: Option, + /// Identifies this surface when emitting patches (`surface.patch.client_id`). + #[serde(default)] + pub surface_client_id: Option, +} + +impl ThinClientConfig { + pub fn surface_client_id_or_generate(&mut self) -> Result { + if let Some(ref id) = self.surface_client_id { + return Ok(id.clone()); + } + let id = uuid::Uuid::new_v4().to_string(); + self.surface_client_id = Some(id.clone()); + self.save()?; + Ok(id) + } + + pub fn load_surface_client_id() -> String { + Self::load_or_create() + .ok() + .and_then(|mut c| c.surface_client_id_or_generate().ok()) + .unwrap_or_else(|| "unknown-client".to_string()) + } + + pub fn set_preferred_remote_route(route: Option<&str>) -> Result<(), io::Error> { + let mut cfg = Self::load_or_create()?; + cfg.preferred_remote_route = route.map(|s| s.to_string()); + cfg.save() + } +} + +impl ConfigFile for ThinClientConfig { + fn file_name() -> &'static str { + FILE_NAME + } +} diff --git a/Shared/ArcadiaCore/src/ffi.rs b/Shared/ArcadiaCore/src/ffi.rs index ef635b7..7e10eb0 100644 --- a/Shared/ArcadiaCore/src/ffi.rs +++ b/Shared/ArcadiaCore/src/ffi.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use crate::config::modules::ModulesConfig; +use crate::config::thin_client::ThinClientConfig; use crate::config::ConfigFile; use crate::modules; @@ -22,6 +23,20 @@ pub struct ModuleStatus { pub enabled: bool, } +#[derive(uniffi::Record)] +pub struct ModuleToggleResult { + pub ok: bool, + pub message: String, + pub missing_requirements: Vec, +} + +#[derive(uniffi::Record)] +pub struct RemoteMirrorDrain { + pub lines: Vec, + /// Host handled inbound NODE_EXEC — surfaces showing **local** state should resync from disk / LAN snapshot. + pub sync_local_surface: bool, +} + /// Set the config directory path. Must be called before any other API on iOS. /// Desktop callers skip this — $HOME is used by default. #[uniffi::export] @@ -29,6 +44,15 @@ pub fn set_config_root_path(path: String) { crate::config::set_config_root(std::path::PathBuf::from(path)); } +/// Drain mirrored NODE_EXEC transcript lines + host UI sync flag (reload modules when showing local state). +#[uniffi::export] +pub fn drain_remote_mirror_batch() -> RemoteMirrorDrain { + RemoteMirrorDrain { + lines: modules::remote_mirror::drain_formatted_mirror_lines(), + sync_local_surface: modules::remote_mirror::take_host_ui_sync_pending(), + } +} + /// Execute a command by dot-separated token (e.g. "lan.scan"). /// Returns the result string; errors are embedded in the return value. #[uniffi::export] @@ -70,6 +94,25 @@ pub fn list_modules() -> Vec { /// Enable or disable a named module. Persists to disk. Returns status message. #[uniffi::export] pub fn set_module_enabled(name: String, enabled: bool) -> String { + let mut cfg = match ModulesConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("Error loading config: {e}"), + }; + let result = cfg.set_module_state(&name, enabled); + match result { + Ok(()) => match cfg.save() { + Ok(()) => { + modules::surface::bump_surface_revision(); + format!("Module {name} {}", if enabled { "enabled" } else { "disabled" }) + } + Err(e) => format!("Error saving config: {e}"), + }, + Err(e) => e, + } +} + +#[uniffi::export] +pub fn set_module_enabled_with_requirements(name: String, enabled: bool) -> String { let mut cfg = match ModulesConfig::load_or_create() { Ok(c) => c, Err(e) => return format!("Error loading config: {e}"), @@ -81,13 +124,54 @@ pub fn set_module_enabled(name: String, enabled: bool) -> String { }; match result { Ok(()) => match cfg.save() { - Ok(()) => format!("Module {name} {}", if enabled { "enabled" } else { "disabled" }), + Ok(()) => { + modules::surface::bump_surface_revision(); + format!("Module {name} {}", if enabled { "enabled" } else { "disabled" }) + } Err(e) => format!("Error saving config: {e}"), }, Err(e) => e, } } +#[uniffi::export] +pub fn probe_module_toggle(name: String, enabled: bool) -> ModuleToggleResult { + let cfg = match ModulesConfig::load_or_create() { + Ok(c) => c, + Err(e) => { + return ModuleToggleResult { + ok: false, + message: format!("Error loading config: {e}"), + missing_requirements: vec![], + }; + } + }; + if !enabled { + return ModuleToggleResult { + ok: true, + message: String::new(), + missing_requirements: vec![], + }; + } + match cfg.missing_requirements_for(&name) { + Ok(missing) if missing.is_empty() => ModuleToggleResult { + ok: true, + message: String::new(), + missing_requirements: vec![], + }, + Ok(missing) => ModuleToggleResult { + ok: false, + message: format!("Cannot enable {name} without required modules."), + missing_requirements: missing, + }, + Err(e) => ModuleToggleResult { + ok: false, + message: e, + missing_requirements: vec![], + }, + } +} + /// Returns the current platform name ("ios", "macos", "linux", "windows", "unknown"). #[uniffi::export] pub fn platform_name() -> String { @@ -95,10 +179,47 @@ pub fn platform_name() -> String { crate::platform::current().name().to_string() } +/// Returns the default navigation registry as JSON. +/// This payload is shared by desktop and mobile shells and can later be merged +/// with extension-provided pages/groups at runtime. +#[uniffi::export] +pub fn navigation_registry_json() -> String { + crate::navigation::default_navigation_registry_json() +} + +/// Stable id for this GUI peer (`surface.patch` client_id, logs). +#[uniffi::export] +pub fn thin_client_surface_client_id() -> String { + ThinClientConfig::load_surface_client_id() +} + +#[uniffi::export] +pub fn thin_client_preferred_route_get() -> Option { + ThinClientConfig::load_or_create() + .ok() + .and_then(|c| c.preferred_remote_route.clone()) +} + +/// Persist default `net_as` route; empty error string on success. +#[uniffi::export] +pub fn thin_client_preferred_route_set(route: Option) -> String { + match ThinClientConfig::set_preferred_remote_route(route.as_deref()) { + Ok(()) => String::new(), + Err(e) => format!("Error saving thin-client preferences: {e}"), + } +} + +/// Override the hostname this node advertises over LAN. Call early, before `lan_start`. +/// iOS should pass `ProcessInfo.processInfo.hostName`; desktop resolves hostname automatically. +#[uniffi::export] +pub fn set_local_hostname(name: String) { + crate::modules::lan::set_hostname_override(name); +} + /// Start the LAN background service thread. Safe to call multiple times. #[uniffi::export] pub fn lan_start() { - crate::modules::lan::start_service(); + let _ = crate::modules::lan::start_service(); } /// Stop the LAN background service thread. @@ -107,6 +228,26 @@ pub fn lan_stop() { crate::modules::lan::stop_service(); } +#[derive(uniffi::Record)] +pub struct LanServiceInfoFfi { + pub running: bool, + pub port: u16, + pub hostname: String, + pub module_enabled: bool, +} + +/// LAN service status: running state, UDP port, hostname, and module-enabled flag. +#[uniffi::export] +pub fn lan_service_info() -> LanServiceInfoFfi { + let info = crate::modules::lan::lan_service_info(); + LanServiceInfoFfi { + running: info.running, + port: info.port, + hostname: info.hostname, + module_enabled: info.module_enabled, + } +} + /// Object-oriented handle for module and command management. /// Useful for SwiftUI @StateObject / @Observable patterns. #[derive(uniffi::Object)] @@ -131,6 +272,14 @@ impl ModuleManager { set_module_enabled(name, enabled) } + pub fn set_enabled_with_requirements(&self, name: String, enabled: bool) -> String { + set_module_enabled_with_requirements(name, enabled) + } + + pub fn probe_toggle(&self, name: String, enabled: bool) -> ModuleToggleResult { + probe_module_toggle(name, enabled) + } + pub fn execute(&self, token: String, args: Vec) -> String { execute_command(token, args, ExecutionContextFfi::default()) } @@ -139,7 +288,10 @@ impl ModuleManager { execute_command( token, args, - ExecutionContextFfi { net_as: Some(net_as), net_timeout_ms: None }, + ExecutionContextFfi { + net_as: Some(net_as), + net_timeout_ms: None, + }, ) } } diff --git a/Shared/ArcadiaCore/src/lib.rs b/Shared/ArcadiaCore/src/lib.rs index fdf0074..5b43139 100644 --- a/Shared/ArcadiaCore/src/lib.rs +++ b/Shared/ArcadiaCore/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; +mod ffi; pub mod modules; +pub mod navigation; pub mod platform; -mod ffi; uniffi::setup_scaffolding!(); diff --git a/Shared/ArcadiaCore/src/modules/lan.rs b/Shared/ArcadiaCore/src/modules/lan.rs deleted file mode 100644 index eab8ac8..0000000 --- a/Shared/ArcadiaCore/src/modules/lan.rs +++ /dev/null @@ -1,797 +0,0 @@ -pub const NAME: &str = "lan"; - -use std::collections::BTreeMap; -use std::env; -use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; - -use serde::{Deserialize, Serialize}; -use crate::config::modules::{LAN_MODULE_NAME, ModulesConfig}; -use crate::config::ConfigFile; -use crate::modules::{ExecutionContext, ModuleCommand}; - -const DISCOVERY_PORT: u16 = 46291; -const DISCOVERY_REQUEST: &str = "ARCADIA_LAN_DISCOVER_V1"; -const DISCOVERY_RESPONSE_PREFIX: &str = "ARCADIA_LAN_HERE_V1"; -const NODE_CONNECT_PREFIX: &str = "ARCADIA_NODE_CONNECT_V1"; -const NODE_ACCEPT_PREFIX: &str = "ARCADIA_NODE_ACCEPT_V1"; -const NODE_REJECT_PREFIX: &str = "ARCADIA_NODE_REJECT_V1"; -const NODE_EXEC_PREFIX: &str = "ARCADIA_NODE_EXEC_V1"; -const NODE_EXEC_RESULT_PREFIX: &str = "ARCADIA_NODE_EXEC_RESULT_V1"; -const SCAN_WAIT_MS: u64 = 800; -const NODE_CONFIG_FILE_NAME: &str = "lan_nodes.toml"; -const DEFAULT_REMOTE_TIMEOUT_MS: u64 = 2_000; - -static SERVICE_RUNNING: AtomicBool = AtomicBool::new(false); -static SERVICE_THREAD: OnceLock>>> = OnceLock::new(); -static NODE_STATE: OnceLock> = OnceLock::new(); - -#[derive(Clone, Copy)] -enum PeerStatus { - PendingInbound, - PendingOutbound, - Connected, - Rejected, -} - -impl PeerStatus { - fn as_str(self) -> &'static str { - match self { - Self::PendingInbound => "pending-inbound", - Self::PendingOutbound => "pending-outbound", - Self::Connected => "connected", - Self::Rejected => "rejected", - } - } -} - -#[derive(Clone)] -struct PeerRecord { - ip: String, - hostname: String, - status: PeerStatus, -} - -#[derive(Default)] -struct NodeState { - peers: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct LanNodeConfig { - auto: bool, - approved_nodes: Vec, - node_rules: BTreeMap, - aliases: BTreeMap, -} - -impl Default for LanNodeConfig { - fn default() -> Self { - Self { - auto: false, - approved_nodes: Vec::new(), - node_rules: BTreeMap::new(), - aliases: BTreeMap::new(), - } - } -} - -impl ConfigFile for LanNodeConfig { - fn file_name() -> &'static str { - NODE_CONFIG_FILE_NAME - } -} - -fn service_thread_slot() -> &'static Mutex>> { - SERVICE_THREAD.get_or_init(|| Mutex::new(None)) -} - -fn node_state() -> &'static Mutex { - NODE_STATE.get_or_init(|| Mutex::new(NodeState::default())) -} - -fn record_peer(ip: String, hostname: String, status: PeerStatus) { - if let Ok(mut guard) = node_state().lock() { - let record = guard.peers.entry(ip.clone()).or_insert(PeerRecord { - ip, - hostname: hostname.clone(), - status, - }); - record.hostname = hostname; - record.status = status; - } -} - -fn normalize_node_identifier(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - -fn load_node_config() -> Result { - LanNodeConfig::load_or_create().map_err(|err| err.to_string()) -} - -fn save_node_config(config: &LanNodeConfig) -> Result<(), String> { - config.save().map_err(|err| err.to_string()) -} - -fn resolve_identifier_for_config(target: &str, state: &NodeState) -> String { - if let Some(key) = match_peer_key(target, &state.peers) { - return normalize_node_identifier(&key); - } - if let Ok(addr) = resolve_target(target) { - return normalize_node_identifier(&addr.ip().to_string()); - } - normalize_node_identifier(target) -} - -fn resolve_alias_target(target: &str, cfg: &LanNodeConfig) -> String { - let key = normalize_node_identifier(target); - cfg.aliases.get(&key).cloned().unwrap_or(key) -} - -fn aliases_for_identifier(identifier: &str, cfg: &LanNodeConfig) -> Vec { - let id = normalize_node_identifier(identifier); - cfg.aliases - .iter() - .filter_map(|(alias, mapped)| { - if normalize_node_identifier(mapped) == id { - Some(alias.clone()) - } else { - None - } - }) - .collect() -} - -fn is_auto_allowed(ip: &str, hostname: &str) -> bool { - let Ok(cfg) = load_node_config() else { - return false; - }; - if !cfg.auto { - return false; - } - - let ip_key = normalize_node_identifier(ip); - let host_key = normalize_node_identifier(hostname); - if let Some(value) = cfg - .node_rules - .get(&ip_key) - .or_else(|| cfg.node_rules.get(&host_key)) - { - return *value; - } - - cfg.approved_nodes.iter().any(|node| { - let key = normalize_node_identifier(node); - key == ip_key || key == host_key - }) -} - -fn is_identifier_approved(cfg: &LanNodeConfig, ip: &str, hostname: &str) -> bool { - let ip_key = normalize_node_identifier(ip); - let host_key = normalize_node_identifier(hostname); - cfg.approved_nodes.iter().any(|node| { - let key = normalize_node_identifier(node); - key == ip_key || key == host_key - }) -} - -fn match_peer_key(identifier: &str, peers: &BTreeMap) -> Option { - let key = normalize_node_identifier(identifier); - if let Some((ip, _)) = peers.iter().find(|(ip, _)| normalize_node_identifier(ip) == key) { - return Some(ip.clone()); - } - peers - .iter() - .find(|(_, peer)| normalize_node_identifier(&peer.hostname) == key) - .map(|(ip, _)| ip.clone()) -} - -pub fn start_service() { - if SERVICE_RUNNING.swap(true, Ordering::SeqCst) { - return; - } - - let mut slot = match service_thread_slot().lock() { - Ok(guard) => guard, - Err(_) => { - SERVICE_RUNNING.store(false, Ordering::SeqCst); - return; - } - }; - - let handle = thread::spawn(|| { - let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DISCOVERY_PORT)) - else { - SERVICE_RUNNING.store(false, Ordering::SeqCst); - return; - }; - let _ = socket.set_read_timeout(Some(Duration::from_millis(200))); - - let mut buf = [0_u8; 1024]; - while SERVICE_RUNNING.load(Ordering::SeqCst) { - let Ok((len, src)) = socket.recv_from(&mut buf) else { - continue; - }; - - let payload = String::from_utf8_lossy(&buf[..len]); - if payload.trim() == DISCOVERY_REQUEST { - if !lan_enabled() { - continue; - } - let hostname = local_hostname(); - let response = format!("{DISCOVERY_RESPONSE_PREFIX}\t{hostname}"); - let _ = socket.send_to(response.as_bytes(), src); - continue; - } - - let Some((prefix, remote_hostname)) = payload.split_once('\t') else { - continue; - }; - let SocketAddr::V4(src_v4) = src else { - continue; - }; - let ip = src_v4.ip().to_string(); - let remote_hostname = remote_hostname.trim().to_string(); - - if prefix == NODE_CONNECT_PREFIX { - if lan_enabled() { - if is_auto_allowed(&ip, &remote_hostname) { - record_peer(ip.clone(), remote_hostname.clone(), PeerStatus::Connected); - let response = format!("{NODE_ACCEPT_PREFIX}\t{}", local_hostname()); - let _ = socket.send_to(response.as_bytes(), src); - } else { - record_peer(ip, remote_hostname, PeerStatus::PendingInbound); - } - } - continue; - } - if prefix == NODE_ACCEPT_PREFIX { - if lan_enabled() { - record_peer(ip, remote_hostname, PeerStatus::Connected); - } - continue; - } - if prefix == NODE_REJECT_PREFIX { - if lan_enabled() { - record_peer(ip, remote_hostname, PeerStatus::Rejected); - } - continue; - } - if prefix == NODE_EXEC_PREFIX { - if !lan_enabled() { - continue; - } - let cfg = load_node_config().unwrap_or_default(); - let guard = match node_state().lock() { - Ok(guard) => guard, - Err(_) => continue, - }; - let Some(peer) = guard.peers.get(&ip) else { - continue; - }; - if !matches!(peer.status, PeerStatus::Connected) - || !is_identifier_approved(&cfg, &ip, &peer.hostname) - { - continue; - } - - let mut parts = remote_hostname.split('\t'); - let Some(token) = parts.next() else { - continue; - }; - let owned_args = parts.map(|value| value.to_string()).collect::>(); - let args = owned_args.iter().map(String::as_str).collect::>(); - let context = crate::modules::ExecutionContext::default(); - let result = match crate::modules::execute_command(token, &args, &context) { - Ok(Some(message)) => message, - Ok(None) => format!("Unknown remote command: {token}"), - Err(err) => err, - }; - let response = format!("{NODE_EXEC_RESULT_PREFIX}\t{result}"); - let _ = socket.send_to(response.as_bytes(), src); - } - } - }); - *slot = Some(handle); -} - -pub fn stop_service() { - SERVICE_RUNNING.store(false, Ordering::SeqCst); - if let Ok(mut slot) = service_thread_slot().lock() { - if let Some(handle) = slot.take() { - let _ = handle.join(); - } - } -} - -fn lan_enabled() -> bool { - ModulesConfig::load_or_create() - .ok() - .and_then(|cfg| cfg.modules.get(LAN_MODULE_NAME).copied()) - .unwrap_or(false) -} - -fn local_hostname() -> String { - env::var("HOSTNAME") - .or_else(|_| env::var("COMPUTERNAME")) - .unwrap_or_else(|_| "unknown-host".to_string()) -} - -fn parse_cidr_target(value: &str) -> Option { - let (ip_part, prefix_part) = value.split_once('/')?; - let ip = ip_part.parse::().ok()?; - let prefix = prefix_part.parse::().ok()?; - if prefix > 32 { - return None; - } - - let ip_u32 = u32::from(ip); - let mask = if prefix == 0 { - 0 - } else { - u32::MAX << (32 - prefix) - }; - let broadcast = Ipv4Addr::from(ip_u32 | !mask); - Some(SocketAddrV4::new(broadcast, DISCOVERY_PORT)) -} - -fn parse_targets(range: Option<&str>) -> Result, String> { - match range { - None => Ok(vec![SocketAddrV4::new( - Ipv4Addr::new(255, 255, 255, 255), - DISCOVERY_PORT, - )]), - Some(value) if value.contains('/') => { - let Some(target) = parse_cidr_target(value) else { - return Err("Invalid --range CIDR value".to_string()); - }; - Ok(vec![target]) - } - Some(value) => { - let ip = value - .parse::() - .map_err(|_| "Invalid --range IP value".to_string())?; - Ok(vec![SocketAddrV4::new(ip, DISCOVERY_PORT)]) - } - } -} - -fn scan(args: &[&str], _context: &ExecutionContext) -> String { - let mut range: Option<&str> = None; - let mut i = 0; - while i < args.len() { - match args[i] { - "--range" => { - let Some(value) = args.get(i + 1) else { - return "Usage: lan.scan [--range ]".to_string(); - }; - range = Some(*value); - i += 2; - } - unknown => { - return format!( - "Unknown argument: {unknown}. Usage: lan.scan [--range ]" - ); - } - } - } - - let Ok(targets) = parse_targets(range) else { - return "Invalid range. Use --range (e.g. 192.168.1.0/24) or --range " - .to_string(); - }; - - let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { - return "Failed to bind UDP socket for lan.scan".to_string(); - }; - if socket.set_broadcast(true).is_err() { - return "Failed to enable UDP broadcast for lan.scan".to_string(); - } - let _ = socket.set_read_timeout(Some(Duration::from_millis(100))); - - for target in targets { - let _ = socket.send_to(DISCOVERY_REQUEST.as_bytes(), target); - } - - let deadline = Instant::now() + Duration::from_millis(SCAN_WAIT_MS); - let mut peers = BTreeMap::::new(); - let mut buf = [0_u8; 1024]; - while Instant::now() < deadline { - match socket.recv_from(&mut buf) { - Ok((len, src)) => { - let payload = String::from_utf8_lossy(&buf[..len]); - let Some((prefix, hostname)) = payload.split_once('\t') else { - continue; - }; - if prefix != DISCOVERY_RESPONSE_PREFIX { - continue; - } - if let SocketAddr::V4(addr_v4) = src { - peers.insert(addr_v4.ip().to_string(), hostname.trim().to_string()); - } - } - Err(_) => continue, - } - } - - if peers.is_empty() { - return "No Arcadia peers found with LAN module enabled".to_string(); - } - - let mut lines = vec!["Arcadia LAN peers:".to_string()]; - for (ip, hostname) in peers { - lines.push(format!("- {ip} ({hostname})")); - } - lines.join("\n") -} - -fn resolve_target(target: &str) -> Result { - let mut resolved = (target, DISCOVERY_PORT) - .to_socket_addrs() - .map_err(|err| format!("Failed to resolve target {target}: {err}"))?; - for addr in resolved.by_ref() { - if let SocketAddr::V4(v4) = addr { - return Ok(v4); - } - } - Err(format!("Target {target} did not resolve to IPv4")) -} - -pub fn execute_remote_command( - target: &str, - token: &str, - args: &[&str], - timeout_ms: Option, -) -> Result { - let cfg = load_node_config().map_err(|_| "Failed to load LAN node config".to_string())?; - let resolved_target = resolve_alias_target(target, &cfg); - let addr = resolve_target(&resolved_target)?; - let ip = addr.ip().to_string(); - - let guard = node_state() - .lock() - .map_err(|_| "Failed to access node state".to_string())?; - let Some(peer) = guard.peers.get(&ip) else { - return Err(format!("Target {target} is not a known node")); - }; - if !matches!(peer.status, PeerStatus::Connected) { - return Err(format!("Target {target} is not connected")); - } - if !is_identifier_approved(&cfg, &peer.ip, &peer.hostname) { - return Err(format!("Target {target} is not approved")); - } - drop(guard); - - let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) - .map_err(|err| format!("Failed to create UDP socket: {err}"))?; - let _ = socket.set_read_timeout(Some(Duration::from_millis( - timeout_ms.unwrap_or(DEFAULT_REMOTE_TIMEOUT_MS), - ))); - - let mut payload = format!("{NODE_EXEC_PREFIX}\t{token}"); - for arg in args { - payload.push('\t'); - payload.push_str(arg); - } - - socket - .send_to(payload.as_bytes(), SocketAddrV4::new(*addr.ip(), DISCOVERY_PORT)) - .map_err(|err| format!("Failed to send remote command: {err}"))?; - - let mut buf = [0_u8; 65_507]; - let (len, _) = socket - .recv_from(&mut buf) - .map_err(|_| "Remote command timed out".to_string())?; - let payload = String::from_utf8_lossy(&buf[..len]); - let Some((prefix, result)) = payload.split_once('\t') else { - return Err("Invalid remote response".to_string()); - }; - if prefix != NODE_EXEC_RESULT_PREFIX { - return Err("Unexpected remote response".to_string()); - } - Ok(result.to_string()) -} - -fn node_connect(target: &str) -> String { - let cfg = load_node_config().unwrap_or_default(); - let resolved_target = resolve_alias_target(target, &cfg); - let Ok(addr) = resolve_target(&resolved_target) else { - return format!("Failed to connect: unable to resolve {target}"); - }; - let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { - return "Failed to create UDP socket for node connect".to_string(); - }; - let payload = format!("{NODE_CONNECT_PREFIX}\t{}", local_hostname()); - if socket.send_to(payload.as_bytes(), addr).is_err() { - return format!("Failed to send node connect request to {}", addr.ip()); - } - record_peer( - addr.ip().to_string(), - resolved_target, - PeerStatus::PendingOutbound, - ); - format!( - "Connection request sent to {}. Waiting for lan.node accept on peer.", - addr.ip() - ) -} - -fn node_accept(target: &str) -> String { - let cfg = load_node_config().unwrap_or_default(); - let resolved_target = resolve_alias_target(target, &cfg); - let (peer_ip, peer_hostname) = { - let Ok(mut guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - let Some(key) = match_peer_key(&resolved_target, &guard.peers) else { - return format!("No known peer matching {target}"); - }; - let Some(peer) = guard.peers.get_mut(&key) else { - return format!("No known peer matching {target}"); - }; - if !matches!(peer.status, PeerStatus::PendingInbound | PeerStatus::PendingOutbound) { - return format!("Peer {} is already {}", peer.ip, peer.status.as_str()); - } - peer.status = PeerStatus::Connected; - (peer.ip.clone(), peer.hostname.clone()) - }; - - let Ok(ip) = peer_ip.parse::() else { - return format!("Cannot accept peer with invalid IP {peer_ip}"); - }; - let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { - return "Failed to create UDP socket for node accept".to_string(); - }; - let payload = format!("{NODE_ACCEPT_PREFIX}\t{}", local_hostname()); - let _ = socket.send_to(payload.as_bytes(), SocketAddrV4::new(ip, DISCOVERY_PORT)); - format!("Accepted node {peer_hostname} ({peer_ip})") -} - -fn node_status(target: Option<&str>) -> String { - let cfg = load_node_config().unwrap_or_default(); - let Ok(guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - if guard.peers.is_empty() { - return "No known LAN nodes".to_string(); - } - - if let Some(identifier) = target { - let resolved = resolve_alias_target(identifier, &cfg); - let Some(key) = match_peer_key(&resolved, &guard.peers) else { - return format!("No node found for {identifier}"); - }; - let peer = &guard.peers[&key]; - let aliases = aliases_for_identifier(&peer.ip, &cfg); - if aliases.is_empty() { - return format!("{} ({}) -> {}", peer.hostname, peer.ip, peer.status.as_str()); - } - return format!( - "{} ({}) [{}] -> {}", - peer.hostname, - peer.ip, - aliases.join(", "), - peer.status.as_str() - ); - } - - let mut lines = vec!["LAN node status:".to_string()]; - for peer in guard.peers.values() { - let aliases = aliases_for_identifier(&peer.ip, &cfg); - let alias_suffix = if aliases.is_empty() { - String::new() - } else { - format!(" [{}]", aliases.join(", ")) - }; - lines.push(format!( - "- {} ({}){} -> {}", - peer.hostname, - peer.ip, - alias_suffix, - peer.status.as_str() - )); - } - lines.join("\n") -} - -fn parse_bool(value: &str) -> Option { - match value.to_ascii_lowercase().as_str() { - "true" => Some(true), - "false" => Some(false), - _ => None, - } -} - -fn node_save(target: Option<&str>) -> String { - let Ok(mut cfg) = load_node_config() else { - return "Failed to load LAN node config".to_string(); - }; - let Ok(guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - - let mut added = Vec::new(); - match target { - None => { - for peer in guard.peers.values() { - if !matches!(peer.status, PeerStatus::Connected) { - continue; - } - let ip_key = normalize_node_identifier(&peer.ip); - if !cfg.approved_nodes.contains(&ip_key) { - cfg.approved_nodes.push(ip_key.clone()); - added.push(ip_key); - } - } - } - Some(identifier) => { - let target_value = resolve_alias_target(identifier, &cfg); - let resolved = resolve_identifier_for_config(&target_value, &guard); - if !cfg.approved_nodes.contains(&resolved) { - cfg.approved_nodes.push(resolved.clone()); - added.push(resolved); - } - } - } - - if save_node_config(&cfg).is_err() { - return "Failed to save LAN node config".to_string(); - } - if added.is_empty() { - "No new node entries were added".to_string() - } else { - format!("Saved node entries: {}", added.join(", ")) - } -} - -fn node_set_auto(value: &str) -> String { - let Some(enabled) = parse_bool(value) else { - return "Usage: lan.node auto ".to_string(); - }; - let Ok(mut cfg) = load_node_config() else { - return "Failed to load LAN node config".to_string(); - }; - cfg.auto = enabled; - if save_node_config(&cfg).is_err() { - return "Failed to save LAN node config".to_string(); - } - format!("LAN node auto mode set to {enabled}") -} - -fn node_set_rule(target: &str, value: &str) -> String { - let Some(allowed) = parse_bool(value) else { - return "Usage: lan.node ".to_string(); - }; - let Ok(mut cfg) = load_node_config() else { - return "Failed to load LAN node config".to_string(); - }; - if !cfg.auto { - return "lan.node requires lan.node auto true".to_string(); - } - - let Ok(guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - let target_value = resolve_alias_target(target, &cfg); - let key = resolve_identifier_for_config(&target_value, &guard); - cfg.node_rules.insert(key.clone(), allowed); - if save_node_config(&cfg).is_err() { - return "Failed to save LAN node config".to_string(); - } - format!("Rule set: {key} -> {allowed}") -} - -fn node_reject(target: &str) -> String { - let cfg = load_node_config().unwrap_or_default(); - let resolved_target = resolve_alias_target(target, &cfg); - let (peer_ip, peer_hostname) = { - let Ok(mut guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - let Some(key) = match_peer_key(&resolved_target, &guard.peers) else { - return format!("No known peer matching {target}"); - }; - let Some(peer) = guard.peers.get(&key) else { - return format!("No known peer matching {target}"); - }; - let peer_ip = peer.ip.clone(); - let peer_hostname = peer.hostname.clone(); - guard.peers.remove(&key); - (peer_ip, peer_hostname) - }; - - let Ok(ip) = peer_ip.parse::() else { - return format!("Rejected node {peer_hostname} ({peer_ip})"); - }; - if let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) { - let payload = format!("{NODE_REJECT_PREFIX}\t{}", local_hostname()); - let _ = socket.send_to(payload.as_bytes(), SocketAddrV4::new(ip, DISCOVERY_PORT)); - } - format!("Rejected node {peer_hostname} ({peer_ip})") -} - -fn node_alias(target: &str, custom_alias_parts: &[&str]) -> String { - if custom_alias_parts.is_empty() { - return "Usage: lan.node alias ".to_string(); - } - let custom_alias = custom_alias_parts.join(" "); - let alias_key = normalize_node_identifier(&custom_alias); - if alias_key.is_empty() { - return "Alias cannot be empty".to_string(); - } - - let Ok(mut cfg) = load_node_config() else { - return "Failed to load LAN node config".to_string(); - }; - let Ok(guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - let target_value = resolve_alias_target(target, &cfg); - let resolved = resolve_identifier_for_config(&target_value, &guard); - cfg.aliases.insert(alias_key.clone(), resolved.clone()); - if save_node_config(&cfg).is_err() { - return "Failed to save LAN node config".to_string(); - } - - format!("Alias set: {alias_key} -> {resolved}") -} - -fn node_pair(target: &str) -> String { - let cfg = load_node_config().unwrap_or_default(); - let resolved_target = resolve_alias_target(target, &cfg); - - let maybe_inbound = { - let Ok(guard) = node_state().lock() else { - return "Failed to access node state".to_string(); - }; - match_peer_key(&resolved_target, &guard.peers) - .and_then(|key| guard.peers.get(&key).cloned()) - .filter(|peer| matches!(peer.status, PeerStatus::PendingInbound)) - }; - - if let Some(peer) = maybe_inbound { - let accepted = node_accept(&peer.ip); - let saved = node_save(Some(&peer.ip)); - return format!("{accepted}\n{saved}"); - } - - let connected = node_connect(&resolved_target); - let saved = node_save(Some(&resolved_target)); - format!("{connected}\n{saved}") -} - -fn node(args: &[&str], _context: &ExecutionContext) -> String { - match args { - ["pair", target] => node_pair(target), - ["connect", target] => node_connect(target), - ["accept", target] => node_accept(target), - ["reject", target] => node_reject(target), - ["alias", target, alias_parts @ ..] => node_alias(target, alias_parts), - ["save"] => node_save(None), - ["save", target] => node_save(Some(target)), - ["auto", value] => node_set_auto(value), - ["status"] => node_status(None), - ["status", target] => node_status(Some(target)), - [target, value] => node_set_rule(target, value), - _ => "Usage: lan.node pair | lan.node connect | lan.node accept | lan.node reject | lan.node alias | lan.node save [hostname/ip] | lan.node auto | lan.node status [hostname/ip] | lan.node ".to_string(), - } -} - -pub fn commands() -> &'static [ModuleCommand] { - &[ - ModuleCommand { - name: "scan", - description: "discover Arcadia LAN peers (--range supported)", - run: scan, - }, - ModuleCommand { - name: "node", - description: "manage LAN nodes: pair|connect|accept|reject|alias|save|auto|status", - run: node, - }, - ] -} diff --git a/Shared/ArcadiaCore/src/modules/lan/config.rs b/Shared/ArcadiaCore/src/modules/lan/config.rs new file mode 100644 index 0000000..817b8c7 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan/config.rs @@ -0,0 +1,98 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::config::ConfigFile; + +const NODE_CONFIG_FILE_NAME: &str = "lan_nodes.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LanNodeConfig { + pub auto: bool, + pub approved_nodes: Vec, + pub node_rules: BTreeMap, + pub aliases: BTreeMap, +} + +impl Default for LanNodeConfig { + fn default() -> Self { + Self { + auto: false, + approved_nodes: Vec::new(), + node_rules: BTreeMap::new(), + aliases: BTreeMap::new(), + } + } +} + +impl ConfigFile for LanNodeConfig { + fn file_name() -> &'static str { + NODE_CONFIG_FILE_NAME + } +} + +pub fn load_node_config() -> Result { + LanNodeConfig::load_or_create().map_err(|err| err.to_string()) +} + +pub fn save_node_config(config: &LanNodeConfig) -> Result<(), String> { + config.save().map_err(|err| err.to_string()) +} + +pub fn normalize_node_identifier(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +pub fn resolve_alias_target(target: &str, cfg: &LanNodeConfig) -> String { + let key = normalize_node_identifier(target); + cfg.aliases.get(&key).cloned().unwrap_or(key) +} + +pub fn aliases_for_identifier(identifier: &str, cfg: &LanNodeConfig) -> Vec { + let id = normalize_node_identifier(identifier); + cfg.aliases + .iter() + .filter_map(|(alias, mapped)| { + if normalize_node_identifier(mapped) == id { + Some(alias.clone()) + } else { + None + } + }) + .collect() +} + +pub fn is_identifier_approved(cfg: &LanNodeConfig, ip: &str, hostname: &str) -> bool { + let ip_key = normalize_node_identifier(ip); + let host_key = normalize_node_identifier(hostname); + cfg.approved_nodes.iter().any(|node| { + let key = normalize_node_identifier(node); + key == ip_key || key == host_key + }) +} + +pub fn is_auto_allowed(ip: &str, hostname: &str) -> bool { + let Ok(cfg) = load_node_config() else { + return false; + }; + // Previously approved nodes always auto-accept — trust was already established. + if is_identifier_approved(&cfg, ip, hostname) { + return true; + } + if !cfg.auto { + return false; + } + let ip_key = normalize_node_identifier(ip); + let host_key = normalize_node_identifier(hostname); + if let Some(value) = cfg + .node_rules + .get(&ip_key) + .or_else(|| cfg.node_rules.get(&host_key)) + { + return *value; + } + cfg.approved_nodes.iter().any(|node| { + let key = normalize_node_identifier(node); + key == ip_key || key == host_key + }) +} diff --git a/Shared/ArcadiaCore/src/modules/lan/discovery.rs b/Shared/ArcadiaCore/src/modules/lan/discovery.rs new file mode 100644 index 0000000..4391148 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan/discovery.rs @@ -0,0 +1,351 @@ +use std::collections::BTreeMap; +use std::env; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::config::modules::{ModulesConfig, LAN_MODULE_NAME}; +use crate::config::ConfigFile; +use crate::modules::ExecutionContext; + +use super::config::{is_auto_allowed, is_identifier_approved, load_node_config}; +use super::peers::{node_state, record_peer}; +use super::protocol::PeerStatus; +use super::protocol::{ + DISCOVERY_PORT, DISCOVERY_REQUEST, DISCOVERY_RESPONSE_PREFIX, NODE_ACCEPT_PREFIX, + NODE_CONNECT_PREFIX, NODE_EXEC_PREFIX, NODE_EXEC_RESULT_PREFIX, NODE_REJECT_PREFIX, + RECV_BUF_SMALL, SCAN_WAIT_MS, +}; + +pub static SERVICE_RUNNING: AtomicBool = AtomicBool::new(false); +static SERVICE_THREAD: OnceLock>>> = OnceLock::new(); +static HOSTNAME_OVERRIDE: OnceLock = OnceLock::new(); + +pub fn set_hostname_override(name: String) { + let _ = HOSTNAME_OVERRIDE.set(name); +} + +fn service_thread_slot() -> &'static Mutex>> { + SERVICE_THREAD.get_or_init(|| Mutex::new(None)) +} + +pub fn lan_enabled() -> bool { + ModulesConfig::load_or_create() + .ok() + .and_then(|cfg| cfg.modules.get(LAN_MODULE_NAME).copied()) + .unwrap_or(false) +} + +pub fn local_hostname() -> String { + if let Some(name) = HOSTNAME_OVERRIDE.get() { + return name.clone(); + } + env::var("HOSTNAME") + .or_else(|_| env::var("COMPUTERNAME")) + .unwrap_or_else(|_| { + std::process::Command::new("hostname") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown-host".to_string()) + }) +} + +pub fn resolve_target(target: &str) -> Result { + let mut resolved = (target, DISCOVERY_PORT) + .to_socket_addrs() + .map_err(|err| format!("Failed to resolve target {target}: {err}"))?; + for addr in resolved.by_ref() { + if let SocketAddr::V4(v4) = addr { + return Ok(v4); + } + } + Err(format!("Target {target} did not resolve to IPv4")) +} + +pub fn parse_cidr_target(value: &str) -> Option { + let (ip_part, prefix_part) = value.split_once('/')?; + let ip = ip_part.parse::().ok()?; + let prefix = prefix_part.parse::().ok()?; + if prefix > 32 { + return None; + } + let ip_u32 = u32::from(ip); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + let broadcast = Ipv4Addr::from(ip_u32 | !mask); + Some(SocketAddrV4::new(broadcast, DISCOVERY_PORT)) +} + +pub fn parse_targets(range: Option<&str>) -> Result, String> { + match range { + None => Ok(vec![SocketAddrV4::new( + Ipv4Addr::new(255, 255, 255, 255), + DISCOVERY_PORT, + )]), + Some(value) if value.contains('/') => { + let Some(target) = parse_cidr_target(value) else { + return Err("Invalid --range CIDR value".to_string()); + }; + Ok(vec![target]) + } + Some(value) => { + let ip = value + .parse::() + .map_err(|_| "Invalid --range IP value".to_string())?; + Ok(vec![SocketAddrV4::new(ip, DISCOVERY_PORT)]) + } + } +} + +fn discover_at(targets: Vec) -> Result, String> { + let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { + return Err("Failed to bind UDP socket for lan.scan".to_string()); + }; + if socket.set_broadcast(true).is_err() { + return Err("Failed to enable UDP broadcast for lan.scan".to_string()); + } + let _ = socket.set_read_timeout(Some(Duration::from_millis(100))); + + for target in &targets { + let _ = socket.send_to(DISCOVERY_REQUEST.as_bytes(), *target); + } + + let deadline = Instant::now() + Duration::from_millis(SCAN_WAIT_MS); + let mut peers = BTreeMap::::new(); + let mut buf = [0_u8; RECV_BUF_SMALL]; + while Instant::now() < deadline { + match socket.recv_from(&mut buf) { + Ok((len, src)) => { + let payload = String::from_utf8_lossy(&buf[..len]); + let Some((prefix, hostname)) = payload.split_once('\t') else { + continue; + }; + if prefix != DISCOVERY_RESPONSE_PREFIX { + continue; + } + if let SocketAddr::V4(addr_v4) = src { + peers.insert(addr_v4.ip().to_string(), hostname.trim().to_string()); + } + } + Err(_) => continue, + } + } + + Ok(peers.into_iter().collect()) +} + +/// UDP discovery only (same semantics as `lan.scan`); returns sorted `(ip, hostname)` pairs. +pub fn discover_lan_peers(range: Option<&str>) -> Result, String> { + discover_at(parse_targets(range)?) +} + +pub fn scan(args: &[&str], _context: &ExecutionContext) -> String { + let mut range: Option<&str> = None; + let mut include_self = false; + let mut i = 0; + while i < args.len() { + match args[i] { + "--range" => { + let Some(value) = args.get(i + 1) else { + return "Usage: lan.scan [--range ] [--self]".to_string(); + }; + range = Some(*value); + i += 2; + } + "--self" => { + include_self = true; + i += 1; + } + unknown => { + return format!( + "Unknown argument: {unknown}. Usage: lan.scan [--range ] [--self]" + ); + } + } + } + + let mut targets = match parse_targets(range) { + Ok(t) => t, + Err(msg) => return msg, + }; + if include_self { + targets.push(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), DISCOVERY_PORT)); + } + + match discover_at(targets) { + Ok(peers) if peers.is_empty() => [ + "No Arcadia peers found on LAN.", + " - Peer must have LAN module enabled to respond", + " - Try: lan.scan --self (tests local service via loopback)", + " - Try: lan.status (check service is running)", + ] + .join("\n"), + Ok(peers) => { + let mut lines = vec!["Arcadia LAN peers:".to_string()]; + for (ip, hostname) in peers { + lines.push(format!("- {ip} ({hostname})")); + } + lines.join("\n") + } + Err(msg) => msg, + } +} + +pub fn service_status(_args: &[&str], _context: &ExecutionContext) -> String { + let running = SERVICE_RUNNING.load(Ordering::SeqCst); + let enabled = lan_enabled(); + let hostname = local_hostname(); + format!( + "LAN service: {}\nPort: {DISCOVERY_PORT}\nHostname: {hostname}\nModule enabled: {enabled}", + if running { "running" } else { "stopped" } + ) +} + +pub fn start_service() -> Result<(), String> { + if SERVICE_RUNNING.load(Ordering::SeqCst) { + return Ok(()); + } + + let mut slot = service_thread_slot() + .lock() + .map_err(|_| "Failed to acquire LAN service lock".to_string())?; + if SERVICE_RUNNING.load(Ordering::SeqCst) { + return Ok(()); + } + + let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DISCOVERY_PORT)) + .map_err(|err| format!("Failed to bind UDP socket on port {DISCOVERY_PORT}: {err}"))?; + socket + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|err| format!("Failed to configure LAN service socket: {err}"))?; + SERVICE_RUNNING.store(true, Ordering::SeqCst); + + let handle = thread::spawn(move || { + + let mut buf = [0_u8; RECV_BUF_SMALL]; + while SERVICE_RUNNING.load(Ordering::SeqCst) { + let Ok((len, src)) = socket.recv_from(&mut buf) else { + continue; + }; + + let payload = String::from_utf8_lossy(&buf[..len]); + if payload.trim() == DISCOVERY_REQUEST { + if !lan_enabled() { + continue; + } + let hostname = local_hostname(); + let response = format!("{DISCOVERY_RESPONSE_PREFIX}\t{hostname}"); + let _ = socket.send_to(response.as_bytes(), src); + continue; + } + + let Some((prefix, remote_hostname)) = payload.split_once('\t') else { + continue; + }; + let SocketAddr::V4(src_v4) = src else { + continue; + }; + let ip = src_v4.ip().to_string(); + let remote_hostname = remote_hostname.trim().to_string(); + + if prefix == NODE_CONNECT_PREFIX { + if lan_enabled() { + if is_auto_allowed(&ip, &remote_hostname) { + record_peer(ip.clone(), remote_hostname.clone(), PeerStatus::Connected); + let response = format!("{NODE_ACCEPT_PREFIX}\t{}", local_hostname()); + let _ = socket.send_to(response.as_bytes(), src); + } else { + record_peer(ip, remote_hostname, PeerStatus::PendingInbound); + } + } + continue; + } + if prefix == NODE_ACCEPT_PREFIX { + if lan_enabled() { + record_peer(ip, remote_hostname, PeerStatus::Connected); + } + continue; + } + if prefix == NODE_REJECT_PREFIX { + if lan_enabled() { + record_peer(ip, remote_hostname, PeerStatus::Rejected); + } + continue; + } + if prefix == NODE_EXEC_PREFIX { + if !lan_enabled() { + continue; + } + let cfg = load_node_config().unwrap_or_default(); + let guard = match node_state().lock() { + Ok(g) => g, + Err(_) => continue, + }; + let Some(peer) = guard.peers.get(&ip) else { + continue; + }; + if !matches!(peer.status, PeerStatus::Connected) + || !is_identifier_approved(&cfg, &ip, &peer.hostname) + { + continue; + } + let mut parts = remote_hostname.split('\t'); + let Some(token) = parts.next() else { + continue; + }; + let owned_args = parts.map(|v| v.to_string()).collect::>(); + let args = owned_args.iter().map(String::as_str).collect::>(); + let context = crate::modules::ExecutionContext::default(); + drop(guard); + let result = match crate::modules::execute_command(token, &args, &context) { + Ok(Some(message)) => message, + Ok(None) => format!("Unknown remote command: {token}"), + Err(err) => err, + }; + crate::modules::remote_mirror::enqueue_remote_exec_mirror( + token.to_string(), + owned_args.clone(), + result.clone(), + ); + crate::modules::remote_mirror::request_host_ui_sync_after_peer_exec(); + let response = format!("{NODE_EXEC_RESULT_PREFIX}\t{result}"); + let _ = socket.send_to(response.as_bytes(), src); + } + } + }); + *slot = Some(handle); + + // Reconnect to previously approved nodes in the background. + thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + let Ok(cfg) = super::config::load_node_config() else { return; }; + if cfg.approved_nodes.is_empty() { return; } + let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { return; }; + let payload = format!("{NODE_CONNECT_PREFIX}\t{}", local_hostname()); + for ip_str in &cfg.approved_nodes { + let key = super::config::normalize_node_identifier(ip_str); + if let Ok(ip) = key.parse::() { + let _ = sock.send_to(payload.as_bytes(), SocketAddrV4::new(ip, DISCOVERY_PORT)); + } + } + }); + + Ok(()) +} + +pub fn stop_service() { + SERVICE_RUNNING.store(false, Ordering::SeqCst); + if let Ok(mut slot) = service_thread_slot().lock() { + if let Some(handle) = slot.take() { + let _ = handle.join(); + } + } +} diff --git a/Shared/ArcadiaCore/src/modules/lan/handlers.rs b/Shared/ArcadiaCore/src/modules/lan/handlers.rs new file mode 100644 index 0000000..b9d480b --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan/handlers.rs @@ -0,0 +1,313 @@ +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; + +use crate::modules::ExecutionContext; + +use super::config::{ + aliases_for_identifier, load_node_config, normalize_node_identifier, resolve_alias_target, + save_node_config, +}; +use super::discovery::{local_hostname, resolve_target}; +use super::peers::{match_peer_key, node_state, record_peer, NodeState}; +use super::protocol::{ + PeerStatus, DISCOVERY_PORT, NODE_ACCEPT_PREFIX, NODE_CONNECT_PREFIX, NODE_REJECT_PREFIX, +}; + +fn parse_bool(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +fn resolve_identifier_for_config(target: &str, state: &NodeState) -> String { + if let Some(key) = match_peer_key(target, &state.peers) { + return normalize_node_identifier(&key); + } + if let Ok(addr) = resolve_target(target) { + return normalize_node_identifier(&addr.ip().to_string()); + } + normalize_node_identifier(target) +} + +fn node_connect(target: &str) -> String { + let cfg = load_node_config().unwrap_or_default(); + let resolved_target = resolve_alias_target(target, &cfg); + let Ok(addr) = resolve_target(&resolved_target) else { + return format!("Failed to connect: unable to resolve {target}"); + }; + let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { + return "Failed to create UDP socket for node connect".to_string(); + }; + let payload = format!("{NODE_CONNECT_PREFIX}\t{}", local_hostname()); + if socket.send_to(payload.as_bytes(), addr).is_err() { + return format!("Failed to send node connect request to {}", addr.ip()); + } + record_peer( + addr.ip().to_string(), + resolved_target, + PeerStatus::PendingOutbound, + ); + format!( + "Connection request sent to {}. Waiting for lan.node accept on peer.", + addr.ip() + ) +} + +fn node_accept(target: &str) -> String { + let cfg = load_node_config().unwrap_or_default(); + let resolved_target = resolve_alias_target(target, &cfg); + let (peer_ip, peer_hostname) = { + let Ok(mut guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + let Some(key) = match_peer_key(&resolved_target, &guard.peers) else { + return format!("No known peer matching {target}"); + }; + let Some(peer) = guard.peers.get_mut(&key) else { + return format!("No known peer matching {target}"); + }; + if !matches!( + peer.status, + PeerStatus::PendingInbound | PeerStatus::PendingOutbound + ) { + return format!("Peer {} is already {}", peer.ip, peer.status.as_str()); + } + peer.status = PeerStatus::Connected; + (peer.ip.clone(), peer.hostname.clone()) + }; + + let Ok(ip) = peer_ip.parse::() else { + return format!("Cannot accept peer with invalid IP {peer_ip}"); + }; + let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) else { + return "Failed to create UDP socket for node accept".to_string(); + }; + let payload = format!("{NODE_ACCEPT_PREFIX}\t{}", local_hostname()); + let _ = socket.send_to(payload.as_bytes(), SocketAddrV4::new(ip, DISCOVERY_PORT)); + format!("Accepted node {peer_hostname} ({peer_ip})") +} + +fn node_reject(target: &str) -> String { + let cfg = load_node_config().unwrap_or_default(); + let resolved_target = resolve_alias_target(target, &cfg); + let (peer_ip, peer_hostname) = { + let Ok(mut guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + let Some(key) = match_peer_key(&resolved_target, &guard.peers) else { + return format!("No known peer matching {target}"); + }; + let Some(peer) = guard.peers.get(&key) else { + return format!("No known peer matching {target}"); + }; + let peer_ip = peer.ip.clone(); + let peer_hostname = peer.hostname.clone(); + guard.peers.remove(&key); + (peer_ip, peer_hostname) + }; + + let Ok(ip) = peer_ip.parse::() else { + return format!("Rejected node {peer_hostname} ({peer_ip})"); + }; + if let Ok(socket) = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) { + let payload = format!("{NODE_REJECT_PREFIX}\t{}", local_hostname()); + let _ = socket.send_to(payload.as_bytes(), SocketAddrV4::new(ip, DISCOVERY_PORT)); + } + format!("Rejected node {peer_hostname} ({peer_ip})") +} + +fn node_status(target: Option<&str>) -> String { + let cfg = load_node_config().unwrap_or_default(); + let Ok(guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + if guard.peers.is_empty() { + return "No known LAN nodes".to_string(); + } + + if let Some(identifier) = target { + let resolved = resolve_alias_target(identifier, &cfg); + let Some(key) = match_peer_key(&resolved, &guard.peers) else { + return format!("No node found for {identifier}"); + }; + let peer = &guard.peers[&key]; + let aliases = aliases_for_identifier(&peer.ip, &cfg); + if aliases.is_empty() { + return format!( + "{} ({}) -> {}", + peer.hostname, + peer.ip, + peer.status.as_str() + ); + } + return format!( + "{} ({}) [{}] -> {}", + peer.hostname, + peer.ip, + aliases.join(", "), + peer.status.as_str() + ); + } + + let mut lines = vec!["LAN node status:".to_string()]; + for peer in guard.peers.values() { + let aliases = aliases_for_identifier(&peer.ip, &cfg); + let alias_suffix = if aliases.is_empty() { + String::new() + } else { + format!(" [{}]", aliases.join(", ")) + }; + lines.push(format!( + "- {} ({}){} -> {}", + peer.hostname, + peer.ip, + alias_suffix, + peer.status.as_str() + )); + } + lines.join("\n") +} + +fn node_save(target: Option<&str>) -> String { + let Ok(mut cfg) = load_node_config() else { + return "Failed to load LAN node config".to_string(); + }; + let Ok(guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + + let mut added = Vec::new(); + match target { + None => { + for peer in guard.peers.values() { + if !matches!(peer.status, PeerStatus::Connected) { + continue; + } + let ip_key = normalize_node_identifier(&peer.ip); + if !cfg.approved_nodes.contains(&ip_key) { + cfg.approved_nodes.push(ip_key.clone()); + added.push(ip_key); + } + } + } + Some(identifier) => { + let target_value = resolve_alias_target(identifier, &cfg); + let resolved = resolve_identifier_for_config(&target_value, &guard); + if !cfg.approved_nodes.contains(&resolved) { + cfg.approved_nodes.push(resolved.clone()); + added.push(resolved); + } + } + } + + if save_node_config(&cfg).is_err() { + return "Failed to save LAN node config".to_string(); + } + if added.is_empty() { + "No new node entries were added".to_string() + } else { + format!("Saved node entries: {}", added.join(", ")) + } +} + +fn node_set_auto(value: &str) -> String { + let Some(enabled) = parse_bool(value) else { + return "Usage: lan.node auto ".to_string(); + }; + let Ok(mut cfg) = load_node_config() else { + return "Failed to load LAN node config".to_string(); + }; + cfg.auto = enabled; + if save_node_config(&cfg).is_err() { + return "Failed to save LAN node config".to_string(); + } + format!("LAN node auto mode set to {enabled}") +} + +fn node_set_rule(target: &str, value: &str) -> String { + let Some(allowed) = parse_bool(value) else { + return "Usage: lan.node ".to_string(); + }; + let Ok(mut cfg) = load_node_config() else { + return "Failed to load LAN node config".to_string(); + }; + if !cfg.auto { + return "lan.node requires lan.node auto true".to_string(); + } + let Ok(guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + let target_value = resolve_alias_target(target, &cfg); + let key = resolve_identifier_for_config(&target_value, &guard); + cfg.node_rules.insert(key.clone(), allowed); + if save_node_config(&cfg).is_err() { + return "Failed to save LAN node config".to_string(); + } + format!("Rule set: {key} -> {allowed}") +} + +fn node_alias(target: &str, alias_parts: &[&str]) -> String { + if alias_parts.is_empty() { + return "Usage: lan.node alias ".to_string(); + } + let custom_alias = alias_parts.join(" "); + let alias_key = normalize_node_identifier(&custom_alias); + if alias_key.is_empty() { + return "Alias cannot be empty".to_string(); + } + let Ok(mut cfg) = load_node_config() else { + return "Failed to load LAN node config".to_string(); + }; + let Ok(guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + let target_value = resolve_alias_target(target, &cfg); + let resolved = resolve_identifier_for_config(&target_value, &guard); + cfg.aliases.insert(alias_key.clone(), resolved.clone()); + if save_node_config(&cfg).is_err() { + return "Failed to save LAN node config".to_string(); + } + format!("Alias set: {alias_key} -> {resolved}") +} + +fn node_pair(target: &str) -> String { + let cfg = load_node_config().unwrap_or_default(); + let resolved_target = resolve_alias_target(target, &cfg); + + let maybe_inbound = { + let Ok(guard) = node_state().lock() else { + return "Failed to access node state".to_string(); + }; + match_peer_key(&resolved_target, &guard.peers) + .and_then(|key| guard.peers.get(&key).cloned()) + .filter(|peer| matches!(peer.status, PeerStatus::PendingInbound)) + }; + + if let Some(peer) = maybe_inbound { + let accepted = node_accept(&peer.ip); + let saved = node_save(Some(&peer.ip)); + return format!("{accepted}\n{saved}"); + } + + let connected = node_connect(&resolved_target); + let saved = node_save(Some(&resolved_target)); + format!("{connected}\n{saved}") +} + +pub fn node(args: &[&str], _context: &ExecutionContext) -> String { + match args { + ["pair", target] => node_pair(target), + ["connect", target] => node_connect(target), + ["accept", target] => node_accept(target), + ["reject", target] => node_reject(target), + ["alias", target, alias_parts @ ..] => node_alias(target, alias_parts), + ["save"] => node_save(None), + ["save", target] => node_save(Some(target)), + ["auto", value] => node_set_auto(value), + ["status"] => node_status(None), + ["status", target] => node_status(Some(target)), + [target, value] => node_set_rule(target, value), + _ => "Usage: lan.node pair | lan.node connect | lan.node accept | lan.node reject | lan.node alias | lan.node save [hostname/ip] | lan.node auto | lan.node status [hostname/ip] | lan.node ".to_string(), + } +} diff --git a/Shared/ArcadiaCore/src/modules/lan/mod.rs b/Shared/ArcadiaCore/src/modules/lan/mod.rs new file mode 100644 index 0000000..1cf4a18 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan/mod.rs @@ -0,0 +1,183 @@ +pub const NAME: &str = "lan"; + +mod config; +mod discovery; +mod handlers; +mod peers; +mod protocol; + +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::time::Duration; + +use serde::Serialize; + +use crate::modules::ModuleCommand; + +use config::{is_identifier_approved, load_node_config, resolve_alias_target}; +use discovery::resolve_target; +use peers::node_state; +use protocol::PeerStatus; +use protocol::{ + DEFAULT_REMOTE_TIMEOUT_MS, DISCOVERY_PORT, NODE_EXEC_PREFIX, NODE_EXEC_RESULT_PREFIX, + RECV_BUF_LARGE, +}; + +pub use discovery::{discover_lan_peers, set_hostname_override, start_service, stop_service}; + +pub struct LanServiceInfo { + pub running: bool, + pub port: u16, + pub hostname: String, + pub module_enabled: bool, +} + +pub fn lan_service_info() -> LanServiceInfo { + use std::sync::atomic::Ordering; + LanServiceInfo { + running: discovery::SERVICE_RUNNING.load(Ordering::SeqCst), + port: protocol::DISCOVERY_PORT, + hostname: discovery::local_hostname(), + module_enabled: discovery::lan_enabled(), + } +} + +/// Row for LAN Nodes UI (desktop / callers); mirrors in-memory peer records. +#[derive(Clone, Debug)] +pub struct LanKnownPeer { + pub ip: String, + pub hostname: String, + pub status: &'static str, +} + +pub fn list_known_lan_peers() -> Vec { + let Ok(guard) = peers::node_state().lock() else { + return Vec::new(); + }; + guard + .peers + .values() + .map(|p| LanKnownPeer { + ip: p.ip.clone(), + hostname: p.hostname.clone(), + status: p.status.as_str(), + }) + .collect() +} + +/// Connected peers approved in local node config (`lan_nodes.toml`), sorted by IP. +pub fn connected_approved_session_peers() -> Vec<(String, String)> { + let Ok(cfg) = load_node_config() else { + return Vec::new(); + }; + let Ok(guard) = peers::node_state().lock() else { + return Vec::new(); + }; + let mut out: Vec<(String, String)> = guard + .peers + .values() + .filter(|p| { + matches!(p.status, PeerStatus::Connected) + && is_identifier_approved(&cfg, &p.ip, &p.hostname) + }) + .map(|p| (p.ip.clone(), p.hostname.clone())) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +#[derive(Serialize)] +struct SessionTargetRow { + ip: String, + hostname: String, +} + +fn session_targets(_args: &[&str], _ctx: &crate::modules::ExecutionContext) -> String { + let rows: Vec = connected_approved_session_peers() + .into_iter() + .map(|(ip, hostname)| SessionTargetRow { ip, hostname }) + .collect(); + serde_json::to_string(&rows).unwrap_or_else(|_| "[]".to_string()) +} + +pub fn execute_remote_command( + target: &str, + token: &str, + args: &[&str], + timeout_ms: Option, +) -> Result { + let cfg = load_node_config().map_err(|_| "Failed to load LAN node config".to_string())?; + let resolved_target = resolve_alias_target(target, &cfg); + let addr = resolve_target(&resolved_target)?; + let ip = addr.ip().to_string(); + + let guard = node_state() + .lock() + .map_err(|_| "Failed to access node state".to_string())?; + let Some(peer) = guard.peers.get(&ip) else { + return Err(format!("Target {target} is not a known node")); + }; + if !matches!(peer.status, PeerStatus::Connected) { + return Err(format!("Target {target} is not connected")); + } + if !is_identifier_approved(&cfg, &peer.ip, &peer.hostname) { + return Err(format!("Target {target} is not approved")); + } + drop(guard); + + let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|err| format!("Failed to create UDP socket: {err}"))?; + let _ = socket.set_read_timeout(Some(Duration::from_millis( + timeout_ms.unwrap_or(DEFAULT_REMOTE_TIMEOUT_MS), + ))); + + let mut payload = format!("{NODE_EXEC_PREFIX}\t{token}"); + for arg in args { + payload.push('\t'); + payload.push_str(arg); + } + + socket + .send_to( + payload.as_bytes(), + SocketAddrV4::new(*addr.ip(), DISCOVERY_PORT), + ) + .map_err(|err| format!("Failed to send remote command: {err}"))?; + + let mut buf = [0_u8; RECV_BUF_LARGE]; + let (len, _) = socket + .recv_from(&mut buf) + .map_err(|_| "Remote command timed out".to_string())?; + let payload = String::from_utf8_lossy(&buf[..len]); + let Some((prefix, result)) = payload.split_once('\t') else { + return Err("Invalid remote response".to_string()); + }; + if prefix != NODE_EXEC_RESULT_PREFIX { + return Err("Unexpected remote response".to_string()); + } + Ok(result.to_string()) +} + +pub fn commands() -> &'static [ModuleCommand] { + &[ + ModuleCommand { + name: "scan", + description: "discover Arcadia LAN peers (--range, --self supported)", + run: discovery::scan, + }, + ModuleCommand { + name: "status", + description: "show LAN service status, port, and local hostname", + run: discovery::service_status, + }, + ModuleCommand { + name: "node", + description: "manage LAN nodes: pair|connect|accept|reject|alias|save|auto|status", + run: handlers::node, + }, + ModuleCommand { + name: "session_targets", + description: "JSON [{\"ip\",\"hostname\"}] for connected approved LAN peers (remote route picker)", + run: session_targets, + }, + ] +} diff --git a/Shared/ArcadiaCore/src/modules/lan/peers.rs b/Shared/ArcadiaCore/src/modules/lan/peers.rs new file mode 100644 index 0000000..a2b53bc --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan/peers.rs @@ -0,0 +1,42 @@ +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock}; + +use super::config::normalize_node_identifier; +use super::protocol::{PeerRecord, PeerStatus}; + +#[derive(Default)] +pub struct NodeState { + pub peers: BTreeMap, +} + +static NODE_STATE: OnceLock> = OnceLock::new(); + +pub fn node_state() -> &'static Mutex { + NODE_STATE.get_or_init(|| Mutex::new(NodeState::default())) +} + +pub fn record_peer(ip: String, hostname: String, status: PeerStatus) { + if let Ok(mut guard) = node_state().lock() { + let record = guard.peers.entry(ip.clone()).or_insert(PeerRecord { + ip, + hostname: hostname.clone(), + status, + }); + record.hostname = hostname; + record.status = status; + } +} + +pub fn match_peer_key(identifier: &str, peers: &BTreeMap) -> Option { + let key = normalize_node_identifier(identifier); + if let Some((ip, _)) = peers + .iter() + .find(|(ip, _)| normalize_node_identifier(ip) == key) + { + return Some(ip.clone()); + } + peers + .iter() + .find(|(_, peer)| normalize_node_identifier(&peer.hostname) == key) + .map(|(ip, _)| ip.clone()) +} diff --git a/Shared/ArcadiaCore/src/modules/lan/protocol.rs b/Shared/ArcadiaCore/src/modules/lan/protocol.rs new file mode 100644 index 0000000..3b5e5e8 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan/protocol.rs @@ -0,0 +1,38 @@ +pub const DISCOVERY_PORT: u16 = 46291; +pub const DISCOVERY_REQUEST: &str = "ARCADIA_LAN_DISCOVER_V1"; +pub const DISCOVERY_RESPONSE_PREFIX: &str = "ARCADIA_LAN_HERE_V1"; +pub const NODE_CONNECT_PREFIX: &str = "ARCADIA_NODE_CONNECT_V1"; +pub const NODE_ACCEPT_PREFIX: &str = "ARCADIA_NODE_ACCEPT_V1"; +pub const NODE_REJECT_PREFIX: &str = "ARCADIA_NODE_REJECT_V1"; +pub const NODE_EXEC_PREFIX: &str = "ARCADIA_NODE_EXEC_V1"; +pub const NODE_EXEC_RESULT_PREFIX: &str = "ARCADIA_NODE_EXEC_RESULT_V1"; +pub const SCAN_WAIT_MS: u64 = 800; +pub const DEFAULT_REMOTE_TIMEOUT_MS: u64 = 2_000; +pub const RECV_BUF_SMALL: usize = 1024; +pub const RECV_BUF_LARGE: usize = 65_507; + +#[derive(Clone, Copy)] +pub enum PeerStatus { + PendingInbound, + PendingOutbound, + Connected, + Rejected, +} + +impl PeerStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::PendingInbound => "pending-inbound", + Self::PendingOutbound => "pending-outbound", + Self::Connected => "connected", + Self::Rejected => "rejected", + } + } +} + +#[derive(Clone)] +pub struct PeerRecord { + pub ip: String, + pub hostname: String, + pub status: PeerStatus, +} diff --git a/Shared/ArcadiaCore/src/modules/mod.rs b/Shared/ArcadiaCore/src/modules/mod.rs index c88fb52..c0e1123 100644 --- a/Shared/ArcadiaCore/src/modules/mod.rs +++ b/Shared/ArcadiaCore/src/modules/mod.rs @@ -1,11 +1,17 @@ pub mod lan; pub mod net; +pub mod remote_mirror; +pub mod remote_session; pub mod shell; +pub mod shell_motd; +pub mod surface; -use crate::config::modules::{ModulesConfig, NET_MODULE_NAME}; +use crate::config::modules::{ + ModulesConfig, LAN_MODULE_NAME, NET_MODULE_NAME, REMOTE_SESSION_MODULE_NAME, +}; use crate::config::ConfigFile; -#[derive(Default)] +#[derive(Clone, Default)] pub struct ExecutionContext { pub net_as: Option, pub net_timeout_ms: Option, @@ -21,7 +27,10 @@ fn module_commands(module_name: &str) -> Option<&'static [ModuleCommand]> { match module_name { lan::NAME => Some(lan::commands()), net::NAME => Some(net::commands()), + remote_session::NAME => Some(remote_session::commands()), shell::NAME => Some(shell::commands()), + shell_motd::NAME => Some(shell_motd::commands()), + surface::NAME => Some(surface::commands()), _ => None, } } @@ -79,9 +88,14 @@ pub fn enabled_module_command_names(module_name: &str) -> Vec { let Some(commands) = module_commands(module_name) else { return Vec::new(); }; - commands.iter().map(|command| command.name.to_string()).collect() + commands + .iter() + .map(|command| command.name.to_string()) + .collect() } +/// Dispatches `module.command` locally or forwards via `ExecutionContext::net_as` (e.g. `lan:`). +/// LAN forwarding uses one code path for every registered token — peer runs normal module checks. pub fn execute_command( token: &str, args: &[&str], @@ -95,10 +109,6 @@ pub fn execute_command( return Ok(None); }; - if !module_enabled(module_name)? { - return Err(format!("Module {module_name} is disabled")); - } - let Some(command) = commands.iter().find(|command| command.name == command_name) else { return Err(format!("Unknown command: {token}")); }; @@ -111,12 +121,31 @@ pub fn execute_command( if let Some(route) = &context.net_as { if let Some(target) = route.strip_prefix("lan:") { - let response = lan::execute_remote_command(target, token, args, context.net_timeout_ms)?; + if target.is_empty() { + return Err("Invalid LAN route: use lan:".to_string()); + } + if !module_enabled(REMOTE_SESSION_MODULE_NAME)? { + return Err( + "LAN command routing requires remote-session module to be enabled locally" + .to_string(), + ); + } + if !module_enabled(LAN_MODULE_NAME)? { + return Err( + "LAN command routing requires lan module to be enabled locally".to_string(), + ); + } + let response = + lan::execute_remote_command(target, token, args, context.net_timeout_ms)?; return Ok(Some(response)); } return Err(format!("Unsupported net route: {route}")); } + if !module_enabled(module_name)? { + return Err(format!("Module {module_name} is disabled")); + } + Ok(Some((command.run)(args, context))) } @@ -165,12 +194,23 @@ pub fn all_command_entries() -> Vec<(String, String)> { } pub fn load_all() { - let _known_modules = [lan::NAME, net::NAME, shell::NAME]; - lan::start_service(); + let _known_modules = [ + lan::NAME, + net::NAME, + remote_session::NAME, + shell::NAME, + shell_motd::NAME, + surface::NAME, + ]; if let Err(err) = ModulesConfig::load_or_create() { eprintln!("Failed to load modules config: {err}"); } + + // Service binds port regardless; respects lan_enabled() per-request. + if let Err(err) = lan::start_service() { + eprintln!("Failed to start LAN service: {err}"); + } } pub fn shutdown_all() { diff --git a/Shared/ArcadiaCore/src/modules/remote_mirror.rs b/Shared/ArcadiaCore/src/modules/remote_mirror.rs new file mode 100644 index 0000000..31d1148 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/remote_mirror.rs @@ -0,0 +1,65 @@ +//! After **every** inbound LAN NODE_EXEC completes (`execute_command` on the host), enqueue token + args + result here. +//! Same path for all modules — not shell-specific. Surfaces drain formatted lines into their transcript / log UI. +//! +//! Also raises [`request_host_ui_sync_after_peer_exec`] so local shells can reload module/nav state from disk. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +const CAP: usize = 256; + +static HOST_UI_SYNC_PENDING: AtomicBool = AtomicBool::new(false); + +/// Peer NODE_EXEC finished on this host — surfaces should reload host-backed UI when showing local state. +pub fn request_host_ui_sync_after_peer_exec() { + HOST_UI_SYNC_PENDING.store(true, Ordering::SeqCst); +} + +pub fn take_host_ui_sync_pending() -> bool { + HOST_UI_SYNC_PENDING.swap(false, Ordering::SeqCst) +} + +pub struct RemoteMirrorEvent { + pub token: String, + pub args: Vec, + pub output: String, +} + +fn queue() -> &'static Mutex> { + static Q: OnceLock>> = OnceLock::new(); + Q.get_or_init(|| Mutex::new(VecDeque::new())) +} + +pub fn enqueue_remote_exec_mirror(token: String, args: Vec, output: String) { + let Ok(mut q) = queue().lock() else { + return; + }; + while q.len() >= CAP { + q.pop_front(); + } + q.push_back(RemoteMirrorEvent { + token, + args, + output, + }); +} + +/// Plain-text lines for the host transcript (one block per mirrored NODE_EXEC). +pub fn drain_formatted_mirror_lines() -> Vec { + let Ok(mut q) = queue().lock() else { + return Vec::new(); + }; + let mut lines = Vec::new(); + while let Some(ev) = q.pop_front() { + let header = if ev.args.is_empty() { + format!("⟵ remote {}", ev.token) + } else { + format!("⟵ remote {} {}", ev.token, ev.args.join(" ")) + }; + lines.push(header); + lines.extend(ev.output.lines().map(|s| s.to_string())); + lines.push(String::new()); + } + lines +} diff --git a/Shared/ArcadiaCore/src/modules/remote_session.rs b/Shared/ArcadiaCore/src/modules/remote_session.rs new file mode 100644 index 0000000..9881d17 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/remote_session.rs @@ -0,0 +1,9 @@ +//! LAN routing gate (`execute_command` with `net_as: lan:…`). No dedicated mirror verbs — use [`crate::modules::surface`]. + +use crate::modules::ModuleCommand; + +pub const NAME: &str = "remote-session"; + +pub fn commands() -> &'static [ModuleCommand] { + &[] +} diff --git a/Shared/ArcadiaCore/src/modules/shell.rs b/Shared/ArcadiaCore/src/modules/shell.rs index 8f07c00..e065d05 100644 --- a/Shared/ArcadiaCore/src/modules/shell.rs +++ b/Shared/ArcadiaCore/src/modules/shell.rs @@ -1,6 +1,37 @@ use crate::modules::{ExecutionContext, ModuleCommand}; +use std::sync::OnceLock; pub const NAME: &str = "shell"; +type InternalExecutor = fn(&str) -> String; +static INTERNAL_EXECUTOR: OnceLock = OnceLock::new(); + +pub fn set_internal_executor(executor: InternalExecutor) { + let _ = INTERNAL_EXECUTOR.set(executor); +} + +fn strip_ansi_sequences(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut i = 0; + while i < bytes.len() { + // Drop ANSI CSI sequences: ESC [ ... final-byte. + if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + i += 2; + while i < bytes.len() { + let b = bytes[i]; + if (0x40..=0x7E).contains(&b) { + i += 1; + break; + } + i += 1; + } + continue; + } + out.push(bytes[i] as char); + i += 1; + } + out +} fn execute(args: &[&str], context: &ExecutionContext) -> String { if args.is_empty() { @@ -31,15 +62,21 @@ fn execute(args: &[&str], context: &ExecutionContext) -> String { match output { Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = strip_ansi_sequences(&String::from_utf8_lossy(&output.stdout)); + let stderr = strip_ansi_sequences(&String::from_utf8_lossy(&output.stderr)); let mut lines = Vec::new(); - lines.push(format!("exit: {:?}", output.status.code())); if !stdout.is_empty() { - lines.push(format!("stdout:\n{stdout}")); + lines.push(stdout); } if !stderr.is_empty() { - lines.push(format!("stderr:\n{stderr}")); + lines.push(stderr); + } + if !output.status.success() { + if let Some(code) = output.status.code() { + lines.push(format!("(exit code: {code})")); + } else { + lines.push("(process terminated by signal)".to_string()); + } } lines.join("\n") } @@ -48,10 +85,29 @@ fn execute(args: &[&str], context: &ExecutionContext) -> String { } } +fn internal(args: &[&str], context: &ExecutionContext) -> String { + if args.is_empty() { + return "Usage: shell.internal ".to_string(); + } + let _ = context; + let command_line = args.join(" "); + match INTERNAL_EXECUTOR.get() { + Some(executor) => executor(&command_line), + None => "shell.internal is not available in this runtime".to_string(), + } +} + pub fn commands() -> &'static [ModuleCommand] { - &[ModuleCommand { - name: "execute", - description: "execute shell command(s): shell.execute ", - run: execute, - }] + &[ + ModuleCommand { + name: "execute", + description: "execute shell command(s): shell.execute ", + run: execute, + }, + ModuleCommand { + name: "internal", + description: "execute internal CLI command(s): shell.internal ", + run: internal, + }, + ] } diff --git a/Shared/ArcadiaCore/src/modules/shell_motd.rs b/Shared/ArcadiaCore/src/modules/shell_motd.rs new file mode 100644 index 0000000..d0fcd00 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/shell_motd.rs @@ -0,0 +1,676 @@ +//! Arcadia MOTD — ethereal gateway scene (gradient sky, parabolic arch, sun, dunes) + system info. + +use crate::modules::{ExecutionContext, ModuleCommand}; +use std::fmt::Write as _; + +pub const NAME: &str = "shell-motd"; + +// ── pixel / color helpers ───────────────────────────────────────────────────── + +/// One monospace cell — full block + matching fg so GPUI/fonts align columns like real TUIs. +fn px(r: u8, g: u8, b: u8) -> String { + format!("\x1b[38;2;{r};{g};{b}m\x1b[48;2;{r};{g};{b}m█\x1b[0m") +} + +fn star_tile(fr: u8, fg: u8, fb: u8, br: u8, bg: u8, bb: u8) -> String { + format!("\x1b[38;2;{fr};{fg};{fb}m\x1b[48;2;{br};{bg};{bb}m█\x1b[0m") +} + +fn lerp(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round() as u8 +} + +fn lerp3(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) { + (lerp(a.0, b.0, t), lerp(a.1, b.1, t), lerp(a.2, b.2, t)) +} + +fn lighten(rgb: (u8, u8, u8), amt: u8) -> (u8, u8, u8) { + ( + rgb.0.saturating_add(amt).min(255), + rgb.1.saturating_add(amt).min(255), + rgb.2.saturating_add((amt / 2).min(255)), + ) +} + +/// Visible char count — strips ANSI CSI/OSC sequences. +fn vw(s: &str) -> usize { + let mut n = 0usize; + let mut it = s.chars().peekable(); + while let Some(ch) = it.next() { + if ch == '\x1b' { + match it.peek().copied() { + Some('[') => { + it.next(); + while let Some(c) = it.next() { + if ('\x40'..='\x7e').contains(&c) { + break; + } + } + } + Some(']') => { + it.next(); + while let Some(c) = it.next() { + if c == '\x07' { + break; + } + if c == '\x1b' && it.peek() == Some(&'\\') { + it.next(); + break; + } + } + } + _ => {} + } + continue; + } + n += 1; + } + n +} + +// ── Arcadia arch art ────────────────────────────────────────────────────────── + +fn arch_art_lines() -> Vec { + /// Inner scene width; outer row width adds symmetric gutters for flush vertical edges. + const GUTTER: usize = 2; + /// Drawable columns — row length is `IW + 2 * GUTTER` (fixed vertical gutters). + const IW: usize = 40; + const SKY_ROWS: usize = 8; + const DUNE_ROWS: usize = 2; + const BASE_W: f32 = 36.0; + + let cx_f = (IW as f32) / 2.0; + let gs = (IW as f32) / BASE_W; + let gutter_rgb = (22, 16, 56); + + // Outer sky: deep indigo → warm dusk (Image #2 vibe) + let sky_out_t: (u8, u8, u8) = (42, 28, 92); + let sky_out_m: (u8, u8, u8) = (88, 48, 138); + let sky_out_b: (u8, u8, u8) = (168, 92, 118); + // Inner sky (through gate): cooler, subtler horizon bloom + let sky_in_t: (u8, u8, u8) = (38, 36, 108); + let sky_in_m: (u8, u8, u8) = (72, 52, 142); + let sky_in_b: (u8, u8, u8) = (122, 72, 132); + + // Arch — luminous lilac stack + deeper silhouettes on outer faces + let arch_core: (u8, u8, u8) = (252, 248, 255); + let arch_mid: (u8, u8, u8) = (224, 214, 248); + let arch_mid2: (u8, u8, u8) = (202, 188, 236); + let arch_edge: (u8, u8, u8) = (168, 150, 222); + let arch_deep: (u8, u8, u8) = (132, 112, 188); + + let sun_c: (u8, u8, u8) = (255, 242, 188); + let sun_i: (u8, u8, u8) = (253, 228, 158); + let sun_g: (u8, u8, u8) = (246, 188, 102); + let sun_o: (u8, u8, u8) = (218, 132, 122); + let sun_r: (u8, u8, u8) = (158, 92, 138); + + // Layered dunes + let dune_back: (u8, u8, u8) = (52, 36, 118); + let dune_mid: (u8, u8, u8) = (44, 32, 98); + let dune_front: (u8, u8, u8) = (36, 26, 82); + + let stars_raw: &[(usize, usize, char)] = &[ + (1, 5, '.'), + (1, 18, '.'), + (1, 31, '.'), + (2, 8, '.'), + (2, 22, '*'), + (2, 33, '.'), + (3, 6, '.'), + (3, 15, '.'), + (3, 28, '.'), + (4, 11, '.'), + (4, 24, '.'), + (5, 5, '.'), + (5, 13, '*'), + (5, 20, '.'), + (5, 30, '.'), + (6, 9, '.'), + (6, 26, '.'), + (7, 7, '.'), + (7, 18, '.'), + (7, 32, '.'), + ]; + let wf_inner = IW as f32; + let stars: Vec<(usize, usize, char)> = stars_raw + .iter() + .copied() + .map(|(r, c, ch)| { + let nc = ((c as f32 / BASE_W) * wf_inner) + .round() + .clamp(0.0, wf_inner - 1.0) as usize; + (r, nc, ch) + }) + .collect(); + + let mut out = Vec::with_capacity(SKY_ROWS + DUNE_ROWS); + + /// Sky color by region and vertical position (smooth 3-stop gradient). + fn sky_at( + outer: bool, + y: f32, + sky_out_t: (u8, u8, u8), + sky_out_m: (u8, u8, u8), + sky_out_b: (u8, u8, u8), + sky_in_t: (u8, u8, u8), + sky_in_m: (u8, u8, u8), + sky_in_b: (u8, u8, u8), + ) -> (u8, u8, u8) { + let (t, m, b) = if outer { + (sky_out_t, sky_out_m, sky_out_b) + } else { + (sky_in_t, sky_in_m, sky_in_b) + }; + if y < 0.5 { + let u = y * 2.0; + lerp3(t, m, u) + } else { + let u = (y - 0.5) * 2.0; + lerp3(m, b, u) + } + } + + for r in 0..SKY_ROWS { + let t = r as f32 / (SKY_ROWS - 1).max(1) as f32; + let mut line = String::new(); + for _ in 0..GUTTER { + line.push_str(&px(gutter_rgb.0, gutter_rgb.1, gutter_rgb.2)); + } + + // Parabolic opening: narrow aloft, wide near horizon (∩ gateway). + let half_open = (3.2 + 11.8 * t.powf(1.35)) * gs; + let inner_l = (cx_f - half_open).floor() as isize; + let inner_r = (cx_f + half_open).ceil() as isize; + let pillar: isize = if r < 2 { + (2.0 * gs).round().clamp(2.0, 4.0) as isize + } else { + (3.0 * gs).round().clamp(3.0, 5.0) as isize + }; + + let lp0 = inner_l - pillar; + let lp1 = inner_l; + let rp0 = inner_r; + let rp1 = inner_r + pillar; + + // Keystone / curved lintel (rows 0–1): stone bridge above gap. + let lintel_half = half_open + pillar as f32 + 1.2 * gs; + let cap_row = r <= 1; + + for ic in 0..IW { + let c = ic as isize; + let cf = ic as f32; + + let dist_top = ((cf - cx_f).powi(2) + ((r as f32) - 0.8).powi(2)).sqrt(); + let on_lintel = + cap_row && dist_top <= lintel_half + 1.8 * gs && dist_top >= lintel_half - 2.6 * gs; + + let in_open = c >= lp1 && c < rp0; + let left_pillar = c >= lp0 && c < lp1; + let right_pillar = c >= rp0 && c < rp1; + let outer_left = c < lp0; + let outer_right = c >= rp1; + + let region_open = in_open && !on_lintel; + + let dx = (cf - cx_f).abs(); + let sr = r as f32; + let sun_layer = if region_open && sr >= 3.0 { + let spread = gs * (5.2 + (sr - 3.0) * 0.75); + if dx <= spread { + if sr >= 6.0 && dx <= 1.05 * gs { + Some(sun_c) + } else if sr >= 5.5 && dx <= 1.85 * gs { + Some(sun_i) + } else if sr >= 5.0 && dx <= 3.2 * gs { + Some(sun_g) + } else if sr >= 4.0 && dx <= 4.9 * gs { + Some(sun_o) + } else if dx <= spread { + Some(sun_r) + } else { + None + } + } else { + None + } + } else { + None + }; + + let arch_px = if on_lintel { + let rim = (dist_top - lintel_half).abs(); + Some(if rim < 0.9 { + arch_core + } else if rim < 1.7 { + arch_mid + } else { + arch_edge + }) + } else if left_pillar || right_pillar { + let toward_inner = if left_pillar { + (lp1 - 1 - c).max(0) + } else { + (c - rp0).max(0) + }; + Some(match toward_inner { + 0 => arch_core, + 1 => arch_mid, + 2 => arch_mid2, + 3 => arch_edge, + _ => arch_deep, + }) + } else { + None + }; + + if let Some(col) = arch_px { + line.push_str(&px(col.0, col.1, col.2)); + } else if let Some(col) = sun_layer { + line.push_str(&px(col.0, col.1, col.2)); + } else { + let outer = outer_left || outer_right; + let bg = sky_at( + outer, t, sky_out_t, sky_out_m, sky_out_b, sky_in_t, sky_in_m, sky_in_b, + ); + let star = stars.iter().find(|s| s.0 == r && s.1 == ic); + if let Some(&(_, _, ch)) = star { + let (fr, fg, fb) = if ch == '*' { + (255, 254, 245) + } else { + (216, 210, 238) + }; + line.push_str(&star_tile(fr, fg, fb, bg.0, bg.1, bg.2)); + } else { + line.push_str(&px(bg.0, bg.1, bg.2)); + } + } + } + + for _ in 0..GUTTER { + line.push_str(&px(gutter_rgb.0, gutter_rgb.1, gutter_rgb.2)); + } + out.push(line); + } + + // Rolling dunes + faint crest highlights (same width as sky incl. gutters). + for hill_r in 0..DUNE_ROWS { + let mut line = String::new(); + for _ in 0..GUTTER { + line.push_str(&px(gutter_rgb.0, gutter_rgb.1, gutter_rgb.2)); + } + for ic in 0..IW { + let x = ic as f32 / (IW - 1).max(1) as f32; + let wave_a = (x * 6.25).sin(); + let wave_b = (x * 4.05 + 1.05).sin(); + let h0 = (wave_a * 0.35 + 0.5) > (hill_r as f32 * 0.12 + 0.38); + let h1 = (wave_b * 0.28 + 0.52) > (hill_r as f32 * 0.1 + 0.42); + let mut col = if hill_r == 0 { + if h0 { + dune_back + } else { + dune_mid + } + } else if h1 { + dune_mid + } else { + dune_front + }; + let crest = if hill_r == 0 { + wave_a.abs() + } else { + wave_b.abs() + }; + if crest > 0.92 { + col = lighten(col, 14); + } + line.push_str(&px(col.0, col.1, col.2)); + } + for _ in 0..GUTTER { + line.push_str(&px(gutter_rgb.0, gutter_rgb.1, gutter_rgb.2)); + } + out.push(line); + } + + out +} + +// ── system info ─────────────────────────────────────────────────────────────── + +fn run_cmd(program: &str, args: &[&str]) -> Option { + std::process::Command::new(program) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "user".into()) +} + +fn machine_model() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(s) = std::fs::read_to_string("/sys/devices/virtual/dmi/id/product_name") { + let s = s.trim(); + if !s.is_empty() && s != "Default string" { + return s.to_string(); + } + } + } + #[cfg(target_os = "macos")] + { + if let Some(m) = run_cmd("sysctl", &["-n", "hw.model"]) { + return m; + } + } + #[cfg(target_os = "windows")] + { + if let Some(o) = run_cmd("wmic", &["computersystem", "get", "model"]) { + let line = o.lines().nth(1).unwrap_or("").trim(); + if !line.is_empty() && !line.eq_ignore_ascii_case("model") { + return line.to_string(); + } + } + } + hostname_str() +} + +fn hostname_str() -> String { + #[cfg(target_os = "windows")] + { + return std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows".into()); + } + #[cfg(not(target_os = "windows"))] + { + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let h = h.lines().next().unwrap_or("").trim(); + if !h.is_empty() { + return h.to_string(); + } + } + run_cmd("hostname", &[]).unwrap_or_else(|| "localhost".into()) + } +} + +fn os_pretty() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(txt) = std::fs::read_to_string("/etc/os-release") { + for line in txt.lines() { + if let Some(rest) = line.strip_prefix("PRETTY_NAME=") { + let v = rest.trim().trim_matches('"').trim_matches('\''); + if !v.is_empty() { + return v.to_string(); + } + } + } + } + return "Linux".into(); + } + #[cfg(target_os = "macos")] + { + let name = run_cmd("sw_vers", &["-productName"]).unwrap_or_else(|| "macOS".into()); + let ver = run_cmd("sw_vers", &["-productVersion"]).unwrap_or_default(); + if ver.is_empty() { + name + } else { + format!("{name} {ver}") + } + } + #[cfg(target_os = "windows")] + { + run_cmd("cmd", &["/C", "ver"]).unwrap_or_else(|| "Windows".into()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + "Arcadia".into() + } +} + +fn kernel_line() -> String { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let sys = run_cmd("uname", &["-s"]).unwrap_or_else(|| "Unix".into()); + let rel = run_cmd("uname", &["-r"]).unwrap_or_default(); + return if rel.is_empty() { + sys + } else { + format!("{sys} {rel}") + }; + } + #[cfg(target_os = "windows")] + { + return run_cmd("cmd", &["/C", "ver"]).unwrap_or_else(|| "Windows NT".into()); + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + "unknown".into() + } +} + +fn uptime_line() -> String { + #[cfg(target_os = "linux")] + { + if let Some(u) = run_cmd("uptime", &["-p"]) { + return u; + } + } + if let Some(u) = run_cmd("uptime", &[]) { + return u; + } + "n/a".into() +} + +fn shell_env() -> String { + std::env::var("SHELL") + .or_else(|_| std::env::var("COMSPEC")) + .unwrap_or_else(|_| "n/a".into()) +} + +fn desktop_env() -> String { + std::env::var("XDG_CURRENT_DESKTOP") + .or_else(|_| std::env::var("DESKTOP_SESSION")) + .unwrap_or_else(|_| "n/a".into()) +} + +fn cpu_model() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") { + for line in cpuinfo.lines() { + if let Some(m) = line + .strip_prefix("model name\t: ") + .or_else(|| line.strip_prefix("model name : ")) + { + return m.trim().to_string(); + } + } + } + } + #[cfg(target_os = "macos")] + { + if let Some(m) = run_cmd("sysctl", &["-n", "machdep.cpu.brand_string"]) { + return m; + } + } + #[cfg(target_os = "windows")] + { + if let Some(o) = run_cmd("wmic", &["cpu", "get", "name"]) { + let line = o.lines().nth(1).unwrap_or("").trim(); + if !line.is_empty() { + return line.to_string(); + } + } + } + "CPU".into() +} + +fn memory_line() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(txt) = std::fs::read_to_string("/proc/meminfo") { + let mut total_kb = 0u64; + let mut avail_kb = None::; + for line in txt.lines() { + if let Some(n) = line.strip_prefix("MemTotal:") { + total_kb = n + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } + if let Some(n) = line.strip_prefix("MemAvailable:") { + avail_kb = n.split_whitespace().next().and_then(|s| s.parse().ok()); + } + } + if total_kb > 0 { + let avail = avail_kb.unwrap_or(0); + let used = total_kb.saturating_sub(avail); + let pct = 100.0 * used as f64 / total_kb as f64; + return format!("{} MiB / {} MiB ({pct:.0}%)", used / 1024, total_kb / 1024); + } + } + } + #[cfg(target_os = "macos")] + { + if let Some(bytes) = run_cmd("sysctl", &["-n", "hw.memsize"]) { + if let Ok(b) = bytes.parse::() { + return format!("{} GiB total", b / (1024 * 1024 * 1024)); + } + } + } + #[cfg(target_os = "windows")] + { + if let Some(o) = run_cmd("wmic", &["computersystem", "get", "TotalPhysicalMemory"]) { + if let Some(line) = o.lines().nth(1) { + if let Ok(bytes) = line.trim().parse::() { + return format!("{} GiB total", bytes / (1024 * 1024 * 1024)); + } + } + } + } + "n/a".into() +} + +/// Left column labels for stats block (fixed width reads cleaner beside wide art). +fn lbl_col(key: &str, width: usize) -> String { + format!( + "\x1b[38;2;176;162;236m{key: String { + format!( + "{} \x1b[38;2;235;238;248m{value}\x1b[0m", + lbl_col(key, label_w), + ) +} + +fn palette_footer() -> String { + let colors: &[(u8, u8, u8)] = &[ + (42, 28, 92), + (88, 48, 138), + (168, 92, 118), + (255, 238, 168), + (218, 208, 246), + (52, 36, 118), + (36, 26, 82), + (255, 252, 255), + ]; + let mut s = String::new(); + s.push_str("\x1b[38;2;140;125;188m.\x1b[0m "); + for &(r, g, b) in colors { + let _ = write!(s, "\x1b[38;2;{r};{g};{b}mo\x1b[0m "); + } + s.trim_end().to_string() +} + +fn gather_right_column() -> Vec { + const KW: usize = 10; + let host = hostname_str(); + let user = username(); + let head = format!( + "\x1b[1m\x1b[38;2;248;246;255m{user}\x1b[0m\x1b[38;2;158;138;220m@\x1b[0m\x1b[1m\x1b[38;2;236;232;252m{host}\x1b[0m" + ); + let sep_n = format!("{user}@{host}").chars().count().clamp(28, 44); + let sep: String = std::iter::repeat('─').take(sep_n).collect(); + let sep_line = format!("\x1b[38;2;118;102;198m{sep}\x1b[0m"); + + let mut lines = vec![head, sep_line]; + let pairs: [(&str, String); 9] = [ + ("OS", os_pretty()), + ("Host", machine_model()), + ("Kernel", kernel_line()), + ("Uptime", uptime_line()), + ("Shell", shell_env()), + ("DE", desktop_env()), + ("Terminal", "Arcadia".into()), + ("CPU", cpu_model()), + ("Memory", memory_line()), + ]; + for (k, v) in pairs { + lines.push(stat_row(k, &v, KW)); + } + lines.push(palette_footer()); + lines +} + +fn merge_ansi(left: &[String], right: &[String]) -> Vec { + let lh = left.len(); + let rh = right.len(); + let max_h = lh.max(rh); + let pad_l_top = (max_h - lh) / 2; + let pad_l_bot = max_h.saturating_sub(pad_l_top + lh); + let pad_r_top = (max_h - rh) / 2; + let pad_r_bot = max_h.saturating_sub(pad_r_top + rh); + + let mut lpadded = vec![String::new(); pad_l_top]; + lpadded.extend(left.iter().cloned()); + lpadded.extend(std::iter::repeat_with(|| String::new()).take(pad_l_bot)); + + let mut rpadded = vec![String::new(); pad_r_top]; + rpadded.extend(right.iter().cloned()); + rpadded.extend(std::iter::repeat_with(|| String::new()).take(pad_r_bot)); + + let max_vw = lpadded.iter().map(|s| vw(s)).max().unwrap_or(0); + let gap = " ".repeat(6); + lpadded + .into_iter() + .zip(rpadded.into_iter()) + .map(|(l, r)| { + let pad = " ".repeat(max_vw.saturating_sub(vw(&l))); + format!("{l}{pad}{gap}{r}") + }) + .collect() +} + +// ── public API ──────────────────────────────────────────────────────────────── + +fn show(_args: &[&str], _ctx: &ExecutionContext) -> String { + motd_string() +} + +pub fn commands() -> &'static [ModuleCommand] { + &[ModuleCommand { + name: "show", + description: "print Arcadia-style system banner (MOTD)", + run: show, + }] +} + +pub fn motd_string() -> String { + motd_lines().join("\n") +} + +pub fn motd_lines() -> Vec { + merge_ansi(&arch_art_lines(), &gather_right_column()) +} diff --git a/Shared/ArcadiaCore/src/modules/surface.rs b/Shared/ArcadiaCore/src/modules/surface.rs new file mode 100644 index 0000000..616bce2 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/surface.rs @@ -0,0 +1,235 @@ +//! Generic host UI snapshot + batched patches (extend [`SurfacePatch`] for editors, settings, etc.). + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use crate::config::modules::ModulesConfig; +use crate::config::ConfigFile; +use crate::navigation::NavigationRegistryOwned; +use crate::modules::{ExecutionContext, ModuleCommand}; + +pub const NAME: &str = "surface"; + +static SURFACE_REVISION: AtomicU64 = AtomicU64::new(1); + +pub fn bump_surface_revision() { + SURFACE_REVISION.fetch_add(1, Ordering::SeqCst); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SurfaceSnapshot { + pub modules: BTreeMap, + #[serde(default)] + pub revision: u64, + #[serde(default)] + pub extra: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum SurfacePatch { + ModulesSet { + #[serde(default)] + client_id: Option, + name: String, + enabled: bool, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct ParsedSurfaceSnapshot { + pub modules: Vec<(String, bool)>, + pub revision: u64, + pub navigation_registry: Option, +} + +pub fn parse_surface_snapshot(payload: &str) -> ParsedSurfaceSnapshot { + serde_json::from_str::(payload) + .map(|s| ParsedSurfaceSnapshot { + modules: s.modules.into_iter().collect(), + revision: s.revision, + navigation_registry: navigation_registry_from_extra(&s.extra), + }) + .unwrap_or_default() +} + +fn navigation_registry_from_extra(extra: &serde_json::Value) -> Option { + extra + .get("navigation_registry") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) +} + +fn snapshot(_args: &[&str], _ctx: &ExecutionContext) -> String { + let Ok(cfg) = ModulesConfig::load_or_create() else { + return "{}".to_string(); + }; + let revision = SURFACE_REVISION.load(Ordering::SeqCst); + let navigation_registry = serde_json::to_value(&NavigationRegistryOwned::from_static_registry()) + .unwrap_or_else(|_| serde_json::json!({})); + let snap = SurfaceSnapshot { + modules: cfg.modules.clone(), + revision, + extra: serde_json::json!({ + "schema_version": 1, + "navigation_registry": navigation_registry, + }), + }; + serde_json::to_string(&snap).unwrap_or_else(|_| "{}".to_string()) +} + +fn patch(args: &[&str], _ctx: &ExecutionContext) -> String { + let Some(payload) = args.first().copied() else { + return "Usage: surface.patch ''".to_string(); + }; + let patches: Vec = match serde_json::from_str(payload) { + Ok(p) => p, + Err(e) => return format!("Invalid surface.patch JSON: {e}"), + }; + let mut messages = Vec::new(); + for p in patches { + match p { + SurfacePatch::ModulesSet { + name, + enabled, + .. + } => { + let mut cfg = match ModulesConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("Error loading config: {e}"), + }; + if let Err(e) = cfg.set_module_state(&name, enabled) { + return e; + } + if let Err(e) = cfg.save() { + return format!("Error saving config: {e}"); + } + messages.push(format!( + "Module {name} {}", + if enabled { "enabled" } else { "disabled" } + )); + } + } + } + if messages.is_empty() { + return "No patches applied".to_string(); + } + bump_surface_revision(); + messages.join("\n") +} + +/// Helpers for surfaces that don't depend on `serde_json` directly. +pub fn snapshot_module_rows(payload: &str) -> Vec<(String, bool)> { + parse_surface_snapshot(payload).modules +} + +pub fn patch_json_modules_set(name: &str, enabled: bool, client_id: Option<&str>) -> String { + #[derive(Serialize)] + struct Row<'a> { + op: &'static str, + name: &'a str, + enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + client_id: Option<&'a str>, + } + serde_json::to_string(&vec![Row { + op: "modules_set", + name, + enabled, + client_id, + }]) + .unwrap_or_else(|_| "[]".to_string()) +} + +pub fn commands() -> &'static [ModuleCommand] { + &[ + ModuleCommand { + name: "snapshot", + description: + "JSON SurfaceSnapshot (modules, revision, extra.navigation_registry); bump revision on patch", + run: snapshot, + }, + ModuleCommand { + name: "patch", + description: + "Apply SurfacePatch JSON array (modules_set + optional client_id); shared host state for multi-client", + run: patch, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_snapshot_valid_json() { + let json = r#"{"modules":{"shell":true,"net":false},"revision":5,"extra":{}}"#; + let parsed = parse_surface_snapshot(json); + assert_eq!(parsed.revision, 5); + assert!(parsed.modules.iter().any(|(n, e)| n == "shell" && *e)); + assert!(parsed.modules.iter().any(|(n, e)| n == "net" && !e)); + } + + #[test] + fn parse_snapshot_returns_default_on_invalid() { + let parsed = parse_surface_snapshot("not json"); + assert_eq!(parsed.revision, 0); + assert!(parsed.modules.is_empty()); + assert!(parsed.navigation_registry.is_none()); + } + + #[test] + fn parse_snapshot_extracts_navigation_registry() { + use crate::navigation::NavigationRegistryOwned; + let registry = NavigationRegistryOwned::from_static_registry(); + let registry_val = serde_json::to_value(®istry).unwrap(); + let snap = SurfaceSnapshot { + modules: Default::default(), + revision: 1, + extra: serde_json::json!({ "schema_version": 1, "navigation_registry": registry_val }), + }; + let json = serde_json::to_string(&snap).unwrap(); + let parsed = parse_surface_snapshot(&json); + let nav = parsed.navigation_registry.expect("navigation_registry must deserialize"); + assert!(!nav.pages.is_empty()); + assert!(!nav.groups.is_empty()); + } + + #[test] + fn patch_json_includes_all_fields() { + let json = patch_json_modules_set("shell", true, Some("client-abc")); + assert!(json.contains(r#""op":"modules_set""#)); + assert!(json.contains(r#""name":"shell""#)); + assert!(json.contains(r#""enabled":true"#)); + assert!(json.contains(r#""client_id":"client-abc""#)); + } + + #[test] + fn patch_json_omits_client_id_when_none() { + let json = patch_json_modules_set("net", false, None); + assert!(!json.contains("client_id")); + } + + #[test] + fn surface_patch_modules_set_round_trips() { + let json = patch_json_modules_set("shell", true, Some("abc")); + let patches: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(patches.len(), 1); + match &patches[0] { + SurfacePatch::ModulesSet { name, enabled, client_id } => { + assert_eq!(name, "shell"); + assert!(*enabled); + assert_eq!(client_id.as_deref(), Some("abc")); + } + } + } + + #[test] + fn snapshot_module_rows_helper() { + let json = r#"{"modules":{"shell":true},"revision":1,"extra":{}}"#; + let rows = snapshot_module_rows(json); + assert!(rows.iter().any(|(n, e)| n == "shell" && *e)); + } +} diff --git a/Shared/ArcadiaCore/src/navigation.rs b/Shared/ArcadiaCore/src/navigation.rs new file mode 100644 index 0000000..42ea244 --- /dev/null +++ b/Shared/ArcadiaCore/src/navigation.rs @@ -0,0 +1,331 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::modules::{LAN_MODULE_NAME, NET_MODULE_NAME, SHELL_MODULE_NAME}; + +#[derive(Clone, Copy, Serialize)] +pub struct NavigationPageDefinition { + pub id: &'static str, + pub title: &'static str, + pub description: &'static str, + pub glyph: &'static str, + pub system_image: &'static str, + /// Theme key for sidebar-selected fills and icon tint (`Desktop/src/gui/theme.rs`, `AppTheme` on iOS). + pub accent: &'static str, + /// When set, the page is shown only if this module is enabled (`MODULE_REGISTRY` name). + #[serde(skip_serializing_if = "Option::is_none")] + pub required_module: Option<&'static str>, +} + +#[derive(Clone, Copy, Serialize)] +pub struct NavigationGroupDefinition { + pub id: &'static str, + pub label: &'static str, + pub glyph: &'static str, + pub system_image: &'static str, + pub pages: &'static [&'static str], + pub accent: &'static str, +} + +#[derive(Serialize)] +pub struct NavigationRegistry { + pub pages: Vec, + pub groups: Vec, + pub global_pages: Vec<&'static str>, + pub default_group: &'static str, + pub default_page: &'static str, +} + +/// Navigation mirrors sent over `surface.snapshot.extra.navigation_registry` (thin clients, mixed versions). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NavigationPageOwned { + pub id: String, + pub title: String, + pub description: String, + pub glyph: String, + #[serde(rename = "system_image")] + pub system_image: String, + pub accent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_module: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NavigationGroupOwned { + pub id: String, + pub label: String, + pub glyph: String, + #[serde(rename = "system_image")] + pub system_image: String, + pub pages: Vec, + pub accent: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NavigationRegistryOwned { + pub pages: Vec, + pub groups: Vec, + #[serde(rename = "global_pages")] + pub global_pages: Vec, + #[serde(rename = "default_group")] + pub default_group: String, + #[serde(rename = "default_page")] + pub default_page: String, +} + +impl NavigationRegistryOwned { + pub fn from_static_registry() -> Self { + Self { + pages: PAGE_DEFINITIONS.iter().map(|p| p.into()).collect(), + groups: GROUP_DEFINITIONS.iter().map(|g| g.into()).collect(), + global_pages: GLOBAL_PAGE_IDS.iter().map(|s| (*s).to_string()).collect(), + default_group: DEFAULT_GROUP_ID.to_string(), + default_page: DEFAULT_PAGE_ID.to_string(), + } + } +} + +impl From<&NavigationPageDefinition> for NavigationPageOwned { + fn from(p: &NavigationPageDefinition) -> Self { + NavigationPageOwned { + id: p.id.to_string(), + title: p.title.to_string(), + description: p.description.to_string(), + glyph: p.glyph.to_string(), + system_image: p.system_image.to_string(), + accent: p.accent.to_string(), + required_module: p.required_module.map(|s| s.to_string()), + } + } +} + +impl From<&NavigationGroupDefinition> for NavigationGroupOwned { + fn from(g: &NavigationGroupDefinition) -> Self { + NavigationGroupOwned { + id: g.id.to_string(), + label: g.label.to_string(), + glyph: g.glyph.to_string(), + system_image: g.system_image.to_string(), + pages: g.pages.iter().map(|s| (*s).to_string()).collect(), + accent: g.accent.to_string(), + } + } +} + +pub const PAGE_DEFINITIONS: &[NavigationPageDefinition] = &[ + NavigationPageDefinition { + id: "utility.shell", + title: "Shell", + description: "Run and manage shell utility actions.", + glyph: "terminal", + system_image: "terminal", + accent: "emerald", + required_module: Some(SHELL_MODULE_NAME), + }, + NavigationPageDefinition { + id: "global.dashboard", + title: "Dashboard", + description: "Overview of the Arcadia application surface.", + glyph: "home", + system_image: "house", + accent: "violet", + required_module: None, + }, + NavigationPageDefinition { + id: "global.logs", + title: "Logs", + description: "Recent logs and activity stream appear here.", + glyph: "logs", + system_image: "doc.text.magnifyingglass", + accent: "sky", + required_module: None, + }, + NavigationPageDefinition { + id: "global.settings", + title: "Settings", + description: "App preferences and configuration controls appear here.", + glyph: "settings", + system_image: "gearshape", + accent: "indigo", + required_module: None, + }, + NavigationPageDefinition { + id: "global.modules", + title: "Modules", + description: "Manage global module availability and dependency requirements.", + glyph: "modules", + system_image: "switch.2", + accent: "fuchsia", + required_module: None, + }, + NavigationPageDefinition { + id: "network.overview", + title: "Overview", + description: "Network status and module connectivity overview.", + glyph: "network", + system_image: "network", + accent: "teal", + required_module: Some(NET_MODULE_NAME), + }, + NavigationPageDefinition { + id: "network.nodes", + title: "Nodes", + description: "Discover LAN peers and manage pairing with lan.scan / lan.node.", + glyph: "nodes", + system_image: "wifi", + accent: "cyan", + required_module: Some(LAN_MODULE_NAME), + }, +]; + +pub const GROUP_DEFINITIONS: &[NavigationGroupDefinition] = &[ + NavigationGroupDefinition { + id: "utilities", + label: "Utilities", + glyph: "tools", + system_image: "wrench.and.screwdriver", + pages: &["utility.shell"], + accent: "amber", + }, + NavigationGroupDefinition { + id: "network", + label: "Network", + glyph: "network", + system_image: "network", + pages: &["network.overview", "network.nodes"], + accent: "cyan", + }, +]; + +pub const GLOBAL_PAGE_IDS: &[&str] = &["global.dashboard", "global.settings", "global.modules"]; +pub const DEFAULT_GROUP_ID: &str = "utilities"; +pub const DEFAULT_PAGE_ID: &str = "global.dashboard"; + +pub fn page_by_id(page_id: &str) -> Option<&'static NavigationPageDefinition> { + PAGE_DEFINITIONS.iter().find(|page| page.id == page_id) +} + +pub fn group_by_id(group_id: &str) -> Option<&'static NavigationGroupDefinition> { + GROUP_DEFINITIONS.iter().find(|group| group.id == group_id) +} + +pub fn default_navigation_registry() -> NavigationRegistry { + NavigationRegistry { + pages: PAGE_DEFINITIONS.to_vec(), + groups: GROUP_DEFINITIONS.to_vec(), + global_pages: GLOBAL_PAGE_IDS.to_vec(), + default_group: DEFAULT_GROUP_ID, + default_page: DEFAULT_PAGE_ID, + } +} + +pub fn default_navigation_registry_json() -> String { + serde_json::to_string(&NavigationRegistryOwned::from_static_registry()) + .expect("navigation registry serialization should always succeed") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_serializes_to_valid_json() { + let json = default_navigation_registry_json(); + assert!(!json.is_empty()); + let v: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON"); + assert!(v.is_object()); + assert!(v["pages"].is_array()); + assert!(v["groups"].is_array()); + } + + #[test] + fn registry_round_trips_through_json() { + let original = NavigationRegistryOwned::from_static_registry(); + let json = serde_json::to_string(&original).unwrap(); + let back: NavigationRegistryOwned = serde_json::from_str(&json).unwrap(); + assert_eq!(original.pages.len(), back.pages.len()); + assert_eq!(original.groups.len(), back.groups.len()); + assert_eq!(original.default_page, back.default_page); + assert_eq!(original.default_group, back.default_group); + } + + #[test] + fn page_by_id_finds_shell() { + let page = page_by_id("utility.shell").expect("utility.shell must exist"); + assert_eq!(page.id, "utility.shell"); + assert_eq!( + page.required_module, + Some(crate::config::modules::SHELL_MODULE_NAME) + ); + } + + #[test] + fn page_by_id_unknown_returns_none() { + assert!(page_by_id("does.not.exist").is_none()); + } + + #[test] + fn group_by_id_finds_network() { + let group = group_by_id("network").expect("network group must exist"); + assert!(group.pages.contains(&"network.nodes")); + } + + #[test] + fn group_by_id_unknown_returns_none() { + assert!(group_by_id("ghost-group").is_none()); + } + + #[test] + fn all_pages_with_required_module_exist_in_registry() { + use crate::config::modules::ModulesConfig; + for page in PAGE_DEFINITIONS { + if let Some(module_name) = page.required_module { + assert!( + ModulesConfig::manifest_for(module_name).is_some(), + "page '{}' requires module '{}' which is not in MODULE_REGISTRY", + page.id, + module_name + ); + } + } + } + + #[test] + fn all_group_pages_exist_in_page_definitions() { + for group in GROUP_DEFINITIONS { + for page_id in group.pages { + assert!( + page_by_id(page_id).is_some(), + "group '{}' references page '{}' not in PAGE_DEFINITIONS", + group.id, + page_id + ); + } + } + } + + #[test] + fn default_page_exists() { + assert!( + page_by_id(DEFAULT_PAGE_ID).is_some(), + "DEFAULT_PAGE_ID '{DEFAULT_PAGE_ID}' not in PAGE_DEFINITIONS" + ); + } + + #[test] + fn default_group_exists() { + assert!( + group_by_id(DEFAULT_GROUP_ID).is_some(), + "DEFAULT_GROUP_ID '{DEFAULT_GROUP_ID}' not in GROUP_DEFINITIONS" + ); + } + + #[test] + fn all_global_page_ids_exist_in_definitions() { + for page_id in GLOBAL_PAGE_IDS { + assert!( + page_by_id(page_id).is_some(), + "GLOBAL_PAGE_IDS contains '{page_id}' not in PAGE_DEFINITIONS" + ); + } + } +} diff --git a/Shared/Cargo.lock b/Shared/Cargo.lock index 19efe4b..5363062 100644 --- a/Shared/Cargo.lock +++ b/Shared/Cargo.lock @@ -63,8 +63,10 @@ name = "arcadia-core" version = "0.1.0" dependencies = [ "serde", + "serde_json", "toml 0.8.23", "uniffi", + "uuid", ] [[package]] @@ -132,6 +134,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" @@ -170,6 +184,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -222,6 +242,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "fs-err" version = "2.11.0" @@ -231,6 +257,43 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -248,6 +311,15 @@ dependencies = [ "scroll", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -260,6 +332,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -267,7 +345,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -282,6 +362,30 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "log" version = "0.4.29" @@ -344,12 +448,28 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -368,6 +488,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scroll" version = "0.12.0" @@ -456,6 +588,12 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smawk" version = "0.3.2" @@ -576,6 +714,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "uniffi" version = "0.28.3" @@ -707,6 +851,114 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -740,6 +992,100 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Shared/Scripts/Launcher.ps1 b/Shared/Scripts/Launcher.ps1 index ef627a0..ac6c64f 100644 --- a/Shared/Scripts/Launcher.ps1 +++ b/Shared/Scripts/Launcher.ps1 @@ -11,17 +11,20 @@ function Invoke-Arcadia { [string]$Feature ) - Push-Location $RootDir + $projectRoot = Resolve-Path (Join-Path $RootDir "..") + $manifestPath = "Desktop/Cargo.toml" + + Push-Location $projectRoot try { if ($Release) { Write-Host "" - Write-Host "Running: cargo run -p arcadia --release --features $Feature" - cargo run -p arcadia --release --features $Feature + Write-Host "Running: cargo run --manifest-path $manifestPath --target-dir target --release --features $Feature" + cargo run --manifest-path $manifestPath --target-dir target --release --features $Feature } else { Write-Host "" - Write-Host "Running: cargo run -p arcadia --features $Feature" - cargo run -p arcadia --features $Feature + Write-Host "Running: cargo run --manifest-path $manifestPath --target-dir target --features $Feature" + cargo run --manifest-path $manifestPath --target-dir target --features $Feature } } finally { @@ -29,6 +32,104 @@ function Invoke-Arcadia { } } +function Invoke-IosDeviceDeploy { + param( + [Parameter(Mandatory = $true)] + [bool]$Release + ) + + $configuration = if ($Release) { "Release" } else { "Debug" } + $projectPath = Join-Path $RootDir "../Mobile/iOS/ArcadiaApp.xcodeproj" + $sharedBuildScript = Join-Path $RootDir "Scripts/build-ios-framework.sh" + $derivedDataPath = Join-Path (Join-Path $RootDir "..") "build/ios-device" + $bundleId = "com.stacknode.arcadia" + $preferredDeviceName = $env:ARCADIA_IOS_DEVICE_NAME + $destinations = "" + $deviceUdid = "" + + if (-not (Get-Command xcodebuild -ErrorAction SilentlyContinue)) { + throw "xcodebuild not found. Install Xcode command line tools." + } + + if (-not (Test-Path $projectPath)) { + throw "iOS project not found at $projectPath" + } + + if (-not (Test-Path $sharedBuildScript)) { + throw "Shared iOS build script not found at $sharedBuildScript" + } + + $destinations = & xcodebuild ` + -project $projectPath ` + -scheme "ArcadiaApp" ` + -showdestinations 2>$null + + if (-not [string]::IsNullOrWhiteSpace($preferredDeviceName)) { + $deviceUdid = $destinations | + rg "platform:iOS, arch:arm64, id:[^,]+, name:$preferredDeviceName" | + rg -o "id:[^,]+" | + ForEach-Object { ($_ -split ":", 2)[1] } | + Select-Object -First 1 + } + + if ([string]::IsNullOrWhiteSpace($deviceUdid)) { + $deviceUdid = $destinations | + rg "platform:iOS, arch:arm64, id:" | + rg -o "id:[^,]+" | + ForEach-Object { ($_ -split ":", 2)[1] } | + Select-Object -First 1 + } + + if ([string]::IsNullOrWhiteSpace($deviceUdid)) { + throw "No connected physical iOS device found. Hint: set ARCADIA_IOS_DEVICE_NAME to your device name." + } + + Write-Host "" + Write-Host "Building shared iOS artifacts..." + & bash $sharedBuildScript + if ($LASTEXITCODE -ne 0) { + throw "Shared iOS artifact build failed." + } + + Push-Location (Join-Path $RootDir "..") + try { + Write-Host "" + Write-Host "Running: xcodebuild -project Mobile/iOS/ArcadiaApp.xcodeproj -scheme ArcadiaApp -configuration $configuration -destination id=$deviceUdid build" + & xcodebuild ` + -project "Mobile/iOS/ArcadiaApp.xcodeproj" ` + -scheme "ArcadiaApp" ` + -configuration $configuration ` + -destination "id=$deviceUdid" ` + -derivedDataPath $derivedDataPath ` + build + } + finally { + Pop-Location + } + + $appPath = Join-Path $derivedDataPath "Build/Products/$configuration-iphoneos/ArcadiaApp.app" + if (-not (Test-Path $appPath)) { + throw "Built app not found at $appPath" + } + + Write-Host "" + Write-Host "Installing app on device $deviceUdid..." + if ($env:ARCADIA_IOS_FORCE_UNINSTALL -eq "1") { + Write-Host "Removing existing app (if installed)..." + & xcrun devicectl device uninstall app --device $deviceUdid $bundleId 2>$null + } + & xcrun devicectl device install app --device $deviceUdid $appPath + if ($LASTEXITCODE -ne 0) { + throw "Failed to install app on device." + } + + Write-Host "Launching app ($bundleId) on device $deviceUdid..." + & xcrun devicectl device process launch --device $deviceUdid $bundleId + if ($LASTEXITCODE -ne 0) { + throw "Failed to launch app on device." + } +} + while ($true) { Clear-Host Write-Host "==================================" @@ -39,6 +140,8 @@ while ($true) { Write-Host " 1B) Launch GUI Debug" Write-Host " 2A) Launch Headless Release" Write-Host " 2B) Launch Headless Debug" + Write-Host " 3A) Deploy iOS Release on Device" + Write-Host " 3B) Deploy iOS Debug on Device" Write-Host " 0X) Exit" Write-Host "" Write-Host -NoNewline "Enter choice: " @@ -65,13 +168,21 @@ while ($true) { Invoke-Arcadia -Release $false -Feature "headless" Read-Host "Press Enter to continue..." } + "3A" { + Invoke-IosDeviceDeploy -Release $true + Read-Host "Press Enter to continue..." + } + "3B" { + Invoke-IosDeviceDeploy -Release $false + Read-Host "Press Enter to continue..." + } { $_ -in @("0X", "00", "0A", "0B") } { Write-Host "Goodbye." exit 0 } default { Write-Host "" - Write-Host "Invalid option. Use 1A, 1B, 2A, 2B, or 0X." + Write-Host "Invalid option. Use 1A, 1B, 2A, 2B, 3A, 3B, or 0X." Read-Host "Press Enter to continue..." } } diff --git a/Shared/Scripts/Launcher.sh b/Shared/Scripts/Launcher.sh index 36161ad..5811011 100755 --- a/Shared/Scripts/Launcher.sh +++ b/Shared/Scripts/Launcher.sh @@ -19,23 +19,125 @@ fi run_arcadia() { local build_mode="$1" local features="$2" + local project_root="${ROOT_DIR}/.." + local manifest_path="Desktop/Cargo.toml" echo if [[ -n "${build_mode}" ]]; then - echo "Running: cargo run -p arcadia ${build_mode} --features ${features}" + echo "Running: cargo run --manifest-path ${manifest_path} --target-dir target ${build_mode} --features ${features}" else - echo "Running: cargo run -p arcadia --features ${features}" + echo "Running: cargo run --manifest-path ${manifest_path} --target-dir target --features ${features}" fi ( - cd "${ROOT_DIR}" || exit 1 + cd "${project_root}" || exit 1 if [[ -n "${build_mode}" ]]; then - cargo run -p arcadia "${build_mode}" --features "${features}" + cargo run --manifest-path "${manifest_path}" --target-dir target "${build_mode}" --features "${features}" else - cargo run -p arcadia --features "${features}" + cargo run --manifest-path "${manifest_path}" --target-dir target --features "${features}" fi ) } +deploy_ios_device() { + if ! command -v rg >/dev/null 2>&1; then + echo "Error: rg (ripgrep) not found. Install via 'brew install ripgrep' and retry." + return 1 + fi + local configuration="$1" + local project_path="${ROOT_DIR}/../Mobile/iOS/ArcadiaApp.xcodeproj" + local shared_build_script="${ROOT_DIR}/Scripts/build-ios-framework.sh" + local derived_data_path="${ROOT_DIR}/../build/ios-device" + local bundle_id="com.stacknode.arcadia" + local preferred_device_name="${ARCADIA_IOS_DEVICE_NAME:-}" + local destinations="" + local device_udid="" + local app_path="" + + if ! command -v xcodebuild >/dev/null 2>&1; then + echo "Error: xcodebuild not found. Install Xcode command line tools." + return 1 + fi + + if [[ ! -d "${project_path}" ]]; then + echo "Error: iOS project not found at ${project_path}" + return 1 + fi + + if [[ ! -f "${shared_build_script}" ]]; then + echo "Error: shared iOS build script not found at ${shared_build_script}" + return 1 + fi + + destinations="$( + xcodebuild \ + -project "${project_path}" \ + -scheme "ArcadiaApp" \ + -showdestinations 2>/dev/null + )" + + if [[ -n "${preferred_device_name}" ]]; then + device_udid="$( + printf "%s\n" "${destinations}" | \ + rg "platform:iOS, arch:arm64, id:[^,]+, name:${preferred_device_name}" | \ + rg -o "id:[^,]+" | \ + cut -d: -f2 | \ + head -n 1 + )" + fi + + if [[ -z "${device_udid}" ]]; then + device_udid="$( + printf "%s\n" "${destinations}" | \ + rg "platform:iOS, arch:arm64, id:" | \ + rg -o "id:[^,]+" | \ + cut -d: -f2 | \ + head -n 1 + )" + fi + + if [[ -z "${device_udid}" ]]; then + echo "Error: no connected physical iOS device found." + echo "Hint: set ARCADIA_IOS_DEVICE_NAME to your device name and retry." + return 1 + fi + + echo + echo "Building shared iOS artifacts..." + bash "${shared_build_script}" || return 1 + + echo + echo "Running: xcodebuild -project Mobile/iOS/ArcadiaApp.xcodeproj -scheme ArcadiaApp -configuration ${configuration} -destination id=${device_udid} build" + ( + cd "${ROOT_DIR}/.." || exit 1 + xcodebuild \ + -project "Mobile/iOS/ArcadiaApp.xcodeproj" \ + -scheme "ArcadiaApp" \ + -configuration "${configuration}" \ + -destination "id=${device_udid}" \ + -derivedDataPath "${derived_data_path}" \ + build + ) + + app_path="${derived_data_path}/Build/Products/${configuration}-iphoneos/ArcadiaApp.app" + if [[ ! -d "${app_path}" ]]; then + echo "Error: built app not found at ${app_path}" + return 1 + fi + + if [[ "${ARCADIA_IOS_FORCE_UNINSTALL:-0}" == "1" ]]; then + echo + echo "Removing existing app (if installed)..." + xcrun devicectl device uninstall app --device "${device_udid}" "${bundle_id}" >/dev/null 2>&1 || true + fi + + echo + echo "Installing app on device ${device_udid}..." + xcrun devicectl device install app --device "${device_udid}" "${app_path}" || return 1 + + echo "Launching app (${bundle_id}) on device ${device_udid}..." + xcrun devicectl device process launch --device "${device_udid}" "${bundle_id}" || return 1 +} + while true; do clear echo "==================================" @@ -46,6 +148,8 @@ while true; do echo " 1B) Launch GUI Debug" echo " 2A) Launch Headless Release" echo " 2B) Launch Headless Debug" + echo " 3A) Deploy iOS Release on Device" + echo " 3B) Deploy iOS Debug on Device" echo " 0X) Exit" echo printf "Enter choice: " @@ -71,13 +175,21 @@ while true; do run_arcadia "" "headless" read -r -p "Press Enter to continue..." ;; + 3A) + deploy_ios_device "Release" + read -r -p "Press Enter to continue..." + ;; + 3B) + deploy_ios_device "Debug" + read -r -p "Press Enter to continue..." + ;; 0X|00|0A|0B) echo "Goodbye." exit 0 ;; *) echo - echo "Invalid option. Use 1A, 1B, 2A, 2B, or 0X." + echo "Invalid option. Use 1A, 1B, 2A, 2B, 3A, 3B, or 0X." read -r -p "Press Enter to continue..." ;; esac diff --git a/Shared/Scripts/build-ios-framework.sh b/Shared/Scripts/build-ios-framework.sh index 4d8893b..5ea3b45 100755 --- a/Shared/Scripts/build-ios-framework.sh +++ b/Shared/Scripts/build-ios-framework.sh @@ -12,6 +12,9 @@ DEVICE_LIB="${SOURCE_DIR}/target/${DEVICE_TARGET}/release/${LIB_NAME}" SIM_LIB="${SOURCE_DIR}/target/${SIM_TARGET}/release/${LIB_NAME}" XCFRAMEWORK_DIR="${OUT_DIR}/ArcadiaCore.xcframework" BINDGEN_OUT="${OUT_DIR}/Generated" +DEVICE_DIR="" +SIM_DIR="" +trap '[[ -n "${DEVICE_DIR}" ]] && rm -rf "${DEVICE_DIR}"; [[ -n "${SIM_DIR}" ]] && rm -rf "${SIM_DIR}"' EXIT # ── 0. Install targets ──────────────────────────────────────────────────────── echo "==> Installing Rust targets" @@ -46,11 +49,34 @@ for DIR in "${DEVICE_DIR}" "${SIM_DIR}"; do mkdir -p "${DIR}/Headers" "${DIR}/Modules" done -HEADER="${BINDGEN_OUT}/arcadia_coreCFFI.h" -MODULEMAP="${BINDGEN_OUT}/arcadia_coreCFFI.modulemap" +HEADER="" +MODULEMAP="" +for candidate in \ + "${BINDGEN_OUT}/arcadia_coreFFI.h" \ + "${BINDGEN_OUT}/arcadia_coreCFFI.h"; do + if [[ -f "${candidate}" ]]; then + HEADER="${candidate}" + break + fi +done + +for candidate in \ + "${BINDGEN_OUT}/arcadia_coreFFI.modulemap" \ + "${BINDGEN_OUT}/arcadia_coreCFFI.modulemap"; do + if [[ -f "${candidate}" ]]; then + MODULEMAP="${candidate}" + break + fi +done + +if [[ -z "${HEADER}" || -z "${MODULEMAP}" ]]; then + echo "Error: expected UniFFI header/modulemap not found in ${BINDGEN_OUT}" + echo " looked for arcadia_coreFFI.{h,modulemap} and arcadia_coreCFFI.{h,modulemap}" + exit 1 +fi for DIR in "${DEVICE_DIR}" "${SIM_DIR}"; do - cp "${HEADER}" "${DIR}/Headers/arcadia_coreCFFI.h" + cp "${HEADER}" "${DIR}/Headers/$(basename "${HEADER}")" cp "${MODULEMAP}" "${DIR}/Modules/module.modulemap" done diff --git a/Shared/Scripts/install-global-commands-macos.sh b/Shared/Scripts/install-global-commands-macos.sh index c8928e6..14e01db 100755 --- a/Shared/Scripts/install-global-commands-macos.sh +++ b/Shared/Scripts/install-global-commands-macos.sh @@ -14,6 +14,8 @@ cat > "${BIN_DIR}/arcadia" </dev/null -exec "${ROOT_DIR}/target/debug/arcadia" "\$@" +cd "\${PROJECT_ROOT}" +cargo build --manifest-path "\${DESKTOP_MANIFEST}" --target-dir target --no-default-features --features headless >/dev/null +exec "\${PROJECT_ROOT}/target/debug/arcadia" "\$@" EOF cat > "${BIN_DIR}/arcadia-gui" < "${BIN_DIR}/arcadia-gui" </dev/null -exec "${ROOT_DIR}/target/debug/arcadia" "\$@" +cd "\${PROJECT_ROOT}" +cargo build --manifest-path "\${DESKTOP_MANIFEST}" --target-dir target --no-default-features --features gui >/dev/null +exec "\${PROJECT_ROOT}/target/debug/arcadia" "\$@" EOF -chmod +x "${BIN_DIR}/arcadia" "${BIN_DIR}/arcadia-gui" +cat > "${BIN_DIR}/arcadia-ios" </dev/null 2>&1; then + echo "Error: xcodebuild not found. Install Xcode command line tools." + exit 1 +fi + +if [[ ! -d "\${PROJECT_ROOT}/\${PROJECT_PATH}" ]]; then + echo "Error: iOS project not found at \${PROJECT_ROOT}/\${PROJECT_PATH}" + exit 1 +fi + +if [[ ! -f "\${SHARED_BUILD_SCRIPT}" ]]; then + echo "Error: shared iOS build script not found at \${SHARED_BUILD_SCRIPT}" + exit 1 +fi + +cd "\${PROJECT_ROOT}" || exit 1 + +if ! command -v rg >/dev/null 2>&1; then + echo "Error: rg (ripgrep) not found. Install via 'brew install ripgrep' and retry." + exit 1 +fi + +DESTINATIONS="\$( + xcodebuild \ + -project "\${PROJECT_PATH}" \ + -scheme "ArcadiaApp" \ + -showdestinations 2>/dev/null +)" + +if [[ -n "\${PREFERRED_DEVICE_NAME}" ]]; then + DEVICE_UDID="\$( + printf "%s\n" "\${DESTINATIONS}" | \ + rg "platform:iOS, arch:arm64, id:[^,]+, name:\${PREFERRED_DEVICE_NAME}" | \ + rg -o "id:[^,]+" | \ + cut -d: -f2 | \ + head -n 1 + )" +fi + +if [[ -z "\${DEVICE_UDID}" ]]; then + DEVICE_UDID="\$( + printf "%s\n" "\${DESTINATIONS}" | \ + rg "platform:iOS, arch:arm64, id:" | \ + rg -o "id:[^,]+" | \ + cut -d: -f2 | \ + head -n 1 + )" +fi + +if [[ -z "\${DEVICE_UDID}" ]]; then + echo "Error: no connected physical iOS device found." + echo "Hint: set ARCADIA_IOS_DEVICE_NAME to your device name and retry." + exit 1 +fi + +echo "Building shared iOS artifacts..." +bash "\${SHARED_BUILD_SCRIPT}" + +xcodebuild \ + -project "\${PROJECT_PATH}" \ + -scheme "ArcadiaApp" \ + -configuration "Release" \ + -destination "id=\${DEVICE_UDID}" \ + -derivedDataPath "\${DERIVED_DATA_PATH}" \ + build + +APP_PATH="\${DERIVED_DATA_PATH}/Build/Products/Release-iphoneos/ArcadiaApp.app" +if [[ ! -d "\${APP_PATH}" ]]; then + echo "Error: built app not found at \${APP_PATH}" + exit 1 +fi + +echo "Installing app on device \${DEVICE_UDID}..." +if [[ "\${ARCADIA_IOS_FORCE_UNINSTALL:-0}" == "1" ]]; then + xcrun devicectl device uninstall app --device "\${DEVICE_UDID}" "\${BUNDLE_ID}" >/dev/null 2>&1 || true +fi +xcrun devicectl device install app --device "\${DEVICE_UDID}" "\${APP_PATH}" + +echo "Launching app (\${BUNDLE_ID}) on device \${DEVICE_UDID}..." +exec xcrun devicectl device process launch --device "\${DEVICE_UDID}" "\${BUNDLE_ID}" +EOF + +chmod +x "${BIN_DIR}/arcadia" "${BIN_DIR}/arcadia-gui" "${BIN_DIR}/arcadia-ios" if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then echo "Installed commands, but ${BIN_DIR} is not currently on PATH in this shell." @@ -88,3 +187,4 @@ fi echo "Use:" echo " arcadia " echo " arcadia-gui " +echo " arcadia-ios" diff --git a/gaps.md b/gaps.md new file mode 100644 index 0000000..8b53dd4 --- /dev/null +++ b/gaps.md @@ -0,0 +1,127 @@ +# Arcadia — “Ultimate” thin-client / remote-surface gaps + +This document tracks intentional limitations and follow-up work for the LAN-routed **`surface.snapshot`** / **`surface.patch`** model, multi-peer GUIs, and related architecture. It complements **`README.md`** (current behavior) and **`CLAUDE.md`** / **`AGENTS.md`** (contributor rules). + +--- + +## 1. `surface.revision` semantics + +The revision counter advances only after a successful **`surface.patch`** batch on the host. Other writers can change **`modules.toml`** without bumping revision — for example: + +- **`module …`** via CLI +- **`set_module_enabled`** / related paths through FFI + +Clients that infer freshness **only** from **`surface.revision`** can miss updates until another **`surface.patch`** occurs or they reload from disk/snapshot for other reasons. + +**Directions:** bump revision from every **`ModulesConfig::save`** (or equivalent), or stop promising revision as a global host-generation marker until coverage is complete. + +--- + +## 2. Stale / concurrent UI + +Desktop keeps **`last_surface_revision`** but does not use it for: + +- “Host changed under you” detection +- Auto-**`reload_modules`** or snapshot refresh +- User-visible warnings + +There is no periodic poll, focus hook, or push channel tied to revision. + +**Directions:** compare **`revision`** on timer/focus/after each routed command; optional banner + reload. + +--- + +## 3. Multi-writer model + +The host exposes a **single** **`modules.toml`**. Multiple GUIs (or CLI + GUI) produce **last write wins** with no: + +- Merge semantics +- Locks +- Optimistic concurrency (e.g. generation tokens on save) +- CRDT / operational transforms + +**Directions:** document as permanent constraint, or add explicit versioning / conflict errors on save. + +--- + +## 4. Transport + +Command routing still centers on **discrete remote executions** (e.g. LAN **`NODE_EXEC`** style request/response), not a **long-lived session** with: + +- Ordering guarantees across unrelated commands +- Low-latency subscriptions for snapshot deltas +- Back-pressure + +**Directions:** optional WebSocket/TCP sidecar for “thin shell” workflows while keeping **`execute_command`** as the logical API. + +--- + +## 5. Identity beyond `client_id` + +**`surface.patch`** may carry **`client_id`** (persisted per GUI in **`thin-client.toml`**). Today it is mainly for **attribution**, not: + +- Authorization (“who may patch”) +- Rate limits +- Per-client sandbox or filtered views + +**Directions:** host-side policy module or capability tokens if multi-tenant control matters. + +--- + +## 6. `SurfaceSnapshot.extra` and patch vocabulary + +**`extra.navigation_registry`** is populated; broader **`extra`** buckets (editors, arbitrary UI state) and corresponding **`SurfacePatch`** variants are **not** fully specified or wired through Desktop/iOS. + +**Directions:** define schema/version fields inside **`extra`**, extend **`SurfacePatch`** incrementally, keep surfaces consuming **`surface.*`** instead of ad hoc modules. + +--- + +## 7. Renderer-only client + +Fallback navigation still lives in **compiled core / bundled JSON** on each surface. A pure **“no local nav table”** client that trusts **only** **`surface.snapshot`** for structure is not fully enforced or documented as a supported SKU. + +**Directions:** optional build profile or runtime flag that refuses static nav when **`remote_route`** is mandatory. + +--- + +## 8. Testing & CI + +There is limited automated coverage for: + +- **`parse_surface_snapshot`** / **`NavigationRegistryOwned`** round-trips +- Thin-client preference persistence +- LAN routing integration + +iOS **`ArcadiaCore.xcframework`** rebuild after FFI changes is **manual** unless CI encodes **`Shared/Scripts/build-ios-framework.sh`**. + +**Directions:** add targeted **`arcadia-core`** tests + workflow step that fails when Generated bindings / xcframework drift from **`ffi.rs`**. + +--- + +## 9. Security posture + +Trust model today assumes **LAN pairing + locally approved peers**. There is no documented story for: + +- Encryption on the wire +- Authenticated remote principals beyond “approved node” +- Scoped capability for dangerous tokens (**`shell.execute`**, etc.) + +**Directions:** threat model doc + optional TLS or pairing secrets if Arcadia leaves trusted LANs. + +--- + +## 10. Cross-surface parity + +Behavior differs across surfaces for historical reasons: + +- Desktop **PTY / TUI** paths vs **generic** **`shell.execute`** when routed +- iOS host constraints on shell / PTY +- Not every panel is strictly **`execute_command`**-only + +**Directions:** converge on one abstraction per capability class (shell, modules, nav) with explicit “unavailable on this surface” messages from core. + +--- + +## Summary + +The shipped model is **good enough for feature development** on a **trusted LAN** with **one logical host** and **thin clients** that periodically **`surface.snapshot`**. Closing gaps above moves toward **stronger freshness guarantees**, **safer multi-writer behavior**, and **production-grade sync/security**.