Просмотр исходного кода

app: make overlay display for darkirc p2p traffic and switching it off and on

darkfi 4 часов назад
Родитель
Сommit
7557d26e41

+ 1 - 1
bin/app/Cargo.lock

@@ -1916,7 +1916,7 @@ dependencies = [
 
 [[package]]
 name = "darkfi-app"
-version = "0.1.0"
+version = "0.4.0-alpha"
 dependencies = [
  "async-broadcast",
  "async-channel 2.5.0",

+ 1 - 1
bin/app/Cargo.toml

@@ -1,7 +1,7 @@
 [package]
 name = "darkfi-app"
 description = "DarkFi UI App"
-version = "0.1.0"
+version = "0.4.0-alpha"
 edition = "2021"
 authors = ["Dyne.org foundation <foundation@dyne.org>"]
 license = "AGPL-3.0-only"

+ 1 - 1
bin/app/Makefile

@@ -30,7 +30,7 @@ DEBUG_FEATURES = --features=schema-app,enable-filelog,enable-plugin-darkirc,enab
 
 #DEV_FEATURES = --features=enable-filelog,enable-netdebug,emulate-android
 #DEV_FEATURES = --features=enable-filelog,enable-netdebug,enable-plugins
-DEV_FEATURES = --features=schema-app,enable-filelog,enable-netdebug
+DEV_FEATURES = --features=schema-app,enable-filelog,enable-netdebug,enable-plugin-darkirc
 
 default: build-release
 	./darkfi-app

+ 8 - 7
bin/app/src/app/mod.rs

@@ -49,8 +49,6 @@ macro_rules! i { ($($arg:tt)*) => { info!(target: "app", $($arg)*); } }
 //macro_rules! w { ($($arg:tt)*) => { warn!(target: "app", $($arg)*); } }
 macro_rules! e { ($($arg:tt)*) => { error!(target: "app", $($arg)*); } }
 
-const IS_FIRST_TIME_KEY: &str = "is_first_time";
-
 //fn print_type_of<T>(_: &T) {
 //    println!("{}", std::any::type_name::<T>())
 //}
@@ -68,8 +66,8 @@ pub struct App {
     pub redraw_trigger: RedrawTrigger,
     /// Receiver side of the redraw queue, handed to the window in `setup()`.
     redraw_rx: async_channel::Receiver<()>,
-    /// True if this is the first time the app has ever been run.
-    /// Loaded from the KVDB in `setup()`.
+    /// True on the first run of a new app version, i.e. when no version
+    /// or a different one is recorded in the app DB. Loaded in `setup()`.
     pub is_first_time: AtomicBool,
 }
 
@@ -92,9 +90,11 @@ impl App {
     pub async fn setup(&self, kv_db: KvDb, app_db: AppDbPtr) {
         t!("App::setup()");
 
-        let is_first_time = !app_db.flag_contains(IS_FIRST_TIME_KEY).await.unwrap();
+        let app_version = env!("CARGO_PKG_VERSION");
+        let is_first_time = app_db.app_version_get().await.unwrap().as_deref() != Some(app_version);
         if is_first_time {
-            app_db.flag_set(IS_FIRST_TIME_KEY).await.unwrap();
+            i!("First run of app version {app_version}");
+            app_db.app_version_set(app_version).await.unwrap();
         }
         self.is_first_time.store(is_first_time, Ordering::Relaxed);
 
@@ -200,7 +200,8 @@ impl App {
         d!("Starting app epoch={epoch}");
         // On Android the foreground service keeps the process alive across
         // UI restarts, so start() runs on every relaunch. swap() consumes
-        // the flag so the sound only plays on the very first launch.
+        // the flag so the sound only plays on the first launch of a new
+        // app version.
         if self.is_first_time.swap(false, Ordering::Relaxed) {
             sfx::play_commup();
         }

+ 212 - 96
bin/app/src/app/schema/mod.rs

@@ -34,7 +34,7 @@ use crate::{
     db::AppDbPtr,
     expr::{self, Compiler},
     gfx::gfxtag,
-    prop::{PropertyAtomicGuard, PropertyFloat32, Role},
+    prop::{PropertyAtomicGuard, PropertyFloat32, PropertyStr, Role},
     scene::{SceneNodePtr, Slot},
     sfx, shape,
     ui::{emoji_picker, Button, Layer, Text, TextScramble, VectorArt, VectorShape, Video},
@@ -226,13 +226,9 @@ pub async fn make(
 
     let atom = &mut PropertyAtomicGuard::none();
 
-    let window_scale = PropertyFloat32::wrap(
-        &app.sg_root.lookup_node("/window").unwrap(),
-        Role::Internal,
-        "scale",
-        0,
-    )
-    .unwrap();
+    let window_scale =
+        PropertyFloat32::wrap(&app.sg_root.lookup_node("/window").unwrap(), Role::App, "scale", 0)
+            .unwrap();
 
     // Root content layer
     let content = create_layer("content");
@@ -254,7 +250,8 @@ pub async fn make(
         content.setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;
     window.link(content.clone());
 
-    // Splash layer with the scramble message, shown only on first app launch
+    // Splash layer with the scramble message, shown on the first run of
+    // a new app version
     if app.is_first_time.load(Ordering::Relaxed) {
         cc.add_const_f32("SPLASH_FONTSIZE", SPLASH_FONTSIZE);
         cc.add_const_f32("SPLASH_MARGIN", SPLASH_MARGIN);
@@ -562,6 +559,7 @@ pub async fn make(
     node.register("click", slot).unwrap();
     let reconnect_task = ex.spawn(async move {
         let mut _fade_task = None;
+        let mut _conn_info_task = None;
         while let Ok(_) = recvr.recv().await {
             i!("Reconnect button clicked");
 
@@ -578,6 +576,39 @@ pub async fn make(
                 // Start from fully transparent so the fade begins hidden
                 overlay.set_property_f32(atom, Role::App, "alpha", 0.).unwrap();
 
+                // While the overlay is shown, keep the conn_info text in sync
+                // with the darkirc outbound peers
+                let sg_root2 = sg_root.clone();
+                let redraw2 = redraw.clone();
+                _conn_info_task = Some(ex_fade.spawn(async move {
+                    let Some(darkirc) = sg_root2.lookup_node("/plugin/darkirc") else {
+                        e!("DarkIrc plugin has not been loaded");
+                        return
+                    };
+                    let conn_info = sg_root2
+                        .lookup_node("/window/content/chat/netstatus_overlay/conn_info")
+                        .unwrap();
+                    let conn_info_text =
+                        PropertyStr::wrap(&conn_info, Role::App, "text", 0).unwrap();
+                    let outbound_peers = darkirc.get_property("outbound_peers").unwrap();
+                    let outbound_peers_sub = outbound_peers.subscribe_modify();
+
+                    loop {
+                        let mut lines = vec![];
+                        for idx in 0..outbound_peers.get_len() {
+                            match outbound_peers.get_str_opt(idx) {
+                                Ok(Some(url)) => lines.push(format!("{idx}  {url}")),
+                                _ => lines.push(format!("{idx}  sleeping")),
+                            }
+                        }
+
+                        let atom = &mut redraw2.make_guard(gfxtag!("conn_info update"));
+                        conn_info_text.set(atom, lines.join("\n"));
+
+                        let Ok(_) = outbound_peers_sub.receive().await else { break };
+                    }
+                }));
+
                 // Fade the overlay alpha from 0 to 1 over 1s
                 let overlay = overlay.clone();
                 let redraw = redraw.clone();
@@ -592,46 +623,10 @@ pub async fn make(
                     }
                 }));
             } else {
-                // Hiding cancels any in-flight fade
+                // Hiding cancels any in-flight fade and the conn_info listener
                 _fade_task = None;
+                _conn_info_task = None;
             }
-
-            /*
-            // Show netstat-klik icon
-            let netstat_klik =
-                sg_root.lookup_node("/window/content/chat/netstatus_layer/netstat_klik").unwrap();
-
-            {
-                let atom = &mut redraw.make_guard(gfxtag!("netstat_klik_show"));
-                if let Err(e) = netstat_klik.set_property_bool(atom, Role::App, "is_visible", true)
-                {
-                    e!("Failed to show netstat_klik: {e}");
-                }
-            }
-
-            // Trigger reconnect
-            match sg_root.lookup_node("/plugin/darkirc") {
-                Some(darkirc) => {
-                    if let Err(e) = darkirc.call_method("reconnect", vec![]).await {
-                        e!("Failed to trigger reconnect: {e}");
-                    }
-                }
-                None => {
-                    e!("DarkIrc plugin has not been loaded");
-                }
-            }
-
-            msleep(200).await;
-
-            // Hide netstat-klik icon
-            {
-                let atom = &mut redraw.make_guard(gfxtag!("netstat_klik_hide"));
-                if let Err(e) = netstat_klik.set_property_bool(atom, Role::App, "is_visible", false)
-                {
-                    e!("Failed to hide netstat_klik: {e}");
-                }
-            }
-            */
         }
     });
     app.tasks.lock().unwrap().push(reconnect_task);
@@ -648,10 +643,12 @@ pub async fn make(
     prop.set_f32(atom, Role::App, 1, NETSTATUS_ICON_SIZE + NETSTAT_OVERLAY_MARGIN).unwrap();
     let code = cc.compile("w - 2 * NETSTAT_OVERLAY_MARGIN").unwrap();
     prop.set_expr(atom, Role::App, 2, code).unwrap();
-    let code = cc.compile("h - NETSTATUS_ICON_SIZE - 2 * NETSTAT_OVERLAY_MARGIN").unwrap();
-    prop.set_expr(atom, Role::App, 3, code).unwrap();
+    //let code = cc.compile("h - NETSTATUS_ICON_SIZE - 2 * NETSTAT_OVERLAY_MARGIN").unwrap();
+    //prop.set_expr(atom, Role::App, 3, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, 380.).unwrap();
     overlay_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
     overlay_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    overlay_node.set_property_u32(atom, Role::App, "priority", 2).unwrap();
     let overlay_node = overlay_node
         .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
         .await;
@@ -673,41 +670,56 @@ pub async fn make(
         expr::const_f32(0.),
         expr::load_var("w"),
         expr::load_var("h"),
-        overlay_color,
+        [0., 0.1, 0.1, 0.7],
+    );
+    shape.add_filled_box(
+        expr::const_f32(1.),
+        expr::const_f32(120.),
+        expr::load_var("w"),
+        expr::const_f32(121.),
+        [0., 0.94, 1., 1.],
+    );
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        2.,
+        [0., 0.94, 1., 1.],
+    );
+    shape.add_filled_box(
+        cc.compile("w - 120").unwrap(),
+        expr::const_f32(20.),
+        cc.compile("w - 20").unwrap(),
+        expr::const_f32(100.),
+        [0., 0.12, 0.08, 1.],
+    );
+    shape.add_outline(
+        cc.compile("w - 120").unwrap(),
+        expr::const_f32(20.),
+        cc.compile("w - 20").unwrap(),
+        expr::const_f32(100.),
+        1.,
+        [0.08, 0.68, 0.72, 1.],
     );
     let node = node
         .setup(|me| VectorArt::new(me, shape, app.renderer.clone(), app.redraw_trigger.clone()))
         .await;
     overlay_node.link(node);
 
-    let node = create_text("info");
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    let node = create_text("p2p_label");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 50.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 50.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 2000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 2000.).unwrap();
     node.set_property_f32(atom, Role::App, "font_size", NETSTAT_OVERLAY_BTN_FONTSIZE).unwrap();
-    /*
-    #[cfg(target_os = "android")]
-    {
-        let info = crate::android::get_display_debug_info();
-        i!("Display debug report:\n{info}");
-        node.set_property_str(atom, Role::App, "text", info).unwrap();
-    }
-    #[cfg(not(target_os = "android"))]
-    {
-        let info = indoc! {"
-            nothing to see here
-            folx
-            hello
-        "};
-        node.set_property_str(atom, Role::App, "text", info).unwrap();
-    }
-    */
-    node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
+    node.set_property_str(atom, Role::App, "text", "P2P").unwrap();
+    node.set_property_enum(atom, Role::App, "text_align", "left").unwrap();
     let prop = node.get_property("text_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.47).unwrap();
     prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.75).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
     let node = node
@@ -723,38 +735,142 @@ pub async fn make(
         .await;
     overlay_node.link(node);
 
-    // Stop/start p2p button
-    let node = create_button("copy_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let node = create_text("toggle_label");
     let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    let code = cc.compile("w - 120").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, 45.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 100.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 2000.).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", NETSTAT_OVERLAY_BTN_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "on").unwrap();
+    node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.08).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.68).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.72).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| {
+            Text::new(
+                me,
+                window_scale.clone(),
+                app.renderer.clone(),
+                i18n_fish.clone(),
+                app.redraw_trigger.clone(),
+            )
+        })
+        .await;
+    let toggle_text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
+    overlay_node.link(node);
 
-    let sg_root = app.sg_root.clone();
-    let redraw = app.redraw_trigger.clone();
-    let (slot, recvr) = Slot::new("stop_btn_clicked");
+    // Create the p2p toggle button
+    let node = create_button("p2p_toggle_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("w - 120").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, 20.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 100.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 80.).unwrap();
+    let (slot, recvr) = Slot::new("toggle_p2p");
     node.register("click", slot).unwrap();
-    let stop_task = app.ex.spawn(async move {
-        let mut p2p_running = true;
+    let redraw = app.redraw_trigger.clone();
+    let sg_root = app.sg_root.clone();
+    let listen_click = ex.spawn(async move {
         while let Ok(_) = recvr.recv().await {
-            /*
-            #[cfg(target_os = "android")]
-            {
-                let info = crate::android::get_display_debug_info();
-                clipboard::set(&info);
-                i!("Copied report!");
+            let is_enabled = toggle_text.get() == "on";
+            i!("toggle_p2p from {is_enabled} to {}", !is_enabled);
+            let atom = &mut redraw.make_guard(gfxtag!("toggle_p2p"));
+            if is_enabled {
+                toggle_text.set(atom, "off");
+            } else {
+                toggle_text.set(atom, "on");
+            }
+            let Some(darkirc) = sg_root.lookup_node("/plugin/darkirc") else {
+                e!("DarkIrc plugin has not been loaded");
+                continue
+            };
+            if is_enabled {
+                darkirc.call_method("stop", vec![]).await.unwrap();
+            } else {
+                darkirc.call_method("start", vec![]).await.unwrap();
             }
-            */
         }
     });
-    app.tasks.lock().unwrap().push(stop_task);
-
+    overlay_node.push_task(listen_click);
     let node =
         node.setup(|me| Button::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;
     overlay_node.link(node);
 
+    let node = create_text("outbound_label");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 50.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 170.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 2000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 2000.).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", NETSTAT_OVERLAY_BTN_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "OUTBOUND").unwrap();
+    node.set_property_enum(atom, Role::App, "text_align", "left").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.47).unwrap();
+    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.75).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| {
+            Text::new(
+                me,
+                window_scale.clone(),
+                app.renderer.clone(),
+                i18n_fish.clone(),
+                app.redraw_trigger.clone(),
+            )
+        })
+        .await;
+    overlay_node.link(node);
+
+    let node = create_text("conn_info");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 50.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 230.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 2000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 2000.).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", NETSTAT_OVERLAY_BTN_FONTSIZE).unwrap();
+    #[cfg(not(feature = "enable-plugin-darkirc"))]
+    node.set_property_str(
+        atom,
+        Role::App,
+        "text",
+        indoc! {"
+            0  tcp+tls://dasman.xyz:9600
+            1  tcp+tls://dasman.xyz:9600
+            2  tcp+tls://dasman.xyz:9600
+        "},
+    )
+    .unwrap();
+    node.set_property_enum(atom, Role::App, "text_align", "left").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| {
+            Text::new(
+                me,
+                window_scale.clone(),
+                app.renderer.clone(),
+                i18n_fish.clone(),
+                app.redraw_trigger.clone(),
+            )
+        })
+        .await;
+    overlay_node.link(node);
+
     menu::make(app, chat_layer.clone(), i18n_fish, app_db.clone(), &kv_db, emoji_meshes.clone())
         .await;
 

+ 20 - 10
bin/app/src/db.rs

@@ -28,6 +28,8 @@ use crate::{
 
 pub type AppDbPtr = Arc<AppDb>;
 
+const APP_VERSION_KEY: &str = "app_version";
+
 /// Single turso SQL database owning all app persistent state: channels,
 /// contacts, darkirc identity, settings, and app flags. The kvdb-overlay
 /// database remains exclusively for the event graph and chat history trees.
@@ -220,18 +222,23 @@ impl AppDb {
         Ok(())
     }
 
-    pub async fn flag_contains(&self, name: &str) -> Result<bool> {
+    /// Semver version of the app build that last ran, or `None` on a
+    /// fresh database.
+    pub async fn app_version_get(&self) -> Result<Option<String>> {
         let conn = self.conn.lock().await;
-        let mut stmt = conn.prepare("SELECT 1 FROM flags WHERE name = ?1").await?;
-        let mut rows = stmt.query(vec![Value::Text(name.to_string())]).await?;
-        Ok(rows.next().await?.is_some())
+        let mut stmt = conn.prepare("SELECT value FROM flags WHERE name = ?1").await?;
+        let mut rows = stmt.query(vec![Value::Text(APP_VERSION_KEY.to_string())]).await?;
+        match rows.next().await? {
+            Some(row) => Ok(Some(row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string())),
+            None => Ok(None),
+        }
     }
 
-    pub async fn flag_set(&self, name: &str) -> Result<()> {
+    pub async fn app_version_set(&self, version: &str) -> Result<()> {
         let conn = self.conn.lock().await;
         conn.execute(
             "INSERT OR REPLACE INTO flags (name, value) VALUES (?1, ?2)",
-            vec![Value::Text(name.to_string()), Value::Blob(vec![])],
+            vec![Value::Text(APP_VERSION_KEY.to_string()), Value::Text(version.to_string())],
         )
         .await?;
         Ok(())
@@ -261,7 +268,7 @@ mod tests {
     }
 
     /// Covers the app-storage spec: fresh start creates schema + seeds,
-    /// channels/contacts/settings/flags roundtrip, identity (nick + DM
+    /// channels/contacts/settings/version roundtrip, identity (nick + DM
     /// secret) survives a reopen.
     #[test]
     fn app_db_persistence() {
@@ -290,8 +297,8 @@ mod tests {
             smol::block_on(db.setting_put("net.localnet", 0, "bool", &[1])).unwrap();
             smol::block_on(db.setting_put("net.localnet", 2, "bool", &[0])).unwrap();
 
-            assert!(!smol::block_on(db.flag_contains("is_first_time")).unwrap());
-            smol::block_on(db.flag_set("is_first_time")).unwrap();
+            assert_eq!(smol::block_on(db.app_version_get()).unwrap(), None);
+            smol::block_on(db.app_version_set(env!("CARGO_PKG_VERSION"))).unwrap();
 
             let secret = smol::block_on(db.dm_secret()).unwrap();
             SecretKey::from_bytes(secret).public_key().to_bytes()
@@ -325,7 +332,10 @@ mod tests {
             assert_eq!(smol::block_on(db.setting_get("net.localnet", 2)).unwrap(), None);
             assert_eq!(smol::block_on(db.settings_all()).unwrap().len(), 1);
 
-            assert!(smol::block_on(db.flag_contains("is_first_time")).unwrap());
+            assert_eq!(
+                smol::block_on(db.app_version_get()).unwrap(),
+                Some(env!("CARGO_PKG_VERSION").to_string())
+            );
 
             // DM identity must be stable across reopen
             let secret = smol::block_on(db.dm_secret()).unwrap();

+ 2 - 1
bin/app/src/main.rs

@@ -796,7 +796,8 @@ pub fn create_darkirc(name: &str) -> SceneNode {
     )
     .unwrap();
 
-    node.add_method("reconnect", vec![], None).unwrap();
+    node.add_method("start", vec![], None).unwrap();
+    node.add_method("stop", vec![], None).unwrap();
     node.add_method("rescan", vec![("channel", "Channel", CallArgType::Str)], None).unwrap();
 
     node

+ 68 - 18
bin/app/src/plugin/darkirc.rs

@@ -521,6 +521,7 @@ impl DarkIrc {
 
     /// Update the `outbound_peers` property with the outgoing connection slots addrs.
     /// Allows us to monitor the network state of our p2p node.
+    /// Intermediate states append ` (status)` to the addr.
     async fn relay_outbound_slots(dnet_sub: Subscription<DnetEvent>, prop: PropertyPtr) {
         loop {
             let event = dnet_sub.receive().await;
@@ -528,7 +529,9 @@ impl DarkIrc {
                 DnetEvent::OutboundSlotConnected(info) => {
                     (info.slot, "connected", Some(info.addr.to_string()))
                 }
-                DnetEvent::OutboundSlotConnecting(info) => (info.slot, "connecting", None),
+                DnetEvent::OutboundSlotConnecting(info) => {
+                    (info.slot, "connecting", Some(info.addr.to_string()))
+                }
                 DnetEvent::OutboundSlotDisconnected(info) => (info.slot, "disconnected", None),
                 DnetEvent::OutboundSlotSleeping(info) => (info.slot, "sleeping", None),
                 _ => continue,
@@ -538,7 +541,13 @@ impl DarkIrc {
             let idx = slot as usize;
             assert!(idx < prop.get_len());
             match addr {
-                Some(addr) => prop.set_str(&mut atom, Role::Internal, idx, addr).unwrap(),
+                Some(addr) if kind == "connected" => {
+                    prop.set_str(&mut atom, Role::Internal, idx, addr).unwrap();
+                }
+                Some(addr) => {
+                    let val = format!("{addr} ({kind})");
+                    prop.set_str(&mut atom, Role::Internal, idx, val).unwrap();
+                }
                 None => prop.set_null(&mut atom, Role::Internal, idx).unwrap(),
             }
         }
@@ -847,28 +856,27 @@ impl DarkIrc {
         true
     }
 
-    async fn process_reconnect(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+    async fn process_start(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
         let Ok(method_call) = sub.receive().await else {
-            d!("Reconnect method closed");
+            d!("Start method closed");
             return false
         };
 
-        t!("method called: reconnect({method_call:?})");
+        t!("method called: start({method_call:?})");
 
         let Some(self_) = me.upgrade() else {
-            e!("DarkIrc destroyed before reconnect completed");
+            e!("DarkIrc destroyed before start completed");
             return false
         };
 
-        self_.handle_reconnect().await;
+        self_.handle_start().await;
 
         true
     }
 
-    /// User requested to reconnect
-    async fn handle_reconnect(&self) {
-        i!("Manual P2P reconnection triggered");
-        self.p2p.clone().stop().await;
+    /// User requested to start the P2P network
+    async fn handle_start(&self) {
+        i!("Manual P2P start triggered");
 
         while let Err(err) = self.p2p.clone().start().await {
             e!("Failed to start P2P network: {err}!");
@@ -876,7 +884,45 @@ impl DarkIrc {
             sleep(P2P_RETRY_TIME).await;
         }
 
-        i!("P2P reconnection completed");
+        let peers_count = self.p2p.peers_count();
+        self.notify_connect(peers_count, self.event_graph.is_synced()).await;
+
+        i!("P2P start completed");
+    }
+
+    async fn process_stop(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("Stop method closed");
+            return false
+        };
+
+        t!("method called: stop({method_call:?})");
+
+        let Some(self_) = me.upgrade() else {
+            e!("DarkIrc destroyed before stop completed");
+            return false
+        };
+
+        self_.handle_stop().await;
+
+        true
+    }
+
+    /// User requested to stop the P2P network
+    async fn handle_stop(&self) {
+        i!("Manual P2P stop triggered");
+        self.p2p.clone().stop().await;
+
+        // Stopped outbound slots emit no dnet events, so clear the property here
+        let node = self.node.upgrade().unwrap();
+        let prop = node.get_property("outbound_peers").unwrap();
+        let mut atom = PropertyAtomicGuard::none();
+        for idx in 0..prop.get_len() {
+            prop.set_null(&mut atom, Role::Internal, idx).unwrap();
+        }
+
+        self.notify_connect(0, self.event_graph.is_synced()).await;
+        i!("P2P stop completed");
     }
 
     async fn set_outbound_connections(&self, count: usize) {
@@ -910,12 +956,15 @@ impl DarkIrc {
         let send_method_task =
             ex.spawn(async move { while Self::process_send(&me2, &method_sub).await {} });
 
-        let reconnect_method_sub = node.subscribe_method_call("reconnect").unwrap();
+        let start_method_sub = node.subscribe_method_call("start").unwrap();
         let me2 = me.clone();
-        let reconnect_method_task =
-            ex.spawn(
-                async move { while Self::process_reconnect(&me2, &reconnect_method_sub).await {} },
-            );
+        let start_method_task =
+            ex.spawn(async move { while Self::process_start(&me2, &start_method_sub).await {} });
+
+        let stop_method_sub = node.subscribe_method_call("stop").unwrap();
+        let me2 = me.clone();
+        let stop_method_task =
+            ex.spawn(async move { while Self::process_stop(&me2, &stop_method_sub).await {} });
 
         let rescan_method_sub = node.subscribe_method_call("rescan").unwrap();
         let me2 = me.clone();
@@ -984,7 +1033,8 @@ impl DarkIrc {
 
         let mut tasks = vec![
             send_method_task,
-            reconnect_method_task,
+            start_method_task,
+            stop_method_task,
             rescan_method_task,
             ev_task,
             dag_task,