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 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() +}