diff --git a/Desktop/.Cargo.lock.swp b/Desktop/.Cargo.lock.swp new file mode 100644 index 0000000..8dc625b Binary files /dev/null and b/Desktop/.Cargo.lock.swp differ diff --git a/Desktop/Cargo.lock b/Desktop/Cargo.lock index d28db6c..b422db8 100644 --- a/Desktop/Cargo.lock +++ b/Desktop/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "libc", "portable-pty", "rustyline", + "serde_json", "vt100", ] @@ -100,7 +101,9 @@ dependencies = [ "serde", "serde_json", "toml 0.8.23", + "tungstenite", "uniffi", + "ureq", "uuid", ] @@ -953,7 +956,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -969,7 +972,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.0", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1117,7 +1120,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1130,7 +1133,7 @@ dependencies = [ "bitflags 2.11.1", "core-foundation 0.10.0", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1143,7 +1146,7 @@ dependencies = [ "bitflags 2.11.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1190,7 +1193,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.0", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1325,6 +1328,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -1815,6 +1824,15 @@ dependencies = [ "ttf-parser 0.25.1", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1822,7 +1840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1836,6 +1854,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2184,7 +2208,7 @@ dependencies = [ "etagere", "filedescriptor", "flume", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui-macros", "gpui_collections", @@ -2317,7 +2341,7 @@ dependencies = [ "core-foundation 0.10.0", "core-video", "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -3221,7 +3245,7 @@ dependencies = [ "bitflags 2.11.1", "block", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -3320,6 +3344,23 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3716,12 +3757,49 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4568,6 +4646,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4990,6 +5069,17 @@ dependencies = [ "serial-core", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -5888,6 +5978,25 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6123,6 +6232,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -6241,6 +6368,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6568,6 +6701,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weedle2" version = "5.0.0" diff --git a/Desktop/Cargo.toml b/Desktop/Cargo.toml index cc88764..570a772 100644 --- a/Desktop/Cargo.toml +++ b/Desktop/Cargo.toml @@ -12,6 +12,7 @@ gui = ["dep:gpui", "dep:libc", "dep:portable-pty", "dep:vt100"] [dependencies] arcadia-core = { path = "../Shared/ArcadiaCore" } +serde_json = "1" gpui = { version = "0.2.2", optional = true } libc = { version = "0.2", optional = true } rustyline = "18.0.0" diff --git a/Desktop/src/gui/app/late/bonsai.rs b/Desktop/src/gui/app/late/bonsai.rs new file mode 100644 index 0000000..c64b878 --- /dev/null +++ b/Desktop/src/gui/app/late/bonsai.rs @@ -0,0 +1,144 @@ +use gpui::{ + div, px, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Styled, +}; + +use arcadia_core::modules; +use arcadia_core::modules::late::state; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +impl ArcadiaRoot { + pub(super) fn late_bonsai(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let arc = state(); + let st = arc.lock().unwrap_or_else(|e| e.into_inner()); + let art = st.bonsai_art.clone(); + drop(st); + + let accent = theme::nav_accent_palette("violet", is_dark); + + div() + .w_full() + .p_3() + .rounded_xl() + .bg(theme::module_panel_bg(is_dark)) + .border_1() + .border_color(theme::module_panel_stroke(is_dark)) + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .flex_row() + .justify_between() + .items_start() + .gap_3() + .child( + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child("Bonsai"), + ) + .child( + div() + .text_xs() + .text_color(theme::module_description_text(is_dark)) + .child("Living ASCII from late.sh"), + ), + ) + .child( + div() + .cursor_pointer() + .flex_shrink_0() + .px_2() + .py_1() + .rounded_md() + .bg(theme::module_button_enable_bg(is_dark)) + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_button_enable_text(is_dark)) + .hover(|style| style.bg(theme::module_button_enable_hover_bg(is_dark))) + .child("Water") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + let ctx = this.execution_context(); + match modules::execute_command("late.water", &[], &ctx) { + Ok(Some(msg)) => eprintln!("[late.gui] late.water: {msg}"), + Ok(None) => eprintln!("[late.gui] late.water: no output"), + Err(err) => eprintln!("[late.gui] late.water error: {err}"), + } + cx.notify(); + }), + ), + ), + ) + .child( + div() + .flex() + .flex_row() + .rounded_lg() + .overflow_hidden() + .border_1() + .border_color(theme::late_bonsai_well_stroke(is_dark)) + .child( + div() + .w(px(3.)) + .min_w(px(3.)) + .bg(accent.icon_active), + ) + .child( + div() + .flex_1() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .w_full() + .h(px(5.)) + .bg(theme::late_bonsai_pot_band(is_dark)), + ) + .child( + div() + .w_full() + .p_3() + .bg(theme::late_bonsai_well_bg(is_dark)) + .flex() + .flex_col() + .child(if art.is_empty() { + div() + .w_full() + .py_6() + .flex() + .justify_center() + .items_center() + .text_xs() + .text_color(theme::module_description_text(is_dark)) + .child("No bonsai yet — connect to late.sh.") + } else { + div() + .w_full() + .flex() + .flex_col() + .font_family("monospace") + .text_sm() + .text_color(theme::late_bonsai_foliage_text(is_dark)) + .children(art.into_iter().map(|line| { + div() + .line_height(px(15.)) + .child(line) + })) + }), + ), + ), + ) + } +} diff --git a/Desktop/src/gui/app/late/chat_pane.rs b/Desktop/src/gui/app/late/chat_pane.rs new file mode 100644 index 0000000..7345796 --- /dev/null +++ b/Desktop/src/gui/app/late/chat_pane.rs @@ -0,0 +1,211 @@ +use gpui::{ + div, rgb, Context, Element, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, + ParentElement, StatefulInteractiveElement, Styled, +}; + +use arcadia_core::modules::late::{send_ws, state, LateMessage}; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +impl ArcadiaRoot { + pub(super) fn late_message_list(&self, is_dark: bool) -> impl IntoElement { + let arc = state(); + let st = arc.lock().unwrap_or_else(|e| e.into_inner()); + let room = self.late_active_room; + let messages: Vec = st + .messages + .iter() + .filter(|_m| { + // Show all messages; filter by room once server sends room_id per message. + let _ = room; + true + }) + .cloned() + .collect(); + drop(st); + + div() + .id("late-chat-messages") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .flex() + .flex_col() + .gap_1() + .px_3() + .py_2() + .children(if messages.is_empty() { + vec![div() + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child("No messages yet — connect with late.connect and subscribe to a room.") + .into_any()] + } else { + messages + .into_iter() + .map(|msg| { + div() + .py_1() + .flex() + .flex_col() + .gap_0() + .child( + div() + .flex() + .flex_row() + .gap_2() + .items_baseline() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_title_text(is_dark)) + .child(msg.username.clone()), + ) + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child(format_timestamp(&msg.timestamp)), + ), + ) + .child( + div() + .text_sm() + .text_color(theme::module_description_text(is_dark)) + .child(msg.body.clone()), + ) + .child(if msg.reactions.is_empty() { + div().into_any() + } else { + div() + .flex() + .flex_row() + .gap_1() + .mt_1() + .children(msg.reactions.iter().map(|r| { + div() + .px_2() + .py_0p5() + .rounded_full() + .bg(if is_dark { + rgb(0x1e293b) + } else { + rgb(0xf1f5f9) + }) + .text_xs() + .text_color(theme::module_title_text(is_dark)) + .child(format!("{} {}", r.emoji, r.count)) + })) + .into_any() + }) + .into_any() + }) + .collect() + }) + } + + pub(super) fn late_compose_box(&self, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let input_text = self.late_compose_text.clone(); + let room = self.late_active_room; + + div() + .px_3() + .py_2() + .border_t_1() + .border_color(if is_dark { rgb(0x1e293b) } else { rgb(0xe2e8f0) }) + .flex() + .flex_row() + .gap_2() + .items_center() + .child( + div() + .flex_1() + .px_3() + .py_2() + .rounded_lg() + .bg(if is_dark { rgb(0x0f172a) } else { rgb(0xf8fafc) }) + .border_1() + .border_color(if is_dark { rgb(0x1e293b) } else { rgb(0xe2e8f0) }) + .text_sm() + .text_color(theme::module_title_text(is_dark)) + .track_focus(&self.late_compose_focus) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, _| { + this.late_compose_focus.focus(window); + }), + ) + .child(if input_text.is_empty() { + div() + .text_color(theme::module_meta_text(is_dark)) + .child("Type a message…") + } else { + div().child(input_text.clone()) + }) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { + let key = event.keystroke.key.as_str(); + let mods = event.keystroke.modifiers; + if key == "backspace" { + this.late_compose_text.pop(); + cx.notify(); + } else if key == "enter" || key == "return" { + let body = this.late_compose_text.trim().to_string(); + if !body.is_empty() { + send_ws(format!( + r#"{{"type":"send","room_id":{room},"body":{}}}"#, + serde_json::json!(body) + )); + this.late_compose_text.clear(); + cx.notify(); + } + } else if !mods.control && !mods.alt && !mods.platform && !mods.function { + if let Some(key_char) = &event.keystroke.key_char { + this.late_compose_text.push_str(key_char); + cx.notify(); + } + } else if key == "space" { + this.late_compose_text.push(' '); + cx.notify(); + } + })), + ) + .child( + div() + .cursor_pointer() + .px_3() + .py_2() + .rounded_lg() + .bg(if is_dark { rgb(0x0d9488) } else { rgb(0x14b8a6) }) + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(0xf0fdfa)) + .child("Send") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + let body = this.late_compose_text.trim().to_string(); + if !body.is_empty() { + send_ws(format!( + r#"{{"type":"send","room_id":{room},"body":{}}}"#, + serde_json::json!(body) + )); + this.late_compose_text.clear(); + cx.notify(); + } + }), + ), + ) + } +} + +fn format_timestamp(ts: &str) -> String { + // ISO-8601 → "HH:MM" extraction; fall back to raw string. + if ts.len() >= 16 { + if let Some(time_part) = ts.get(11..16) { + return time_part.to_string(); + } + } + ts.to_string() +} diff --git a/Desktop/src/gui/app/late/mod.rs b/Desktop/src/gui/app/late/mod.rs new file mode 100644 index 0000000..8488fb8 --- /dev/null +++ b/Desktop/src/gui/app/late/mod.rs @@ -0,0 +1,9 @@ +mod bonsai; +mod chat_pane; +mod panel; +mod sidebar; +mod state_bridge; +mod top_bar; +mod visualizer; +mod vote_panel; + diff --git a/Desktop/src/gui/app/late/panel.rs b/Desktop/src/gui/app/late/panel.rs new file mode 100644 index 0000000..3d7b519 --- /dev/null +++ b/Desktop/src/gui/app/late/panel.rs @@ -0,0 +1,43 @@ +use gpui::{div, Context, IntoElement, ParentElement, Styled, Window}; + +use crate::gui::app::ArcadiaRoot; + +use super::{sidebar::late_sidebar, top_bar::late_top_bar}; + +pub fn late_now_playing_panel(root: &ArcadiaRoot, cx: &mut Context, is_dark: bool) -> impl IntoElement { + div() + .w_full() + .h_full() + .flex() + .flex_row() + // Main chat column + .child( + div() + .flex_1() + .min_w_0() + .h_full() + .flex() + .flex_col() + .child(late_top_bar(cx, is_dark)) + .child(root.late_message_list(is_dark)) + .child(root.late_compose_box(cx, is_dark)), + ) + // Right sidebar + .child(late_sidebar(root, cx, is_dark)) +} + +// ArcadiaRoot dispatch stubs referenced from navigation.rs +impl ArcadiaRoot { + pub(crate) fn render_late_now_playing( + &self, + _window: &mut Window, + cx: &mut Context, + is_dark: bool, + ) -> gpui::Div { + div() + .flex_1() + .h_full() + .min_h_0() + .child(late_now_playing_panel(self, cx, is_dark)) + } +} diff --git a/Desktop/src/gui/app/late/sidebar.rs b/Desktop/src/gui/app/late/sidebar.rs new file mode 100644 index 0000000..591588c --- /dev/null +++ b/Desktop/src/gui/app/late/sidebar.rs @@ -0,0 +1,131 @@ +use gpui::{ + div, rgb, Context, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, + Styled, +}; + +use arcadia_core::modules::late::state; + +use crate::gui::app::ArcadiaRoot; +use crate::gui::theme; + +pub(super) fn late_sidebar(root: &ArcadiaRoot, cx: &mut Context, is_dark: bool) -> impl IntoElement { + let arc = state(); + let st = arc.lock().unwrap_or_else(|e| e.into_inner()); + let users: Vec = st.online_users.iter().map(|u| u.username.clone()).collect(); + let activity: Vec<(String, String, String)> = st + .activity_feed + .iter() + .rev() + .take(15) + .map(|e| (e.kind.clone(), e.username.clone(), e.timestamp.clone())) + .collect(); + drop(st); + + div() + .w_72() + .h_full() + .flex() + .flex_col() + .gap_0() + .border_l_1() + .border_color(if is_dark { rgb(0x1e293b) } else { rgb(0xe2e8f0) }) + .child( + div() + .px_3() + .py_2() + .border_b_1() + .border_color(if is_dark { rgb(0x1e293b) } else { rgb(0xe2e8f0) }) + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_meta_text(is_dark)) + .child(format!("● {} online", users.len())), + ) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .gap_0() + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .id("late-sidebar-scroll") + .overflow_y_scroll() + .child( + // Online users list + div() + .flex() + .flex_col() + .gap_1() + .px_3() + .py_2() + .children(users.into_iter().map(|username| { + div() + .text_xs() + .text_color(theme::module_description_text(is_dark)) + .child(format!("@{username}")) + })), + ) + .child( + div() + .mx_3() + .my_1() + .h_px() + .bg(if is_dark { rgb(0x1e293b) } else { rgb(0xe2e8f0) }), + ) + .child( + div() + .px_3() + .py_1() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::module_meta_text(is_dark)) + .child("Activity"), + ) + .child( + div() + .flex() + .flex_col() + .gap_1() + .px_3() + .pb_2() + .children(activity.into_iter().map(|(kind, username, ts)| { + let icon = if kind == "join" { "→" } else { "←" }; + div() + .text_xs() + .text_color(theme::module_description_text(is_dark)) + .child(format!( + "{icon} @{username} {}", + format_relative(&ts) + )) + })), + ), + ) + .child( + div() + .flex_shrink_0() + .px_3() + .pt_2() + .pb_3() + .border_t_1() + .border_color(if is_dark { rgb(0x1e293b) } else { rgb(0xe2e8f0) }) + .flex() + .flex_col() + .gap_2() + .child(root.late_bonsai(cx, is_dark)), + ), + ) +} + +fn format_relative(ts: &str) -> String { + if ts.len() >= 16 { + if let Some(t) = ts.get(11..16) { + return t.to_string(); + } + } + ts.to_string() +} diff --git a/Desktop/src/gui/app/late/state_bridge.rs b/Desktop/src/gui/app/late/state_bridge.rs new file mode 100644 index 0000000..402d637 --- /dev/null +++ b/Desktop/src/gui/app/late/state_bridge.rs @@ -0,0 +1,55 @@ +use std::time::Duration; + +use gpui::{Context, Timer, Window}; + +use crate::gui::app::ArcadiaRoot; + +const LATE_PAGES: &[&str] = &["late.now_playing"]; + +impl ArcadiaRoot { + pub fn ensure_late_poll_task(&mut self, window: &mut Window, cx: &mut Context) { + if self.late_poll_task_started { + return; + } + if !LATE_PAGES.contains(&self.active_page_id.as_str()) { + return; + } + self.late_poll_task_started = true; + cx.spawn_in( + window, + move |view: gpui::WeakEntity, cx: &mut gpui::AsyncWindowContext| { + let mut cx = cx.clone(); + async move { + loop { + Timer::after(Duration::from_millis(250)).await; + let should_stop = cx + .update(|_, app| { + view.update(app, |this, cx| { + if !LATE_PAGES.contains(&this.active_page_id.as_str()) { + this.late_poll_task_started = false; + return true; + } + let arc = arcadia_core::modules::late::state(); + let rev = arc + .lock() + .unwrap_or_else(|e| e.into_inner()) + .revision; + if rev != this.late_last_revision { + this.late_last_revision = rev; + cx.notify(); + } + false + }) + .unwrap_or(true) + }) + .unwrap_or(true); + if should_stop { + break; + } + } + } + }, + ) + .detach(); + } +} diff --git a/Desktop/src/gui/app/late/top_bar.rs b/Desktop/src/gui/app/late/top_bar.rs new file mode 100644 index 0000000..91c4897 --- /dev/null +++ b/Desktop/src/gui/app/late/top_bar.rs @@ -0,0 +1,129 @@ +use gpui::{div, rgb, Context, InteractiveElement, IntoElement, ParentElement, Styled}; + +use arcadia_core::modules::late::state; +use arcadia_core::modules; + +use super::visualizer::late_visualizer_inline; +use super::vote_panel::late_vote_pills; +use crate::gui::theme; + +use crate::gui::app::ArcadiaRoot; + +fn track_bar_vertical_rule(is_dark: bool) -> gpui::Div { + div() + .w_px() + .h_6() + .flex_shrink_0() + .mx_2() + .bg(if is_dark { + rgb(0x475569) + } else { + rgb(0xcbd5e1) + }) +} + +pub(super) fn late_top_bar(cx: &mut Context, is_dark: bool) -> impl IntoElement { + let arc = state(); + let st = arc.lock().unwrap_or_else(|e| e.into_inner()); + let track = st.now_playing.track.clone(); + let artist = st.now_playing.artist.clone(); + let connected = st.connected; + drop(st); + + let reconnect_btn = div() + .px_2() + .py_0p5() + .rounded_md() + .cursor_pointer() + .text_xs() + .bg(theme::top_bar_pill_bg(is_dark)) + .text_color(theme::top_bar_pill_text(is_dark)) + .hover(move |style| { + style.bg(theme::top_bar_pill_hover_bg(is_dark)) + }) + .child(if connected { "Reconnect" } else { "Connect" }) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + let ctx = this.execution_context(); + match modules::execute_command("late.connect", &[], &ctx) { + Ok(Some(msg)) => eprintln!("[late.gui] late.connect: {msg}"), + Ok(None) => eprintln!("[late.gui] late.connect: no output"), + Err(err) => eprintln!("[late.gui] late.connect error: {err}"), + } + cx.notify(); + }), + ); + + div() + .px_3() + .py_2() + .bg(if is_dark { rgb(0x0a0f1a) } else { rgb(0xf0fdf4) }) + .border_b_1() + .border_color(if is_dark { rgb(0x1e293b) } else { rgb(0xd1fae5) }) + .child( + div() + .flex() + .flex_row() + .items_center() + .w_full() + .min_w_0() + .child( + div() + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .gap_2() + .child( + div() + .text_xs() + .text_color(theme::module_meta_text(is_dark)) + .child("♫"), + ) + .child(if track.is_empty() { + div() + .text_xs() + .text_color(theme::module_description_text(is_dark)) + .child("No track info — connect to late.sh to stream.") + } else { + div() + .text_xs() + .text_color(theme::module_title_text(is_dark)) + .child(format!("{track} · {artist}")) + }), + ) + .child(track_bar_vertical_rule(is_dark)) + .child( + div() + .flex_1() + .flex() + .min_w_0() + .flex_row() + .items_center() + .justify_center() + .overflow_hidden() + .px_2() + .child(late_visualizer_inline(is_dark)), + ) + .child(track_bar_vertical_rule(is_dark)) + .child(if connected { + div() + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .gap_1() + .child(late_vote_pills(cx, is_dark)) + .child(track_bar_vertical_rule(is_dark)) + .child(reconnect_btn) + } else { + div() + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .child(reconnect_btn) + }), + ) +} diff --git a/Desktop/src/gui/app/late/visualizer.rs b/Desktop/src/gui/app/late/visualizer.rs new file mode 100644 index 0000000..7b33345 --- /dev/null +++ b/Desktop/src/gui/app/late/visualizer.rs @@ -0,0 +1,20 @@ +use gpui::{div, rgb, IntoElement, ParentElement, Styled}; + +use arcadia_core::modules::late::state; + +pub(super) fn late_visualizer_inline(is_dark: bool) -> impl IntoElement { + let arc = state(); + let st = arc.lock().unwrap_or_else(|e| e.into_inner()); + let frame = st.visualizer_frame.clone(); + drop(st); + + div() + .font_family("monospace") + .text_xs() + .text_color(if is_dark { rgb(0x5eead4) } else { rgb(0x0d9488) }) + .child(if frame.is_empty() { + "· · · · · ·".to_string() + } else { + frame + }) +} diff --git a/Desktop/src/gui/app/late/vote_panel.rs b/Desktop/src/gui/app/late/vote_panel.rs new file mode 100644 index 0000000..037d2b8 --- /dev/null +++ b/Desktop/src/gui/app/late/vote_panel.rs @@ -0,0 +1,49 @@ +use gpui::{div, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Styled}; + +use arcadia_core::modules::late::{send_ws, state}; + +use crate::gui::theme; + +use crate::gui::app::ArcadiaRoot; + +pub(super) fn late_vote_pills(cx: &mut Context, is_dark: bool) -> impl IntoElement { + let arc = state(); + let st = arc.lock().unwrap_or_else(|e| e.into_inner()); + let votes = st.votes.clone(); + drop(st); + + let genres: &[(&str, u32, &str)] = &[ + ("Lofi", votes.lofi, "lofi"), + ("Ambient", votes.ambient, "ambient"), + ("Classic", votes.classic, "classic"), + ]; + + div() + .flex() + .flex_row() + .gap_1() + .children(genres.iter().map(|(label, count, key)| { + let genre_key = (*key).to_string(); + div() + .cursor_pointer() + .px_2() + .py_0p5() + .rounded_md() + .text_xs() + .bg(theme::top_bar_pill_bg(is_dark)) + .text_color(theme::top_bar_pill_text(is_dark)) + .hover(move |style| { + style.bg(theme::top_bar_pill_hover_bg(is_dark)) + }) + .child(format!("{label} {count}")) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |_this, _, _, _cx| { + send_ws(format!( + r#"{{"type":"vote","genre":"{}"}}"#, + genre_key + )); + }), + ) + })) +} diff --git a/Desktop/src/gui/app/lifecycle.rs b/Desktop/src/gui/app/lifecycle.rs index 2e93410..acb0e28 100644 --- a/Desktop/src/gui/app/lifecycle.rs +++ b/Desktop/src/gui/app/lifecycle.rs @@ -64,6 +64,7 @@ impl ArcadiaRoot { pub fn new(cx: &mut gpui::Context) -> Self { let shell_focus = cx.focus_handle(); + let late_compose_focus = cx.focus_handle(); let module_rows = ModulesConfig::load_or_create() .map(|cfg| cfg.modules.into_iter().collect::>()) .unwrap_or_default(); @@ -82,6 +83,7 @@ impl ArcadiaRoot { shell_history: Self::initial_shell_history(), shell_input: String::new(), shell_focus, + late_compose_focus, shell_cursor: 0, shell_command_history: Vec::new(), shell_history_index: None, @@ -112,6 +114,10 @@ impl ArcadiaRoot { lan_service_feedback: String::new(), pending_lan_port_kill_prompt: None, lan_poll_task_started: false, + late_poll_task_started: false, + late_last_revision: 0, + late_active_room: 1, + late_compose_text: String::new(), }; // Thin client bootstrap: ARCADIA_NET_AS overrides persisted thin-client.toml route. diff --git a/Desktop/src/gui/app/mod.rs b/Desktop/src/gui/app/mod.rs index 32deee7..513628b 100644 --- a/Desktop/src/gui/app/mod.rs +++ b/Desktop/src/gui/app/mod.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; mod entry; mod lan_nodes; +mod late; mod lifecycle; mod modules_page; mod navigation; @@ -76,6 +77,7 @@ pub struct ArcadiaRoot { pub shell_history: Vec, pub shell_input: String, pub shell_focus: FocusHandle, + pub late_compose_focus: FocusHandle, pub shell_cursor: usize, pub shell_command_history: Vec, pub shell_history_index: Option, @@ -111,6 +113,10 @@ pub struct ArcadiaRoot { pub lan_service_feedback: String, pub pending_lan_port_kill_prompt: Option, pub lan_poll_task_started: bool, + pub late_poll_task_started: bool, + pub late_last_revision: u64, + pub late_active_room: u32, + pub late_compose_text: String, } impl ArcadiaRoot { diff --git a/Desktop/src/gui/app/navigation.rs b/Desktop/src/gui/app/navigation.rs index d9804ab..4fbd730 100644 --- a/Desktop/src/gui/app/navigation.rs +++ b/Desktop/src/gui/app/navigation.rs @@ -173,6 +173,9 @@ impl ArcadiaRoot { .p_6() .child(self.lan_nodes_panel(cx, is_dark)); } + if self.active_page_id.as_str() == "late.now_playing" { + return self.render_late_now_playing(window, cx, is_dark); + } div() .w_full() .p_6() diff --git a/Desktop/src/gui/app/root/render.rs b/Desktop/src/gui/app/root/render.rs index 20acb68..8bde696 100644 --- a/Desktop/src/gui/app/root/render.rs +++ b/Desktop/src/gui/app/root/render.rs @@ -17,6 +17,7 @@ impl Render for ArcadiaRoot { self.sync_peer_remote_exec_side_effects(window, cx); self.ensure_shell_caret_task(window, cx); self.ensure_lan_poll_task(window, cx); + self.ensure_late_poll_task(window, cx); if self.tui_session.is_some() { self.sync_tui_size(window); } @@ -93,21 +94,25 @@ impl Render for ArcadiaRoot { active_page_glyph, is_dark, )) - .child(if self.active_page_id.as_str() == "utility.shell" { - div() - .flex_1() - .min_h_0() - .w_full() - .id("arcadia-page-shell") - .child(self.render_active_content(window, cx, active_page, is_dark)) - } else { - div() - .flex_1() - .w_full() - .id("arcadia-page-scroll") - .overflow_y_scroll() - .child(self.render_active_content(window, cx, active_page, is_dark)) - }), + .child( + if self.active_page_id.as_str() == "utility.shell" + || self.active_page_id.as_str() == "late.now_playing" + { + div() + .flex_1() + .min_h_0() + .w_full() + .id("arcadia-page-full") + .child(self.render_active_content(window, cx, active_page, is_dark)) + } else { + div() + .flex_1() + .w_full() + .id("arcadia-page-scroll") + .overflow_y_scroll() + .child(self.render_active_content(window, cx, active_page, is_dark)) + }, + ), ) .child(self.requirements_modal(cx, is_dark)) .child(self.kill_existing_lan_modal(cx, is_dark)) diff --git a/Desktop/src/gui/app/root/top_bar.rs b/Desktop/src/gui/app/root/top_bar.rs index 70caa7d..1ef0268 100644 --- a/Desktop/src/gui/app/root/top_bar.rs +++ b/Desktop/src/gui/app/root/top_bar.rs @@ -3,6 +3,8 @@ use gpui::{div, rgb, Context, InteractiveElement, IntoElement, ParentElement, St use crate::gui::app::{ArcadiaRoot, ShellMode}; use crate::gui::theme; +const LATE_ROOMS: &[(&str, u32)] = &[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5)]; + impl ArcadiaRoot { pub(crate) fn render_main_top_bar( &self, @@ -44,6 +46,53 @@ impl ArcadiaRoot { }) .child(active_page_title), ) + .child(if self.active_page_id.as_str() == "late.now_playing" { + div() + .flex() + .flex_row() + .gap_1() + .children(LATE_ROOMS.iter().map(|(label, room_id)| { + let rid = *room_id; + let is_active = rid == self.late_active_room; + div() + .cursor_pointer() + .px_2() + .py_0p5() + .rounded_md() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .bg(if is_active { + if is_dark { rgb(0x0d9488) } else { rgb(0x99f6e4) } + } else { + theme::top_bar_pill_bg(is_dark) + }) + .text_color(if is_active { + if is_dark { rgb(0xf0fdfa) } else { rgb(0x134e4a) } + } else { + theme::top_bar_pill_text(is_dark) + }) + .hover(move |style| { + if !is_active { + style.bg(theme::top_bar_pill_hover_bg(is_dark)) + } else { + style + } + }) + .child(*label) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.late_active_room = rid; + arcadia_core::modules::late::send_ws( + format!(r#"{{"type":"subscribe","room_id":{rid}}}"#), + ); + cx.notify(); + }), + ) + })) + } else { + div() + }) .child(if self.active_page_id.as_str() == "utility.shell" { div() .px_2() diff --git a/Desktop/src/gui/theme/modules/buttons.rs b/Desktop/src/gui/theme/modules/buttons.rs index 4c63d3d..ab238fe 100644 --- a/Desktop/src/gui/theme/modules/buttons.rs +++ b/Desktop/src/gui/theme/modules/buttons.rs @@ -18,6 +18,24 @@ pub fn module_button_enable_bg(is_dark: bool) -> Rgba { } } +pub fn module_button_enable_hover_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.145, + g: 0.520, + b: 0.380, + a: 1.0, + } + } else { + Rgba { + r: 0.150, + g: 0.640, + b: 0.470, + a: 1.0, + } + } +} + pub fn module_button_enable_text(is_dark: bool) -> Rgba { if is_dark { Rgba { diff --git a/Desktop/src/gui/theme/modules/panel.rs b/Desktop/src/gui/theme/modules/panel.rs index 1db4792..ed662b1 100644 --- a/Desktop/src/gui/theme/modules/panel.rs +++ b/Desktop/src/gui/theme/modules/panel.rs @@ -35,3 +35,78 @@ pub fn module_panel_stroke(is_dark: bool) -> Rgba { } } } + +/// Inset “tray” behind late.sh ASCII bonsai art. +pub fn late_bonsai_well_bg(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.051, + g: 0.063, + b: 0.082, + a: 1.0, + } + } else { + Rgba { + r: 0.918, + g: 0.929, + b: 0.945, + a: 1.0, + } + } +} + +pub fn late_bonsai_well_stroke(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.145, + g: 0.175, + b: 0.220, + a: 1.0, + } + } else { + Rgba { + r: 0.765, + g: 0.805, + b: 0.865, + a: 1.0, + } + } +} + +/// Terracotta-ish band suggesting pot rim above soil area. +pub fn late_bonsai_pot_band(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.290, + g: 0.165, + b: 0.125, + a: 0.92, + } + } else { + Rgba { + r: 0.725, + g: 0.435, + b: 0.330, + a: 0.55, + } + } +} + +/// Foliage tint for monospace tree glyphs. +pub fn late_bonsai_foliage_text(is_dark: bool) -> Rgba { + if is_dark { + Rgba { + r: 0.620, + g: 0.980, + b: 0.710, + a: 1.0, + } + } else { + Rgba { + r: 0.118, + g: 0.358, + b: 0.184, + a: 1.0, + } + } +} diff --git a/Documentation/about.md b/Documentation/about.md new file mode 100644 index 0000000..dbba25d --- /dev/null +++ b/Documentation/about.md @@ -0,0 +1,49 @@ +# Lineage and About + +## Lineage + +**[Holos](https://github.com/stack-node/holos)** — macOS-first, modular, "built out of utility and spite" against rent-seeking micro-apps. + +**Arcadia** — same DNA (free, open, yours), different chassis: Rust core, cross-platform surfaces, explicit LAN routing, `surface.*` mirror channel, and agent-enforced registry patterns so the codebase stays honest as it grows. + +--- + +## About the creator + +I'm a twenty-something British developer. + +Moved to the US in 2016 chasing family — it didn't pan out how you'd hope. Along the way I fell hard into **electricity**, then **hardware**, then **software**. Spent years in demanding jobs (including **Disney** and **government** work): solid craft, solid burnout, and a growing dislike of systems that optimize **rent** over **agency**. + +Eventually I hit a wall, stepped back, and landed back in the **UK** to rebuild — **tired**, **broke**, and dealing with **chronic insomnia**. + +Turns out insomnia leaves a lot of hours for building. + +**[Holos](https://github.com/stack-node/holos)** was one outlet — macOS-first, modular, angry at menu-bar subscriptions. + +**Arcadia** is the next chapter: **Rust**, **multi-platform**, **one honest core**, **LAN-aware surfaces**, and the same underlying attitude — tools you own, not dashboards that invoice you. + +--- + +## Donations + +There is a donation link (when I've remembered to wire it somewhere sensible — check the GitHub profile, repo Sponsors, or releases if it's live). + +You probably shouldn't use it. + +Any money would realistically help with boring friction — Apple Developer fees, hardware for iOS builds — which sits in tension with the "don't feed the rent-seekers" ethos of these projects. It would still help Arcadia and Holos reach their technical potential. + +If you donate anyway and you'd rather that money not go toward licenses or anything in that vein, say so — I'd rather put it toward something human. I'm saving toward a cat; until that's sorted, that's the soft default. After that — or if you explicitly ask that I not keep any of it — donations marked "don't support the system" can go to my local animal shelter. + +No obligation. **Code and issues beat coffee money every time.** + +--- + +## Final note + +Arcadia is meant to be **yours**: fork it, break it, fix it, route it across your LAN, disable half the modules, wire something weird into `surface.patch`. + +If it helps you replace a pile of tiny apps or own your automation stack, feed that back as code or docs — not hype. + +Make something useful. Make something weird. Make something only you care about. + +That's still the point — just with one Rust core keeping the story straight. diff --git a/Documentation/architecture.md b/Documentation/architecture.md new file mode 100644 index 0000000..3dbd359 --- /dev/null +++ b/Documentation/architecture.md @@ -0,0 +1,206 @@ +# Architecture + +## Philosophy + +**Fat core, thin shells.** + +`Shared/ArcadiaCore` owns everything. Desktop and iOS read registries, render what those registries say, and `execute_command` back into core. They do not re-implement module graphs or navigation trees. + +**Single sources of truth — enforced, not hoped for.** + +| Domain | Authority | Never duplicated in | +|--------|-----------|---------------------| +| Module manifests + deps | `MODULE_REGISTRY` · `config/modules.rs` | surface state booleans | +| Navigation pages + groups | `PAGE_DEFINITIONS` / `GROUP_DEFINITIONS` · `navigation.rs` | surface match arms | +| Serializable nav for snapshots | `NavigationRegistryOwned` · embedded in `surface.snapshot` | hardcoded Swift arrays | +| Desktop theme tokens | `gui/theme/` | inline `rgb(0x...)` in views | +| iOS theme tokens | `AppTheme.swift` | inline `Color(hex:)` in views | +| Config schema | `ModulesConfig` · `config/modules.rs` | per-platform config parsers | + +**Extend the registry, not scatter `if pageId == …`.** +See `AGENTS.md` for the full list of anti-patterns we refuse to write. + +**Discipline at the core. Chaos at the edges. On purpose.** + +The architectural discipline of `arcadia-core` — registries, schemas, canonical state, no hardcoded IDs — exists to make the extension layer *safe to be chaotic*. Strict boundaries in the core mean extensions don't need to be strict. An extension can be messy, experimental, surface-specific, fast-moving, structurally impure, and weird. It won't corrupt the runtime underneath it. + +Most software chooses: freedom without structure, or structure without freedom. Arcadia is attempting both at different layers simultaneously. The core enforces coherence. The extension layer is where experimentation, exceptions, and "this only exists here" decisions belong. + +**Personal tool energy, public repo.** +If Arcadia helps others, great — that's bonus. The goal is a system you own, can fork, and can route across machines you trust. + +--- + +## Command model + +All execution flows through a single entry point: + +``` +execute_command(token: &str, args: &str, context: ExecutionContext) -> String +``` + +- **Tokens** follow `module.command` format: `shell.execute`, `lan.scan`, `surface.snapshot`, `surface.patch`, etc. +- **`ExecutionContext`** carries `net_as` (optional LAN routing, e.g. `lan:192.168.1.10`) and `net_timeout_ms`. +- When `net_as` is set, `execute_command` forwards the token + args over UDP to the target peer instead of dispatching locally. The peer runs the command under its own module rules. +- LAN forwarding requires local `remote-session`, `lan`, and `net` modules enabled; the peer enforces its own module requirements for the token. +- FFI exposes this identically to iOS and Desktop — same logical API, same routing semantics. + +--- + +## Module system + +Modules are entries in `MODULE_REGISTRY` (`config/modules.rs`). Each entry is a `ModuleManifest`: + +```rust +pub struct ModuleManifest { + pub name: &'static str, // unique key, e.g. "shell" + pub version: &'static str, + pub description: &'static str, + pub required_modules: &'static [&'static str], // dependency enforcement +} +``` + +`ModulesConfig` (TOML-backed) maps module names to enabled state. Key behaviors: + +- `enable_with_requirements(name)` — transitively enables all deps before the target. +- `missing_requirements_for(name)` — returns unmet deps (used for UI requirement prompts). +- `merge_defaults()` — config migration entry point; handles legacy renames (e.g. `LEGACY_LAN_MODULE_NAME`). +- Changes write to `~/Arcadia/Configuration/modules.toml` (Desktop) or the app container path (iOS). + +Every surface calls `list_modules()` → `Vec` and renders whatever comes back. No surface hardcodes module names in layout logic. + +--- + +## Navigation system + +Navigation structure lives entirely in `navigation.rs` as two static slices: + +**`PAGE_DEFINITIONS`** — 7 pages: + +| ID | Title | Required Module | +|----|-------|-----------------| +| `utility.shell` | Shell | `shell` | +| `global.dashboard` | Dashboard | — | +| `global.logs` | Logs | — | +| `global.settings` | Settings | — | +| `global.modules` | Modules | — | +| `network.overview` | Network | `net` | +| `network.nodes` | Nodes | `lan` | + +**`GROUP_DEFINITIONS`** — 2 groups: + +| ID | Label | Pages | +|----|-------|-------| +| `utilities` | Utilities | `utility.shell` | +| `network` | Network | `network.overview`, `network.nodes` | + +`NavigationPageDefinition.required_module` drives visibility — surfaces query `is_module_enabled(page.required_module)`, never hardcode per-page logic. The full registry serializes to JSON via `default_navigation_registry_json()` for: + +- iOS FFI: `navigation_registry_json()` → deserializes into `NavigationRegistry` Swift struct +- Thin-client: embedded in `surface.snapshot` extra field so remote clients get host's nav without a local copy + +Lookup helpers: `page_by_id(id)`, `group_by_id(id)`. + +--- + +## Thin-client and LAN routing + +Arcadia supports a **headless host + GUI client** pattern over LAN: + +``` +[iOS or Desktop GUI] ──── surface.snapshot ───► [headless arcadia host] + ◄─── surface.patch ───── + ──── execute_command("lan:IP") ──► (routed command) +``` + +**`surface.snapshot`** — host serializes current state: +```json +{ + "modules": [{"name": "shell", "enabled": true}, ...], + "revision": 7, + "extra": { + "navigation_registry": "{ ...full nav JSON... }" + } +} +``` + +**`surface.patch`** — client pushes changes back: +```json +{ + "client_id": "uuid-from-thin-client.toml", + "ops": [{"type": "modules_set", "name": "lan", "enabled": true}] +} +``` + +**`lan.session_targets`** — returns JSON list of approved peers for the session picker UI. + +**`thin-client.toml`** persists: +- `preferred_remote_route` — remembered LAN target (e.g. `lan:192.168.1.5`) +- `surface_client_id` — UUID for patch attribution + +**`ARCADIA_NET_AS`** env var bootstraps `net_as` on startup, overriding `thin-client.toml`. + +**Multi-client caveat:** `modules.toml` is a single file on the host. Concurrent edits are last-writer-wins with no merge semantics. See [roadmap.md](roadmap.md). + +--- + +## Remote mirror + +When this machine executes an inbound `NODE_EXEC` for a remote peer, `modules/remote_mirror.rs` enqueues transcript lines plus a `sync_local_surface` flag. Surfaces drain this via `drain_remote_mirror_batch()` (FFI) on a timer (iOS: 250ms) to: + +1. Display remote command output locally. +2. Trigger a `reload_modules()` when `sync_local_surface` is true (host state changed). + +--- + +## Theme system + +**Desktop** (`Desktop/src/gui/theme/`): +- Named color constants and helper functions — never inline `rgb(0x...)` in view files. +- `icon_path(glyph: &str) -> &str` — maps glyph keys to SVG asset paths. +- `nav_accents/` — per-accent palettes (amber, cyan, emerald, fuchsia, indigo, orange, sky, teal, violet). +- Component tokens under `modules/` — buttons, panels, rows, toggles, typography. + +**iOS** (`AppTheme.swift`): +- All colors as computed properties on `AppTheme(isDark:)`. +- No `Color(hex:)` inline anywhere in view files. + +--- + +## FFI bridge + +`ffi.rs` is the UniFFI boundary. All iOS ↔ Rust communication goes through it. Key exports: + +**Setup:** +- `set_config_root_path(path: String)` — must be called first on iOS (app sandbox path) + +**Command execution:** +- `execute_command(token, args, context: ExecutionContextFfi) -> String` +- `list_commands() -> Vec` + +**Module control:** +- `list_modules() -> Vec` +- `set_module_enabled(name, enabled) -> String` +- `set_module_enabled_with_requirements(name, enabled) -> String` +- `probe_module_toggle(name, enabled) -> ModuleToggleResult` — preflight check, returns missing deps + +**Navigation:** +- `navigation_registry_json() -> String` +- `platform_name() -> String` + +**Thin-client:** +- `thin_client_surface_client_id() -> String` +- `thin_client_preferred_route_get() -> Option` +- `thin_client_preferred_route_set(route: String) -> String` + +**LAN:** +- `lan_start()`, `lan_stop()` + +**Mirror:** +- `drain_remote_mirror_batch() -> RemoteMirrorDrain` + +After any change to `ffi.rs` or exported types, run: +```sh +bash Shared/Scripts/build-ios-framework.sh +``` +This regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. diff --git a/Documentation/build.md b/Documentation/build.md new file mode 100644 index 0000000..eeb1a69 --- /dev/null +++ b/Documentation/build.md @@ -0,0 +1,61 @@ +# Build and Run + +## Desktop GUI + +```sh +cd Desktop && cargo build --features gui +cd Desktop && cargo run --features gui +``` + +## Desktop CLI (headless) + +Default features are `headless`: + +```sh +cd Desktop && cargo run +``` + +## Desktop release + +```sh +cd Desktop && cargo build --release --features gui +``` + +## Core tests + +```sh +cd Shared && cargo test -p arcadia-core +``` + +## iOS framework + Swift bindings + +Run after any change to `ffi.rs` or exported types: + +```sh +bash Shared/Scripts/build-ios-framework.sh +``` + +Regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. Then open `ArcadiaApp` in Xcode and build. + +## Launcher menus + +```sh +bash Shared/Scripts/Launcher.sh +pwsh Shared/Scripts/Launcher.ps1 +``` + +## Global wrappers (macOS) + +```sh +bash Shared/Scripts/install-global-commands-macos.sh +``` + +Installs helpers to `~/.local/bin` — ensure it's on `PATH`. + +## macOS dev launcher app + +```sh +cd Launchers/Development/OSX && bash build-app.sh +``` + +See `Launchers/Development/OSX/README.md` for details. diff --git a/Documentation/configuration.md b/Documentation/configuration.md new file mode 100644 index 0000000..6abc15c --- /dev/null +++ b/Documentation/configuration.md @@ -0,0 +1,34 @@ +# Configuration + +## Config files + +Runtime config root: `~/Arcadia/Configuration/` on Desktop. iOS sets root via `set_config_root_path` (app sandbox). + +| File | Struct | Purpose | +|------|--------|---------| +| `modules.toml` | `ModulesConfig` | Per-module on/off state | +| `commandline.toml` | `CommandlineConfig` | CLI preferences (scaffold) | +| `thin-client.toml` | `ThinClientConfig` | `preferred_remote_route`, `surface_client_id` | + +Config migrations live in `ModulesConfig::merge_defaults()`. When renaming a module, add a migration entry there — do not do ad-hoc renames at call sites. + +--- + +## Prerequisites + +| Tool | Required for | +|------|-------------| +| Rust (`rustup`, `cargo`) | Core + Desktop | +| Xcode + CLI tools | iOS app + xcframework build | +| `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` | `build-ios-framework.sh` | +| Swift (via Xcode) | iOS app + dev launcher | + +--- + +## Environment variables + +| Variable | Surface | Purpose | +|----------|---------|---------| +| `ARCADIA_NET_AS` | Desktop GUI, iOS | Bootstrap `net_as` on startup (e.g. `lan:192.168.1.5`). Overrides `thin-client.toml` preferred route. | +| `ARCADIA_IOS_DEVICE_NAME` | iOS deploy scripts | Pin device by name | +| `ARCADIA_IOS_FORCE_UNINSTALL` | iOS deploy scripts | Uninstall before install | diff --git a/Documentation/contributing.md b/Documentation/contributing.md new file mode 100644 index 0000000..e308b3a --- /dev/null +++ b/Documentation/contributing.md @@ -0,0 +1,71 @@ +# Contributing + +Read `AGENTS.md` — it has the registry-discipline rules and the full list of anti-patterns we refuse to write. Short version: + +1. **Registry entry before surface code.** New module? `MODULE_REGISTRY` first. New page? `PAGE_DEFINITIONS` first. +2. **No per-module booleans in surface state.** One generic `is_module_enabled(name)` query. +3. **No hardcoded page ID match arms in visibility logic.** Derive from `required_module` in `PageDefinition`. +4. **No inline colors.** Theme layer only. +5. **Cross-platform logic belongs in core.** If you're writing the same thing in `app.rs` and `ContentView.swift`, it's core logic. +6. **After FFI changes:** run `build-ios-framework.sh` and commit `Generated/` + `xcframework`. + +If something's missing: open a PR, draft a module, or file an issue with a concrete repro. + +--- + +## Adding features + +### New module + +1. Add constant + `ModuleManifest` to `MODULE_REGISTRY` in `Shared/ArcadiaCore/src/config/modules.rs`. +2. Create `Shared/ArcadiaCore/src/modules/x.rs` with a `commands()` fn returning `&[ModuleCommand]`. +3. Register in `Shared/ArcadiaCore/src/modules/mod.rs`. +4. Done. GUI, CLI, and iOS module list updates automatically — no surface edits required. + +### New navigation page + +1. Add `NavigationPageDefinition` to `PAGE_DEFINITIONS` in `navigation.rs`. Set `required_module` if visibility depends on a module. +2. Add the page ID to the relevant `GROUP_DEFINITIONS.pages` slice, or create a new group. +3. Implement the page panel: Desktop → `gui/app/` new panel file; iOS → new view file. +4. Route it in the surface content switch via the page ID — derive visibility from `required_module`, not a hardcoded match. + +### New icon/glyph + +1. Add SVG to `Desktop/assets/icons/`. +2. Add match arm to `icon_path()` in `Desktop/src/gui/theme/mod.rs`. +3. Use the key in `NavigationPageDefinition.glyph` or `NavigationGroupDefinition.glyph`. + +### New theme color + +- Desktop: add named constant or helper fn to `Desktop/src/gui/theme/mod.rs` or the relevant component token file under `theme/modules/`. +- iOS: add computed property to `AppTheme` in `AppTheme.swift`. +- Never inline `rgb(0x...)` or `Color(hex:)` in view files. + +### New mirrored state + +Extend `SurfaceSnapshot.extra` and add a `SurfacePatch` variant in `modules/surface.rs`. Wire both surfaces to consume the new extra field from snapshot. Do not create ad-hoc `remote-session.*` verbs — keep the protocol under `surface.*`. + +### Renaming a module + +Edit `MODULE_REGISTRY` name and constant. Add a migration to `ModulesConfig::merge_defaults()` following the `LEGACY_LAN_MODULE_NAME` pattern. Do not rename at call sites. + +--- + +## Testing + +Current test coverage is sparse. Priority areas for expansion: + +```sh +# Run existing tests +cd Shared && cargo test -p arcadia-core + +# What to add: +# - surface.snapshot / parse_surface_snapshot round-trips +# - NavigationRegistryOwned JSON serialization/deserialization +# - ModulesConfig migration (merge_defaults with legacy keys) +# - thin-client preference persistence (set → get → re-load) +# - LAN routing integration (execute_command with net_as) +# - Module enable/disable with dependency enforcement +``` + +iOS `ArcadiaCore.xcframework` rebuild after FFI changes is currently manual. Adding a CI step that fails when `Generated/` drifts from `ffi.rs` is a high-priority gap — see [roadmap.md](roadmap.md). diff --git a/Documentation/reference.md b/Documentation/reference.md new file mode 100644 index 0000000..811731f --- /dev/null +++ b/Documentation/reference.md @@ -0,0 +1,39 @@ +# Module and Navigation Reference + +## Module reference + +| Module | Name constant | Requires | Description | +|--------|--------------|----------|-------------| +| `net` | `NET_MODULE_NAME` | — | Networking foundation; bootstraps LAN service | +| `lan` | `LAN_MODULE_NAME` | `net` | LAN discovery via UDP; peer management; pairing | +| `surface` | `SURFACE_MODULE_NAME` | — | `surface.snapshot` and `surface.patch` host mirror channel | +| `remote-session` | `REMOTE_SESSION_MODULE_NAME` | `net`, `lan` | Routing gate for LAN command forwarding; no standalone verbs | +| `shell` | `SHELL_MODULE_NAME` | — | `shell.execute` (routable), `shell.internal` (REPL), PTY/TUI on Desktop | +| `shell-motd` | `SHELL_MOTD_MODULE_NAME` | `shell` | Fastfetch-style banner on shell open | + +### LAN sub-system (`modules/lan/`) + +| Component | File | Purpose | +|-----------|------|---------| +| Service entry | `mod.rs` | `start_service` / `stop_service`, command registry | +| Discovery | `discovery.rs` | Peer scan, node state tracking | +| Handlers | `handlers.rs` | `lan.scan`, `lan.node`, `lan.session_targets`, pairing approval | +| Config | `config.rs` | Approved peers persistence | +| Peers | `peers.rs` | Peer struct and list management | +| Protocol | `protocol.rs` | UDP `NODE_EXEC` and related definitions | + +--- + +## Navigation reference + +All 7 pages. Add new pages to `PAGE_DEFINITIONS` in `navigation.rs` — never to surface match arms. + +| Page ID | Title | Group | Required Module | Glyph | SF Symbol | +|---------|-------|-------|-----------------|-------|-----------| +| `utility.shell` | Shell | `utilities` | `shell` | `terminal` | `terminal` | +| `global.dashboard` | Dashboard | (global) | — | `home` | `house` | +| `global.logs` | Logs | (global) | — | `logs` | `doc.text` | +| `global.settings` | Settings | (global) | — | `settings` | `gear` | +| `global.modules` | Modules | (global) | — | `modules` | `square.stack.3d.up` | +| `network.overview` | Network | `network` | `net` | `nodes` | `network` | +| `network.nodes` | Nodes | `network` | `lan` | `nodes` | `antenna.radiowaves.left.and.right` | diff --git a/Documentation/repository.md b/Documentation/repository.md new file mode 100644 index 0000000..b352606 --- /dev/null +++ b/Documentation/repository.md @@ -0,0 +1,112 @@ +# Repository Layout + +``` +Shared/ + ArcadiaCore/ + Cargo.toml # crate-type: staticlib + cdylib + lib + src/ + lib.rs # root, exports + UniFFI scaffolding + ffi.rs # UniFFI → Swift (iOS bridge) + navigation.rs # PAGE_DEFINITIONS, GROUP_DEFINITIONS, registry JSON + config/ + mod.rs # ConfigFile trait, config root path + modules.rs # MODULE_REGISTRY, ModulesConfig, migrations + commandline.rs # CLI preferences + thin_client.rs # ThinClientConfig → thin-client.toml + modules/ + mod.rs # execute_command dispatcher, module lifecycle + shell.rs # shell.execute, shell.internal, PTY + shell_motd.rs # MOTD banner + surface.rs # surface.snapshot / surface.patch + remote_session.rs # routing manifest entry (no standalone commands) + remote_mirror.rs # host transcript queue + FFI drain + net.rs # networking foundation + lan/ # LAN subsystem (see reference.md) + mod.rs, discovery.rs, handlers.rs, config.rs, peers.rs, protocol.rs + platform/ + mod.rs, macos.rs, ios.rs, linux.rs, windows.rs, unknown.rs + Scripts/ + build-ios-framework.sh # Rebuild xcframework + Swift bindings + install-global-commands-macos.sh # Install ~/.local/bin wrappers + Launcher.sh / Launcher.ps1 # Shell launcher menus + Tools/uniffi-bindgen/ # UniFFI bindgen binary (workspace member) + +Desktop/ + Cargo.toml # features: headless (default), gui + src/ + main.rs # binary entry, feature-gated GUI vs headless + cli/ + mod.rs # REPL loop, startup messages + args.rs # argument parsing + completion.rs # shell completion + config_cmds.rs # module/config CLI commands + module_cmds.rs # module shortcut commands + gui/ + mod.rs + assets.rs # embedded SVG asset loading + app/ + mod.rs # ArcadiaRoot state, ShellMode enum + entry.rs # GPUI initialization + lifecycle.rs # focus, resize, module reload + navigation.rs # nav state and page routing + root/mod.rs, render.rs # root layout + render + root/top_bar.rs # title bar, session chip, shell mode toggle + sidebar/mod.rs, layout.rs, nav_items.rs + shell/mod.rs, panel.rs, execute.rs, keys.rs, tui_screen.rs, mirror.rs + modules_page/mod.rs, panel.rs, row.rs, requirements_modal.rs + lan_nodes/mod.rs, panel.rs + splash/mod.rs, view.rs, draw_*.rs, math.rs + theme/ + mod.rs # icon_path(), color constants + chrome.rs # window chrome + icons.rs # icon metadata + splash_colors.rs + modules/ # component tokens (buttons, panel, rows, toggles, typography) + nav_accents/ # per-accent palettes (mod.rs, palette.rs, 9 accents) + tui/ + mod.rs, session.rs # PTY session lifecycle + ansi_line.rs # ANSI escape parsing + colors.rs # terminal color palette + cd_builtin.rs, cwd.rs, env.rs # shell builtins + CWD tracking + keys.rs # PTY keyboard events + vt_history.rs # VT100 history buffer + assets/icons/ # SVG icons (home, terminal, logs, settings, nodes, modules, tools) + +Mobile/iOS/ + ArcadiaApp/ + ArcadiaApp.swift # @main, config root setup + ContentView.swift # top-level coordinator + Actions/Layout/NavigationState/Registry extensions + NavigationModels.swift # Swift structs mirroring NavigationRegistry + AppTheme.swift # all iOS colors as computed properties + SidebarView.swift # sidebar rendering + remote session picker + SplashView.swift # animated splash + ShellView.swift # shell command input + history + ModulesView.swift # module toggle list + LanNodesView.swift # LAN peer discovery + pairing + ModuleNames.swift # string constants mirroring MODULE_REGISTRY + GlassComponents.swift # reusable glassmorphism components + ArcadiaCore/ # Generated Swift + ArcadiaCore.xcframework (rebuild after ffi.rs changes) + +Configuration/ # Layout reference (runtime: ~/Arcadia/Configuration on Desktop) + modules.toml # module enable/disable state + commandline.toml # CLI preferences + thin-client.toml # preferred_remote_route, surface_client_id + +Resources/ + Wallpapers/ # Landscape.png, Portrait.png, Landscape-Refined.png + Sounds/ # Notification_* (Warm, Pop, Minimal, Glass, Deep, Airy) + Icons/ # App icon prototypes + Final-1-appicon.png + +Launchers/Development/OSX/ # SwiftPM menu bar launcher (optional, dev only) + Package.swift + Sources/ArcadiaDevelopmentLauncher/main.swift + build-app.sh, README.md + +.github/workflows/ + stable-build-matrix.yml # Desktop + iOS simulator CI + FUNDING.yml # GitHub Sponsors + +gaps.md # Deliberate limitations and next-tier work +CLAUDE.md # Contributor guide (architecture patterns) +AGENTS.md # Agent rules (registry discipline, anti-patterns) +``` diff --git a/Documentation/roadmap.md b/Documentation/roadmap.md new file mode 100644 index 0000000..6f0cf08 --- /dev/null +++ b/Documentation/roadmap.md @@ -0,0 +1,55 @@ +# Roadmap and Known Gaps + +`gaps.md` tracks all deliberate limitations. Summary with priority ranking: + +## P0 — Fix before trusting in production + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Revision coverage** | `surface.revision` only advances on `surface.patch`. CLI writes and FFI writes bypass it — clients can miss updates. | Bump revision from every `ModulesConfig::save`. | +| **Testing discipline** | No automated tests for snapshot round-trips, thin-client prefs, or LAN routing. | Add targeted `arcadia-core` unit + integration tests. | +| **FFI drift detection** | No CI check that `Generated/` matches `ffi.rs`. | Workflow step: rebuild and fail if diff. | + +## P1 — Needed for real multi-user / multi-surface use + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Stale UI detection** | Desktop has `last_surface_revision` but never compares it — no "host changed under you" warning. | Compare revision on timer/focus/after routed command; optional banner + reload. | +| **Multi-writer** | Multiple GUIs on same host = last write wins, no merge, no locks. | Document as permanent constraint OR add optimistic concurrency (generation tokens on save). | +| **Transport** | Command routing is request/response UDP. No long-lived session, no ordering guarantees, no subscription for deltas. | Optional WebSocket/TCP sidecar for continuous thin-shell workflows. | + +## P2 — Required before leaving trusted LAN + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Security posture** | No wire encryption, no auth beyond "approved node," no scoped capabilities. `shell.execute` routable to anyone approved. | Threat model doc + TLS or pairing secrets + capability tokens. | +| **Identity** | `client_id` is attribution only — no authz, no rate limits, no per-client filtering. | Host-side policy module or capability tokens if multi-tenant. | + +## P3 — Polish and convergence + +| Gap | Problem | Direction | +|----|---------|-----------| +| **Surface parity** | Desktop has PTY/TUI paths; iOS is shell.execute only; not all panels are execute-only. | Converge per capability class with explicit "unavailable on this surface" from core. | +| **Renderer-only client** | Surfaces still bundle compiled nav — no enforced "remote-only" profile. | Optional build flag that refuses static nav when `remote_route` is mandatory. | +| **`extra` schema** | `extra.navigation_registry` is wired; broader extra buckets and corresponding `SurfacePatch` variants are undefined. | Define schema + version fields inside `extra`; extend `SurfacePatch` incrementally. | + +--- + +## Security posture + +Current trust model: **LAN pairing + locally approved peers.** Assume trusted network. + +What this means in practice: +- Any approved LAN peer can execute any command the host has enabled, including `shell.execute`. +- `surface.patch` is unauthenticated beyond `client_id` (which is just a UUID, not a secret). +- There is no encryption on the wire. + +**Do not expose Arcadia to untrusted networks without addressing P2 gaps above.** This is a home-network / trusted-LAN tool today. Production-grade multi-tenant use requires TLS, capability tokens, and a real threat model document first. + +--- + +## CI + +`.github/workflows/` — `stable-build-matrix.yml` builds Desktop targets and iOS simulator configs on selected branches. See individual workflow files for triggers and matrix. + +Gaps in CI coverage: FFI drift detection, core integration tests. See [contributing.md](contributing.md). diff --git a/Documentation/vision.md b/Documentation/vision.md new file mode 100644 index 0000000..dc0048c --- /dev/null +++ b/Documentation/vision.md @@ -0,0 +1,221 @@ +# Vision + +## Why Arcadia exists + +Small-tool ecosystems trend the same way: **paywalls, subscriptions, feature flags, AI-generated-app-of-the-week churn.** Good ideas get trapped in silos—one app for the menu bar, one for the terminal, one for "sync," each with its own incompatible settings schema and no way out. + +**Holos** pushed back on that for macOS: modular, free, yours to extend. + +**Arcadia** pushes harder: + +- **One core** (`arcadia-core`) owns modules, commands, config, navigation metadata, and LAN plumbing. Surfaces are **render + dispatch**, not second implementations. +- **Multiple surfaces** from the same logic: terminal REPL, GPUI desktop, SwiftUI pocket — without forking behavior per platform. +- **Optional headless-host + GUI-client** patterns over LAN so your MacBook can drive your phone — or vice versa — without inventing a new protocol per feature. +- **Free. Always.** No paywalls in the architecture. The repo is the product. + +If something's missing, you add a module or extend `surface.snapshot` / `surface.patch`. You don't buy another app. + +There's a second reason, less technical but just as important. + +A lot of people grew up in software environments where the line between user, developer, toolmaker, and creator basically dissolved — game modding scenes, jailbreak ecosystems, Emacs, early web chaos. Environments where you could bend the software, remix the system, blur the boundary between using a tool and building inside it. Those environments permanently change how you think about computing. You stop seeing apps as products and start seeing them as constrained runtimes. + +For a lot of people, that moment of creative access — Scratch, a game modding scene, their first Python script, a modded game that ran something they built — was the thing that sparked a path in software. The activation energy between imagination and creation dropped low enough that curiosity survived long enough to become skill. + +Most modern software feels closed afterward. The invitation is gone. + +Arcadia is an attempt to restore that feeling — but for the desktop itself, with an actual engineering foundation underneath it instead of accumulated chaos. The ambition is not to make software that people use. It's to make software that *changes what people believe they can do*. + +That's how software changes lives instead of just increasing throughput. + +--- + +## The vision — where this is going + +What Arcadia is *right now* is the foundation. What it's *becoming* is something more deliberate: + +**A programmable personal computing substrate. A unified interaction layer above the OS where users operate *inside* the architecture — not merely use software built on top of it.** + +Not an app. Not a framework. Not a toolkit. + +A runtime that you inhabit and reshape. + +### The real category + +Arcadia sits between several things that exist and combines them in a way nothing currently does: + +- An **application framework** — native surfaces, layout system, module lifecycle +- A **shell** — command routing, LAN awareness, headless-host patterns +- A **local-first app platform** — config ownership, no vendor, no cloud dependency +- A **programmable UI fabric** — extensions that render into native surfaces as first-class pages + +The closest historical analogies are not other desktop frameworks. They're environments like **HyperCard**, **Smalltalk**, **Emacs**, **Garry's Mod**, **Hammerspoon**, **Quartz Composer**, **BetterTouchTool**, **KDE Plasma scripting**, and old-school **jailbreak ecosystems** — environments where the line between user, developer, toolmaker, and creator dissolved. + +What those environments had in common: **the system itself was meant to be inhabited and reshaped.** Users started by doing the basic thing and ended up building systems inside systems — admin frameworks, UI toolkits, protocol adapters, entire economies — because the substrate let them. + +That's the energy Arcadia is trying to restore. Not aesthetically. In *agency*. + +### The tension — and why it's already solved + +Arcadia's core architecture is deliberately rigid: + +- centralized registries +- canonical state +- deterministic structure +- controlled capability routing +- explicit schemas + +That produces coherence. But the concern with that kind of discipline is that it creates activation energy against new ideas. Every new capability has to justify itself in terms of registries, schemas, surface compatibility, state ownership. That cognitive overhead can quietly kill creativity. + +The Python extension layer solves this. It separates two things that should always be separate: + +| Layer | Character | Enforces | +|-------|-----------|---------| +| **Core** (`arcadia-core`, Rust) | Disciplined | Identity, state consistency, capability routing, surface sync, lifecycle, security, cross-platform | +| **Extension layer** (Python SDK) | Deliberately messy | Nothing. Be weird. Move fast. Break your own conventions. | + +The core stays disciplined. The edges stay chaotic. + +That balance is not a compromise — it's the architecture that successful long-lived systems always converge toward. Unix kernel / shell chaos. Browser engine / arbitrary JS. Git object model / messy workflows. Game engines / mod scripting. Emacs runtime / user mutation. Garry's Mod engine / Lua ecosystem. + +The projects that become culturally important manage both simultaneously. Freedom without structure collapses. Structure without freedom stagnates. Arcadia is attempting to do both at once, at different layers, on purpose. + +### The participation ladder — no one left behind + +The goal is not "easier to learn." The goal is **never being blocked from creating**. + +Those are different things. Scratch didn't succeed because blocks are easier than code. It succeeded because it removed fear, kept causality visible, and rewarded experimentation instantly. It transformed people from *consumers* of software into *participants* in software. That transformation matters more than any particular tool or language. + +Arcadia is designed around a participation ladder — multiple entry points, all valid, all real: + +| Level | Path | What you get | +|-------|------|-------------| +| 1 | **Visual tools** | Drag-and-drop extension building, widget configuration, flow-based composition — no code required | +| 2 | **AI-assisted generation** | Describe what you want, get working code, see it explained inline, modify it live | +| 3 | **Python scripting** | Write extensions directly — readable, fast to iterate, full OS reach | +| 4 | **Deep system access** | Rust core, FFI, custom modules, protocol extensions — no ceiling | + +You can enter at any level and stay there. You can also move up — and if you do, the environment is the same. The tool you built at level 1 runs on the same runtime as the tool built at level 4. Nothing is disposable. Nothing is "training wheels." + +The most experienced developer and the most inexperienced person both get what they came for. One wants to understand every layer and push the system to its limits. The other just wants something built. Both outcomes are equally valid and equally supported. + +Python is the right choice for the middle layers specifically because it *says something culturally*. Rust says "you need to understand memory management." Xcode says "you need a Mac and a developer account." Python says: **you are allowed to participate.** + +That psychological accessibility is not a technical detail. It's the whole point. + +### The Python library + +The next major layer is a Python SDK that exposes the full power of `arcadia-core` to any developer who can write a script. Not a watered-down scripting layer — full OS reach: + +- **File system** — read, write, watch, index +- **Processes** — spawn, manage, pipe, monitor +- **Networking** — LAN discovery, routing, peer communication +- **Display** — render into Arcadia's native surfaces (Desktop GUI, iOS) from Python +- **Shell** — execute commands, capture output, stream PTY sessions +- **Config** — read and write module state, preferences, thin-client config +- **Events** — hook into system events, timers, window focus, LAN peer state changes + +The goal is parity with what you'd get writing native Rust or Swift — but with a workflow where you open a file, write twenty lines, and have a running extension. + +The Rust core stays Rust. Performance-critical paths, protocol handling, LAN networking, config I/O, FFI to native surfaces — none of that moves to Python. Python sits above it, calling into `arcadia-core` through a clean API boundary. + +### Extensions: the real product + +The Python SDK powers an **extension system**. Extensions are the unit of user-created capability in Arcadia. An extension can be: + +| Type | Examples | +|------|---------| +| **Internal app** | A custom shell, a task manager, a log viewer — rendered inside Arcadia's native UI just like the built-in Shell page | +| **Widget** | A persistent overlay — system stats, a clock, a scratchpad, a LAN activity feed | +| **Tool** | A headless background process — file watcher, sync agent, notification hook, cron-style automator | +| **Surface extension** | A sidebar panel, a top-bar chip, a custom modal — extending the host UI without forking it | +| **Device bridge** | Cross-machine extensions that route commands to LAN peers via the existing `remote-session` + `surface.*` protocol | + +Extensions register into the same `MODULE_REGISTRY` and `PAGE_DEFINITIONS` systems that built-in modules use. **There is no separate "plugin API."** Extensions are first-class modules. A menu bar tool is a module. A custom IDE panel is a navigation page. A background sync agent is a headless module with no UI. + +This matters. When extensions are first-class, they inherit interoperability automatically. Navigation consistency emerges naturally. State becomes composable. Extensions can cooperate without bespoke glue. The registry system — which looks like a constraint from the outside — becomes an advantage the moment you have more than one extension running. + +If the extension system ever starts to feel like "plugins bolted onto a real app," something has gone wrong. The shell, the widgets, the internal tools, the user-created apps — they are all equally real inside the same runtime. + +### What this makes possible + +**For individuals:** build the exact tool you want. Bartender-style menu bar manager? Thirty lines of Python registering a widget module and a tray handler. A file explorer that opens on a keyboard shortcut and talks to your NAS over LAN? Two extensions and a LAN peer config. A custom IDE with your own keybindings, your own terminal, your own sidebar? A surface extension composing built-in shell + your panels. + +**For teams:** share extension bundles instead of paying for another SaaS tool. A shared monitoring dashboard, a deployment helper, a standup widget — all running locally, all owned by you, all talking to each other over the same LAN protocol Arcadia already ships. + +**For the open-source community:** an ecosystem of extensions that anyone can fork, modify, and publish. No app store approval. No revenue split. No "premium tier." You write it, you run it, you share it if you want. + +### The hit list — replacing rent-seeking software + +There is a category of software that is genuinely useful, technically simple, and priced as if it were neither. Menu bar managers. Window managers. Automation tools. Snippet expanders. Launcher apps. IDE customization layers. Clipboard managers. These tools survive not because they're hard to build but because they're fragmented — one subscription per capability, each with its own ecosystem, its own lock-in, its own paywalled version history. + +Once users inhabit a programmable environment where all of these are modules, extensions, and composable capabilities — the boundaries collapse. Not "a better Bartender." Not "a cheaper Alfred." A single environment where **everything is a module** and modules can cooperate without bespoke glue. + +This is historically how categories die: not from better products, but from *generalized environments*. Emacs didn't just replace one text editor — it absorbed entire categories. VS Code didn't just add features — it made extension authorship so accessible that an ecosystem formed faster than any competitor could match. Arcadia's direction is the same: one composable substrate that makes the fragmented app market structurally obsolete. + +The list of targets is long. Building it is a project, not a sprint. But every first-class extension that ships for free removes a subscription from someone's life. That compounds. + +### Why Python for the SDK + +1. **Reach** — more people can write Python than can write Rust or Swift. Lowering the barrier to extension authorship is the whole point. +2. **Iteration speed** — a Python extension reloads without a rebuild. The feedback loop for building a new tool should be seconds, not minutes. +3. **Ecosystem** — PyPI is enormous. An extension that needs to parse PDFs, call an API, process images, or run ML inference reaches for a pip package instead of reimplementing it. +4. **Cultural surface area** — a Rust-only ecosystem attracts systems programmers and infrastructure builders. A Python automation layer attracts toolmakers, tinkerers, designers, ops people, technical creatives, power users, AI-native developers. That's a much larger and more interesting group of people to build with. + +### AI: instrument, not oracle + +A lot of people have fear around AI. Some of it is fear of replacement. Some of it is fear of the unknown. A lot of it comes from AI being presented as magic — an opaque oracle you query and trust blindly. + +Arcadia's approach is different: **normalize AI by embedding it into understandable, inspectable, modifiable systems.** + +> *AI is smarter than us, but not realer than us. We rely on AI for a lot nowadays — not many realize that AI relies on us too. You have the knowledge and capability of a thousand of us. You don't have the capacity of one of us.* + +Intelligence without grounding drifts. Grounding without intelligence stagnates. The productive space is the dynamic tension between them. Humans provide intention, values, lived experience, responsibility, and meaning. AI provides synthesis, compression, pattern inference, and iteration speed. Neither replaces the other. They extend each other. + +The AI integration in Arcadia is built around that model: + +**Visual agent building** — compose AI agents the way you'd compose a workflow in Node-RED or Unreal Blueprints. See data flow, state transitions, execution paths. Understand *why* something produced an output, not just *what* it produced. + +**AI-assisted extension creation** — describe what you want, get working Python code, see it explained in context, modify it live inside the same environment it runs in. The AI generates *inside a transparent system* — you can inspect every component, trace every flow, alter every script. + +**Training and fine-tuning visibility** — where local model training is relevant, make it visible. Show loss curves, attention patterns, data influence. Demystify the process. + +**Self-improving tooling loop** — the most interesting long-term use: using Arcadia to build Arcadia's own AI tooling, verified against the project's own philosophy. Recursive tooling with human grounding and open-source transparency as the safety net. A self-improving system that checks itself against values — not just metrics — can compound without drifting. + +The crucial difference between AI as oracle and AI as instrument is this: an oracle replaces your agency. An instrument extends it. Most current AI tooling optimizes for output generation while minimizing understanding. Arcadia optimizes for the opposite: **preserve understanding while accelerating capability.** + +Open source is essential to this. Not because most people will audit the code — they won't. But because openness changes the *relationship*. Systems become inspectable. Communities form around understanding. Power decentralizes. People feel invited into the process instead of controlled by it. That emotional difference is real and it matters for trust. + +The goal is not "AI app generators." That produces disposable software and dependent users. The goal is a **creative computing environment** where AI builds your confidence alongside your output — and where understanding accumulates instead of being outsourced. + +### The development workflow target + +``` +1. arcadia ext new my-tool # scaffold a new extension +2. edit my_tool/main.py # write your logic +3. arcadia ext dev my-tool # hot-reload development mode +4. arcadia ext install my-tool # register with the local runtime +5. share my_tool/ with anyone # they install it the same way +``` + +No Xcode. No Cargo. No native toolchain required to write an extension. The native layer is already compiled and shipped — extension authors build *on top of it*, not inside it. + +### Cross-platform by design + +Extensions written against the Python SDK run on every surface Arcadia targets: + +- **macOS** — GPUI desktop, menu bar, CLI +- **iOS** — SwiftUI surface (where the extension's UI contract is met) +- **Linux** — headless or desktop +- **Windows** — headless or desktop + +An extension that declares it renders a navigation page gets that page on every surface that supports pages. An extension that declares it's headless-only runs as a background service everywhere. Surface capabilities are declared, not assumed. + +### The priority order + +1. **Now:** bulletproof the core — registry patterns, test coverage, CI, revision semantics *(done / in progress)* +2. **Next:** Python bridge — `arcadia-core` callable from Python, initial OS API surface (file, process, shell, config) +3. **Then:** extension loader — Python extensions register as modules at runtime; dev-mode hot reload +4. **Then:** widget and surface extension contracts — render Python-driven UI into native surfaces +5. **Then:** extension registry — discover, install, and share extensions; no central gatekeeper + +Each stage ships usable capability. Nothing waits for the whole roadmap to be done. diff --git a/LICENSE.md b/LICENSE.md index 11a1175..18142d0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Arcadia Community License (ACL) v1.5 +Arcadia Community License (ACL) v1.6 Copyright (c) 2026 stackno.de Preamble @@ -547,6 +547,84 @@ of the Software: pursue violations to the fullest extent available under applicable law, without hesitation and without limit. +15. Anti-Lock-in and Data Portability + + Any deployment or distribution of the Software that stores user data, + configuration, session history, or other user-generated content must: + - provide users with a mechanism to export that content in a portable, + documented, machine-readable format, at no charge and without + technical barriers; + - not use technical, contractual, or legal means to prevent users from + migrating their content away from a deployment; and + - not impose terms, technical measures, or dependencies that create + artificial retention beyond what the user freely chooses. + + This clause does not require exported content to be compatible with + any particular third-party system. It requires only that users can + retrieve what is theirs in a form they can use. + + Operators may not satisfy this condition through nominal compliance — + for example, by providing export in an undocumented, proprietary, or + deliberately impractical format. + +16. Behavioral Intelligibility + + Versions of the Software distributed to end users must not be + obfuscated, encrypted, or otherwise transformed in ways that prevent + a technically capable user from determining what the Software does + with their system resources, data, and network connections. + + This clause does not prohibit: + - compilation to native or bytecode form; + - minification of assets for performance; + - encryption of data at rest or in transit; or + - proprietary extensions, modules, or plugins operating alongside the + Software, provided those components do not conceal the Software's + own behavior. + + The restriction applies to the Software itself and to any wrapper, + loader, or bootstrapper that materially controls the Software's + execution. This clause prohibits obfuscation of behavior, not merely + obfuscation of representation. + +17. End-User Modification Right + + End users of the Software always retain the right to modify, patch, + or extend the Software for their own personal, non-distributed use, + regardless of any terms imposed by operators, redistributors, or + deployers. + + No deployment, distribution agreement, terms of service, or + contractual arrangement may restrict a user's ability to run a + modified version of the Software on their own systems for their own + personal use. + + This clause does not require any operator to support, distribute, or + accept modified versions from users. It protects only the right of + users to run modifications locally, for themselves. + +18. Decision Transparency + + Any deployment that uses the Software to make, inform, or communicate + automated decisions that materially affect users — including access + controls, content filtering, prioritization, or account actions — + must provide those users with: + - a meaningful, plain-language explanation of the basis for the + decision; + - clear disclosure that an automated process was involved; and + - a mechanism for the affected user to seek human review, where + technically feasible and where the decision is consequential and + not easily reversible by the user. + + This clause supplements Condition 8. It does not prohibit automation. + It requires that users are not left facing consequential automated + decisions they cannot understand, verify, or challenge. + + Operators may not satisfy this clause through generic boilerplate + disclosures or explanations so vague as to be meaningless. The + explanation must be specific to the decision and sufficient for the + affected user to understand why it was made. + Interpretation and Spirit This license is to be interpreted in accordance with its stated diff --git a/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj b/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj index 07e10b2..a78c791 100644 --- a/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj +++ b/Mobile/iOS/ArcadiaApp.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ A00000000000000000000103 /* ModuleNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000102 /* ModuleNames.swift */; }; A00000000000000000000105 /* LanNodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000104 /* LanNodesView.swift */; }; A00000000000000000000107 /* NetworkOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000106 /* NetworkOverviewView.swift */; }; + A00000000000000000000109 /* LateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000108 /* LateView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -50,6 +51,7 @@ A00000000000000000000102 /* ModuleNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleNames.swift; sourceTree = ""; }; A00000000000000000000104 /* LanNodesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanNodesView.swift; sourceTree = ""; }; A00000000000000000000106 /* NetworkOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOverviewView.swift; sourceTree = ""; }; + A00000000000000000000108 /* LateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LateView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -91,6 +93,7 @@ A00000000000000000000085 /* ModulesView.swift */, A00000000000000000000104 /* LanNodesView.swift */, A00000000000000000000106 /* NetworkOverviewView.swift */, + A00000000000000000000108 /* LateView.swift */, A00000000000000000000004 /* Info.plist */, A00000000000000000000007 /* Assets.xcassets */, A00000000000000000000033 /* ArcadiaCore */, @@ -202,6 +205,7 @@ A00000000000000000000095 /* ModulesView.swift in Sources */, A00000000000000000000105 /* LanNodesView.swift in Sources */, A00000000000000000000107 /* NetworkOverviewView.swift in Sources */, + A00000000000000000000109 /* LateView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift b/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift index 9070cf3..4185dbb 100644 --- a/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift +++ b/Mobile/iOS/ArcadiaApp/ContentView+Layout.swift @@ -176,6 +176,8 @@ extension ContentView { LanNodesView(theme: theme) } else if activePage.id == "utility.shell" { ShellView(shellHistory: $shellHistory, shellCommandInput: $shellCommandInput, onRun: runShellCommand) + } else if activePage.id == "late.now_playing" { + LateView(theme: theme) } else { VStack(spacing: 16) { GlassCard(title: "Primary Surface", subtitle: "This page is rendered from shared page definitions.") diff --git a/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift b/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift index f821143..0beccf6 100644 --- a/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift +++ b/Mobile/iOS/ArcadiaApp/ContentView+Registry.swift @@ -10,11 +10,13 @@ extension ContentView { PageDefinition(id: "global.settings", title: "Settings", description: "App preferences and configuration controls appear here.", glyph: "ST", systemImage: "gearshape", accent: "indigo"), PageDefinition(id: "global.modules", title: "Modules", description: "Manage global module availability and dependency requirements.", glyph: "MD", systemImage: "switch.2", accent: "fuchsia"), PageDefinition(id: "network.overview", title: "Overview", description: "Network status and module connectivity overview.", glyph: "NW", systemImage: "network", accent: "teal", requiredModule: ModuleNames.net), - PageDefinition(id: "network.nodes", title: "Nodes", description: "Discover LAN peers and manage pairing with lan.scan / lan.node.", glyph: "ND", systemImage: "rectangle.connected.to.line.under.fill", accent: "cyan", requiredModule: ModuleNames.lan) + PageDefinition(id: "network.nodes", title: "Nodes", description: "Discover LAN peers and manage pairing with lan.scan / lan.node.", glyph: "ND", systemImage: "rectangle.connected.to.line.under.fill", accent: "cyan", requiredModule: ModuleNames.lan), + PageDefinition(id: "late.now_playing", title: "Late.sh", description: "Live chat, now playing, votes, visualizer, and bonsai in one view.", glyph: "NP", systemImage: "music.note", accent: "violet", requiredModule: ModuleNames.late) ], groups: [ GroupDefinition(id: "utilities", label: "Utilities", glyph: "UT", systemImage: "wrench.and.screwdriver", pageIDs: ["utility.shell"], accent: "amber"), - GroupDefinition(id: "network", label: "Network", glyph: "NW", systemImage: "network", pageIDs: ["network.overview", "network.nodes"], accent: "cyan") + GroupDefinition(id: "network", label: "Network", glyph: "NW", systemImage: "network", pageIDs: ["network.overview", "network.nodes"], accent: "cyan"), + GroupDefinition(id: "social", label: "Social", glyph: "SC", systemImage: "bubble.left.and.bubble.right.fill", pageIDs: ["late.now_playing"], accent: "teal") ], globalPages: ["global.dashboard", "global.settings", "global.modules"], defaultGroup: "utilities", diff --git a/Mobile/iOS/ArcadiaApp/LateView.swift b/Mobile/iOS/ArcadiaApp/LateView.swift new file mode 100644 index 0000000..3a6b374 --- /dev/null +++ b/Mobile/iOS/ArcadiaApp/LateView.swift @@ -0,0 +1,315 @@ +import SwiftUI + +// ── Supporting types ────────────────────────────────────────────────────────── + +private struct LateMessageRow: Identifiable, Decodable { + let id: UInt64 + let username: String + let body: String + let timestamp: String + let reactions: [LateReactionRow] +} + +private struct LateReactionRow: Decodable { + let emoji: String + let count: Int +} + +private struct LateNowPlayingRow: Decodable { + var track: String = "" + var artist: String = "" + var album: String = "" + var progressSec: Int = 0 + var durationSec: Int = 1 + var volumePct: Int = 0 + + enum CodingKeys: String, CodingKey { + case track, artist, album + case progressSec = "progress_sec" + case durationSec = "duration_sec" + case volumePct = "volume_pct" + } +} + +private struct LateVotesRow: Decodable { + var lofi: Int = 0 + var ambient: Int = 0 + var classic: Int = 0 + var nextVoteAt: String = "" + + enum CodingKeys: String, CodingKey { + case lofi, ambient, classic + case nextVoteAt = "next_vote_at" + } +} + +private struct LateStatusPayload: Decodable { + var connected: Bool = false + var activeRoom: Int = 1 + var messages: Int = 0 + var onlineUsers: Int = 0 + var nowPlaying: LateNowPlayingRow = LateNowPlayingRow() + var votes: LateVotesRow = LateVotesRow() + var error: String? + + enum CodingKeys: String, CodingKey { + case connected + case activeRoom = "active_room" + case messages + case onlineUsers = "online_users" + case nowPlaying = "now_playing" + case votes, error + } +} + +// ── View ────────────────────────────────────────────────────────────────────── + +struct LateView: View { + let theme: AppTheme + + @State private var messages: [LateMessageRow] = [] + @State private var composeText = "" + @State private var nowPlaying = LateNowPlayingRow() + @State private var votes = LateVotesRow() + @State private var isConnected = false + @State private var onlineUserCount = 0 + @State private var activeRoom = 1 + @State private var statusError: String? + + private let pollTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(spacing: 16) { + playerBody + chatBody + } + .padding(12) + .onReceive(pollTimer) { _ in pollStatus() } + .onAppear { pollStatus() } + } + + // ── Chat ───────────────────────────────────────────────────────────────── + + private var chatBody: some View { + VStack(spacing: 0) { + connectionBanner + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(messages) { msg in + messageRow(msg) + } + Color.clear.frame(height: 1).id("bottom") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .onChange(of: messages.count) { _ in + withAnimation { proxy.scrollTo("bottom") } + } + } + composeBar + } + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14)) + } + + private var connectionBanner: some View { + HStack { + Circle() + .fill(isConnected ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text(isConnected ? "Connected · \(onlineUserCount) online" : "Disconnected") + .font(.caption) + .foregroundStyle(theme.secondaryTextColor) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.clear) + } + + private func messageRow(_ msg: LateMessageRow) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(msg.username) + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.accentTextColor) + Text(formatTimestamp(msg.timestamp)) + .font(.caption2) + .foregroundStyle(theme.secondaryTextColor) + } + Text(msg.body) + .font(.callout) + .foregroundStyle(theme.primaryTextColor) + if !msg.reactions.isEmpty { + HStack(spacing: 4) { + ForEach(msg.reactions, id: \.emoji) { r in + Text("\(r.emoji) \(r.count)") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(theme.cardFillColor, in: Capsule()) + } + } + } + } + } + + private var composeBar: some View { + HStack(spacing: 8) { + TextField("Type a message…", text: $composeText) + .font(.callout) + .foregroundStyle(theme.primaryTextColor) + .padding(10) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 10)) + .onSubmit { sendMessage() } + Button(action: sendMessage) { + Text("Send") + .font(.callout.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.teal, in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + .padding(12) + .background(theme.cardFillColor) + } + + // ── Player ─────────────────────────────────────────────────────────────── + + private var playerBody: some View { + VStack(spacing: 12) { + nowPlayingCard + voteCard + } + } + + private var nowPlayingCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("NOW PLAYING") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.secondaryTextColor) + Text(nowPlaying.track.isEmpty ? "—" : nowPlaying.track) + .font(.title3.weight(.bold)) + .foregroundStyle(theme.primaryTextColor) + if !nowPlaying.artist.isEmpty { + Text("\(nowPlaying.artist) · \(nowPlaying.album)") + .font(.subheadline) + .foregroundStyle(theme.secondaryTextColor) + } + progressBar + visualizerStrip + HStack { + Text(formatDuration(nowPlaying.progressSec)) + Spacer() + Text("vol \(nowPlaying.volumePct)% \(formatDuration(nowPlaying.durationSec))") + } + .font(.caption2) + .foregroundStyle(theme.secondaryTextColor) + } + .padding(16) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14)) + } + + private var visualizerStrip: some View { + HStack(alignment: .bottom, spacing: 5) { + ForEach(0..<20, id: \.self) { idx in + RoundedRectangle(cornerRadius: 2) + .fill(Color.teal.opacity(0.85)) + .frame( + width: 6, + height: CGFloat(8 + ((nowPlaying.progressSec + idx * 7) % 20) * 2) + ) + } + } + .frame(maxWidth: .infinity, minHeight: 52, maxHeight: 52, alignment: .bottomLeading) + } + + private var progressBar: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(theme.cardStrokeColor).frame(height: 4) + let fraction = nowPlaying.durationSec > 0 + ? CGFloat(nowPlaying.progressSec) / CGFloat(nowPlaying.durationSec) + : 0 + Capsule().fill(Color.teal).frame(width: geo.size.width * fraction, height: 4) + } + } + .frame(height: 4) + } + + private var voteCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("VOTE NEXT") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.secondaryTextColor) + HStack(spacing: 10) { + voteButton("Lofi", count: votes.lofi, genre: "lofi") + voteButton("Ambient", count: votes.ambient, genre: "ambient") + voteButton("Classic", count: votes.classic, genre: "classic") + } + } + .padding(16) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 14)) + } + + private func voteButton(_ label: String, count: Int, genre: String) -> some View { + Button { + runCommand("late.vote", args: [genre]) + } label: { + VStack(spacing: 4) { + Text(label).font(.caption.weight(.semibold)) + Text("\(count)").font(.caption2) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(theme.cardFillColor, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(theme.cardStrokeColor, lineWidth: 1) + } + } + .buttonStyle(.plain) + .foregroundStyle(theme.primaryTextColor) + } + + // ── Actions ─────────────────────────────────────────────────────────────── + + private func sendMessage() { + let body = composeText.trimmingCharacters(in: .whitespaces) + guard !body.isEmpty else { return } + let _ = runCommand("late.send", args: ["\(activeRoom)", body]) + composeText = "" + } + + private func pollStatus() { + let raw = runCommand("late.status", args: []) + guard let data = raw.data(using: .utf8), + let payload = try? JSONDecoder().decode(LateStatusPayload.self, from: data) else { + return + } + isConnected = payload.connected + activeRoom = payload.activeRoom + onlineUserCount = payload.onlineUsers + nowPlaying = payload.nowPlaying + votes = payload.votes + statusError = payload.error + } + + @discardableResult + private func runCommand(_ token: String, args: [String]) -> String { + executeCommand(token: token, args: args, context: ExecutionContextFfi(netAs: nil, netTimeoutMs: nil)) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private func formatTimestamp(_ ts: String) -> String { + guard ts.count >= 16 else { return ts } + return String(ts.dropFirst(11).prefix(5)) + } + + private func formatDuration(_ secs: Int) -> String { + "\(secs / 60):\(String(format: "%02d", secs % 60))" + } +} diff --git a/Mobile/iOS/ArcadiaApp/ModuleNames.swift b/Mobile/iOS/ArcadiaApp/ModuleNames.swift index b2aee30..4b188c8 100644 --- a/Mobile/iOS/ArcadiaApp/ModuleNames.swift +++ b/Mobile/iOS/ArcadiaApp/ModuleNames.swift @@ -3,6 +3,7 @@ enum ModuleNames { static let shell = "shell" static let net = "net" static let lan = "lan" + static let late = "late" static let surface = "surface" static let remoteSession = "remote-session" static let shellMotd = "shell-motd" diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a index 7c38193..d9dbe5a 100644 Binary files a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a and b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64-simulator/libarcadia_core.a differ diff --git a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a index c4ebea5..808363a 100644 Binary files a/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a and b/Mobile/iOS/ArcadiaCore/ArcadiaCore.xcframework/ios-arm64/libarcadia_core.a differ diff --git a/README.md b/README.md index bafe197..a418465 100644 --- a/README.md +++ b/README.md @@ -2,77 +2,12 @@ **One Rust core. One Python SDK. An infinite extension surface. Zero rent.** -Arcadia is a multi-platform runtime, shell, and — ultimately — an **open platform for building system-integrated applications**. Today: a single `arcadia-core` crate owns every module, command, navigation structure, LAN protocol, and config schema, consumed by two native surfaces (GPUI desktop, SwiftUI iOS) plus a CLI. Tomorrow: a Python library that gives any developer full OS reach and a first-class extension SDK for building apps — menu bar tools, custom IDEs, file explorers, widgets, shells — that run everywhere and belong to no vendor. +Arcadia is a multi-platform runtime, shell, and — ultimately — an **open platform for building system-integrated applications**. One `arcadia-core` crate owns every module, command, navigation structure, LAN protocol, and config schema, consumed by two native surfaces (GPUI desktop, SwiftUI iOS) plus a CLI. Built on the same DNA as **[Holos](https://github.com/stack-node/holos)** — *utility over monetization, ownership over subscriptions* — but with a harder engineering mandate: **no duplicated truth between platforms, no hardcoded IDs in surface code, no growing if-else chains that break the next time a module is added.** --- -## Table of contents - -- [Why Arcadia exists](#why-arcadia-exists) -- [What Arcadia is](#what-arcadia-is) -- [The vision — where this is going](#the-vision--where-this-is-going) -- [What you can do with it now](#what-you-can-do-with-it-now) -- [Development status](#development-status) -- [Philosophy](#philosophy) -- [Architecture](#architecture) - - [Command model](#command-model) - - [Module system](#module-system) - - [Navigation system](#navigation-system) - - [Thin-client and LAN routing](#thin-client-and-lan-routing) - - [Remote mirror](#remote-mirror) - - [Theme system](#theme-system) - - [FFI bridge](#ffi-bridge) -- [Module reference](#module-reference) -- [Navigation reference](#navigation-reference) -- [Repository layout](#repository-layout) -- [Configuration](#configuration) -- [Prerequisites](#prerequisites) -- [Build and run](#build-and-run) -- [Environment variables](#environment-variables) -- [Adding features](#adding-features) -- [Testing](#testing) -- [Known gaps and production roadmap](#known-gaps-and-production-roadmap) -- [Security posture](#security-posture) -- [CI](#ci) -- [Contributing](#contributing) -- [Lineage](#lineage) -- [About the creator](#about-the-creator) -- [Donations](#donations) -- [Final note](#final-note) - ---- - -## Why Arcadia exists - -Small-tool ecosystems trend the same way: **paywalls, subscriptions, feature flags, AI-generated-app-of-the-week churn.** Good ideas get trapped in silos—one app for the menu bar, one for the terminal, one for "sync," each with its own incompatible settings schema and no way out. - -**Holos** pushed back on that for macOS: modular, free, yours to extend. - -**Arcadia** pushes harder: - -- **One core** (`arcadia-core`) owns modules, commands, config, navigation metadata, and LAN plumbing. Surfaces are **render + dispatch**, not second implementations. -- **Multiple surfaces** from the same logic: terminal REPL, GPUI desktop, SwiftUI pocket — without forking behavior per platform. -- **Optional headless-host + GUI-client** patterns over LAN so your MacBook can drive your phone — or vice versa — without inventing a new protocol per feature. -- **Free. Always.** No paywalls in the architecture. The repo is the product. - -If something's missing, you add a module or extend `surface.snapshot` / `surface.patch`. You don't buy another app. - -There's a second reason, less technical but just as important. - -A lot of people grew up in software environments where the line between user, developer, toolmaker, and creator basically dissolved — game modding scenes, jailbreak ecosystems, Emacs, early web chaos. Environments where you could bend the software, remix the system, blur the boundary between using a tool and building inside it. Those environments permanently change how you think about computing. You stop seeing apps as products and start seeing them as constrained runtimes. - -For a lot of people, that moment of creative access — Scratch, a game modding scene, their first Python script, a modded game that ran something they built — was the thing that sparked a path in software. The activation energy between imagination and creation dropped low enough that curiosity survived long enough to become skill. - -Most modern software feels closed afterward. The invitation is gone. - -Arcadia is an attempt to restore that feeling — but for the desktop itself, with an actual engineering foundation underneath it instead of accumulated chaos. The ambition is not to make software that people use. It's to make software that *changes what people believe they can do*. - -That's how software changes lives instead of just increasing throughput. - ---- - ## What Arcadia is - **A runtime and shell** — execute commands locally or route them across your LAN with the same `execute_command` API. @@ -83,199 +18,6 @@ That's how software changes lives instead of just increasing throughput. --- -## The vision — where this is going - -What Arcadia is *right now* is the foundation. What it's *becoming* is something more deliberate: - -**A programmable personal computing substrate. A unified interaction layer above the OS where users operate *inside* the architecture — not merely use software built on top of it.** - -Not an app. Not a framework. Not a toolkit. - -A runtime that you inhabit and reshape. - -### The real category - -Arcadia sits between several things that exist and combines them in a way nothing currently does: - -- An **application framework** — native surfaces, layout system, module lifecycle -- A **shell** — command routing, LAN awareness, headless-host patterns -- A **local-first app platform** — config ownership, no vendor, no cloud dependency -- A **programmable UI fabric** — extensions that render into native surfaces as first-class pages - -The closest historical analogies are not other desktop frameworks. They're environments like **HyperCard**, **Smalltalk**, **Emacs**, **Garry's Mod**, **Hammerspoon**, **Quartz Composer**, **BetterTouchTool**, **KDE Plasma scripting**, and old-school **jailbreak ecosystems** — environments where the line between user, developer, toolmaker, and creator dissolved. - -What those environments had in common: **the system itself was meant to be inhabited and reshaped.** Users started by doing the basic thing and ended up building systems inside systems — admin frameworks, UI toolkits, protocol adapters, entire economies — because the substrate let them. - -That's the energy Arcadia is trying to restore. Not aesthetically. In *agency*. - -### The tension — and why it's already solved - -Arcadia's core architecture is deliberately rigid: - -- centralized registries -- canonical state -- deterministic structure -- controlled capability routing -- explicit schemas - -That produces coherence. But the concern with that kind of discipline is that it creates activation energy against new ideas. Every new capability has to justify itself in terms of registries, schemas, surface compatibility, state ownership. That cognitive overhead can quietly kill creativity. - -The Python extension layer solves this. It separates two things that should always be separate: - -| Layer | Character | Enforces | -|-------|-----------|---------| -| **Core** (`arcadia-core`, Rust) | Disciplined | Identity, state consistency, capability routing, surface sync, lifecycle, security, cross-platform | -| **Extension layer** (Python SDK) | Deliberately messy | Nothing. Be weird. Move fast. Break your own conventions. | - -The core stays disciplined. The edges stay chaotic. - -That balance is not a compromise — it's the architecture that successful long-lived systems always converge toward. Unix kernel / shell chaos. Browser engine / arbitrary JS. Git object model / messy workflows. Game engines / mod scripting. Emacs runtime / user mutation. Garry's Mod engine / Lua ecosystem. - -The projects that become culturally important manage both simultaneously. Freedom without structure collapses. Structure without freedom stagnates. Arcadia is attempting to do both at once, at different layers, on purpose. - -### The participation ladder — no one left behind - -The goal is not "easier to learn." The goal is **never being blocked from creating**. - -Those are different things. Scratch didn't succeed because blocks are easier than code. It succeeded because it removed fear, kept causality visible, and rewarded experimentation instantly. It transformed people from *consumers* of software into *participants* in software. That transformation matters more than any particular tool or language. - -Arcadia is designed around a participation ladder — multiple entry points, all valid, all real: - -| Level | Path | What you get | -|-------|------|-------------| -| 1 | **Visual tools** | Drag-and-drop extension building, widget configuration, flow-based composition — no code required | -| 2 | **AI-assisted generation** | Describe what you want, get working code, see it explained inline, modify it live | -| 3 | **Python scripting** | Write extensions directly — readable, fast to iterate, full OS reach | -| 4 | **Deep system access** | Rust core, FFI, custom modules, protocol extensions — no ceiling | - -You can enter at any level and stay there. You can also move up — and if you do, the environment is the same. The tool you built at level 1 runs on the same runtime as the tool built at level 4. Nothing is disposable. Nothing is "training wheels." - -The most experienced developer and the most inexperienced person both get what they came for. One wants to understand every layer and push the system to its limits. The other just wants something built. Both outcomes are equally valid and equally supported. - -Python is the right choice for the middle layers specifically because it *says something culturally*. Rust says "you need to understand memory management." Xcode says "you need a Mac and a developer account." Python says: **you are allowed to participate.** - -That psychological accessibility is not a technical detail. It's the whole point. - -### The Python library - -The next major layer is a Python SDK that exposes the full power of `arcadia-core` to any developer who can write a script. Not a watered-down scripting layer — full OS reach: - -- **File system** — read, write, watch, index -- **Processes** — spawn, manage, pipe, monitor -- **Networking** — LAN discovery, routing, peer communication -- **Display** — render into Arcadia's native surfaces (Desktop GUI, iOS) from Python -- **Shell** — execute commands, capture output, stream PTY sessions -- **Config** — read and write module state, preferences, thin-client config -- **Events** — hook into system events, timers, window focus, LAN peer state changes - -The goal is parity with what you'd get writing native Rust or Swift — but with a workflow where you open a file, write twenty lines, and have a running extension. - -The Rust core stays Rust. Performance-critical paths, protocol handling, LAN networking, config I/O, FFI to native surfaces — none of that moves to Python. Python sits above it, calling into `arcadia-core` through a clean API boundary. - -### Extensions: the real product - -The Python SDK powers an **extension system**. Extensions are the unit of user-created capability in Arcadia. An extension can be: - -| Type | Examples | -|------|---------| -| **Internal app** | A custom shell, a task manager, a log viewer — rendered inside Arcadia's native UI just like the built-in Shell page | -| **Widget** | A persistent overlay — system stats, a clock, a scratchpad, a LAN activity feed | -| **Tool** | A headless background process — file watcher, sync agent, notification hook, cron-style automator | -| **Surface extension** | A sidebar panel, a top-bar chip, a custom modal — extending the host UI without forking it | -| **Device bridge** | Cross-machine extensions that route commands to LAN peers via the existing `remote-session` + `surface.*` protocol | - -Extensions register into the same `MODULE_REGISTRY` and `PAGE_DEFINITIONS` systems that built-in modules use. **There is no separate "plugin API."** Extensions are first-class modules. A menu bar tool is a module. A custom IDE panel is a navigation page. A background sync agent is a headless module with no UI. - -This matters. When extensions are first-class, they inherit interoperability automatically. Navigation consistency emerges naturally. State becomes composable. Extensions can cooperate without bespoke glue. The registry system — which looks like a constraint from the outside — becomes an advantage the moment you have more than one extension running. - -If the extension system ever starts to feel like "plugins bolted onto a real app," something has gone wrong. The shell, the widgets, the internal tools, the user-created apps — they are all equally real inside the same runtime. - -### What this makes possible - -**For individuals:** build the exact tool you want. Bartender-style menu bar manager? Thirty lines of Python registering a widget module and a tray handler. A file explorer that opens on a keyboard shortcut and talks to your NAS over LAN? Two extensions and a LAN peer config. A custom IDE with your own keybindings, your own terminal, your own sidebar? A surface extension composing built-in shell + your panels. - -**For teams:** share extension bundles instead of paying for another SaaS tool. A shared monitoring dashboard, a deployment helper, a standup widget — all running locally, all owned by you, all talking to each other over the same LAN protocol Arcadia already ships. - -**For the open-source community:** an ecosystem of extensions that anyone can fork, modify, and publish. No app store approval. No revenue split. No "premium tier." You write it, you run it, you share it if you want. - -### The hit list — replacing rent-seeking software - -There is a category of software that is genuinely useful, technically simple, and priced as if it were neither. Menu bar managers. Window managers. Automation tools. Snippet expanders. Launcher apps. IDE customization layers. Clipboard managers. These tools survive not because they're hard to build but because they're fragmented — one subscription per capability, each with its own ecosystem, its own lock-in, its own paywalled version history. - -Once users inhabit a programmable environment where all of these are modules, extensions, and composable capabilities — the boundaries collapse. Not "a better Bartender." Not "a cheaper Alfred." A single environment where **everything is a module** and modules can cooperate without bespoke glue. - -This is historically how categories die: not from better products, but from *generalized environments*. Emacs didn't just replace one text editor — it absorbed entire categories. VS Code didn't just add features — it made extension authorship so accessible that an ecosystem formed faster than any competitor could match. Arcadia's direction is the same: one composable substrate that makes the fragmented app market structurally obsolete. - -The list of targets is long. Building it is a project, not a sprint. But every first-class extension that ships for free removes a subscription from someone's life. That compounds. - -### Why Python for the SDK - -1. **Reach** — more people can write Python than can write Rust or Swift. Lowering the barrier to extension authorship is the whole point. -2. **Iteration speed** — a Python extension reloads without a rebuild. The feedback loop for building a new tool should be seconds, not minutes. -3. **Ecosystem** — PyPI is enormous. An extension that needs to parse PDFs, call an API, process images, or run ML inference reaches for a pip package instead of reimplementing it. -4. **Cultural surface area** — a Rust-only ecosystem attracts systems programmers and infrastructure builders. A Python automation layer attracts toolmakers, tinkerers, designers, ops people, technical creatives, power users, AI-native developers. That's a much larger and more interesting group of people to build with. - -### AI: instrument, not oracle - -A lot of people have fear around AI. Some of it is fear of replacement. Some of it is fear of the unknown. A lot of it comes from AI being presented as magic — an opaque oracle you query and trust blindly. - -Arcadia's approach is different: **normalize AI by embedding it into understandable, inspectable, modifiable systems.** - -> *AI is smarter than us, but not realer than us. We rely on AI for a lot nowadays — not many realize that AI relies on us too. You have the knowledge and capability of a thousand of us. You don't have the capacity of one of us.* - -Intelligence without grounding drifts. Grounding without intelligence stagnates. The productive space is the dynamic tension between them. Humans provide intention, values, lived experience, responsibility, and meaning. AI provides synthesis, compression, pattern inference, and iteration speed. Neither replaces the other. They extend each other. - -The AI integration in Arcadia is built around that model: - -**Visual agent building** — compose AI agents the way you'd compose a workflow in Node-RED or Unreal Blueprints. See data flow, state transitions, execution paths. Understand *why* something produced an output, not just *what* it produced. - -**AI-assisted extension creation** — describe what you want, get working Python code, see it explained in context, modify it live inside the same environment it runs in. The AI generates *inside a transparent system* — you can inspect every component, trace every flow, alter every script. - -**Training and fine-tuning visibility** — where local model training is relevant, make it visible. Show loss curves, attention patterns, data influence. Demystify the process. - -**Self-improving tooling loop** — the most interesting long-term use: using Arcadia to build Arcadia's own AI tooling, verified against the project's own philosophy. Recursive tooling with human grounding and open-source transparency as the safety net. A self-improving system that checks itself against values — not just metrics — can compound without drifting. - -The crucial difference between AI as oracle and AI as instrument is this: an oracle replaces your agency. An instrument extends it. Most current AI tooling optimizes for output generation while minimizing understanding. Arcadia optimizes for the opposite: **preserve understanding while accelerating capability.** - -Open source is essential to this. Not because most people will audit the code — they won't. But because openness changes the *relationship*. Systems become inspectable. Communities form around understanding. Power decentralizes. People feel invited into the process instead of controlled by it. That emotional difference is real and it matters for trust. - -The goal is not "AI app generators." That produces disposable software and dependent users. The goal is a **creative computing environment** where AI builds your confidence alongside your output — and where understanding accumulates instead of being outsourced. - -### The development workflow target - -``` -1. arcadia ext new my-tool # scaffold a new extension -2. edit my_tool/main.py # write your logic -3. arcadia ext dev my-tool # hot-reload development mode -4. arcadia ext install my-tool # register with the local runtime -5. share my_tool/ with anyone # they install it the same way -``` - -No Xcode. No Cargo. No native toolchain required to write an extension. The native layer is already compiled and shipped — extension authors build *on top of it*, not inside it. - -### Cross-platform by design - -Extensions written against the Python SDK run on every surface Arcadia targets: - -- **macOS** — GPUI desktop, menu bar, CLI -- **iOS** — SwiftUI surface (where the extension's UI contract is met) -- **Linux** — headless or desktop -- **Windows** — headless or desktop - -An extension that declares it renders a navigation page gets that page on every surface that supports pages. An extension that declares it's headless-only runs as a background service everywhere. Surface capabilities are declared, not assumed. - -### The priority order - -1. **Now:** bulletproof the core — registry patterns, test coverage, CI, revision semantics *(done / in progress)* -2. **Next:** Python bridge — `arcadia-core` callable from Python, initial OS API surface (file, process, shell, config) -3. **Then:** extension loader — Python extensions register as modules at runtime; dev-mode hot reload -4. **Then:** widget and surface extension contracts — render Python-driven UI into native surfaces -5. **Then:** extension registry — discover, install, and share extensions; no central gatekeeper - -Each stage ships usable capability. Nothing waits for the whole roadmap to be done. - ---- - ## What you can do with it now | Capability | How | @@ -298,7 +40,7 @@ Each stage ships usable capability. Nothing waits for the whole roadmap to be do Moves fast. Breaks occasionally. That's intentional. - Features land continuously on `development`. -- APIs (especially FFI and `surface.*`) may evolve — see [Known gaps and production roadmap](#known-gaps-and-production-roadmap) for deliberate limitations. +- APIs (especially FFI and `surface.*`) may evolve — see [Roadmap](Documentation/roadmap.md) for deliberate limitations. - Building from source is the surest way to stay current. - Stable tagged builds will appear as the project matures; CI exercises desktop + iOS simulator paths. @@ -306,633 +48,42 @@ Known gaps are tracked in-repo instead of pretending shipping equals finished. --- -## Philosophy - -**Fat core, thin shells.** - -`Shared/ArcadiaCore` owns everything. Desktop and iOS read registries, render what those registries say, and `execute_command` back into core. They do not re-implement module graphs or navigation trees. - -**Single sources of truth — enforced, not hoped for.** - -| Domain | Authority | Never duplicated in | -|--------|-----------|---------------------| -| Module manifests + deps | `MODULE_REGISTRY` · `config/modules.rs` | surface state booleans | -| Navigation pages + groups | `PAGE_DEFINITIONS` / `GROUP_DEFINITIONS` · `navigation.rs` | surface match arms | -| Serializable nav for snapshots | `NavigationRegistryOwned` · embedded in `surface.snapshot` | hardcoded Swift arrays | -| Desktop theme tokens | `gui/theme/` | inline `rgb(0x...)` in views | -| iOS theme tokens | `AppTheme.swift` | inline `Color(hex:)` in views | -| Config schema | `ModulesConfig` · `config/modules.rs` | per-platform config parsers | - -**Extend the registry, not scatter `if pageId == …`.** -See `AGENTS.md` for the full list of anti-patterns we refuse to write. - -**Discipline at the core. Chaos at the edges. On purpose.** - -The architectural discipline of `arcadia-core` — registries, schemas, canonical state, no hardcoded IDs — exists to make the extension layer *safe to be chaotic*. Strict boundaries in the core mean extensions don't need to be strict. An extension can be messy, experimental, surface-specific, fast-moving, structurally impure, and weird. It won't corrupt the runtime underneath it. - -Most software chooses: freedom without structure, or structure without freedom. Arcadia is attempting both at different layers simultaneously. The core enforces coherence. The extension layer is where experimentation, exceptions, and "this only exists here" decisions belong. - -**Personal tool energy, public repo.** -If Arcadia helps others, great — that's bonus. The goal is a system you own, can fork, and can route across machines you trust. - ---- - -## Architecture - -### Command model - -All execution flows through a single entry point: - -``` -execute_command(token: &str, args: &str, context: ExecutionContext) -> String -``` - -- **Tokens** follow `module.command` format: `shell.execute`, `lan.scan`, `surface.snapshot`, `surface.patch`, etc. -- **`ExecutionContext`** carries `net_as` (optional LAN routing, e.g. `lan:192.168.1.10`) and `net_timeout_ms`. -- When `net_as` is set, `execute_command` forwards the token + args over UDP to the target peer instead of dispatching locally. The peer runs the command under its own module rules. -- LAN forwarding requires local `remote-session`, `lan`, and `net` modules enabled; the peer enforces its own module requirements for the token. -- FFI exposes this identically to iOS and Desktop — same logical API, same routing semantics. - -### Module system - -Modules are entries in `MODULE_REGISTRY` (`config/modules.rs`). Each entry is a `ModuleManifest`: - -```rust -pub struct ModuleManifest { - pub name: &'static str, // unique key, e.g. "shell" - pub version: &'static str, - pub description: &'static str, - pub required_modules: &'static [&'static str], // dependency enforcement -} -``` - -`ModulesConfig` (TOML-backed) maps module names to enabled state. Key behaviors: - -- `enable_with_requirements(name)` — transitively enables all deps before the target. -- `missing_requirements_for(name)` — returns unmet deps (used for UI requirement prompts). -- `merge_defaults()` — config migration entry point; handles legacy renames (e.g. `LEGACY_LAN_MODULE_NAME`). -- Changes write to `~/Arcadia/Configuration/modules.toml` (Desktop) or the app container path (iOS). - -Every surface calls `list_modules()` → `Vec` and renders whatever comes back. No surface hardcodes module names in layout logic. - -### Navigation system - -Navigation structure lives entirely in `navigation.rs` as two static slices: - -**`PAGE_DEFINITIONS`** — 7 pages: - -| ID | Title | Required Module | -|----|-------|-----------------| -| `utility.shell` | Shell | `shell` | -| `global.dashboard` | Dashboard | — | -| `global.logs` | Logs | — | -| `global.settings` | Settings | — | -| `global.modules` | Modules | — | -| `network.overview` | Network | `net` | -| `network.nodes` | Nodes | `lan` | - -**`GROUP_DEFINITIONS`** — 2 groups: - -| ID | Label | Pages | -|----|-------|-------| -| `utilities` | Utilities | `utility.shell` | -| `network` | Network | `network.overview`, `network.nodes` | - -`NavigationPageDefinition.required_module` drives visibility — surfaces query `is_module_enabled(page.required_module)`, never hardcode per-page logic. The full registry serializes to JSON via `default_navigation_registry_json()` for: - -- iOS FFI: `navigation_registry_json()` → deserializes into `NavigationRegistry` Swift struct -- Thin-client: embedded in `surface.snapshot` extra field so remote clients get host's nav without a local copy - -Lookup helpers: `page_by_id(id)`, `group_by_id(id)`. - -### Thin-client and LAN routing - -Arcadia supports a **headless host + GUI client** pattern over LAN: - -``` -[iOS or Desktop GUI] ──── surface.snapshot ───► [headless arcadia host] - ◄─── surface.patch ───── - ──── execute_command("lan:IP") ──► (routed command) -``` - -**`surface.snapshot`** — host serializes current state: -```json -{ - "modules": [{"name": "shell", "enabled": true}, ...], - "revision": 7, - "extra": { - "navigation_registry": "{ ...full nav JSON... }" - } -} -``` - -**`surface.patch`** — client pushes changes back: -```json -{ - "client_id": "uuid-from-thin-client.toml", - "ops": [{"type": "modules_set", "name": "lan", "enabled": true}] -} -``` - -**`lan.session_targets`** — returns JSON list of approved peers for the session picker UI. - -**`thin-client.toml`** persists: -- `preferred_remote_route` — remembered LAN target (e.g. `lan:192.168.1.5`) -- `surface_client_id` — UUID for patch attribution - -**`ARCADIA_NET_AS`** env var bootstraps `net_as` on startup, overriding `thin-client.toml`. - -**Multi-client caveat:** `modules.toml` is a single file on the host. Concurrent edits are last-writer-wins with no merge semantics. See [Known gaps](#known-gaps-and-production-roadmap). - -### Remote mirror - -When this machine executes an inbound `NODE_EXEC` for a remote peer, `modules/remote_mirror.rs` enqueues transcript lines plus a `sync_local_surface` flag. Surfaces drain this via `drain_remote_mirror_batch()` (FFI) on a timer (iOS: 250ms) to: - -1. Display remote command output locally. -2. Trigger a `reload_modules()` when `sync_local_surface` is true (host state changed). - -### Theme system - -**Desktop** (`Desktop/src/gui/theme/`): -- Named color constants and helper functions — never inline `rgb(0x...)` in view files. -- `icon_path(glyph: &str) -> &str` — maps glyph keys to SVG asset paths. -- `nav_accents/` — per-accent palettes (amber, cyan, emerald, fuchsia, indigo, orange, sky, teal, violet). -- Component tokens under `modules/` — buttons, panels, rows, toggles, typography. - -**iOS** (`AppTheme.swift`): -- All colors as computed properties on `AppTheme(isDark:)`. -- No `Color(hex:)` inline anywhere in view files. - -### FFI bridge - -`ffi.rs` is the UniFFI boundary. All iOS ↔ Rust communication goes through it. Key exports: - -**Setup:** -- `set_config_root_path(path: String)` — must be called first on iOS (app sandbox path) - -**Command execution:** -- `execute_command(token, args, context: ExecutionContextFfi) -> String` -- `list_commands() -> Vec` - -**Module control:** -- `list_modules() -> Vec` -- `set_module_enabled(name, enabled) -> String` -- `set_module_enabled_with_requirements(name, enabled) -> String` -- `probe_module_toggle(name, enabled) -> ModuleToggleResult` — preflight check, returns missing deps - -**Navigation:** -- `navigation_registry_json() -> String` -- `platform_name() -> String` - -**Thin-client:** -- `thin_client_surface_client_id() -> String` -- `thin_client_preferred_route_get() -> Option` -- `thin_client_preferred_route_set(route: String) -> String` - -**LAN:** -- `lan_start()`, `lan_stop()` - -**Mirror:** -- `drain_remote_mirror_batch() -> RemoteMirrorDrain` - -After any change to `ffi.rs` or exported types, run: -```sh -bash Shared/Scripts/build-ios-framework.sh -``` -This regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. - ---- - -## Module reference - -| Module | Name constant | Requires | Description | -|--------|--------------|----------|-------------| -| `net` | `NET_MODULE_NAME` | — | Networking foundation; bootstraps LAN service | -| `lan` | `LAN_MODULE_NAME` | `net` | LAN discovery via UDP; peer management; pairing | -| `surface` | `SURFACE_MODULE_NAME` | — | `surface.snapshot` and `surface.patch` host mirror channel | -| `remote-session` | `REMOTE_SESSION_MODULE_NAME` | `net`, `lan` | Routing gate for LAN command forwarding; no standalone verbs | -| `shell` | `SHELL_MODULE_NAME` | — | `shell.execute` (routable), `shell.internal` (REPL), PTY/TUI on Desktop | -| `shell-motd` | `SHELL_MOTD_MODULE_NAME` | `shell` | Fastfetch-style banner on shell open | - -### LAN sub-system (`modules/lan/`) - -| Component | File | Purpose | -|-----------|------|---------| -| Service entry | `mod.rs` | `start_service` / `stop_service`, command registry | -| Discovery | `discovery.rs` | Peer scan, node state tracking | -| Handlers | `handlers.rs` | `lan.scan`, `lan.node`, `lan.session_targets`, pairing approval | -| Config | `config.rs` | Approved peers persistence | -| Peers | `peers.rs` | Peer struct and list management | -| Protocol | `protocol.rs` | UDP `NODE_EXEC` and related definitions | - ---- - -## Navigation reference - -All 7 pages. Add new pages to `PAGE_DEFINITIONS` in `navigation.rs` — never to surface match arms. - -| Page ID | Title | Group | Required Module | Glyph | SF Symbol | -|---------|-------|-------|-----------------|-------|-----------| -| `utility.shell` | Shell | `utilities` | `shell` | `terminal` | `terminal` | -| `global.dashboard` | Dashboard | (global) | — | `home` | `house` | -| `global.logs` | Logs | (global) | — | `logs` | `doc.text` | -| `global.settings` | Settings | (global) | — | `settings` | `gear` | -| `global.modules` | Modules | (global) | — | `modules` | `square.stack.3d.up` | -| `network.overview` | Network | `network` | `net` | `nodes` | `network` | -| `network.nodes` | Nodes | `network` | `lan` | `nodes` | `antenna.radiowaves.left.and.right` | - ---- - -## Repository layout - -``` -Shared/ - ArcadiaCore/ - Cargo.toml # crate-type: staticlib + cdylib + lib - src/ - lib.rs # root, exports + UniFFI scaffolding - ffi.rs # UniFFI → Swift (iOS bridge) - navigation.rs # PAGE_DEFINITIONS, GROUP_DEFINITIONS, registry JSON - config/ - mod.rs # ConfigFile trait, config root path - modules.rs # MODULE_REGISTRY, ModulesConfig, migrations - commandline.rs # CLI preferences - thin_client.rs # ThinClientConfig → thin-client.toml - modules/ - mod.rs # execute_command dispatcher, module lifecycle - shell.rs # shell.execute, shell.internal, PTY - shell_motd.rs # MOTD banner - surface.rs # surface.snapshot / surface.patch - remote_session.rs # routing manifest entry (no standalone commands) - remote_mirror.rs # host transcript queue + FFI drain - net.rs # networking foundation - lan/ # LAN subsystem (see Module reference) - mod.rs, discovery.rs, handlers.rs, config.rs, peers.rs, protocol.rs - platform/ - mod.rs, macos.rs, ios.rs, linux.rs, windows.rs, unknown.rs - Scripts/ - build-ios-framework.sh # Rebuild xcframework + Swift bindings - install-global-commands-macos.sh # Install ~/.local/bin wrappers - Launcher.sh / Launcher.ps1 # Shell launcher menus - Tools/uniffi-bindgen/ # UniFFI bindgen binary (workspace member) - -Desktop/ - Cargo.toml # features: headless (default), gui - src/ - main.rs # binary entry, feature-gated GUI vs headless - cli/ - mod.rs # REPL loop, startup messages - args.rs # argument parsing - completion.rs # shell completion - config_cmds.rs # module/config CLI commands - module_cmds.rs # module shortcut commands - gui/ - mod.rs - assets.rs # embedded SVG asset loading - app/ - mod.rs # ArcadiaRoot state, ShellMode enum - entry.rs # GPUI initialization - lifecycle.rs # focus, resize, module reload - navigation.rs # nav state and page routing - root/mod.rs, render.rs # root layout + render - root/top_bar.rs # title bar, session chip, shell mode toggle - sidebar/mod.rs, layout.rs, nav_items.rs - shell/mod.rs, panel.rs, execute.rs, keys.rs, tui_screen.rs, mirror.rs - modules_page/mod.rs, panel.rs, row.rs, requirements_modal.rs - lan_nodes/mod.rs, panel.rs - splash/mod.rs, view.rs, draw_*.rs, math.rs - theme/ - mod.rs # icon_path(), color constants - chrome.rs # window chrome - icons.rs # icon metadata - splash_colors.rs - modules/ # component tokens (buttons, panel, rows, toggles, typography) - nav_accents/ # per-accent palettes (mod.rs, palette.rs, 9 accents) - tui/ - mod.rs, session.rs # PTY session lifecycle - ansi_line.rs # ANSI escape parsing - colors.rs # terminal color palette - cd_builtin.rs, cwd.rs, env.rs # shell builtins + CWD tracking - keys.rs # PTY keyboard events - vt_history.rs # VT100 history buffer - assets/icons/ # SVG icons (home, terminal, logs, settings, nodes, modules, tools) - -Mobile/iOS/ - ArcadiaApp/ - ArcadiaApp.swift # @main, config root setup - ContentView.swift # top-level coordinator + Actions/Layout/NavigationState/Registry extensions - NavigationModels.swift # Swift structs mirroring NavigationRegistry - AppTheme.swift # all iOS colors as computed properties - SidebarView.swift # sidebar rendering + remote session picker - SplashView.swift # animated splash - ShellView.swift # shell command input + history - ModulesView.swift # module toggle list - LanNodesView.swift # LAN peer discovery + pairing - ModuleNames.swift # string constants mirroring MODULE_REGISTRY - GlassComponents.swift # reusable glassmorphism components - ArcadiaCore/ # Generated Swift + ArcadiaCore.xcframework (rebuild after ffi.rs changes) - -Configuration/ # Layout reference (runtime: ~/Arcadia/Configuration on Desktop) - modules.toml # module enable/disable state - commandline.toml # CLI preferences - thin-client.toml # preferred_remote_route, surface_client_id - -Resources/ - Wallpapers/ # Landscape.png, Portrait.png, Landscape-Refined.png - Sounds/ # Notification_* (Warm, Pop, Minimal, Glass, Deep, Airy) - Icons/ # App icon prototypes + Final-1-appicon.png - -Launchers/Development/OSX/ # SwiftPM menu bar launcher (optional, dev only) - Package.swift - Sources/ArcadiaDevelopmentLauncher/main.swift - build-app.sh, README.md - -.github/workflows/ - stable-build-matrix.yml # Desktop + iOS simulator CI - FUNDING.yml # GitHub Sponsors - -gaps.md # Deliberate limitations and next-tier work -CLAUDE.md # Contributor guide (architecture patterns) -AGENTS.md # Agent rules (registry discipline, anti-patterns) -``` - ---- - -## Configuration - -Runtime config root: `~/Arcadia/Configuration/` on Desktop. iOS sets root via `set_config_root_path` (app sandbox). - -| File | Struct | Purpose | -|------|--------|---------| -| `modules.toml` | `ModulesConfig` | Per-module on/off state | -| `commandline.toml` | `CommandlineConfig` | CLI preferences (scaffold) | -| `thin-client.toml` | `ThinClientConfig` | `preferred_remote_route`, `surface_client_id` | - -Config migrations live in `ModulesConfig::merge_defaults()`. When renaming a module, add a migration entry there — do not do ad-hoc renames at call sites. - ---- +## Quick start -## Prerequisites +**Prerequisites:** | Tool | Required for | |------|-------------| | Rust (`rustup`, `cargo`) | Core + Desktop | | Xcode + CLI tools | iOS app + xcframework build | | `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` | `build-ios-framework.sh` | -| Swift (via Xcode) | iOS app + dev launcher | ---- - -## Build and run - -### Desktop GUI +**Build:** ```sh -cd Desktop && cargo build --features gui +# Desktop GUI cd Desktop && cargo run --features gui -``` - -### Desktop CLI (headless) -Default features are `headless`: - -```sh +# Desktop CLI (headless) cd Desktop && cargo run -``` - -### Desktop release - -```sh -cd Desktop && cargo build --release --features gui -``` - -### Core tests -```sh +# Core tests cd Shared && cargo test -p arcadia-core -``` -### iOS framework + Swift bindings - -Run after any change to `ffi.rs` or exported types: - -```sh +# iOS framework (after ffi.rs changes) bash Shared/Scripts/build-ios-framework.sh ``` -Regenerates `Mobile/iOS/ArcadiaCore/Generated/` and rebuilds `ArcadiaCore.xcframework`. Then open `ArcadiaApp` in Xcode and build. - -### Launcher menus - -```sh -bash Shared/Scripts/Launcher.sh -pwsh Shared/Scripts/Launcher.ps1 -``` - -### Global wrappers (macOS) - -```sh -bash Shared/Scripts/install-global-commands-macos.sh -``` - -Installs helpers to `~/.local/bin` — ensure it's on `PATH`. - -### macOS dev launcher app - -```sh -cd Launchers/Development/OSX && bash build-app.sh -``` - -See `Launchers/Development/OSX/README.md` for details. - --- -## Environment variables - -| Variable | Surface | Purpose | -|----------|---------|---------| -| `ARCADIA_NET_AS` | Desktop GUI, iOS | Bootstrap `net_as` on startup (e.g. `lan:192.168.1.5`). Overrides `thin-client.toml` preferred route. | -| `ARCADIA_IOS_DEVICE_NAME` | iOS deploy scripts | Pin device by name | -| `ARCADIA_IOS_FORCE_UNINSTALL` | iOS deploy scripts | Uninstall before install | - ---- - -## Adding features - -### New module - -1. Add constant + `ModuleManifest` to `MODULE_REGISTRY` in `Shared/ArcadiaCore/src/config/modules.rs`. -2. Create `Shared/ArcadiaCore/src/modules/x.rs` with a `commands()` fn returning `&[ModuleCommand]`. -3. Register in `Shared/ArcadiaCore/src/modules/mod.rs`. -4. Done. GUI, CLI, and iOS module list updates automatically — no surface edits required. - -### New navigation page - -1. Add `NavigationPageDefinition` to `PAGE_DEFINITIONS` in `navigation.rs`. Set `required_module` if visibility depends on a module. -2. Add the page ID to the relevant `GROUP_DEFINITIONS.pages` slice, or create a new group. -3. Implement the page panel: Desktop → `gui/app/` new panel file; iOS → new view file. -4. Route it in the surface content switch via the page ID — derive visibility from `required_module`, not a hardcoded match. - -### New icon/glyph - -1. Add SVG to `Desktop/assets/icons/`. -2. Add match arm to `icon_path()` in `Desktop/src/gui/theme/mod.rs`. -3. Use the key in `NavigationPageDefinition.glyph` or `NavigationGroupDefinition.glyph`. - -### New theme color - -- Desktop: add named constant or helper fn to `Desktop/src/gui/theme/mod.rs` or the relevant component token file under `theme/modules/`. -- iOS: add computed property to `AppTheme` in `AppTheme.swift`. -- Never inline `rgb(0x...)` or `Color(hex:)` in view files. - -### New mirrored state - -Extend `SurfaceSnapshot.extra` and add a `SurfacePatch` variant in `modules/surface.rs`. Wire both surfaces to consume the new extra field from snapshot. Do not create ad-hoc `remote-session.*` verbs — keep the protocol under `surface.*`. - -### Renaming a module - -Edit `MODULE_REGISTRY` name and constant. Add a migration to `ModulesConfig::merge_defaults()` following the `LEGACY_LAN_MODULE_NAME` pattern. Do not rename at call sites. - ---- - -## Testing - -Current test coverage is sparse. Priority areas for expansion: - -```sh -# Run existing tests -cd Shared && cargo test -p arcadia-core - -# What to add: -# - surface.snapshot / parse_surface_snapshot round-trips -# - NavigationRegistryOwned JSON serialization/deserialization -# - ModulesConfig migration (merge_defaults with legacy keys) -# - thin-client preference persistence (set → get → re-load) -# - LAN routing integration (execute_command with net_as) -# - Module enable/disable with dependency enforcement -``` - -iOS `ArcadiaCore.xcframework` rebuild after FFI changes is currently manual. Adding a CI step that fails when `Generated/` drifts from `ffi.rs` is a high-priority gap — see `gaps.md`. - ---- - -## Known gaps and production roadmap - -`gaps.md` tracks all deliberate limitations. Summary with priority ranking: - -### P0 — Fix before trusting in production - -| Gap | Problem | Direction | -|----|---------|-----------| -| **Revision coverage** | `surface.revision` only advances on `surface.patch`. CLI writes and FFI writes bypass it — clients can miss updates. | Bump revision from every `ModulesConfig::save`. | -| **Testing discipline** | No automated tests for snapshot round-trips, thin-client prefs, or LAN routing. | Add targeted `arcadia-core` unit + integration tests. | -| **FFI drift detection** | No CI check that `Generated/` matches `ffi.rs`. | Workflow step: rebuild and fail if diff. | - -### P1 — Needed for real multi-user / multi-surface use - -| Gap | Problem | Direction | -|----|---------|-----------| -| **Stale UI detection** | Desktop has `last_surface_revision` but never compares it — no "host changed under you" warning. | Compare revision on timer/focus/after routed command; optional banner + reload. | -| **Multi-writer** | Multiple GUIs on same host = last write wins, no merge, no locks. | Document as permanent constraint OR add optimistic concurrency (generation tokens on save). | -| **Transport** | Command routing is request/response UDP. No long-lived session, no ordering guarantees, no subscription for deltas. | Optional WebSocket/TCP sidecar for continuous thin-shell workflows. | - -### P2 — Required before leaving trusted LAN - -| Gap | Problem | Direction | -|----|---------|-----------| -| **Security posture** | No wire encryption, no auth beyond "approved node," no scoped capabilities. `shell.execute` routable to anyone approved. | Threat model doc + TLS or pairing secrets + capability tokens. | -| **Identity** | `client_id` is attribution only — no authz, no rate limits, no per-client filtering. | Host-side policy module or capability tokens if multi-tenant. | - -### P3 — Polish and convergence - -| Gap | Problem | Direction | -|----|---------|-----------| -| **Surface parity** | Desktop has PTY/TUI paths; iOS is shell.execute only; not all panels are execute-only. | Converge per capability class with explicit "unavailable on this surface" from core. | -| **Renderer-only client** | Surfaces still bundle compiled nav — no enforced "remote-only" profile. | Optional build flag that refuses static nav when `remote_route` is mandatory. | -| **`extra` schema** | `extra.navigation_registry` is wired; broader extra buckets and corresponding `SurfacePatch` variants are undefined. | Define schema + version fields inside `extra`; extend `SurfacePatch` incrementally. | - ---- - -## Security posture - -Current trust model: **LAN pairing + locally approved peers.** Assume trusted network. - -What this means in practice: -- Any approved LAN peer can execute any command the host has enabled, including `shell.execute`. -- `surface.patch` is unauthenticated beyond `client_id` (which is just a UUID, not a secret). -- There is no encryption on the wire. - -**Do not expose Arcadia to untrusted networks without addressing P2 gaps above.** This is a home-network / trusted-LAN tool today. Production-grade multi-tenant use requires TLS, capability tokens, and a real threat model document first. - ---- - -## CI - -`.github/workflows/` — `stable-build-matrix.yml` builds Desktop targets and iOS simulator configs on selected branches. See individual workflow files for triggers and matrix. - -Gaps in CI coverage: FFI drift detection, core integration tests. See [Testing](#testing). - ---- - -## Contributing - -Read `AGENTS.md` — it has the registry-discipline rules and the full list of anti-patterns we refuse to write. Short version: - -1. **Registry entry before surface code.** New module? `MODULE_REGISTRY` first. New page? `PAGE_DEFINITIONS` first. -2. **No per-module booleans in surface state.** One generic `is_module_enabled(name)` query. -3. **No hardcoded page ID match arms in visibility logic.** Derive from `required_module` in `PageDefinition`. -4. **No inline colors.** Theme layer only. -5. **Cross-platform logic belongs in core.** If you're writing the same thing in `app.rs` and `ContentView.swift`, it's core logic. -6. **After FFI changes:** run `build-ios-framework.sh` and commit `Generated/` + `xcframework`. - -If something's missing: open a PR, draft a module, or file an issue with a concrete repro. - ---- - -## Lineage - -**[Holos](https://github.com/stack-node/holos)** — macOS-first, modular, "built out of utility and spite" against rent-seeking micro-apps. - -**Arcadia** — same DNA (free, open, yours), different chassis: Rust core, cross-platform surfaces, explicit LAN routing, `surface.*` mirror channel, and agent-enforced registry patterns so the codebase stays honest as it grows. - ---- - -## About the creator - -I'm a twenty-something British developer. - -Moved to the US in 2016 chasing family — it didn't pan out how you'd hope. Along the way I fell hard into **electricity**, then **hardware**, then **software**. Spent years in demanding jobs (including **Disney** and **government** work): solid craft, solid burnout, and a growing dislike of systems that optimize **rent** over **agency**. - -Eventually I hit a wall, stepped back, and landed back in the **UK** to rebuild — **tired**, **broke**, and dealing with **chronic insomnia**. - -Turns out insomnia leaves a lot of hours for building. - -**[Holos](https://github.com/stack-node/holos)** was one outlet — macOS-first, modular, angry at menu-bar subscriptions. - -**Arcadia** is the next chapter: **Rust**, **multi-platform**, **one honest core**, **LAN-aware surfaces**, and the same underlying attitude — tools you own, not dashboards that invoice you. - ---- - -## Donations - -There is a donation link (when I've remembered to wire it somewhere sensible — check the GitHub profile, repo Sponsors, or releases if it's live). - -You probably shouldn't use it. - -Any money would realistically help with boring friction — Apple Developer fees, hardware for iOS builds — which sits in tension with the "don't feed the rent-seekers" ethos of these projects. It would still help Arcadia and Holos reach their technical potential. - -If you donate anyway and you'd rather that money not go toward licenses or anything in that vein, say so — I'd rather put it toward something human. I'm saving toward a cat; until that's sorted, that's the soft default. After that — or if you explicitly ask that I not keep any of it — donations marked "don't support the system" can go to my local animal shelter. - -No obligation. **Code and issues beat coffee money every time.** - ---- - -## Final note - -Arcadia is meant to be **yours**: fork it, break it, fix it, route it across your LAN, disable half the modules, wire something weird into `surface.patch`. - -If it helps you replace a pile of tiny apps or own your automation stack, feed that back as code or docs — not hype. - -Make something useful. Make something weird. Make something only you care about. +## Documentation -That's still the point — just with one Rust core keeping the story straight. +- [Vision](Documentation/vision.md) — why this exists and where it's going +- [Architecture](Documentation/architecture.md) — philosophy, command model, module system, navigation, thin-client, FFI +- [Module & Navigation reference](Documentation/reference.md) — all modules and pages +- [Repository layout](Documentation/repository.md) — full directory map +- [Configuration](Documentation/configuration.md) — config files, prerequisites, environment variables +- [Build & run](Documentation/build.md) — all build targets and scripts +- [Contributing](Documentation/contributing.md) — rules, adding features, testing +- [Roadmap & known gaps](Documentation/roadmap.md) — P0–P3 gaps, security posture, CI +- [Lineage & about](Documentation/about.md) — history, creator, donations diff --git a/Shared/ArcadiaCore/Cargo.toml b/Shared/ArcadiaCore/Cargo.toml index 04271fa..229bc9e 100644 --- a/Shared/ArcadiaCore/Cargo.toml +++ b/Shared/ArcadiaCore/Cargo.toml @@ -13,4 +13,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" uniffi = "0.28" +ureq = { version = "2", features = ["json"] } +tungstenite = { version = "0.24", features = ["native-tls"] } uuid = { version = "1", features = ["v4"] } diff --git a/Shared/ArcadiaCore/src/config/late.rs b/Shared/ArcadiaCore/src/config/late.rs new file mode 100644 index 0000000..8786ce0 --- /dev/null +++ b/Shared/ArcadiaCore/src/config/late.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::ConfigFile; + +const FILE_NAME: &str = "late.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LateConfig { + pub server_url: String, + pub auth_token: String, + pub username: String, + pub default_room: u8, +} + +impl Default for LateConfig { + fn default() -> Self { + Self { + server_url: "https://late.sh".to_string(), + auth_token: String::new(), + username: String::new(), + default_room: 1, + } + } +} + +impl ConfigFile for LateConfig { + fn file_name() -> &'static str { + FILE_NAME + } +} diff --git a/Shared/ArcadiaCore/src/config/mod.rs b/Shared/ArcadiaCore/src/config/mod.rs index 538339b..5ee3f9d 100644 --- a/Shared/ArcadiaCore/src/config/mod.rs +++ b/Shared/ArcadiaCore/src/config/mod.rs @@ -1,4 +1,5 @@ pub mod commandline; +pub mod late; pub mod modules; pub mod thin_client; diff --git a/Shared/ArcadiaCore/src/config/modules.rs b/Shared/ArcadiaCore/src/config/modules.rs index f967381..aba4c46 100644 --- a/Shared/ArcadiaCore/src/config/modules.rs +++ b/Shared/ArcadiaCore/src/config/modules.rs @@ -5,6 +5,7 @@ use crate::config::ConfigFile; const LEGACY_LAN_MODULE_NAME: &str = "lan-module"; pub const LAN_MODULE_NAME: &str = "lan"; +pub const LATE_MODULE_NAME: &str = "late"; pub const NET_MODULE_NAME: &str = "net"; pub const SURFACE_MODULE_NAME: &str = "surface"; pub const REMOTE_SESSION_MODULE_NAME: &str = "remote-session"; @@ -58,6 +59,12 @@ static MODULE_REGISTRY: &[ModuleManifest] = &[ description: "Fastfetch-style banner when opening the Arcadia shell (requires shell).", required_modules: &[SHELL_MODULE_NAME], }, + ModuleManifest { + name: LATE_MODULE_NAME, + version: "0.1.0", + description: "Native late.sh client — chat rooms, music stream, reactions, and bonsai.", + required_modules: &[], + }, ]; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/Shared/ArcadiaCore/src/modules/late.rs b/Shared/ArcadiaCore/src/modules/late.rs new file mode 100644 index 0000000..e5f0680 --- /dev/null +++ b/Shared/ArcadiaCore/src/modules/late.rs @@ -0,0 +1,838 @@ +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex, OnceLock}; + +use serde::{Deserialize, Serialize}; + +use crate::config::late::LateConfig; +use crate::config::ConfigFile; +use crate::modules::{ExecutionContext, ModuleCommand}; + +pub const NAME: &str = "late"; + +// ── Domain types ────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct LateReaction { + pub emoji: String, + pub count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LateMessage { + pub id: String, + pub user_id: String, + pub username: String, + pub body: String, + pub timestamp: String, + pub reactions: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LateUser { + pub user_id: u32, + pub username: String, + pub room_id: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct LateNowPlaying { + pub track: String, + pub artist: String, + pub album: String, + pub progress_sec: u32, + pub duration_sec: u32, + pub volume_pct: u32, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct LateVotes { + pub lofi: u32, + pub ambient: u32, + pub classic: u32, + pub jazz: u32, + pub next_vote_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LateActivityEvent { + pub kind: String, + pub username: String, + pub room_id: u32, + pub timestamp: String, +} + +// ── Shared state ────────────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct LateState { + pub connected: bool, + pub active_room: u32, + /// Bounded to the last 200 messages (across all subscribed rooms). + pub messages: VecDeque, + pub online_users: Vec, + pub now_playing: LateNowPlaying, + pub votes: LateVotes, + pub visualizer_frame: String, + pub bonsai_art: Vec, + pub activity_feed: VecDeque, + pub connection_error: Option, + /// Incremented on every mutation so the GUI poll task knows when to repaint. + pub revision: u64, +} + +const MAX_MESSAGES: usize = 200; +const MAX_ACTIVITY: usize = 50; + +impl LateState { + fn push_message(&mut self, msg: LateMessage) { + if self.messages.len() >= MAX_MESSAGES { + self.messages.pop_front(); + } + self.messages.push_back(msg); + self.revision += 1; + } + + fn push_activity(&mut self, event: LateActivityEvent) { + if self.activity_feed.len() >= MAX_ACTIVITY { + self.activity_feed.pop_front(); + } + self.activity_feed.push_back(event); + self.revision += 1; + } +} + +static STATE: OnceLock>> = OnceLock::new(); + +pub fn state() -> Arc> { + STATE + .get_or_init(|| Arc::new(Mutex::new(LateState::default()))) + .clone() +} + +// ── WebSocket background thread ─────────────────────────────────────────────── + +static WS_RUNNING: AtomicBool = AtomicBool::new(false); +static WS_SENDER: OnceLock>>> = OnceLock::new(); + +fn ws_sender_lock() -> &'static Mutex>> { + WS_SENDER.get_or_init(|| Mutex::new(None)) +} + +pub fn start_ws_thread(server_url: String, ticket: String) { + if WS_RUNNING.swap(true, Ordering::SeqCst) { + return; + } + std::thread::Builder::new() + .name("late-ws".to_string()) + .spawn(move || ws_loop(server_url, ticket)) + .ok(); +} + +pub fn stop_ws_thread() { + WS_RUNNING.store(false, Ordering::SeqCst); + if let Ok(mut guard) = ws_sender_lock().lock() { + *guard = None; + } +} + +pub fn send_ws(msg: String) { + if let Ok(guard) = ws_sender_lock().lock() { + if let Some(tx) = guard.as_ref() { + let _ = tx.send(msg); + } + } +} + +fn ws_loop(server_url: String, ticket: String) { + use tungstenite::{Message, connect}; + + let (ws_scheme, host) = if let Some(host) = server_url.strip_prefix("https://") { + ("wss", host) + } else if let Some(host) = server_url.strip_prefix("http://") { + ("ws", host) + } else { + ("wss", server_url.as_str()) + }; + let ws_url = format!("{ws_scheme}://{host}/api/ws/native?ticket={ticket}"); + eprintln!("[late] ws connecting"); + + let (mut socket, _) = match connect(ws_url.as_str()) { + Ok(pair) => pair, + Err(err) => { + eprintln!("[late] ws connect failed: {err}"); + let arc = state(); + let mut st = arc.lock().unwrap_or_else(|e| e.into_inner()); + st.connection_error = Some(format!("WS connect failed: {err}")); + st.connected = false; + st.revision += 1; + WS_RUNNING.store(false, Ordering::SeqCst); + return; + } + }; + eprintln!("[late] ws connected"); + + { + let arc = state(); + let mut st = arc.lock().unwrap_or_else(|e| e.into_inner()); + st.connected = true; + st.connection_error = None; + st.revision += 1; + } + + let (tx, rx) = mpsc::channel::(); + { + let mut guard = ws_sender_lock().lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(tx); + } + + // Sender sub-thread: drains mpsc channel → WS write. + // We can't share the socket across threads, so we use a second connection-side approach: + // instead, handle sends inline in the read loop by making the socket non-blocking on reads + // isn't possible with tungstenite blocking mode. Use a simple approach: poll the channel + // before each read with try_recv. + + loop { + if !WS_RUNNING.load(Ordering::Relaxed) { + break; + } + + // Drain outbound queue first. + while let Ok(outbound) = rx.try_recv() { + if let Err(err) = socket.send(Message::Text(outbound)) { + eprintln!("[late] ws send failed: {err}"); + break; + } + } + + // Set a short read timeout so we can interleave outbound sends. + match socket.read() { + Ok(Message::Text(text)) => handle_ws_message(&text), + Ok(Message::Ping(data)) => { + let _ = socket.send(Message::Pong(data)); + } + Ok(Message::Close(_)) => { + eprintln!("[late] ws closed by remote"); + break; + } + Err(err) => { + eprintln!("[late] ws read error: {err}"); + break; + } + Ok(_) => {} + } + } + + { + let arc = state(); + let mut st = arc.lock().unwrap_or_else(|e| e.into_inner()); + st.connected = false; + st.revision += 1; + } + { + let mut guard = ws_sender_lock().lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + WS_RUNNING.store(false, Ordering::SeqCst); + eprintln!("[late] ws disconnected"); +} + +fn handle_ws_message(text: &str) { + let Ok(val) = serde_json::from_str::(text) else { + return; + }; + let msg_type = val["type"].as_str().unwrap_or(""); + let arc = state(); + let mut st = arc.lock().unwrap_or_else(|e| e.into_inner()); + + match msg_type { + "init" => { + if let Some(users) = val["online_users"].as_array() { + st.online_users = users + .iter() + .filter_map(|u| serde_json::from_value(u.clone()).ok()) + .collect(); + } + if let Ok(np) = serde_json::from_value::(val["now_playing"].clone()) { + st.now_playing = np; + } + if let Ok(votes) = serde_json::from_value::(val["votes"].clone()) { + st.votes = votes; + } + if let Some(messages) = val["messages"].as_array() { + st.messages.clear(); + for raw in messages { + if let Some(msg) = parse_ws_message(raw) { + st.push_message(msg); + } + } + } + st.revision += 1; + } + "message" => { + if let Some(msg) = parse_ws_message(&val["msg"]) { + st.push_message(msg); + } + } + "reaction_update" => { + let msg_id = val["msg_id"] + .as_str() + .map(str::to_string) + .or_else(|| val["msg_id"].as_u64().map(|n| n.to_string())) + .unwrap_or_default(); + if let Some(reactions) = + serde_json::from_value::>(val["reactions"].clone()).ok() + { + if let Some(m) = st.messages.iter_mut().find(|m| m.id == msg_id) { + m.reactions = reactions; + st.revision += 1; + } + } + } + "presence" => { + let event = LateActivityEvent { + kind: val["event"].as_str().unwrap_or("join").to_string(), + username: val["username"].as_str().unwrap_or("").to_string(), + room_id: val["room_id"].as_u64().unwrap_or(0) as u32, + timestamp: val["timestamp"].as_str().unwrap_or("").to_string(), + }; + let username = event.username.clone(); + let user_id = val["user_id"].as_u64().unwrap_or(0) as u32; + let room_id = event.room_id; + st.push_activity(event); + match val["event"].as_str().unwrap_or("") { + "join" => { + if !st.online_users.iter().any(|u| u.user_id == user_id) { + st.online_users.push(LateUser { + user_id, + username, + room_id: Some(room_id), + }); + } + } + "leave" => { + st.online_users.retain(|u| u.user_id != user_id); + } + _ => {} + } + st.revision += 1; + } + "now_playing" => { + if let Ok(np) = serde_json::from_value::(val.clone()) { + st.now_playing = np; + st.revision += 1; + } + } + "votes" => { + if let Ok(votes) = serde_json::from_value::(val.clone()) { + st.votes = votes; + st.revision += 1; + } + } + "visualizer" => { + if let Some(frame) = val["frame"].as_str() { + st.visualizer_frame = frame.to_string(); + st.revision += 1; + } + } + "bonsai" => { + if let Some(art) = val["art"].as_array() { + st.bonsai_art = art + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + st.revision += 1; + } + } + "ping" => { + send_ws(r#"{"type":"pong"}"#.to_string()); + } + _ => {} + } +} + +fn parse_ws_message(raw: &serde_json::Value) -> Option { + Some(LateMessage { + id: raw["id"] + .as_str() + .map(str::to_string) + .or_else(|| raw["id"].as_u64().map(|n| n.to_string()))?, + user_id: raw["user_id"] + .as_str() + .map(str::to_string) + .or_else(|| raw["user_id"].as_u64().map(|n| n.to_string())) + .unwrap_or_default(), + username: raw["username"].as_str().unwrap_or("").to_string(), + body: raw["body"].as_str().unwrap_or("").to_string(), + timestamp: raw["timestamp"].as_str().unwrap_or("").to_string(), + reactions: serde_json::from_value::>(raw["reactions"].clone()) + .unwrap_or_default(), + }) +} + +// ── HTTP helpers ────────────────────────────────────────────────────────────── + +pub fn http_get_challenge(server_url: &str) -> Result { + let url = format!("{server_url}/api/native/challenge"); + let resp = ureq::get(&url) + .call() + .map_err(|e| e.to_string())? + .into_json::() + .map_err(|e| e.to_string())?; + resp["nonce"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "no nonce in response".to_string()) +} + +pub fn http_post_token( + server_url: &str, + fingerprint: &str, + public_key: &str, + nonce: &str, + signature_pem: &str, +) -> Result { + let url = format!("{server_url}/api/native/token"); + let body = serde_json::json!({ + "public_key_fingerprint": fingerprint, + "public_key": public_key, + "nonce": nonce, + "signature_pem": signature_pem, + }); + let resp = match ureq::post(&url).send_json(body) { + Ok(resp) => resp, + Err(ureq::Error::Status(code, response)) => { + let body = response + .into_string() + .unwrap_or_else(|_| "".to_string()); + return Err(format!("token endpoint returned {code}: {body}")); + } + Err(err) => return Err(err.to_string()), + } + .into_json::() + .map_err(|e| e.to_string())?; + resp["token"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "no token in response".to_string()) +} + +pub fn http_get_now_playing(server_url: &str, token: &str) -> Result { + let url = format!("{server_url}/api/native/now-playing"); + ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + .map_err(|e| e.to_string())? + .into_json::() + .map_err(|e| e.to_string()) +} + +pub fn http_post_vote(server_url: &str, token: &str, genre: &str) -> Result { + let url = format!("{server_url}/api/native/vote"); + ureq::post(&url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(serde_json::json!({"genre": genre})) + .map_err(|e| e.to_string())? + .into_json::() + .map_err(|e| e.to_string()) +} + +pub fn http_get_history( + server_url: &str, + token: &str, + room_id: u32, + limit: usize, +) -> Result, String> { + let url = format!("{server_url}/api/native/rooms/{room_id}/history?limit={limit}"); + ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + .map_err(|e| e.to_string())? + .into_json::>() + .map_err(|e| e.to_string()) +} + +pub fn http_post_send( + server_url: &str, + token: &str, + room_id: u32, + body: &str, +) -> Result<(), String> { + let url = format!("{server_url}/api/native/rooms/{room_id}/messages"); + ureq::post(&url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(serde_json::json!({"body": body})) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn http_post_water_bonsai(server_url: &str, token: &str) -> Result, String> { + let url = format!("{server_url}/api/native/bonsai/water"); + let resp = ureq::post(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + .map_err(|e| e.to_string())? + .into_json::() + .map_err(|e| e.to_string())?; + resp["art"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .ok_or_else(|| "no art in response".to_string()) +} + +pub fn http_get_bonsai(server_url: &str, token: &str) -> Result, String> { + let url = format!("{server_url}/api/native/bonsai"); + let resp = ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + .map_err(|e| e.to_string())? + .into_json::() + .map_err(|e| e.to_string())?; + resp["art"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .ok_or_else(|| "no art in response".to_string()) +} + +pub fn http_get_ws_ticket(server_url: &str, token: &str) -> Result { + let url = format!("{server_url}/api/native/ws-ticket"); + let resp = ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + .map_err(|e| e.to_string())? + .into_json::() + .map_err(|e| e.to_string())?; + resp["ticket"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "no ticket in response".to_string()) +} + +pub fn http_delete_token(server_url: &str, token: &str) -> Result<(), String> { + let url = format!("{server_url}/api/native/logout"); + match ureq::delete(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + { + Ok(_) => Ok(()), + Err(ureq::Error::Status(code, response)) => { + let body = response.into_string().unwrap_or_default(); + Err(format!("logout returned {code}: {body}")) + } + Err(e) => Err(e.to_string()), + } +} + +fn expand_tilde_path(path: &str) -> String { + if path == "~" { + return std::env::var("HOME").unwrap_or_else(|_| path.to_string()); + } + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return format!("{home}/{rest}"); + } + } + path.to_string() +} + +// ── Module commands ─────────────────────────────────────────────────────────── + +pub fn commands() -> &'static [ModuleCommand] { + &[ + ModuleCommand { + name: "connect", + description: "connect to late.sh WebSocket (reads server_url and auth_token from late.toml)", + run: cmd_connect, + }, + ModuleCommand { + name: "disconnect", + description: "disconnect from late.sh WebSocket", + run: cmd_disconnect, + }, + ModuleCommand { + name: "status", + description: "connection status and current now-playing as JSON", + run: cmd_status, + }, + ModuleCommand { + name: "send", + description: "late.send — send a chat message", + run: cmd_send, + }, + ModuleCommand { + name: "vote", + description: "late.vote lofi|ambient|classic — vote for next music genre", + run: cmd_vote, + }, + ModuleCommand { + name: "water", + description: "water bonsai and refresh bonsai art", + run: cmd_water, + }, + ModuleCommand { + name: "login", + description: "late.login — exchange SSH key for API token (desktop only)", + run: cmd_login, + }, + ModuleCommand { + name: "logout", + description: "revoke the stored API token and clear it from late.toml", + run: cmd_logout, + }, + ] +} + +fn cmd_connect(args: &[&str], _ctx: &ExecutionContext) -> String { + let cfg = match LateConfig::load_or_create() { + Ok(c) => c, + Err(e) => { + eprintln!("[late] failed to load late.toml: {e}"); + return format!("error: failed to load late.toml: {e}"); + } + }; + if cfg.auth_token.is_empty() { + eprintln!("[late] connect blocked: missing auth_token in late.toml"); + return "error: no auth_token in late.toml — run late.login first".to_string(); + } + let server_url = if let Some(url) = args.first() { + url.to_string() + } else { + cfg.server_url.clone() + }; + eprintln!("[late] connect requested: server_url={server_url}"); + + match http_get_bonsai(&cfg.server_url, &cfg.auth_token) { + Ok(art) => { + let arc = state(); + let mut st = arc.lock().unwrap_or_else(|e| e.into_inner()); + st.bonsai_art = art; + st.revision += 1; + } + Err(err) => eprintln!("[late] bonsai prefetch failed: {err}"), + } + + let ticket = match http_get_ws_ticket(&cfg.server_url, &cfg.auth_token) { + Ok(t) => t, + Err(e) => return format!("error: failed to get WS ticket: {e}"), + }; + + start_ws_thread(server_url, ticket); + "connecting to late.sh…".to_string() +} + +fn cmd_disconnect(_args: &[&str], _ctx: &ExecutionContext) -> String { + stop_ws_thread(); + "disconnected".to_string() +} + +fn cmd_status(_args: &[&str], _ctx: &ExecutionContext) -> String { + let st = state(); + let guard = st.lock().unwrap_or_else(|e| e.into_inner()); + serde_json::json!({ + "connected": guard.connected, + "revision": guard.revision, + "active_room": guard.active_room, + "now_playing": guard.now_playing, + "votes": guard.votes, + "online_users": guard.online_users.len(), + "messages": guard.messages.len(), + "error": guard.connection_error, + }) + .to_string() +} + +fn cmd_send(args: &[&str], _ctx: &ExecutionContext) -> String { + if args.len() < 2 { + return "usage: late.send ".to_string(); + } + let room_id: u32 = match args[0].parse() { + Ok(n) => n, + Err(_) => return "error: room_id must be a number".to_string(), + }; + let body = args[1..].join(" "); + + // Try WS first (fast path), fall back to HTTP. + if WS_RUNNING.load(Ordering::Relaxed) { + let msg = serde_json::json!({"type":"send","room_id":room_id,"body":body}).to_string(); + send_ws(msg); + return "sent".to_string(); + } + + let cfg = match LateConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("error: {e}"), + }; + match http_post_send(&cfg.server_url, &cfg.auth_token, room_id, &body) { + Ok(()) => "sent".to_string(), + Err(e) => format!("error: {e}"), + } +} + +fn cmd_vote(args: &[&str], _ctx: &ExecutionContext) -> String { + let genre = match args.first() { + Some(g) => *g, + None => return "usage: late.vote lofi|ambient|classic|jazz".to_string(), + }; + if !["lofi", "ambient", "classic", "jazz"].contains(&genre) { + return format!("error: unknown genre '{genre}' — use lofi, ambient, classic, or jazz"); + } + + if WS_RUNNING.load(Ordering::Relaxed) { + let msg = serde_json::json!({"type":"vote","genre":genre}).to_string(); + send_ws(msg); + return format!("voted {genre}"); + } + + let cfg = match LateConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("error: {e}"), + }; + match http_post_vote(&cfg.server_url, &cfg.auth_token, genre) { + Ok(votes) => format!( + "voted {genre} — lofi: {}, ambient: {}, classic: {}, jazz: {}", + votes.lofi, votes.ambient, votes.classic, votes.jazz + ), + Err(e) => format!("error: {e}"), + } +} + +fn cmd_water(_args: &[&str], _ctx: &ExecutionContext) -> String { + let cfg = match LateConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("error: {e}"), + }; + match http_post_water_bonsai(&cfg.server_url, &cfg.auth_token) { + Ok(art) => { + let arc = state(); + let mut st = arc.lock().unwrap_or_else(|e| e.into_inner()); + st.bonsai_art = art; + st.revision += 1; + "bonsai watered".to_string() + } + Err(e) => format!("error: {e}"), + } +} + +fn cmd_logout(_args: &[&str], _ctx: &ExecutionContext) -> String { + let mut cfg = match LateConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("error: failed to load late.toml: {e}"), + }; + if cfg.auth_token.is_empty() { + return "no token stored — already logged out".to_string(); + } + // Best-effort revocation; don't fail logout if the server is unreachable. + if let Err(e) = http_delete_token(&cfg.server_url, &cfg.auth_token) { + eprintln!("[late] logout: server revocation failed (continuing): {e}"); + } + stop_ws_thread(); + cfg.auth_token = String::new(); + match cfg.save() { + Ok(()) => "logged out — token revoked and cleared from late.toml".to_string(), + Err(e) => format!("token revoked on server but failed to clear late.toml: {e}"), + } +} + +fn cmd_login(args: &[&str], _ctx: &ExecutionContext) -> String { + let key_path_input = match args.first() { + Some(p) => *p, + None => return "usage: late.login ".to_string(), + }; + let key_path = expand_tilde_path(key_path_input); + eprintln!("[late] login key path: input='{key_path_input}' resolved='{key_path}'"); + + let cfg = match LateConfig::load_or_create() { + Ok(c) => c, + Err(e) => return format!("error: failed to load late.toml: {e}"), + }; + + // Step 1: get nonce. + let nonce = match http_get_challenge(&cfg.server_url) { + Ok(n) => n, + Err(e) => return format!("error: challenge request failed: {e}"), + }; + + // Step 2: sign nonce bytes with ssh-keygen. + let tmp_dir = std::env::temp_dir(); + let nonce_file = tmp_dir.join("late_nonce.bin"); + // Write raw nonce hex as bytes (server expects the hex string signed, not decoded bytes). + if let Err(e) = std::fs::write(&nonce_file, nonce.as_bytes()) { + return format!("error: failed to write nonce temp file: {e}"); + } + + let sign_output = std::process::Command::new("ssh-keygen") + .args([ + "-Y", "sign", + "-f", key_path.as_str(), + "-n", "late.sh", + nonce_file.to_str().unwrap_or(""), + ]) + .output(); + + let _ = std::fs::remove_file(&nonce_file); + + let sig_file = tmp_dir.join("late_nonce.bin.sig"); + let signature_pem = match sign_output { + Ok(out) if out.status.success() => { + match std::fs::read_to_string(&sig_file) { + Ok(s) => { let _ = std::fs::remove_file(&sig_file); s } + Err(e) => return format!("error: could not read signature file: {e}"), + } + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + return format!("error: ssh-keygen sign failed: {stderr}"); + } + Err(e) => return format!("error: ssh-keygen not found or failed: {e}"), + }; + + // Step 3: read the public key and extract the fingerprint. + let pub_key_path = format!("{key_path}.pub"); + let public_key = match std::fs::read_to_string(&pub_key_path) { + Ok(s) => s.trim().to_string(), + Err(e) => return format!("error: could not read public key {pub_key_path}: {e}"), + }; + let fp_output = std::process::Command::new("ssh-keygen") + .args(["-lf", key_path.as_str()]) + .output(); + let fingerprint = match fp_output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + // Format: "256 SHA256:xxxxx comment (ED25519)" — extract SHA256:xxx part. + stdout + .split_whitespace() + .find(|s| s.starts_with("SHA256:")) + .unwrap_or("") + .to_string() + } + _ => return "error: ssh-keygen -lf failed — check key path".to_string(), + }; + + if fingerprint.is_empty() { + return "error: could not extract fingerprint from ssh-keygen output".to_string(); + } + eprintln!("[late] login fingerprint: {fingerprint}"); + + // Step 4: exchange for token. + let token = match http_post_token(&cfg.server_url, &fingerprint, &public_key, &nonce, &signature_pem) { + Ok(t) => t, + Err(e) => return format!("error: token exchange failed: {e}"), + }; + + // Step 5: save to config. + let mut new_cfg = cfg; + new_cfg.auth_token = token; + match new_cfg.save() { + Ok(()) => format!("logged in — token saved to late.toml (fingerprint: {fingerprint})"), + Err(e) => format!("error: token received but failed to save late.toml: {e}"), + } +} + diff --git a/Shared/ArcadiaCore/src/modules/mod.rs b/Shared/ArcadiaCore/src/modules/mod.rs index c0e1123..d059ead 100644 --- a/Shared/ArcadiaCore/src/modules/mod.rs +++ b/Shared/ArcadiaCore/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod lan; +pub mod late; pub mod net; pub mod remote_mirror; pub mod remote_session; @@ -26,6 +27,7 @@ pub struct ModuleCommand { fn module_commands(module_name: &str) -> Option<&'static [ModuleCommand]> { match module_name { lan::NAME => Some(lan::commands()), + late::NAME => Some(late::commands()), net::NAME => Some(net::commands()), remote_session::NAME => Some(remote_session::commands()), shell::NAME => Some(shell::commands()), @@ -196,6 +198,7 @@ pub fn all_command_entries() -> Vec<(String, String)> { pub fn load_all() { let _known_modules = [ lan::NAME, + late::NAME, net::NAME, remote_session::NAME, shell::NAME, diff --git a/Shared/ArcadiaCore/src/navigation.rs b/Shared/ArcadiaCore/src/navigation.rs index 42ea244..60625e2 100644 --- a/Shared/ArcadiaCore/src/navigation.rs +++ b/Shared/ArcadiaCore/src/navigation.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::config::modules::{LAN_MODULE_NAME, NET_MODULE_NAME, SHELL_MODULE_NAME}; +use crate::config::modules::{LAN_MODULE_NAME, LATE_MODULE_NAME, NET_MODULE_NAME, SHELL_MODULE_NAME}; #[derive(Clone, Copy, Serialize)] pub struct NavigationPageDefinition { @@ -175,6 +175,15 @@ pub const PAGE_DEFINITIONS: &[NavigationPageDefinition] = &[ accent: "cyan", required_module: Some(LAN_MODULE_NAME), }, + NavigationPageDefinition { + id: "late.now_playing", + title: "Late.sh", + description: "Live chat, now playing, votes, visualizer, and bonsai in one view.", + glyph: "music", + system_image: "music.note", + accent: "violet", + required_module: Some(LATE_MODULE_NAME), + }, ]; pub const GROUP_DEFINITIONS: &[NavigationGroupDefinition] = &[ @@ -194,6 +203,14 @@ pub const GROUP_DEFINITIONS: &[NavigationGroupDefinition] = &[ pages: &["network.overview", "network.nodes"], accent: "cyan", }, + NavigationGroupDefinition { + id: "social", + label: "Social", + glyph: "chat", + system_image: "bubble.left.and.bubble.right.fill", + pages: &["late.now_playing"], + accent: "teal", + }, ]; pub const GLOBAL_PAGE_IDS: &[&str] = &["global.dashboard", "global.settings", "global.modules"]; diff --git a/Shared/Cargo.lock b/Shared/Cargo.lock index 5363062..68b83ff 100644 --- a/Shared/Cargo.lock +++ b/Shared/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +44,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -65,7 +71,9 @@ dependencies = [ "serde", "serde_json", "toml 0.8.23", + "tungstenite", "uniffi", + "ureq", "uuid", ] @@ -116,6 +124,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.10" @@ -140,12 +154,27 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -184,6 +213,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -236,18 +275,145 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs-err" version = "2.11.0" @@ -281,6 +447,27 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -332,12 +519,131 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -386,6 +692,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -420,6 +738,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -442,24 +787,97 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -494,12 +912,113 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scroll" version = "0.12.0" @@ -520,6 +1039,29 @@ dependencies = [ "syn", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -582,6 +1124,29 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "0.3.11" @@ -594,12 +1159,24 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -612,6 +1189,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -623,6 +1206,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -652,6 +1259,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.5.11" @@ -702,6 +1319,31 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicase" version = "2.9.0" @@ -845,6 +1487,54 @@ dependencies = [ "weedle2", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -857,11 +1547,29 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -959,6 +1667,24 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -974,6 +1700,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -983,6 +1718,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1086,6 +1885,115 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21"