Sfoglia il codice sorgente

app/netdbg: get/set property expr over netdebug backend

darkfi 3 settimane fa
parent
commit
7c169b48dc

+ 1 - 2
bin/app/gui/api.py

@@ -1,9 +1,8 @@
 from collections import namedtuple
-from pydrk import Api, HostApi, PropertyType, PropertySubType, Property, serial
+from pydrk import Api, PropertyType, PropertySubType, Property, serial
 import zmq
 
 api = Api()
-host = HostApi(api)
 print("Node status:", api.hello())
 
 def make_sub_socket():

+ 19 - 18
bin/app/gui/print_tree.py

@@ -1,4 +1,4 @@
-from pydrk import SceneNodeType, PropertyType
+from pydrk import SceneNodeType, PropertyType, CallArgType, Expr
 from .api import api
 
 def join(parent_path, child_name):
@@ -45,8 +45,6 @@ def print_node_info(parent_path, depth, indent):
                 child_type = "chat_view"
             case SceneNodeType.BUTTON:
                 child_type = "button"
-            case SceneNodeType.SETTING_ROOT:
-                child_type = "setting_root"
             case SceneNodeType.SETTING:
                 child_type = "setting"
 
@@ -63,20 +61,23 @@ def print_node_info(parent_path, depth, indent):
         print_node_info(child_path, depth, indent+1)
 
     for prop in api.get_properties(parent_path):
-        if prop.type != PropertyType.BUFFER:
-            prop_val = api.get_property_value(parent_path, prop.name)
+        prop_val = api.get_property_value(parent_path, prop.name)
 
-            if prop.type == PropertyType.STR:
-                prop_val = "[" + ", ".join(f"\"{pv}\"" for pv in prop_val) + "]"
-            elif prop.type == PropertyType.FLOAT32:
-                prop_val = "[" + ", ".join(f"{pv:.2f}" for pv in prop_val) + "]"
+        def fmt_str(pv):
+            return pv if isinstance(pv, Expr) else f"\"{pv}\""
 
-            if len(prop_val) == 1:
-                prop_val = prop_val[0]
+        def fmt_f32(pv):
+            return pv if isinstance(pv, Expr) else f"{pv:.2f}"
 
-            prop_val = f" = {prop_val}"
-        else:
-            prop_val = ""
+        if prop.type == PropertyType.STR:
+            prop_val = "[" + ", ".join(fmt_str(pv) for pv in prop_val) + "]"
+        elif prop.type == PropertyType.FLOAT32:
+            prop_val = "[" + ", ".join(fmt_f32(pv) for pv in prop_val) + "]"
+
+        if len(prop_val) == 1:
+            prop_val = prop_val[0]
+
+        prop_val = f" = {prop_val}"
 
         prop_type = PropertyType.to_str(prop.type)
 
@@ -86,14 +87,14 @@ def print_node_info(parent_path, depth, indent):
 
     for sig in api.get_signals(parent_path):
         print(f"{ws}~{sig}")
-        for slot_id, slot in api.get_slots(parent_path, sig):
-            print(f"{ws}- '{slot}' ({slot_id})")
+        for slot_name, slot_id in api.get_slots(parent_path, sig):
+            print(f"{ws}- '{slot_name}' ({slot_id})")
 
     for method_name in api.get_methods(parent_path):
         args, results = api.get_method(parent_path, method_name)
 
-        args = [f"{name}: " + PropertyType.to_str(typ) for (name, _, typ) in args]
-        results = [f"{name}: " + PropertyType.to_str(typ) for (name, _, typ) in results]
+        args = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in args]
+        results = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in results]
 
         method_str = f"{method_name}(" + ", ".join(args) + ") -> (" + ", ".join(results) + ")"
         print(f"{ws}{method_str}")

+ 2 - 4
bin/app/pydrk/__init__.py

@@ -1,6 +1,4 @@
 from .api import (Api, ErrorCode, SceneNodeType,
-                  PropertyType, PropertySubType, Property,
-                  vertex, face)
-from .host import HostApi
+                  PropertyType, PropertySubType, CallArgType, Property,
+                  Expr, vertex, face)
 from . import exc, serial
-

+ 152 - 75
bin/app/pydrk/api.py

@@ -1,6 +1,12 @@
 import zmq
 from collections import namedtuple
