From 135cff7a2963bbf9f42f02b1b6d31a6e6e85db77 Mon Sep 17 00:00:00 2001 From: X Date: Wed, 6 May 2026 15:05:05 +0100 Subject: [PATCH 1/2] restructure arcadia into desktop/shared/mobile and update stable CI builds Move the Rust app and shared core into the new Desktop and Shared layout, add the iOS app scaffold, and update the stable workflow to build named desktop/mobile artifacts for pushes and PRs to stable. Co-authored-by: Cursor --- .github/workflows/stable-build-matrix.yml | 80 +- .gitignore | 7 + Apps/Arcadia/src/config/mod.rs | 29 - Apps/Arcadia/src/main.rs | 162 ---- Cargo.toml | 3 - Cargo.lock => Desktop/Cargo.lock | 382 +++++++- {Apps/Arcadia => Desktop}/Cargo.toml | 6 +- Desktop/src/cli.rs | 894 ++++++++++++++++++ Desktop/src/main.rs | 76 ++ .../iOS/ArcadiaApp.xcodeproj/project.pbxproj | 216 +++++ .../xcschemes/ArcadiaApp.xcscheme | 78 ++ Mobile/iOS/ArcadiaApp/ArcadiaApp.swift | 10 + .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 8 + .../ArcadiaApp/Assets.xcassets/Contents.json | 6 + Mobile/iOS/ArcadiaApp/ContentView.swift | 460 +++++++++ Mobile/iOS/ArcadiaApp/Info.plist | 38 + Shared/ArcadiaCore/Cargo.toml | 14 + .../ArcadiaCore}/src/config/commandline.rs | 56 +- Shared/ArcadiaCore/src/config/mod.rs | 85 ++ Shared/ArcadiaCore/src/config/modules.rs | 124 +++ Shared/ArcadiaCore/src/ffi.rs | 145 +++ Shared/ArcadiaCore/src/lib.rs | 6 + Shared/ArcadiaCore/src/modules/lan.rs | 797 ++++++++++++++++ Shared/ArcadiaCore/src/modules/mod.rs | 178 ++++ Shared/ArcadiaCore/src/modules/net.rs | 26 + Shared/ArcadiaCore/src/modules/shell.rs | 57 ++ Shared/ArcadiaCore/src/platform/ios.rs | 13 + .../ArcadiaCore}/src/platform/linux.rs | 0 .../ArcadiaCore}/src/platform/macos.rs | 0 .../ArcadiaCore}/src/platform/mod.rs | 19 +- .../ArcadiaCore}/src/platform/unknown.rs | 0 .../ArcadiaCore}/src/platform/windows.rs | 0 Shared/Cargo.lock | 747 +++++++++++++++ Shared/Cargo.toml | 6 + {Scripts => Shared/Scripts}/Launcher.ps1 | 0 {Scripts => Shared/Scripts}/Launcher.sh | 0 Shared/Scripts/build-ios-framework.sh | 72 ++ .../Scripts}/install-global-commands-macos.sh | 0 Shared/Tools/uniffi-bindgen/Cargo.toml | 11 + Shared/Tools/uniffi-bindgen/src/main.rs | 3 + 41 files changed, 4597 insertions(+), 237 deletions(-) delete mode 100644 Apps/Arcadia/src/config/mod.rs delete mode 100644 Apps/Arcadia/src/main.rs delete mode 100644 Cargo.toml rename Cargo.lock => Desktop/Cargo.lock (95%) rename {Apps/Arcadia => Desktop}/Cargo.toml (62%) create mode 100644 Desktop/src/cli.rs create mode 100644 Desktop/src/main.rs create mode 100644 Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj create mode 100644 Mobile/iOS/ArcadiaApp.xcodeproj/xcshareddata/xcschemes/ArcadiaApp.xcscheme create mode 100644 Mobile/iOS/ArcadiaApp/ArcadiaApp.swift create mode 100644 Mobile/iOS/ArcadiaApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Mobile/iOS/ArcadiaApp/Assets.xcassets/Contents.json create mode 100644 Mobile/iOS/ArcadiaApp/ContentView.swift create mode 100644 Mobile/iOS/ArcadiaApp/Info.plist create mode 100644 Shared/ArcadiaCore/Cargo.toml rename {Apps/Arcadia => Shared/ArcadiaCore}/src/config/commandline.rs (52%) create mode 100644 Shared/ArcadiaCore/src/config/mod.rs create mode 100644 Shared/ArcadiaCore/src/config/modules.rs create mode 100644 Shared/ArcadiaCore/src/ffi.rs create mode 100644 Shared/ArcadiaCore/src/lib.rs create mode 100644 Shared/ArcadiaCore/src/modules/lan.rs create mode 100644 Shared/ArcadiaCore/src/modules/mod.rs create mode 100644 Shared/ArcadiaCore/src/modules/net.rs create mode 100644 Shared/ArcadiaCore/src/modules/shell.rs create mode 100644 Shared/ArcadiaCore/src/platform/ios.rs rename {Apps/Arcadia => Shared/ArcadiaCore}/src/platform/linux.rs (100%) rename {Apps/Arcadia => Shared/ArcadiaCore}/src/platform/macos.rs (100%) rename {Apps/Arcadia => Shared/ArcadiaCore}/src/platform/mod.rs (54%) rename {Apps/Arcadia => Shared/ArcadiaCore}/src/platform/unknown.rs (100%) rename {Apps/Arcadia => Shared/ArcadiaCore}/src/platform/windows.rs (100%) create mode 100644 Shared/Cargo.lock create mode 100644 Shared/Cargo.toml rename {Scripts => Shared/Scripts}/Launcher.ps1 (100%) rename {Scripts => Shared/Scripts}/Launcher.sh (100%) create mode 100755 Shared/Scripts/build-ios-framework.sh rename {Scripts => Shared/Scripts}/install-global-commands-macos.sh (100%) create mode 100644 Shared/Tools/uniffi-bindgen/Cargo.toml create mode 100644 Shared/Tools/uniffi-bindgen/src/main.rs diff --git a/.github/workflows/stable-build-matrix.yml b/.github/workflows/stable-build-matrix.yml index ee3ab46..dd643ca 100644 --- a/.github/workflows/stable-build-matrix.yml +++ b/.github/workflows/stable-build-matrix.yml @@ -4,20 +4,46 @@ on: push: branches: - stable + pull_request: + branches: + - stable workflow_dispatch: permissions: contents: write jobs: - build: - name: ${{ matrix.os }} / ${{ matrix.feature }} + build-desktop: + name: ${{ matrix.artifact_name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - feature: [headless, gui] + include: + - os: ubuntu-latest + os_name: linux + feature: gui + artifact_name: arcadia-desktop-linux + - os: ubuntu-latest + os_name: linux + feature: headless + artifact_name: arcadia-desktop-linux-headless + - os: macos-latest + os_name: macos + feature: gui + artifact_name: arcadia-desktop-macos + - os: macos-latest + os_name: macos + feature: headless + artifact_name: arcadia-desktop-macos-headless + - os: windows-latest + os_name: windows + feature: gui + artifact_name: arcadia-desktop-windows + - os: windows-latest + os_name: windows + feature: headless + artifact_name: arcadia-desktop-windows-headless steps: - name: Checkout repository @@ -36,14 +62,14 @@ jobs: libxkbcommon-x11-dev - name: Build Arcadia - run: cargo build -p arcadia --no-default-features --features ${{ matrix.feature }} + run: cargo build --manifest-path Desktop/Cargo.toml --target-dir target --no-default-features --features ${{ matrix.feature }} - name: Package binary (Windows) if: runner.os == 'Windows' shell: pwsh run: | New-Item -ItemType Directory -Force -Path dist | Out-Null - $target = "dist/arcadia-${{ matrix.feature }}-windows.exe" + $target = "dist/${{ matrix.artifact_name }}.exe" Copy-Item "target/debug/arcadia.exe" $target - name: Package binary (macOS/Linux) @@ -51,25 +77,57 @@ jobs: shell: bash run: | mkdir -p dist - os_name="$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]')" - cp target/debug/arcadia "dist/arcadia-${{ matrix.feature }}-${os_name}" + cp target/debug/arcadia "dist/${{ matrix.artifact_name }}" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: dist/* + if-no-files-found: error + + build-mobile-ios: + name: arcadia-mobile-ios + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build iOS app (simulator) + shell: bash + run: | + xcodebuild \ + -project Mobile/iOS/ArcadiaApp.xcodeproj \ + -scheme ArcadiaApp \ + -configuration Release \ + -sdk iphonesimulator \ + -destination "generic/platform=iOS Simulator" \ + CODE_SIGNING_ALLOWED=NO \ + -derivedDataPath build/ios \ + build + + - name: Package iOS app + shell: bash + run: | + mkdir -p dist + cp -R "build/ios/Build/Products/Release-iphonesimulator/ArcadiaApp.app" "dist/arcadia-mobile-ios.app" - name: Upload build artifact uses: actions/upload-artifact@v4 with: - name: build-${{ matrix.os }}-${{ matrix.feature }} + name: arcadia-mobile-ios path: dist/* if-no-files-found: error release: name: Create Stable Release - needs: build + needs: [build-desktop, build-mobile-ios] runs-on: ubuntu-latest steps: - name: Download matrix artifacts uses: actions/download-artifact@v4 with: - pattern: build-* + pattern: arcadia-* path: release-assets merge-multiple: true diff --git a/.gitignore b/.gitignore index 53eaa21..6b723ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ /target **/*.rs.bk +/Models/ +/Configuration/ +/Desktop/target/ +/Shared/target/ +/Mobile/iOS/.xcode/ +**/xcuserdata/ +.DS_Store diff --git a/Apps/Arcadia/src/config/mod.rs b/Apps/Arcadia/src/config/mod.rs deleted file mode 100644 index 25398d0..0000000 --- a/Apps/Arcadia/src/config/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -pub mod commandline; - -use std::env; -use std::io; -use std::path::PathBuf; - -pub fn config_root_dir() -> io::Result { - let home = env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))?; - - let mut root = PathBuf::from(home); - root.push("Arcadia"); - root.push("Configuration"); - Ok(root) -} - -pub fn config_file_path(file_name: &str) -> io::Result { - if file_name.trim().is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "config file name cannot be empty", - )); - } - - let mut path = config_root_dir()?; - path.push(file_name); - Ok(path) -} diff --git a/Apps/Arcadia/src/main.rs b/Apps/Arcadia/src/main.rs deleted file mode 100644 index adfa918..0000000 --- a/Apps/Arcadia/src/main.rs +++ /dev/null @@ -1,162 +0,0 @@ -mod config; -mod platform; - -fn main() { - #[cfg(feature = "gui")] - { - gui::run(); - return; - } - - #[cfg(not(feature = "gui"))] - headless::run(); -} - -mod cli { - use std::io::{self, Write}; - use std::sync::OnceLock; - - use crate::config::commandline::CommandlineConfig; - - pub enum CommandResult { - Continue, - Quit, - } - - fn settings() -> &'static CommandlineConfig { - static SETTINGS: OnceLock = OnceLock::new(); - SETTINGS.get_or_init(|| match CommandlineConfig::load_or_create() { - Ok(config) => config, - Err(err) => { - eprintln!("Failed to load commandline config; using defaults: {err}"); - CommandlineConfig::default() - } - }) - } - - pub fn print_prompt() { - let cfg = settings(); - print!("{}{}\x1b[0m ", cfg.input_ansi_code(), cfg.input_symbol); - let _ = io::stdout().flush(); - } - - pub fn print_response(message: &str) { - let cfg = settings(); - println!("{}{}\x1b[0m {message}", cfg.output_ansi_code(), cfg.output_symbol); - } - - pub fn handle(input: &str) -> CommandResult { - match input.trim() { - "ping" => { - print_response("pong"); - CommandResult::Continue - } - "quit" => CommandResult::Quit, - "" => CommandResult::Continue, - other => { - print_response(&format!("Unknown command: {other}")); - CommandResult::Continue - } - } - } -} - -#[cfg(feature = "gui")] -mod gui { - use std::io; - 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() { - thread::spawn(|| { - let stdin = io::stdin(); - let mut buffer = String::new(); - - loop { - cli::print_prompt(); - buffer.clear(); - match stdin.read_line(&mut buffer) { - Ok(0) => break, - Ok(_) => { - if let cli::CommandResult::Quit = cli::handle(&buffer) { - process::exit(0); - } - } - Err(err) => { - eprintln!("CLI input error: {err}"); - break; - } - } - } - }); - - 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"); - }); - } -} - -#[cfg(not(feature = "gui"))] -mod headless { - use std::io; - - use crate::cli; - use crate::platform; - use crate::platform::PlatformInfo; - - pub fn run() { - println!("Arcadia base app"); - println!("Detected platform: {}", platform::current().name()); - println!("Mode: headless"); - println!("Status: bootstrap complete"); - println!("CLI ready. Commands: ping, quit"); - - let stdin = io::stdin(); - let mut buffer = String::new(); - - loop { - cli::print_prompt(); - buffer.clear(); - match stdin.read_line(&mut buffer) { - Ok(0) => break, - Ok(_) => { - if let cli::CommandResult::Quit = cli::handle(&buffer) { - break; - } - } - Err(err) => { - eprintln!("CLI input error: {err}"); - break; - } - } - } - } -} diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 3bad5e7..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,3 +0,0 @@ -[workspace] -members = ["Apps/Arcadia"] -resolver = "2" diff --git a/Cargo.lock b/Desktop/Cargo.lock similarity index 95% rename from Cargo.lock rename to Desktop/Cargo.lock index 0d40208..2375ab7 100644 --- a/Cargo.lock +++ b/Desktop/Cargo.lock @@ -85,9 +85,18 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" name = "arcadia" version = "0.1.0" dependencies = [ + "arcadia-core", "gpui", + "rustyline", +] + +[[package]] +name = "arcadia-core" +version = "0.1.0" +dependencies = [ "serde", "toml 0.8.23", + "uniffi", ] [[package]] @@ -187,6 +196,47 @@ dependencies = [ "zbus", ] +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -485,6 +535,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -737,6 +805,38 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "cbc" version = "0.1.2" @@ -828,6 +928,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1404,6 +1513,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1472,6 +1587,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etagere" version = "0.2.15" @@ -1736,6 +1857,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futf" version = "0.1.5" @@ -1975,6 +2105,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -3162,6 +3303,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" @@ -3188,9 +3338,9 @@ dependencies = [ [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] @@ -3641,18 +3791,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -3969,6 +4119,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -4441,6 +4601,27 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "rustyline" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "clipboard-win", + "home", + "libc", + "log", + "memchr", + "nix 0.31.2", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.23" @@ -4526,6 +4707,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4566,6 +4767,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -4742,6 +4947,12 @@ dependencies = [ "log", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.3" @@ -4779,6 +4990,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "smol" version = "2.0.2" @@ -5025,7 +5242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ "kurbo", - "siphasher", + "siphasher 1.0.3", ] [[package]] @@ -5188,6 +5405,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5351,6 +5577,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -5635,6 +5870,122 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck 0.5.0", + "once_cell", + "paste", + "serde", + "textwrap", + "toml 0.5.11", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher 0.3.11", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5671,7 +6022,7 @@ dependencies = [ "roxmltree", "rustybuzz 0.20.1", "simplecss", - "siphasher", + "siphasher 1.0.3", "strict-num", "svgtypes", "tiny-skia-path", @@ -5693,6 +6044,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -6047,6 +6404,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Apps/Arcadia/Cargo.toml b/Desktop/Cargo.toml similarity index 62% rename from Apps/Arcadia/Cargo.toml rename to Desktop/Cargo.toml index 88ccc1f..200e28a 100644 --- a/Apps/Arcadia/Cargo.toml +++ b/Desktop/Cargo.toml @@ -11,6 +11,6 @@ headless = [] gui = ["dep:gpui"] [dependencies] -gpui = { version = "0.2.2", optional = true } -serde = { version = "1", features = ["derive"] } -toml = "0.8" +arcadia-core = { path = "../Shared/ArcadiaCore" } +gpui = { version = "0.2.2", optional = true } +rustyline = "18.0.0" diff --git a/Desktop/src/cli.rs b/Desktop/src/cli.rs new file mode 100644 index 0000000..1c3933d --- /dev/null +++ b/Desktop/src/cli.rs @@ -0,0 +1,894 @@ +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/main.rs b/Desktop/src/main.rs new file mode 100644 index 0000000..9dcefec --- /dev/null +++ b/Desktop/src/main.rs @@ -0,0 +1,76 @@ +mod cli; + +use arcadia_core::modules; + +fn main() { + modules::load_all(); + + #[cfg(feature = "gui")] + { + gui::run(); + modules::shutdown_all(); + return; + } + + #[cfg(not(feature = "gui"))] + { + headless::run(); + modules::shutdown_all(); + } +} + +#[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"); + }); + } +} + +#[cfg(not(feature = "gui"))] +mod headless { + use crate::cli; + + pub fn run() { + cli::print_startup("headless"); + cli::start_loop(|| {}); + } +} diff --git a/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj b/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..505a340 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj @@ -0,0 +1,216 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* 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 */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A00000000000000000000000 /* ArcadiaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ArcadiaApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A00000000000000000000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A00000000000000000000030 = { + isa = PBXGroup; + children = ( + A00000000000000000000031 /* ArcadiaApp */, + A00000000000000000000032 /* Products */, + ); + sourceTree = ""; + }; + A00000000000000000000031 /* ArcadiaApp */ = { + isa = PBXGroup; + children = ( + A00000000000000000000001 /* ArcadiaApp.swift */, + A00000000000000000000002 /* ContentView.swift */, + A00000000000000000000004 /* Info.plist */, + ); + path = ArcadiaApp; + sourceTree = ""; + }; + A00000000000000000000032 /* Products */ = { + isa = PBXGroup; + children = ( + A00000000000000000000000 /* ArcadiaApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A00000000000000000000040 /* ArcadiaApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A00000000000000000000050 /* Build configuration list for PBXNativeTarget "ArcadiaApp" */; + buildPhases = ( + A00000000000000000000021 /* Sources */, + A00000000000000000000020 /* Frameworks */, + A00000000000000000000022 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ArcadiaApp; + productName = ArcadiaApp; + productReference = A00000000000000000000000 /* ArcadiaApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A00000000000000000000060 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + A00000000000000000000040 = { + CreatedOnToolsVersion = 26.4.1; + }; + }; + }; + buildConfigurationList = A00000000000000000000051 /* Build configuration list for PBXProject "ArcadiaApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A00000000000000000000030; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A00000000000000000000032 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A00000000000000000000040 /* ArcadiaApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A00000000000000000000022 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A00000000000000000000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000000000000000000010 /* ArcadiaApp.swift in Sources */, + A00000000000000000000011 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A00000000000000000000070 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 8248296AJX; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ArcadiaApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stacknode.arcadia; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A00000000000000000000071 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8248296AJX; + ENABLE_NS_ASSERTIONS = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ArcadiaApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stacknode.arcadia; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A00000000000000000000050 /* Build configuration list for PBXNativeTarget "ArcadiaApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000070 /* Debug */, + A00000000000000000000071 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A00000000000000000000051 /* Build configuration list for PBXProject "ArcadiaApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000070 /* Debug */, + A00000000000000000000071 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A00000000000000000000060 /* Project object */; +} diff --git a/Mobile/iOS/ArcadiaApp.xcodeproj/xcshareddata/xcschemes/ArcadiaApp.xcscheme b/Mobile/iOS/ArcadiaApp.xcodeproj/xcshareddata/xcschemes/ArcadiaApp.xcscheme new file mode 100644 index 0000000..968239c --- /dev/null +++ b/Mobile/iOS/ArcadiaApp.xcodeproj/xcshareddata/xcschemes/ArcadiaApp.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift b/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift new file mode 100644 index 0000000..5dcb25e --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ArcadiaApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ArcadiaApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..2711c8c --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.741", + "green" : "0.514", + "red" : "0.188" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..31fbaca --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mobile/iOS/ArcadiaApp/Assets.xcassets/Contents.json b/Mobile/iOS/ArcadiaApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mobile/iOS/ArcadiaApp/ContentView.swift b/Mobile/iOS/ArcadiaApp/ContentView.swift new file mode 100644 index 0000000..e985bfb --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/ContentView.swift @@ -0,0 +1,460 @@ +import SwiftUI + +struct SidebarItem: Identifiable, Hashable { + let id = UUID() + let title: String + let systemImage: String +} + +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" + + var body: some View { + ZStack(alignment: .leading) { + glassBackground + + mainContent + .overlay { + if isSidebarOpen { + Rectangle() + .fill(.black.opacity(0.12)) + .background(.ultraThinMaterial.opacity(0.25)) + .ignoresSafeArea() + .transition(.opacity) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.26)) { + isSidebarOpen = false + } + } + } + } + + 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 + ) + + 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) + } + .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) + } + } + .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)) + } + .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) + } + .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) + } + } + } + } + + 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 + } + } + } + .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 + ) + } + } + } + + 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) + } + } + .tint(.white.opacity(0.9)) + } + } + } + + 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) + } + } +} diff --git a/Mobile/iOS/ArcadiaApp/Info.plist b/Mobile/iOS/ArcadiaApp/Info.plist new file mode 100644 index 0000000..7721865 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Shared/ArcadiaCore/Cargo.toml b/Shared/ArcadiaCore/Cargo.toml new file mode 100644 index 0000000..6ae56ea --- /dev/null +++ b/Shared/ArcadiaCore/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "arcadia-core" +version = "0.1.0" +edition = "2021" +description = "Arcadia shared core logic (cross-platform)" +license = "MIT" + +[lib] +crate-type = ["staticlib", "cdylib", "lib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" +uniffi = "0.28" diff --git a/Apps/Arcadia/src/config/commandline.rs b/Shared/ArcadiaCore/src/config/commandline.rs similarity index 52% rename from Apps/Arcadia/src/config/commandline.rs rename to Shared/ArcadiaCore/src/config/commandline.rs index 68c1ee5..4d368ee 100644 --- a/Apps/Arcadia/src/config/commandline.rs +++ b/Shared/ArcadiaCore/src/config/commandline.rs @@ -1,8 +1,6 @@ use serde::{Deserialize, Serialize}; -use std::fs; -use std::io; -use crate::config::{config_file_path, config_root_dir}; +use crate::config::ConfigFile; const FILE_NAME: &str = "commandline.toml"; @@ -12,6 +10,8 @@ pub struct CommandlineConfig { pub output_symbol: String, pub input_color: String, pub output_color: String, + #[serde(default = "default_clear_on_start")] + pub clear_on_start: bool, } impl Default for CommandlineConfig { @@ -21,28 +21,18 @@ impl Default for CommandlineConfig { output_symbol: "~".to_string(), input_color: "magenta".to_string(), output_color: "cyan".to_string(), + clear_on_start: default_clear_on_start(), } } } -impl CommandlineConfig { - pub fn load_or_create() -> io::Result { - let root = config_root_dir()?; - fs::create_dir_all(&root)?; - - let path = config_file_path(FILE_NAME)?; - - if !path.exists() { - let default = Self::default(); - let content = toml::to_string_pretty(&default).map_err(io::Error::other)?; - fs::write(&path, content)?; - return Ok(default); - } - - let content = fs::read_to_string(&path)?; - toml::from_str::(&content).map_err(io::Error::other) +impl ConfigFile for CommandlineConfig { + fn file_name() -> &'static str { + FILE_NAME } +} +impl CommandlineConfig { pub fn input_ansi_code(&self) -> &'static str { color_to_ansi(&self.input_color) } @@ -50,6 +40,34 @@ impl CommandlineConfig { pub fn output_ansi_code(&self) -> &'static str { color_to_ansi(&self.output_color) } + + pub fn color_warnings(&self) -> Vec { + let mut warnings = Vec::new(); + if !is_known_color(&self.input_color) { + warnings.push(format!( + "Unknown input_color '{}'; valid: black red green yellow blue magenta cyan white", + self.input_color + )); + } + if !is_known_color(&self.output_color) { + warnings.push(format!( + "Unknown output_color '{}'; valid: black red green yellow blue magenta cyan white", + self.output_color + )); + } + warnings + } +} + +fn default_clear_on_start() -> bool { + true +} + +fn is_known_color(color: &str) -> bool { + matches!( + color.trim().to_ascii_lowercase().as_str(), + "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" + ) } fn color_to_ansi(color: &str) -> &'static str { diff --git a/Shared/ArcadiaCore/src/config/mod.rs b/Shared/ArcadiaCore/src/config/mod.rs new file mode 100644 index 0000000..d7049e6 --- /dev/null +++ b/Shared/ArcadiaCore/src/config/mod.rs @@ -0,0 +1,85 @@ +pub mod commandline; +pub mod modules; + +use std::env; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +static CONFIG_ROOT_OVERRIDE: OnceLock = OnceLock::new(); + +pub fn set_config_root(path: PathBuf) { + let _ = CONFIG_ROOT_OVERRIDE.set(path); +} + +pub fn config_root_dir() -> io::Result { + if let Some(p) = CONFIG_ROOT_OVERRIDE.get() { + return Ok(p.clone()); + } + + let home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))?; + + let mut root = PathBuf::from(home); + root.push("Arcadia"); + root.push("Configuration"); + Ok(root) +} + +pub fn config_file_path(file_name: &str) -> io::Result { + if file_name.trim().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "config file name cannot be empty", + )); + } + + let mut path = config_root_dir()?; + path.push(file_name); + Ok(path) +} + +pub trait ConfigFile: Default + Serialize + for<'de> Deserialize<'de> + Sized { + fn file_name() -> &'static str; + + fn file_path() -> io::Result { + config_file_path(Self::file_name()) + } + + fn merge_defaults(&mut self) -> bool { + false + } + + fn load_or_create() -> io::Result { + let root = config_root_dir()?; + fs::create_dir_all(&root)?; + + let path = Self::file_path()?; + + if !path.exists() { + let default = Self::default(); + let content = toml::to_string_pretty(&default).map_err(io::Error::other)?; + fs::write(&path, content)?; + return Ok(default); + } + + let content = fs::read_to_string(&path)?; + let mut cfg = toml::from_str::(&content).map_err(io::Error::other)?; + if cfg.merge_defaults() { + cfg.save()?; + } + Ok(cfg) + } + + fn save(&self) -> io::Result<()> { + let root = config_root_dir()?; + fs::create_dir_all(&root)?; + let path = Self::file_path()?; + let content = toml::to_string_pretty(self).map_err(io::Error::other)?; + fs::write(path, content) + } +} diff --git a/Shared/ArcadiaCore/src/config/modules.rs b/Shared/ArcadiaCore/src/config/modules.rs new file mode 100644 index 0000000..f978ad8 --- /dev/null +++ b/Shared/ArcadiaCore/src/config/modules.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +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 SHELL_MODULE_NAME: &str = "shell"; +const FILE_NAME: &str = "modules.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModulesConfig { + pub modules: BTreeMap, +} + +fn required_modules(module_name: &str) -> &'static [&'static str] { + let _ = 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); + Self { modules } + } +} + +impl ModulesConfig { + 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()); + } + + for required in required_modules(module_name) { + if !self.modules.contains_key(*required) { + return Err(format!( + "Cannot enable {module_name}: required module {required} is missing" + )); + } + self.enable_with_requirements(required)?; + } + + self.set_module_state(module_name, true) + } + + pub fn set_module_state(&mut self, module_name: &str, enabled: bool) -> Result<(), String> { + if !self.modules.contains_key(module_name) { + return Err("Unknown module key".to_string()); + } + + if enabled { + for required in required_modules(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 { + return Err(format!( + "Cannot enable {module_name}: requires {required} to be enabled" + )); + } + } + } else { + let blocking_dependents = self + .modules + .iter() + .filter(|(_, is_enabled)| **is_enabled) + .filter_map(|(name, _)| { + if required_modules(name).contains(&module_name) { + Some(name.as_str()) + } else { + None + } + }) + .collect::>(); + + if !blocking_dependents.is_empty() { + return Err(format!( + "Cannot disable {module_name}: required by enabled module(s): {}", + blocking_dependents.join(", ") + )); + } + } + + if let Some(entry) = self.modules.get_mut(module_name) { + *entry = enabled; + } + Ok(()) + } +} + +impl ConfigFile for ModulesConfig { + fn file_name() -> &'static str { + FILE_NAME + } + + fn merge_defaults(&mut self) -> bool { + let mut changed = false; + + if let Some(legacy_value) = self.modules.remove(LEGACY_LAN_MODULE_NAME) { + self.modules + .entry(LAN_MODULE_NAME.to_string()) + .or_insert(legacy_value); + changed = true; + } + if self.modules.remove("lan-mobile").is_some() { + changed = true; + } + + let defaults = Self::default(); + for (key, value) in defaults.modules { + if !self.modules.contains_key(&key) { + self.modules.insert(key, value); + changed = true; + } + } + changed + } +} diff --git a/Shared/ArcadiaCore/src/ffi.rs b/Shared/ArcadiaCore/src/ffi.rs new file mode 100644 index 0000000..ef635b7 --- /dev/null +++ b/Shared/ArcadiaCore/src/ffi.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use crate::config::modules::ModulesConfig; +use crate::config::ConfigFile; +use crate::modules; + +#[derive(uniffi::Record, Default)] +pub struct ExecutionContextFfi { + pub net_as: Option, + pub net_timeout_ms: Option, +} + +#[derive(uniffi::Record)] +pub struct CommandInfo { + pub token: String, + pub description: String, +} + +#[derive(uniffi::Record)] +pub struct ModuleStatus { + pub name: String, + pub enabled: 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] +pub fn set_config_root_path(path: String) { + crate::config::set_config_root(std::path::PathBuf::from(path)); +} + +/// Execute a command by dot-separated token (e.g. "lan.scan"). +/// Returns the result string; errors are embedded in the return value. +#[uniffi::export] +pub fn execute_command(token: String, args: Vec, context: ExecutionContextFfi) -> String { + let ctx = modules::ExecutionContext { + net_as: context.net_as, + net_timeout_ms: context.net_timeout_ms, + }; + let arg_slices: Vec<&str> = args.iter().map(String::as_str).collect(); + match modules::execute_command(&token, &arg_slices, &ctx) { + Ok(Some(result)) => result, + Ok(None) => format!("Unknown command: {token}"), + Err(e) => e, + } +} + +/// List all commands in currently-enabled modules. +#[uniffi::export] +pub fn list_commands() -> Vec { + modules::all_command_entries() + .into_iter() + .map(|(token, description)| CommandInfo { token, description }) + .collect() +} + +/// List all modules and their enabled state. +#[uniffi::export] +pub fn list_modules() -> Vec { + ModulesConfig::load_or_create() + .map(|cfg| { + cfg.modules + .into_iter() + .map(|(name, enabled)| ModuleStatus { name, enabled }) + .collect() + }) + .unwrap_or_default() +} + +/// 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 = if enabled { + cfg.enable_with_requirements(&name) + } else { + cfg.set_module_state(&name, false) + }; + match result { + Ok(()) => match cfg.save() { + Ok(()) => format!("Module {name} {}", if enabled { "enabled" } else { "disabled" }), + Err(e) => format!("Error saving config: {e}"), + }, + Err(e) => e, + } +} + +/// Returns the current platform name ("ios", "macos", "linux", "windows", "unknown"). +#[uniffi::export] +pub fn platform_name() -> String { + use crate::platform::PlatformInfo; + crate::platform::current().name().to_string() +} + +/// Start the LAN background service thread. Safe to call multiple times. +#[uniffi::export] +pub fn lan_start() { + crate::modules::lan::start_service(); +} + +/// Stop the LAN background service thread. +#[uniffi::export] +pub fn lan_stop() { + crate::modules::lan::stop_service(); +} + +/// Object-oriented handle for module and command management. +/// Useful for SwiftUI @StateObject / @Observable patterns. +#[derive(uniffi::Object)] +pub struct ModuleManager; + +#[uniffi::export] +impl ModuleManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(ModuleManager) + } + + pub fn list_modules(&self) -> Vec { + list_modules() + } + + pub fn list_commands(&self) -> Vec { + list_commands() + } + + pub fn set_enabled(&self, name: String, enabled: bool) -> String { + set_module_enabled(name, enabled) + } + + pub fn execute(&self, token: String, args: Vec) -> String { + execute_command(token, args, ExecutionContextFfi::default()) + } + + pub fn execute_remote(&self, token: String, args: Vec, net_as: String) -> String { + execute_command( + token, + args, + ExecutionContextFfi { net_as: Some(net_as), net_timeout_ms: None }, + ) + } +} diff --git a/Shared/ArcadiaCore/src/lib.rs b/Shared/ArcadiaCore/src/lib.rs new file mode 100644 index 0000000..fdf0074 --- /dev/null +++ b/Shared/ArcadiaCore/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod modules; +pub mod platform; +mod ffi; + +uniffi::setup_scaffolding!(); diff --git a/Shared/ArcadiaCore/src/modules/lan.rs b/Shared/ArcadiaCore/src/modules/lan.rs new file mode 100644 index 0000000..eab8ac8 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/lan.rs @@ -0,0 +1,797 @@ +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/mod.rs b/Shared/ArcadiaCore/src/modules/mod.rs new file mode 100644 index 0000000..c88fb52 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/mod.rs @@ -0,0 +1,178 @@ +pub mod lan; +pub mod net; +pub mod shell; + +use crate::config::modules::{ModulesConfig, NET_MODULE_NAME}; +use crate::config::ConfigFile; + +#[derive(Default)] +pub struct ExecutionContext { + pub net_as: Option, + pub net_timeout_ms: Option, +} + +pub struct ModuleCommand { + pub name: &'static str, + pub description: &'static str, + pub run: fn(&[&str], &ExecutionContext) -> String, +} + +fn module_commands(module_name: &str) -> Option<&'static [ModuleCommand]> { + match module_name { + lan::NAME => Some(lan::commands()), + net::NAME => Some(net::commands()), + shell::NAME => Some(shell::commands()), + _ => None, + } +} + +fn module_enabled(module_name: &str) -> Result { + let cfg = ModulesConfig::load_or_create().map_err(|err| err.to_string())?; + cfg.modules + .get(module_name) + .copied() + .ok_or_else(|| "Unknown module key".to_string()) +} + +pub fn enabled_command_tokens() -> Vec { + let Ok(cfg) = ModulesConfig::load_or_create() else { + return Vec::new(); + }; + + let mut tokens = Vec::new(); + for (module_name, enabled) in &cfg.modules { + if !enabled { + continue; + } + if let Some(commands) = module_commands(module_name) { + for command in commands { + tokens.push(format!("{module_name}.{}", command.name)); + } + } + } + tokens +} + +pub fn enabled_module_names() -> Vec { + let Ok(cfg) = ModulesConfig::load_or_create() else { + return Vec::new(); + }; + + cfg.modules + .iter() + .filter(|(_, enabled)| **enabled) + .filter_map(|(module_name, _)| { + if module_commands(module_name).is_some() { + Some(module_name.clone()) + } else { + None + } + }) + .collect() +} + +pub fn enabled_module_command_names(module_name: &str) -> Vec { + if !matches!(module_enabled(module_name), Ok(true)) { + return Vec::new(); + } + + let Some(commands) = module_commands(module_name) else { + return Vec::new(); + }; + commands.iter().map(|command| command.name.to_string()).collect() +} + +pub fn execute_command( + token: &str, + args: &[&str], + context: &ExecutionContext, +) -> Result, String> { + let Some((module_name, command_name)) = token.split_once('.') else { + return Ok(None); + }; + + let Some(commands) = module_commands(module_name) else { + 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}")); + }; + + if (context.net_as.is_some() || context.net_timeout_ms.is_some()) + && !module_enabled(NET_MODULE_NAME)? + { + return Err("Global flags --net:* require module net to be enabled".to_string()); + } + + 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)?; + return Ok(Some(response)); + } + return Err(format!("Unsupported net route: {route}")); + } + + Ok(Some((command.run)(args, context))) +} + +pub fn enabled_command_help_lines() -> Vec { + let Ok(cfg) = ModulesConfig::load_or_create() else { + return Vec::new(); + }; + + let mut lines = Vec::new(); + for (module_name, enabled) in &cfg.modules { + if !enabled { + continue; + } + if let Some(commands) = module_commands(module_name) { + for command in commands { + lines.push(format!( + "- {module_name}.{}: {}", + command.name, command.description + )); + } + } + } + lines +} + +pub fn all_command_entries() -> Vec<(String, String)> { + let Ok(cfg) = ModulesConfig::load_or_create() else { + return Vec::new(); + }; + + let mut entries = Vec::new(); + for (module_name, enabled) in &cfg.modules { + if !enabled { + continue; + } + if let Some(commands) = module_commands(module_name) { + for command in commands { + entries.push(( + format!("{module_name}.{}", command.name), + command.description.to_string(), + )); + } + } + } + entries +} + +pub fn load_all() { + let _known_modules = [lan::NAME, net::NAME, shell::NAME]; + lan::start_service(); + + if let Err(err) = ModulesConfig::load_or_create() { + eprintln!("Failed to load modules config: {err}"); + } +} + +pub fn shutdown_all() { + lan::stop_service(); +} diff --git a/Shared/ArcadiaCore/src/modules/net.rs b/Shared/ArcadiaCore/src/modules/net.rs new file mode 100644 index 0000000..a2b0034 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/net.rs @@ -0,0 +1,26 @@ +use crate::modules::{ExecutionContext, ModuleCommand}; + +pub const NAME: &str = "net"; + +fn help(_args: &[&str], context: &ExecutionContext) -> String { + let timeout = context + .net_timeout_ms + .map(|ms| ms.to_string()) + .unwrap_or_else(|| "unset".to_string()); + match &context.net_as { + Some(target) => format!( + "Net context active: --net:as {target}, --net:timeout {timeout}" + ), + None => format!( + "Net module ready. Use global flags: --net:as lan:, --net:timeout (current timeout: {timeout})" + ), + } +} + +pub fn commands() -> &'static [ModuleCommand] { + &[ModuleCommand { + name: "help", + description: "show net context and global net flag usage", + run: help, + }] +} diff --git a/Shared/ArcadiaCore/src/modules/shell.rs b/Shared/ArcadiaCore/src/modules/shell.rs new file mode 100644 index 0000000..8f07c00 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/shell.rs @@ -0,0 +1,57 @@ +use crate::modules::{ExecutionContext, ModuleCommand}; + +pub const NAME: &str = "shell"; + +fn execute(args: &[&str], context: &ExecutionContext) -> String { + if args.is_empty() { + return "Usage: shell.execute ".to_string(); + } + let _ = context; + + #[cfg(target_os = "ios")] + { + return "shell.execute is not available on iOS".to_string(); + } + + #[cfg(not(target_os = "ios"))] + { + use std::process::Command; + + let command_line = args.join(" "); + let output = { + #[cfg(target_os = "windows")] + { + Command::new("cmd").args(["/C", &command_line]).output() + } + #[cfg(not(target_os = "windows"))] + { + Command::new("sh").args(["-c", &command_line]).output() + } + }; + + 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 mut lines = Vec::new(); + lines.push(format!("exit: {:?}", output.status.code())); + if !stdout.is_empty() { + lines.push(format!("stdout:\n{stdout}")); + } + if !stderr.is_empty() { + lines.push(format!("stderr:\n{stderr}")); + } + lines.join("\n") + } + Err(err) => format!("Failed to execute shell command: {err}"), + } + } +} + +pub fn commands() -> &'static [ModuleCommand] { + &[ModuleCommand { + name: "execute", + description: "execute shell command(s): shell.execute ", + run: execute, + }] +} diff --git a/Shared/ArcadiaCore/src/platform/ios.rs b/Shared/ArcadiaCore/src/platform/ios.rs new file mode 100644 index 0000000..1b4e26c --- /dev/null +++ b/Shared/ArcadiaCore/src/platform/ios.rs @@ -0,0 +1,13 @@ +use super::PlatformInfo; + +pub struct IosPlatform; + +impl PlatformInfo for IosPlatform { + fn name(&self) -> &'static str { + "ios" + } +} + +pub fn current() -> impl PlatformInfo { + IosPlatform +} diff --git a/Apps/Arcadia/src/platform/linux.rs b/Shared/ArcadiaCore/src/platform/linux.rs similarity index 100% rename from Apps/Arcadia/src/platform/linux.rs rename to Shared/ArcadiaCore/src/platform/linux.rs diff --git a/Apps/Arcadia/src/platform/macos.rs b/Shared/ArcadiaCore/src/platform/macos.rs similarity index 100% rename from Apps/Arcadia/src/platform/macos.rs rename to Shared/ArcadiaCore/src/platform/macos.rs diff --git a/Apps/Arcadia/src/platform/mod.rs b/Shared/ArcadiaCore/src/platform/mod.rs similarity index 54% rename from Apps/Arcadia/src/platform/mod.rs rename to Shared/ArcadiaCore/src/platform/mod.rs index 786265f..1c2ae76 100644 --- a/Apps/Arcadia/src/platform/mod.rs +++ b/Shared/ArcadiaCore/src/platform/mod.rs @@ -17,7 +17,22 @@ mod linux; #[cfg(target_os = "linux")] pub use linux::current; -#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +#[cfg(target_os = "ios")] +mod ios; +#[cfg(target_os = "ios")] +pub use ios::current; + +#[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "linux", + target_os = "ios" +)))] mod unknown; -#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +#[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "linux", + target_os = "ios" +)))] pub use unknown::current; diff --git a/Apps/Arcadia/src/platform/unknown.rs b/Shared/ArcadiaCore/src/platform/unknown.rs similarity index 100% rename from Apps/Arcadia/src/platform/unknown.rs rename to Shared/ArcadiaCore/src/platform/unknown.rs diff --git a/Apps/Arcadia/src/platform/windows.rs b/Shared/ArcadiaCore/src/platform/windows.rs similarity index 100% rename from Apps/Arcadia/src/platform/windows.rs rename to Shared/ArcadiaCore/src/platform/windows.rs diff --git a/Shared/Cargo.lock b/Shared/Cargo.lock new file mode 100644 index 0000000..19efe4b --- /dev/null +++ b/Shared/Cargo.lock @@ -0,0 +1,747 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arcadia-core" +version = "0.1.0" +dependencies = [ + "serde", + "toml 0.8.23", + "uniffi", +] + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi-bindgen" +version = "0.1.0" +dependencies = [ + "uniffi", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml 0.5.11", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Shared/Cargo.toml b/Shared/Cargo.toml new file mode 100644 index 0000000..04f0be5 --- /dev/null +++ b/Shared/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = [ + "ArcadiaCore", + "Tools/uniffi-bindgen", +] +resolver = "2" diff --git a/Scripts/Launcher.ps1 b/Shared/Scripts/Launcher.ps1 similarity index 100% rename from Scripts/Launcher.ps1 rename to Shared/Scripts/Launcher.ps1 diff --git a/Scripts/Launcher.sh b/Shared/Scripts/Launcher.sh similarity index 100% rename from Scripts/Launcher.sh rename to Shared/Scripts/Launcher.sh diff --git a/Shared/Scripts/build-ios-framework.sh b/Shared/Scripts/build-ios-framework.sh new file mode 100755 index 0000000..4d8893b --- /dev/null +++ b/Shared/Scripts/build-ios-framework.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +OUT_DIR="${SOURCE_DIR}/../Mobile/iOS/ArcadiaCore" +LIB_NAME="libarcadia_core.a" +DEVICE_TARGET="aarch64-apple-ios" +SIM_TARGET="aarch64-apple-ios-sim" + +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" + +# ── 0. Install targets ──────────────────────────────────────────────────────── +echo "==> Installing Rust targets" +rustup target add "${DEVICE_TARGET}" "${SIM_TARGET}" + +# ── 1. Build for device + simulator ────────────────────────────────────────── +echo "==> Building for ${DEVICE_TARGET}" +(cd "${SOURCE_DIR}" && cargo build -p arcadia-core --release --target "${DEVICE_TARGET}") + +echo "==> Building for ${SIM_TARGET}" +(cd "${SOURCE_DIR}" && cargo build -p arcadia-core --release --target "${SIM_TARGET}") + +# ── 2. Generate Swift bindings ──────────────────────────────────────────────── +echo "==> Generating Swift bindings" +mkdir -p "${BINDGEN_OUT}" +(cd "${SOURCE_DIR}" && cargo run -p uniffi-bindgen -- \ + generate \ + --library "${DEVICE_LIB}" \ + --language swift \ + --out-dir "${BINDGEN_OUT}") + +# ── 3. Build xcframework ────────────────────────────────────────────────────── +echo "==> Creating xcframework" +rm -rf "${XCFRAMEWORK_DIR}" +mkdir -p "${OUT_DIR}" + +DEVICE_DIR="${OUT_DIR}/_device" +SIM_DIR="${OUT_DIR}/_sim" +rm -rf "${DEVICE_DIR}" "${SIM_DIR}" + +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" + +for DIR in "${DEVICE_DIR}" "${SIM_DIR}"; do + cp "${HEADER}" "${DIR}/Headers/arcadia_coreCFFI.h" + cp "${MODULEMAP}" "${DIR}/Modules/module.modulemap" +done + +cp "${DEVICE_LIB}" "${DEVICE_DIR}/libarcadia_core.a" +cp "${SIM_LIB}" "${SIM_DIR}/libarcadia_core.a" + +xcodebuild -create-xcframework \ + -library "${DEVICE_DIR}/libarcadia_core.a" \ + -headers "${DEVICE_DIR}/Headers" \ + -library "${SIM_DIR}/libarcadia_core.a" \ + -headers "${SIM_DIR}/Headers" \ + -output "${XCFRAMEWORK_DIR}" + +rm -rf "${DEVICE_DIR}" "${SIM_DIR}" + +echo "" +echo "Swift bindings : ${BINDGEN_OUT}/arcadia_core.swift" +echo "xcframework : ${XCFRAMEWORK_DIR}" +echo "Done." diff --git a/Scripts/install-global-commands-macos.sh b/Shared/Scripts/install-global-commands-macos.sh similarity index 100% rename from Scripts/install-global-commands-macos.sh rename to Shared/Scripts/install-global-commands-macos.sh diff --git a/Shared/Tools/uniffi-bindgen/Cargo.toml b/Shared/Tools/uniffi-bindgen/Cargo.toml new file mode 100644 index 0000000..141e40e --- /dev/null +++ b/Shared/Tools/uniffi-bindgen/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "uniffi-bindgen" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "uniffi-bindgen" +path = "src/main.rs" + +[dependencies] +uniffi = { version = "0.28", features = ["cli"] } diff --git a/Shared/Tools/uniffi-bindgen/src/main.rs b/Shared/Tools/uniffi-bindgen/src/main.rs new file mode 100644 index 0000000..f6cff6c --- /dev/null +++ b/Shared/Tools/uniffi-bindgen/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} From 5a9bb79f631a2397367984bb8a011222dd33ab57 Mon Sep 17 00:00:00 2001 From: X Date: Wed, 6 May 2026 15:07:48 +0100 Subject: [PATCH 2/2] funding.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..520a28a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: stacknode