-from . import serial, exc, expr
+from . import serial, exc
+
+class Expr(str):
+    """String rendering of an expr-bound property value, sent over netdebug.
+    Subclasses str so it formats naturally, but stays isinstance-distinct
+    from plain str property values."""
+    pass
 
 Property = namedtuple("Property", [
     "name",
@@ -64,9 +70,11 @@ class SceneNodeType:
     SHORTCUT = 17
     GESTURE = 18
     EMOJI_PICKER = 19
-    SETTING_ROOT = 20
     SETTING = 21
-    PLUGINS = 100
+    MENU = 22
+    TOKEN_TABLE = 23
+    TEXT_SCRAMBLE = 24
+    PLUGIN_ROOT = 100
     PLUGIN = 101
 
 class PropertyType:
@@ -76,7 +84,6 @@ class PropertyType:
     FLOAT32 = 3
     STR = 4
     ENUM = 5
-    BUFFER = 6
     SCENE_NODE_ID = 7
     SEXPR = 8
 
@@ -95,8 +102,6 @@ class PropertyType:
                 return "str"
             case PropertyType.ENUM:
                 return "enum"
-            case PropertyType.BUFFER:
-                return "buffer"
             case PropertyType.SCENE_NODE_ID:
                 return "scene_node_id"
             case PropertyType.SEXPR:
@@ -107,18 +112,50 @@ class PropertySubType:
     COLOR = 1
     PIXEL = 2
     RESOURCE_ID = 3
+    LOCALE = 4
+    FLAG = 5
 
     @staticmethod
     def to_str(prop_type):
         match prop_type:
             case PropertySubType.NULL:
                 return "null"
-            case PropertySubType.Color:
+            case PropertySubType.COLOR:
                 return "color"
             case PropertySubType.PIXEL:
                 return "pixel"
             case PropertySubType.RESOURCE_ID:
                 return "resource_id"
+            case PropertySubType.LOCALE:
+                return "locale"
+            case PropertySubType.FLAG:
+                return "flag"
+
+class CallArgType:
+    UINT32 = 0
+    UINT64 = 1
+    FLOAT32 = 2
+    BOOL = 3
+    STR = 4
+    HASH = 5
+
+    @staticmethod
+    def to_str(arg_type):
+        match arg_type:
+            case CallArgType.UINT32:
+                return "uint32"
+            case CallArgType.UINT64:
+                return "uint64"
+            case CallArgType.FLOAT32:
+                return "float32"
+            case CallArgType.BOOL:
+                return "bool"
+            case CallArgType.STR:
+                return "str"
+            case CallArgType.HASH:
+                return "hash"
+            case _:
+                return "unknown"
 
 class PropertyStatus:
     OK = 0
@@ -134,7 +171,6 @@ class ErrorCode:
     PROPERTY_ALREADY_EXISTS = 5
     PROPERTY_NOT_FOUND = 6
     PROPERTY_WRONG_TYPE = 7
-    PROPERTY_WRONG_SUB_TYPE = 8
     PROPERTY_WRONG_LEN = 9
     PROPERTY_WRONG_INDEX = 10
     PROPERTY_OUT_OF_RANGE = 11
@@ -154,12 +190,22 @@ class ErrorCode:
     NODE_PARENT_NAME_CONFLICT = 25
     NODE_CHILD_NAME_CONFLICT = 26
     NODE_SIBLING_NAME_CONFLICT = 27
-    FILE_NOT_FOUND = 28
-    RESOURCE_NOT_FOUND = 29
-    PY_EVAL_ERR = 30
-    SEXPR_EMPTY = 31
     SEXPR_GLOBAL_NOT_FOUND = 32
+    PUBLISHER_DESTROYED = 34
     CHANNEL_CLOSED = 36
+    NODES_ARE_SAME = 37
+    UNEXPECTED_TOKEN = 38
+    KVDB_ERR = 39
+    SERVICE_FAILED = 40
+    GFX_DUPLICATE_TEXTURE_ID = 41
+    GFX_UNKNOWN_TEXTURE_ID = 42
+    GFX_DUPLICATE_BUFFER_ID = 43
+    GFX_UNKNOWN_BUFFER_ID = 44
+    GFX_DUPLICATE_ANIM_ID = 45
+    GFX_UNKNOWN_ANIM_ID = 46
+    CONTACT_NOT_FOUND = 47
+    SERIAL_ERR = 48
+    TURSO_ERR = 49
 
     @staticmethod
     def to_str(errc):
@@ -216,18 +262,40 @@ class ErrorCode:
                 return "node_child_name_conflict"
             case ErrorCode.NODE_SIBLING_NAME_CONFLICT:
                 return "node_sibling_name_conflict"
-            case ErrorCode.FILE_NOT_FOUND:
-                return "file_not_found"
-            case ErrorCode.RESOURCE_NOT_FOUND:
-                return "resource_not_found"
-            case ErrorCode.PY_EVAL_ERR:
-                return "py_eval_err"
-            case ErrorCode.SEXPR_EMPTY:
-                return "sexpr_empty"
             case ErrorCode.SEXPR_GLOBAL_NOT_FOUND:
                 return "sexpr_global_not_found"
+            case ErrorCode.PUBLISHER_DESTROYED:
+                return "publisher_destroyed"
             case ErrorCode.CHANNEL_CLOSED:
                 return "channel_closed"
+            case ErrorCode.NODES_ARE_SAME:
+                return "nodes_are_same"
+            case ErrorCode.UNEXPECTED_TOKEN:
+                return "unexpected_token"
+            case ErrorCode.KVDB_ERR:
+                return "kvdb_err"
+            case ErrorCode.SERVICE_FAILED:
+                return "service_failed"
+            case ErrorCode.GFX_DUPLICATE_TEXTURE_ID:
+                return "gfx_duplicate_texture_id"
+            case ErrorCode.GFX_UNKNOWN_TEXTURE_ID:
+                return "gfx_unknown_texture_id"
+            case ErrorCode.GFX_DUPLICATE_BUFFER_ID:
+                return "gfx_duplicate_buffer_id"
+            case ErrorCode.GFX_UNKNOWN_BUFFER_ID:
+                return "gfx_unknown_buffer_id"
+            case ErrorCode.GFX_DUPLICATE_ANIM_ID:
+                return "gfx_duplicate_anim_id"
+            case ErrorCode.GFX_UNKNOWN_ANIM_ID:
+                return "gfx_unknown_anim_id"
+            case ErrorCode.CONTACT_NOT_FOUND:
+                return "contact_not_found"
+            case ErrorCode.SERIAL_ERR:
+                return "serial_err"
+            case ErrorCode.TURSO_ERR:
+                return "turso_err"
+            case _:
+                return "unknown"
 
 def vertex(x, y, r, g, b, a, u, v):
     buf = bytearray()
@@ -265,72 +333,94 @@ class Api:
         errc = int.from_bytes(errc, "little")
         cursor = serial.Cursor(reply)
         match errc:
-            case 1:
+            case 0:
+                pass
+            case ErrorCode.INVALID_SCENE_PATH:
                 raise exc.InvalidScenePath
-            case 2:
+            case ErrorCode.NODE_NOT_FOUND:
                 raise exc.NodeNotFound
-            case 3:
+            case ErrorCode.CHILD_NODE_NOT_FOUND:
                 raise exc.ChildNodeNotFound
-            case 4:
+            case ErrorCode.PARENT_NODE_NOT_FOUND:
                 raise exc.ParentNodeNotFound
-            case 5:
+            case ErrorCode.PROPERTY_ALREADY_EXISTS:
                 raise exc.PropertyAlreadyExists
-            case 6:
+            case ErrorCode.PROPERTY_NOT_FOUND:
                 raise exc.PropertyNotFound
-            case 7:
+            case ErrorCode.PROPERTY_WRONG_TYPE:
                 raise exc.PropertyWrongType
-            case 8:
-                raise exc.PropertyWrongSubType
-            case 9:
+            case ErrorCode.PROPERTY_WRONG_LEN:
                 raise exc.PropertyWrongLen
-            case 10:
+            case ErrorCode.PROPERTY_WRONG_INDEX:
                 raise exc.PropertyWrongIndex
-            case 11:
+            case ErrorCode.PROPERTY_OUT_OF_RANGE:
                 raise exc.PropertyOutOfRange
-            case 12:
+            case ErrorCode.PROPERTY_NULL_NOT_ALLOWED:
                 raise exc.PropertyNullNotAllowed
-            case 12:
+            case ErrorCode.PROPERTY_SEXPR_NOT_ALLOWED:
                 raise exc.PropertySExprNotAllowed
-            case 14:
+            case ErrorCode.PROPERTY_IS_BOUNDED:
                 raise exc.PropertyIsBounded
-            case 15:
+            case ErrorCode.PROPERTY_WRONG_ENUM_ITEM:
                 raise exc.PropertyWrongEnumItem
-            case 16:
+            case ErrorCode.SIGNAL_ALREADY_EXISTS:
                 raise exc.SignalAlreadyExists
-            case 17:
+            case ErrorCode.SIGNAL_NOT_FOUND:
                 raise exc.SignalNotFound
-            case 18:
+            case ErrorCode.SLOT_NOT_FOUND:
                 raise exc.SlotNotFound
-            case 19:
+            case ErrorCode.METHOD_ALREADY_EXISTS:
                 raise exc.MethodAlreadyExists
-            case 20:
+            case ErrorCode.METHOD_NOT_FOUND:
                 raise exc.MethodNotFound
-            case 21:
+            case ErrorCode.NODES_ARE_LINKED:
                 raise exc.NodesAreLinked
-            case 22:
+            case ErrorCode.NODES_NOT_LINKED:
                 raise exc.NodesNotLinked
-            case 23:
+            case ErrorCode.NODE_HAS_PARENTS:
                 raise exc.NodeHasParents
-            case 24:
+            case ErrorCode.NODE_HAS_CHILDREN:
                 raise exc.NodeHasChildren
-            case 25:
+            case ErrorCode.NODE_PARENT_NAME_CONFLICT:
                 raise exc.NodeParentNameConflict
-            case 26:
+            case ErrorCode.NODE_CHILD_NAME_CONFLICT:
                 raise exc.NodeChildNameConflict
-            case 27:
+            case ErrorCode.NODE_SIBLING_NAME_CONFLICT:
                 raise exc.NodeSiblingNameConflict
-            case 28:
-                raise exc.FileNotFound
-            case 29:
-                raise exc.ResourceNotFound
-            case 30:
-                raise exc.PyEvalErr
-            case 31:
-                raise exc.SExprEmpty
-            case 32:
+            case ErrorCode.SEXPR_GLOBAL_NOT_FOUND:
                 raise exc.SExprGlobalNotFound
-            case 36:
+            case ErrorCode.PUBLISHER_DESTROYED:
+                raise exc.PublisherDestroyed
+            case ErrorCode.CHANNEL_CLOSED:
                 raise exc.ChannelClosed
+            case ErrorCode.NODES_ARE_SAME:
+                raise exc.NodesAreSame
+            case ErrorCode.UNEXPECTED_TOKEN:
+                raise exc.UnexpectedToken
+            case ErrorCode.KVDB_ERR:
+                raise exc.KvdbErr
+            case ErrorCode.SERVICE_FAILED:
+                raise exc.ServiceFailed
+            case ErrorCode.GFX_DUPLICATE_TEXTURE_ID:
+                raise exc.GfxDuplicateTextureID
+            case ErrorCode.GFX_UNKNOWN_TEXTURE_ID:
+                raise exc.GfxUnknownTextureID
+            case ErrorCode.GFX_DUPLICATE_BUFFER_ID:
+                raise exc.GfxDuplicateBufferID
+            case ErrorCode.GFX_UNKNOWN_BUFFER_ID:
+                raise exc.GfxUnknownBufferID
+            case ErrorCode.GFX_DUPLICATE_ANIM_ID:
+                raise exc.GfxDuplicateAnimID
+            case ErrorCode.GFX_UNKNOWN_ANIM_ID:
+                raise exc.GfxUnknownAnimID
+            case ErrorCode.CONTACT_NOT_FOUND:
+                raise exc.ContactNotFound
+            case ErrorCode.SERIAL_ERR:
+                raise exc.SerialErr
+            case ErrorCode.TURSO_ERR:
+                raise exc.TursoErr
+            case _:
+                raise exc.UnknownError(f"unknown error code: {errc}")
         return cursor
 
     def hello(self):
@@ -436,8 +526,6 @@ class Api:
                 return serial.decode_str(cur)
             case PropertyType.ENUM:
                 return serial.decode_str(cur)
-            case PropertyType.BUFFER:
-                pass
             case PropertyType.SCENE_NODE_ID:
                 return serial.read_u32(cur)
             case _:
@@ -456,7 +544,7 @@ class Api:
                 case PropertyStatus.NULL:
                     return None
                 case PropertyStatus.EXPR:
-                    return None
+                    return Expr(serial.decode_str(cur))
                 case PropertyStatus.UNSET | PropertyStatus.OK:
                     return Api.read_prop_val(cur, prop_type)
 
@@ -618,24 +706,13 @@ class Api:
         serial.encode_str(req, val)
         self._make_request(Command.SET_PROPERTY_VALUE, req)
 
-    def set_property_buf(self, node_path, prop_name, i, buf):
-        req = bytearray()
-        serial.encode_str(req, node_path)
-        serial.encode_str(req, prop_name)
-        serial.write_u32(req, i)
-        serial.write_u8(req, PropertyType.BUFFER)
-        serial.encode_buf(req, buf)
-        self._make_request(Command.SET_PROPERTY_VALUE, req)
-
-    def set_property_expr(self, node_path, prop_name, i, code):
+    def set_property_expr(self, node_path, prop_name, i, expr_str):
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, prop_name)
         serial.write_u32(req, i)
         serial.write_u8(req, PropertyType.SEXPR)
-        serial.encode_varint(req, len(code))
-        for sexpr in code:
-            expr.encode_expr(req, sexpr)
+        serial.encode_str(req, expr_str)
         self._make_request(Command.SET_PROPERTY_VALUE, req)
 
     def get_signals(self, node_path):
@@ -673,7 +750,7 @@ class Api:
         serial.encode_str(req, slot_name)
         try:
             cur = self._make_request(Command.LOOKUP_SLOT_ID, req)
-        except exc.RequestSlotNotFound:
+        except exc.SlotNotFound:
             return None
         return serial.read_u32(cur)
 

+ 30 - 7
bin/app/pydrk/exc.py

@@ -12,8 +12,6 @@ class PropertyNotFound(Exception):
     pass
 class PropertyWrongType(Exception):
     pass
-class PropertyWrongSubType(Exception):
-    pass
 class PropertyWrongLen(Exception):
     pass
 class PropertyWrongIndex(Exception):
@@ -52,12 +50,37 @@ class NodeChildNameConflict(Exception):
     pass
 class NodeSiblingNameConflict(Exception):
     pass
-class FileNotFound(Exception):
+class SExprGlobalNotFound(Exception):
+    pass
+class PublisherDestroyed(Exception):
     pass
-class PyEvalErr(Exception):
+class ChannelClosed(Exception):
     pass
-class SExprEmpty(Exception):
+class NodesAreSame(Exception):
     pass
-class SExprGlobalNotFound(Exception):
+class UnexpectedToken(Exception):
+    pass
+class KvdbErr(Exception):
+    pass
+class ServiceFailed(Exception):
+    pass
+class GfxDuplicateTextureID(Exception):
+    pass
+class GfxUnknownTextureID(Exception):
+    pass
+class GfxDuplicateBufferID(Exception):
+    pass
+class GfxUnknownBufferID(Exception):
+    pass
+class GfxDuplicateAnimID(Exception):
+    pass
+class GfxUnknownAnimID(Exception):
+    pass
+class ContactNotFound(Exception):
+    pass
+class SerialErr(Exception):
+    pass
+class TursoErr(Exception):
+    pass
+class UnknownError(Exception):
     pass
-

+ 0 - 87
bin/app/pydrk/expr.py

@@ -1,87 +0,0 @@
-from . import serial
-
-class Op:
-    NULL = 0
-    ADD = 1
-    SUB = 2
-    MUL = 3
-    DIV = 4
-    CONST_BOOL = 5
-    CONST_UINT_32 = 6
-    CONST_FLOAT_32 = 7
-    CONST_STR = 8
-    LOAD_VAR = 9
-    MIN = 11
-    MAX = 12
-    IS_EQUAL = 13
-    LESS_THAN = 14
-    FLOAT32_TO_UINT32 = 15
-
-    @staticmethod
-    def from_str(op):
-        match op:
-            case "null":
-                return Op.NULL
-            case "+":
-                return Op.ADD
-            case "-":
-                return Op.SUB
-            case "*":
-                return Op.MUL
-            case "/":
-                return Op.DIV
-            case "bool":
-                return Op.CONST_BOOL
-            case "u32":
-                return Op.CONST_UINT_32
-            case "f32":
-                return Op.CONST_FLOAT_32
-            case "str":
-                return Op.CONST_STR
-            case "load":
-                return Op.LOAD_VAR
-            case "min":
-                return Op.MIN
-            case "max":
-                return Op.MAX
-            case "==":
-                return Op.IS_EQUAL
-            case "<":
-                return Op.LESS_THAN
-            case "as_u32":
-                return Op.FLOAT32_TO_UINT32
-
-def encode_expr(by, code):
-    op, args = code[0], code[1:]
-    op = Op.from_str(op)
-    serial.write_u8(by, op)
-    match op:
-        case Op.CONST_BOOL:
-            serial.write_u8(by, int(args[0]))
-        case Op.CONST_UINT_32:
-            serial.write_u32(by, args[0])
-        case Op.CONST_FLOAT_32:
-            serial.write_f32(by, args[0])
-        case Op.CONST_STR:
-            serial.encode_str(by, args[0])
-        case Op.LOAD_VAR:
-            serial.encode_str(by, args[0])
-        case _:
-            for arg in args:
-                encode_expr(by, arg)
-
-# python -m pydrk.expr
-if __name__ == "__main__":
-    code = ["+",
-        ["u32", 5],
-        ["/",
-            ["load", "sw"],
-            ["u32", 2]
-        ]
-    ]
-    code_s = bytearray()
-    encode_expr(code_s, code)
-    assert code_s == bytearray(
-        [1, 6, 5, 0, 0, 0, 4, 9, 2, 115, 119, 6, 2, 0, 0, 0]
-    )
-

+ 0 - 33
bin/app/pydrk/host.py

@@ -1,33 +0,0 @@
-from . import serial, ErrorCode
-
-class HostApi:
-
-    def __init__(self, api):
-        self.api = api
-
-    def create_text(self, font_path, node_name, text, font_size):
-        call_data = bytearray()
-        serial.encode_str(call_data, node_name)
-        serial.encode_str(call_data, text)
-        serial.write_f32(call_data, font_size)
-        font_id = self.api.lookup_node_id(font_path)
-        (errc, result) = self.api.call_method(font_id, "create_text", call_data)
-        if errc != 0:
-            print("create_text error:", ErrorCode.to_str(errc))
-            return None
-        text_id = int.from_bytes(result, "little")
-        return text_id
-
-    def load_texture(self, node_name, filepath):
-        call_data = bytearray()
-        serial.encode_str(call_data, node_name)
-        serial.encode_str(call_data, filepath)
-        win_id = self.api.lookup_node_id("/window")
-        (errc, result) = self.api.call_method(win_id, "load_texture", call_data)
-        if errc != 0:
-            print("load_texture error:", ErrorCode.to_str(errc))
-            return None
-        texture_id = int.from_bytes(result, "little")
-        return texture_id
-
-

+ 2 - 1
bin/app/src/app/schema/menu/channel.rs

@@ -1663,7 +1663,8 @@ pub async fn make(
             node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
 
             // Add to main_menu items (check if not already there)
-            let main_menu = sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
+            let main_menu =
+                sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
             let items_prop = main_menu.get_property("items").unwrap();
 
             if !items_prop.contains_str(&channel) {

+ 2 - 1
bin/app/src/app/schema/menu/contact.rs

@@ -1558,7 +1558,8 @@ pub async fn make(
             }
             node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
 
-            let main_menu = sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
+            let main_menu =
+                sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
             let items_prop = main_menu.get_property("items").unwrap();
             if !items_prop.contains_str(&contact) {
                 items_prop.push_str(atom, Role::App, &contact).unwrap();

+ 177 - 0
bin/app/src/expr/decompile.rs

@@ -0,0 +1,177 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use super::{Op, SExprCode};
+
+/// Renders an expr op tree back into a human-readable string. Used by the
+/// netdebug backend so debug clients can see what an expr does. The output
+/// is informational: it recompiles to an equivalent expr when restricted to
+/// the compiler-expressible subset, but ops without source syntax render in
+/// prefix form and u32 constants are tagged to keep their u32-ness visible.
+pub fn decompile(code: &SExprCode) -> String {
+    let stmts: Vec<String> = code.iter().map(render_op).collect();
+    stmts.join("; ")
+}
+
+fn render_op(op: &Op) -> String {
+    match op {
+        Op::Null => "null".to_string(),
+        Op::Add((lhs, rhs)) => format!("({} + {})", render_op(lhs), render_op(rhs)),
+        Op::Sub((lhs, rhs)) => format!("({} - {})", render_op(lhs), render_op(rhs)),
+        Op::Mul((lhs, rhs)) => format!("({} * {})", render_op(lhs), render_op(rhs)),
+        Op::Div((lhs, rhs)) => format!("({} / {})", render_op(lhs), render_op(rhs)),
+        Op::ConstBool(val) => format!("{val}"),
+        Op::ConstUint32(val) => format!("u32({val})"),
+        Op::ConstFloat32(val) => format!("{val}"),
+        Op::ConstStr(val) => format!("{val:?}"),
+        Op::LoadVar(var) => var.clone(),
+        Op::StoreVar((var, val)) => format!("{var} = {}", render_op(val)),
+        Op::Min((lhs, rhs)) => format!("min({}, {})", render_op(lhs), render_op(rhs)),
+        Op::Max((lhs, rhs)) => format!("max({}, {})", render_op(lhs), render_op(rhs)),
+        Op::IsEqual((lhs, rhs)) => format!("({} == {})", render_op(lhs), render_op(rhs)),
+        Op::LessThan((lhs, rhs)) => format!("({} < {})", render_op(lhs), render_op(rhs)),
+        Op::Float32ToUint32(val) => format!("as_u32({})", render_op(val)),
+        Op::IfElse((cond, if_val, else_val)) => {
+            format!(
+                "if {} {{ {} }} else {{ {} }}",
+                render_op(cond),
+                decompile(if_val),
+                decompile(else_val)
+            )
+        }
+        Op::NativeFn(_) => "<native_fn>".to_string(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        super::{Compiler, NativeFnCallback, SExprVal},
+        *,
+    };
+
+    #[test]
+    fn render_null() {
+        assert_eq!(decompile(&vec![Op::Null]), "null");
+    }
+
+    #[test]
+    fn render_arith() {
+        let code = vec![Op::Add((
+            Box::new(Op::ConstUint32(5)),
+            Box::new(Op::Div((
+                Box::new(Op::LoadVar("sw".to_string())),
+                Box::new(Op::ConstUint32(2)),
+            ))),
+        ))];
+        assert_eq!(decompile(&code), "(u32(5) + (sw / u32(2)))");
+    }
+
+    #[test]
+    fn render_consts() {
+        assert_eq!(decompile(&vec![Op::ConstBool(true)]), "true");
+        assert_eq!(decompile(&vec![Op::ConstBool(false)]), "false");
+        assert_eq!(decompile(&vec![Op::ConstUint32(110)]), "u32(110)");
+        assert_eq!(decompile(&vec![Op::ConstFloat32(2.5)]), "2.5");
+        assert_eq!(decompile(&vec![Op::ConstFloat32(200.)]), "200");
+        assert_eq!(decompile(&vec![Op::ConstStr("hello".to_string())]), "\"hello\"");
+    }
+
+    #[test]
+    fn render_vars() {
+        assert_eq!(decompile(&vec![Op::LoadVar("w".to_string())]), "w");
+        let code = vec![Op::StoreVar((
+            "r".to_string(),
+            Box::new(Op::Div((Box::new(Op::ConstFloat32(10.)), Box::new(Op::ConstFloat32(4.))))),
+        ))];
+        assert_eq!(decompile(&code), "r = (10 / 4)");
+    }
+
+    #[test]
+    fn render_ops_without_source_syntax() {
+        let lhs = Box::new(Op::LoadVar("a".to_string()));
+        let rhs = Box::new(Op::LoadVar("b".to_string()));
+        assert_eq!(decompile(&vec![Op::Min((lhs.clone(), rhs.clone()))]), "min(a, b)");
+        assert_eq!(decompile(&vec![Op::Max((lhs.clone(), rhs.clone()))]), "max(a, b)");
+        assert_eq!(decompile(&vec![Op::IsEqual((lhs.clone(), rhs.clone()))]), "(a == b)");
+        assert_eq!(decompile(&vec![Op::LessThan((lhs, rhs))]), "(a < b)");
+        assert_eq!(
+            decompile(&vec![Op::Float32ToUint32(Box::new(Op::ConstFloat32(1.6)))]),
+            "as_u32(1.6)"
+        );
+    }
+
+    #[test]
+    fn render_if_else() {
+        let code = vec![Op::IfElse((
+            Box::new(Op::LessThan((
+                Box::new(Op::LoadVar("h".to_string())),
+                Box::new(Op::ConstFloat32(4.)),
+            ))),
+            vec![Op::Sub((Box::new(Op::LoadVar("h".to_string())), Box::new(Op::ConstFloat32(1.))))],
+            vec![Op::Add((
+                Box::new(Op::Mul((
+                    Box::new(Op::ConstFloat32(2.)),
+                    Box::new(Op::LoadVar("h".to_string())),
+                ))),
+                Box::new(Op::ConstFloat32(5.)),
+            ))],
+        ))];
+        assert_eq!(decompile(&code), "if (h < 4) { (h - 1) } else { ((2 * h) + 5) }");
+    }
+
+    #[test]
+    fn render_native_fn() {
+        let code = vec![Op::NativeFn(NativeFnCallback(|_| Ok(SExprVal::Null)))];
+        assert_eq!(decompile(&code), "<native_fn>");
+    }
+
+    #[test]
+    fn render_stmts() {
+        let code = vec![
+            Op::StoreVar(("r".to_string(), Box::new(Op::ConstFloat32(1.)))),
+            Op::Add((Box::new(Op::LoadVar("r".to_string())), Box::new(Op::ConstFloat32(1.)))),
+        ];
+        assert_eq!(decompile(&code), "r = 1; (r + 1)");
+    }
+
+    #[test]
+    fn decompile_compiled() {
+        let cc = Compiler::new();
+        let code = cc.compile("h/2 - 200").unwrap();
+        assert_eq!(decompile(&code), "((h / 2) - 200)");
+    }
+
+    #[test]
+    fn roundtrip() {
+        let cc = Compiler::new();
+        let fixtures = [
+            "h/2 - 200",
+            "(x + h/2 + (y + 7)/5) - 200",
+            "h - 1",
+            "r = 10 / 4",
+            "r = 10 / 4;\n\ns = if h < 4 {\n h - 1\n} else {\n 2 * h + 5\n};\n\nr + 1",
+        ];
+        for src in fixtures {
+            let once = cc.compile(src).unwrap();
+            let src2 = decompile(&once);
+            let twice = cc.compile(&src2).unwrap();
+            assert_eq!(once, twice, "fixture: {src} rendered as: {src2}");
+        }
+    }
+}

+ 4 - 0
bin/app/src/expr/mod.rs

@@ -27,6 +27,10 @@ use std::io::{Read, Write};
 
 mod compile;
 pub use compile::Compiler;
+#[cfg(feature = "enable-netdebug")]
+mod decompile;
+#[cfg(feature = "enable-netdebug")]
+pub use decompile::decompile;
 
 pub type MachineGlobals = Vec<(String, SExprVal)>;
 #[derive(Debug, Clone)]

+ 17 - 8
bin/app/src/net.rs

@@ -23,7 +23,7 @@ use zeromq::{Socket, SocketRecv, SocketSend};
 
 use crate::{
     error::{Error, Result},
-    expr::SExprCode,
+    expr::{decompile, Compiler},
     gfx::gfxtag,
     prop::{PropertyType, Role},
     scene::{SceneNodeId, SceneNodePtr, ScenePath, Slot},
@@ -228,15 +228,20 @@ impl ZeroMQAdapter {
                 prop.typ.encode(&mut reply).unwrap();
                 VarInt(prop.get_len() as u64).encode(&mut reply).unwrap();
                 for i in 0..prop.get_len() {
-                    let val = prop.get_value(i)?;
-                    if val.is_unset() {
+                    // Check the raw stored value, since get_value() resolves
+                    // exprs to their cached/default value and would never
+                    // report the EXPR status.
+                    let val = prop.get_raw_value(i)?;
+                    if val.is_expr() {
+                        3u8.encode(&mut reply).unwrap();
+                        let expr = prop.get_expr(i)?;
+                        decompile(&expr).encode(&mut reply).unwrap();
+                    } else if val.is_unset() {
                         1u8.encode(&mut reply).unwrap();
                         let default = &prop.defaults[i];
                         default.encode(&mut reply).unwrap();
                     } else if val.is_null() {
                         2u8.encode(&mut reply).unwrap();
-                    } else if val.is_expr() {
-                        3u8.encode(&mut reply).unwrap();
                     } else {
                         0u8.encode(&mut reply).unwrap();
                         val.encode(&mut reply).unwrap();
@@ -284,9 +289,13 @@ impl ZeroMQAdapter {
                         prop.set_node_id(atom, Role::User, prop_i, val)?;
                     }
                     PropertyType::SExpr => {
-                        let val = SExprCode::decode(&mut cur).unwrap();
-                        debug!(target: "req", "  received code {:?}", val);
-                        prop.set_expr(atom, Role::User, prop_i, val)?;
+                        // Exprs are sent as source strings and compiled here.
+                        // The netdebug compiler is const-free: only machine
+                        // globals (w, h, ...) are available as variables.
+                        let expr_str = String::decode(&mut cur).unwrap();
+                        debug!(target: "req", "  compiling expr \"{expr_str}\"");
+                        let code = Compiler::new().compile(&expr_str)?;
+                        prop.set_expr(atom, Role::User, prop_i, code)?;
                     }
                 }
             }

+ 10 - 5
bin/app/src/pubsub.rs

@@ -93,8 +93,11 @@ impl<T: Piped> Publisher<T> {
                 continue
             }
 
-            if let Err(e) = sub.try_send(message_result.clone()) {
-                panic!("[system::publisher] Error returned sending message in notify_with_include() call! {}", e);
+            if sub.try_send(message_result.clone()).is_err() {
+                // The receiver was dropped before Drop::unsubscribe() took
+                // effect, e.g. a task cancellation racing this notify.
+                // Prune the dead subscription.
+                self.subs.lock().unwrap().remove(&id);
             }
         }
     }
@@ -103,9 +106,11 @@ impl<T: Piped> Publisher<T> {
     pub fn notify(&self, msg: T) {
         let subs = self.subs.lock().unwrap().clone();
         for (id, sub) in subs {
-            if let Err(e) = sub.try_send(msg.clone()) {
-                // This should never happen since Drop calls unsubscribe()
-                panic!("Error in notify() call for sub={}! {}", id, e);
+            if sub.try_send(msg.clone()).is_err() {
+                // The receiver was dropped before Drop::unsubscribe() took
+                // effect, e.g. a task cancellation racing this notify.
+                // Prune the dead subscription.
+                self.subs.lock().unwrap().remove(&id);
             }
         }
     }

+ 1 - 4
bin/app/src/ui/chatview/mod.rs

@@ -50,7 +50,7 @@ use crate::{
         PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyRect, PropertyStr,
         PropertyUint32, Role,
     },
-    scene::{MethodCallSub, Pimpl, SceneNodePtr, SceneNodeWeak},
+    scene::{MethodCallSub, Pimpl, SceneNodeWeak},
     text,
     util::clipboard,
     ExecutorPtr,
@@ -222,7 +222,6 @@ pub struct ChatView {
     tasks: SyncMutex<Vec<smol::Task<()>>>,
     renderer: Renderer,
     redraw: RedrawTrigger,
-    sg_root: SceneNodePtr,
 
     tree: Tree,
     msgbuf: AsyncMutex<MessageBuffer>,
@@ -295,7 +294,6 @@ impl ChatView {
         window_scale: PropertyFloat32,
         renderer: Renderer,
         redraw: RedrawTrigger,
-        sg_root: SceneNodePtr,
         ex: ExecutorPtr,
     ) -> Pimpl {
         let node_ref = &node.upgrade().unwrap();
@@ -359,7 +357,6 @@ impl ChatView {
             tasks: SyncMutex::new(vec![]),
             renderer: renderer.clone(),
             redraw,
-            sg_root,
 
             tree,
             msgbuf: AsyncMutex::new(MessageBuffer::new(