diff --git a/Cargo.lock b/Cargo.lock index 1158704ac..777178316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "ab_glyph_rasterizer" version = "0.1.8" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "accessory" @@ -262,7 +262,7 @@ dependencies = [ [[package]] name = "arrayvec" version = "0.7.6" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "as-slice" @@ -282,7 +282,7 @@ checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a" [[package]] name = "ash" version = "0.38.0+1.3.281" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "libloading 0.8.9", ] @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "bit-set" version = "0.8.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "bit-vec", ] @@ -842,7 +842,7 @@ dependencies = [ [[package]] name = "bit-vec" version = "0.8.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "bit_field" @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "bitflags" version = "2.10.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "bitmaps" @@ -1020,7 +1020,7 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytemuck" version = "1.25.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "byteorder" @@ -1031,7 +1031,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder" version = "1.5.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "byteorder-lite" @@ -1132,7 +1132,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg-if" version = "1.0.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "cfg_aliases" @@ -1143,7 +1143,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cfg_aliases" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "chacha20" @@ -1505,7 +1505,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crunchy" version = "0.2.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "crypto-bigint" @@ -1921,7 +1921,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "downcast-rs" version = "1.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "dunce" @@ -2076,7 +2076,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "equivalent" version = "1.0.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "errno" @@ -2300,7 +2300,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foldhash" version = "0.2.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "foreign-types" @@ -2457,9 +2457,9 @@ dependencies = [ [[package]] name = "fxhash" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "byteorder 1.5.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "byteorder 1.5.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] @@ -2665,9 +2665,9 @@ dependencies = [ [[package]] name = "hashbrown" version = "0.16.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "foldhash 0.2.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "foldhash 0.2.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] @@ -2700,7 +2700,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hexf-parse" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "hilog-sys" @@ -3182,10 +3182,10 @@ dependencies = [ [[package]] name = "indexmap" version = "2.13.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "equivalent 1.0.2 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "equivalent 1.0.2 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", + "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] @@ -3404,9 +3404,9 @@ dependencies = [ [[package]] name = "libloading" version = "0.8.9" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "windows-link 0.2.1", ] @@ -3478,7 +3478,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "log" version = "0.4.29" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "loom" @@ -3568,7 +3568,7 @@ dependencies = [ [[package]] name = "makepad-apple-sys" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-objc-sys", ] @@ -3576,12 +3576,12 @@ dependencies = [ [[package]] name = "makepad-byteorder-lite" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-code-editor" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-widgets", ] @@ -3589,7 +3589,7 @@ dependencies = [ [[package]] name = "makepad-derive-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-proc-macro", ] @@ -3597,7 +3597,7 @@ dependencies = [ [[package]] name = "makepad-derive-widget" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-live-id", "makepad-micro-proc-macro", @@ -3606,7 +3606,7 @@ dependencies = [ [[package]] name = "makepad-draw" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "ab_glyph_rasterizer", "fxhash", @@ -3623,49 +3623,54 @@ dependencies = [ "rustybuzz", "sdfer", "serde", - "unicode-bidi 0.3.18 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "unicode-bidi 0.3.18 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "unicode-linebreak", - "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] name = "makepad-error-log" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-serde", ] +[[package]] +name = "makepad-fast-inflate" +version = "0.1.0" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" + [[package]] name = "makepad-filesystem-watcher" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-futures" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-futures-legacy" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-gif" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "weezl 0.1.12 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "weezl 0.1.12 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] name = "makepad-half" version = "2.7.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "crunchy 0.2.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", + "crunchy 0.2.4 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "num-traits 0.2.20", "zerocopy 0.8.39", ] @@ -3673,7 +3678,7 @@ dependencies = [ [[package]] name = "makepad-html" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-live-id", ] @@ -3687,7 +3692,7 @@ checksum = "9775cbec5fa0647500c3e5de7c850280a88335d1d2d770e5aa2332b801ba7064" [[package]] name = "makepad-latex-math" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "ttf-parser", ] @@ -3695,7 +3700,7 @@ dependencies = [ [[package]] name = "makepad-live-id" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-live-id-macros", "serde", @@ -3704,7 +3709,7 @@ dependencies = [ [[package]] name = "makepad-live-id-macros" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-proc-macro", ] @@ -3712,7 +3717,7 @@ dependencies = [ [[package]] name = "makepad-live-reload-core" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-filesystem-watcher", ] @@ -3720,7 +3725,7 @@ dependencies = [ [[package]] name = "makepad-math" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-serde", ] @@ -3728,12 +3733,12 @@ dependencies = [ [[package]] name = "makepad-micro-proc-macro" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-micro-serde" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-live-id", "makepad-micro-serde-derive", @@ -3742,7 +3747,7 @@ dependencies = [ [[package]] name = "makepad-micro-serde-derive" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-proc-macro", ] @@ -3750,7 +3755,7 @@ dependencies = [ [[package]] name = "makepad-network" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-apple-sys", "makepad-error-log", @@ -3764,15 +3769,15 @@ dependencies = [ [[package]] name = "makepad-objc-sys" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-platform" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "ash", - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "ctrlc", "hilog-sys", "makepad-android-state", @@ -3788,13 +3793,14 @@ dependencies = [ "makepad-shared-bytes", "makepad-studio-protocol", "makepad-tsdf", + "makepad-video", "makepad-wasm-bridge", "makepad-zune-png", "naga", "napi-derive-ohos", "napi-ohos", "ohos-sys", - "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "wayland-client 0.31.12", "wayland-egl", "wayland-protocols 0.32.10", @@ -3806,12 +3812,12 @@ dependencies = [ [[package]] name = "makepad-regex" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-script" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-error-log", "makepad-html", @@ -3819,13 +3825,14 @@ dependencies = [ "makepad-math", "makepad-regex", "makepad-script-derive", - "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "makepad-stitch", + "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] name = "makepad-script-derive" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-proc-macro", ] @@ -3833,7 +3840,7 @@ dependencies = [ [[package]] name = "makepad-script-std" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-network", "makepad-script", @@ -3842,14 +3849,19 @@ dependencies = [ [[package]] name = "makepad-shared-bytes" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" + +[[package]] +name = "makepad-stitch" +version = "0.1.0" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "makepad-studio-protocol" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "makepad-error-log", "makepad-live-id", "makepad-micro-serde", @@ -3859,7 +3871,7 @@ dependencies = [ [[package]] name = "makepad-svg" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-html", "makepad-live-id", @@ -3868,16 +3880,26 @@ dependencies = [ [[package]] name = "makepad-tsdf" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-math", "makepad-micro-serde", ] +[[package]] +name = "makepad-video" +version = "1.0.0" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" +dependencies = [ + "makepad-apple-sys", + "makepad-objc-sys", + "windows 0.62.2", +] + [[package]] name = "makepad-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-derive-wasm-bridge", "makepad-live-id", @@ -3886,7 +3908,7 @@ dependencies = [ [[package]] name = "makepad-webp" version = "0.2.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-byteorder-lite", ] @@ -3894,7 +3916,7 @@ dependencies = [ [[package]] name = "makepad-widgets" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-derive-widget", "makepad-draw", @@ -3903,13 +3925,13 @@ dependencies = [ "pulldown-cmark 0.12.2", "serde", "ttf-parser", - "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] name = "makepad-zune-bmp" version = "0.5.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "log 0.4.28", "makepad-zune-core", @@ -3918,7 +3940,7 @@ dependencies = [ [[package]] name = "makepad-zune-core" version = "0.5.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "log 0.4.28", ] @@ -3926,15 +3948,15 @@ dependencies = [ [[package]] name = "makepad-zune-inflate" version = "0.2.54" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "simd-adler32 0.3.9 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "simd-adler32 0.3.9 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] name = "makepad-zune-jpeg" version = "0.5.15" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-zune-core", ] @@ -3942,8 +3964,9 @@ dependencies = [ [[package]] name = "makepad-zune-png" version = "0.5.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ + "makepad-fast-inflate", "makepad-zune-core", "makepad-zune-inflate", ] @@ -3951,7 +3974,7 @@ dependencies = [ [[package]] name = "makepad-zune-qoi" version = "0.5.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-zune-core", ] @@ -4347,7 +4370,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memchr" version = "2.7.6" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "memoffset" @@ -4436,20 +4459,20 @@ dependencies = [ [[package]] name = "naga" version = "27.0.3" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "arrayvec 0.7.6 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "arrayvec 0.7.6 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "bit-set", "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "cfg_aliases 0.2.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", + "cfg_aliases 0.2.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", + "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "hexf-parse", - "indexmap 2.13.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "indexmap 2.13.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "makepad-error-log", "makepad-half", "num-traits 0.2.20", - "once_cell 1.21.3 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "once_cell 1.21.3 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "rustc-hash 1.1.0", "spirv", "thiserror 2.0.18", @@ -4676,7 +4699,7 @@ dependencies = [ [[package]] name = "num-traits" version = "0.2.20" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "num_cpus" @@ -4803,7 +4826,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell" version = "1.21.3" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "once_cell_polyfill" @@ -5075,7 +5098,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "pkg-config" version = "0.3.32" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "png" @@ -5281,10 +5304,10 @@ dependencies = [ [[package]] name = "pulldown-cmark" version = "0.12.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "memchr 2.7.6 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", + "memchr 2.7.6 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "unicase 2.9.0", ] @@ -6134,7 +6157,7 @@ dependencies = [ [[package]] name = "rustc-hash" version = "1.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "rustc-hash" @@ -6259,12 +6282,12 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" version = "0.18.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "bytemuck 1.25.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", + "bytemuck 1.25.0 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "makepad-error-log", - "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", @@ -6353,7 +6376,7 @@ checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scoped-tls" version = "1.0.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "scopeguard" @@ -6364,7 +6387,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdfer" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "sealed" @@ -6680,7 +6703,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd-adler32" version = "0.3.9" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "simd_helpers" @@ -6715,7 +6738,7 @@ dependencies = [ [[package]] name = "smallvec" version = "1.15.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "socket2" @@ -6739,7 +6762,7 @@ dependencies = [ [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -7231,7 +7254,7 @@ dependencies = [ [[package]] name = "thiserror" version = "2.0.18" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "thiserror-impl 2.0.18", ] @@ -7261,7 +7284,7 @@ dependencies = [ [[package]] name = "thiserror-impl" version = "2.0.18" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "makepad-micro-proc-macro", ] @@ -7657,7 +7680,7 @@ dependencies = [ [[package]] name = "ttf-parser" version = "0.24.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "tungstenite" @@ -7726,7 +7749,7 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicase" version = "2.9.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-bidi" @@ -7737,17 +7760,17 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi" version = "0.3.18" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-bidi-mirroring" version = "0.3.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-ccc" version = "0.3.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-ident" @@ -7758,7 +7781,7 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" version = "0.1.5" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-normalization" @@ -7778,12 +7801,12 @@ checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-properties" version = "0.1.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-script" version = "0.5.8" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-segmentation" @@ -7794,7 +7817,7 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-segmentation" version = "1.12.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "unicode-xid" @@ -7882,9 +7905,13 @@ dependencies = [ name = "ux-harness" version = "0.1.0" dependencies = [ + "makepad-micro-serde", + "makepad-studio-protocol", "png", + "proptest", "serde", "serde_json", + "tungstenite", ] [[package]] @@ -8140,11 +8167,11 @@ dependencies = [ [[package]] name = "wayland-backend" version = "0.3.12" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ - "downcast-rs 1.2.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "downcast-rs 1.2.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "libc", - "scoped-tls 1.0.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "scoped-tls 1.0.1 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", "wayland-sys 0.31.8", ] @@ -8166,7 +8193,7 @@ dependencies = [ [[package]] name = "wayland-client" version = "0.31.12" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", @@ -8188,7 +8215,7 @@ dependencies = [ [[package]] name = "wayland-egl" version = "0.32.9" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "wayland-backend 0.3.12", "wayland-sys 0.31.8", @@ -8197,7 +8224,7 @@ dependencies = [ [[package]] name = "wayland-protocols" version = "0.32.10" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "wayland-backend 0.3.12", @@ -8230,10 +8257,10 @@ dependencies = [ [[package]] name = "wayland-sys" version = "0.31.8" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "log 0.4.29", - "pkg-config 0.3.32 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "pkg-config 0.3.32 (git+https://github.com/ZhangHanDong/makepad?branch=dev)", ] [[package]] @@ -8307,7 +8334,7 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "weezl" version = "0.1.12" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "whoami" @@ -8360,7 +8387,7 @@ dependencies = [ [[package]] name = "windows" version = "0.62.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "windows-collections 0.3.2", "windows-core 0.62.2", @@ -8379,7 +8406,7 @@ dependencies = [ [[package]] name = "windows-collections" version = "0.3.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "windows-core 0.62.2", ] @@ -8412,7 +8439,7 @@ dependencies = [ [[package]] name = "windows-core" version = "0.62.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "windows-link 0.2.1", "windows-result 0.4.1", @@ -8433,7 +8460,7 @@ dependencies = [ [[package]] name = "windows-future" version = "0.3.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "windows-core 0.62.2", ] @@ -8497,7 +8524,7 @@ checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-link" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "windows-numerics" @@ -8541,7 +8568,7 @@ dependencies = [ [[package]] name = "windows-result" version = "0.4.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "windows-link 0.2.1", ] @@ -8558,7 +8585,7 @@ dependencies = [ [[package]] name = "windows-strings" version = "0.5.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" dependencies = [ "windows-link 0.2.1", ] @@ -9128,7 +9155,7 @@ dependencies = [ [[package]] name = "zerocopy" version = "0.8.39" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?branch=dev#139d751442608653df2afcef0d0f4f060a10343e" [[package]] name = "zerocopy-derive" diff --git a/Cargo.toml b/Cargo.toml index 9911bc16f..c28b9058c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -387,10 +387,7 @@ used_underscore_binding = "allow" ## sandbox dirs, mobile-layout detection, ArkUI native inputs), with the legacy ## `old/` crate copies removed so the repo resolves as a cargo git dependency. [patch."https://github.com/makepad/makepad"] -## Pinned to ZhangHanDong/makepad dev at the merge of PR #6 (Splash-isolate -## shader identity, headless renderer correctness, ttf-parser gvar-alloc for -## Apple variable fonts, the CoreText outline fallback for hvgl-only system -## fonts like PingFang, and the closed-Modal overdraw fix), plus the -## selectable-Html fix from PR #5. -makepad-widgets = { git = "https://github.com/ZhangHanDong/makepad", rev = "1dd8d372cfb65b20160a3782586ed4d593cf8193", features = ["serde"] } -makepad-code-editor = { git = "https://github.com/ZhangHanDong/makepad", rev = "1dd8d372cfb65b20160a3782586ed4d593cf8193" } +## Track the fork's dev branch; Cargo.lock records the exact tested commit. +## Build Makepad Studio from that same commit for GUI E2E tests. +makepad-widgets = { git = "https://github.com/ZhangHanDong/makepad", branch = "dev" } +makepad-code-editor = { git = "https://github.com/ZhangHanDong/makepad", branch = "dev" } diff --git a/specs/task-studio-e2e-harness.spec.md b/specs/task-studio-e2e-harness.spec.md new file mode 100644 index 000000000..19d377b4d --- /dev/null +++ b/specs/task-studio-e2e-harness.spec.md @@ -0,0 +1,249 @@ +spec: task +name: "Studio E2E harness — trustworthy evidence and real widget interactions" +inherits: project +tags: [ux-harness, studio, e2e] +--- + +## Intent + +Make the existing ux-harness report actual protocol, capture and process failures and drive Robrix through Makepad Studio. Preserve the static UX gate while recording explicit GUI outcomes for the operator-approved local three-layer E2E. + +## Constraints + +- Do not run cargo fmt or weaken existing UX thresholds or tests. +- Matrix traffic is restricted to local Docker Palpo at 127.0.0.1:8008. +- Preserve existing user sessions; input and shutdown target only the selected test build/process. +- GUI dispatch uses actual member-picker selection and input events; direct API message injection is not GUI evidence. +- Protocol failures, missing state and stale images never become successful evidence. + +## Decisions + +- Keep serde/serde_json/png for the existing harness. This task approves direct harness dependencies on the already-locked tungstenite 0.26.2, matching Makepad protocol/micro-serde libraries and the already-approved proptest test dependency; no unrelated dependency upgrades. +- `ROBRIX_DATA_DIR` selects an absolute test profile directory; unset preserves the existing directory and invalid relative/empty overrides fail explicitly. +- `decode` preserves a valid empty widget snapshot but emits an explicit protocol error for malformed widget data or a missing request ID. +- Capture success requires a fresh response/frame and a successfully written artifact. Process cleanup runs on success and early error. +- `unique_match(widgets, selector)` requires exactly one visible match, optionally within an observed container. `input_center(widgets, target, windowed)` converts desktop snapshot coordinates only in windowed mode. Actions wait for an explicit state predicate with a bounded deadline. +- Studio `click`, `scroll`, `type` and `key` steps require an `expect` selector and a 1..60000 ms deadline (default 10000). The CLI validates the entire step plan before input and records success only after a correlated snapshot satisfies the predicate. A preexisting predicate proves observed state, not event acknowledgement or causality; choose a changed value or newly visible control when testing transitions. +- Studio output uses a newly created directory. An existing output path fails before connection or input and is never cleared or overwritten, keeping each run's artifacts separate. +- `validate_capture` checks request identity and PNG content; `Diagnostics::push` bounds retained bytes; `resolve_app_data_dir` isolates the selected profile. Studio binary input uses the official `StudioToAppVec` envelope. +- Business results remain separate from UX scores and require independent Matrix/HAFleet/artifact evidence. + +## Boundaries + +### Allowed Changes +- tools/ux-harness/** +- src/lib.rs +- src/data_directory.rs +- ./Cargo.toml +- ./Cargo.lock +- src/app.rs +- specs/task-studio-e2e-harness.spec.md +- docs/superpowers/plans/2026-09-09-studio-e2e.md + +### Forbidden +- Do not edit global agent configuration, user session databases or unrelated projects. +- Do not raise tools/ux-harness/gate.json thresholds. +- Do not write task done or fabricated inner-loop results from the GUI driver. + +## Acceptance Criteria + +### Rule: protocol-evidence — malformed protocol data is never valid empty state + +Scenario: Reject malformed snapshot fields + Test: + Package: ux-harness + Filter: malformed_widget_snapshot_is_error + Given a WidgetSnapshot response whose widgets field is an invalid object + When decode parses the response + Then it reports a protocol error instead of an empty widget vector + +Scenario: Reject a missing correlation ID + Test: + Package: ux-harness + Filter: missing_snapshot_request_id_is_error + Given a WidgetSnapshot response without request_id + When decode parses the response + Then it reports a protocol error instead of substituting zero + +Scenario: Accept a valid empty snapshot + Test: + Package: ux-harness + Filter: valid_empty_widget_snapshot_is_preserved + Given a WidgetSnapshot response with request_id 17 and widgets [] + When decode parses the response + Then it preserves request_id 17 and the empty vector + +Scenario: Preserve Unicode through protocol cleanup + Test: + Package: ux-harness + Filter: prop_protocol_cleanup_preserves_text + Given generated Unicode labels including Chinese + When protocol cleanup removes trailing delimiters + Then valid JSON text remains byte-identical + +Scenario: Correlate real websocket replies + Test: + Package: ux-harness + Filter: studio_snapshot_requires_matching_build_and_query + Given a local websocket server returning stale-query and foreign-build snapshots + When the harness requests its selected build snapshot + Then only its matching reply is accepted + +Scenario: Fail on a disconnected Studio socket + Test: + Package: ux-harness + Filter: studio_disconnect_is_error_not_empty_state + Given a local websocket server closing before its reply + When the harness requests a snapshot + Then the transport returns an error + +### Rule: capture-evidence — stale or unwritten captures fail + +Scenario: Reject an old frame after capture deadline + Test: + Package: ux-harness + Filter: capture_deadline_never_reuses_old_file_or_response + Given only the frame that existed before a screenshot request + When the capture deadline expires + Then capture returns an error and no success artifact + +Scenario: Reject a capture artifact write failure + Test: + Package: ux-harness + Filter: capture_write_failure_is_error + Given a valid fresh frame and an unwritable destination + When capture writes its evidence + Then it returns the write error + +Scenario: Generate request identities + Test: + Package: ux-harness + Filter: prop_capture_requires_matching_request + Given generated expected and received request identities + When a valid PNG is checked + Then acceptance is equivalent to matching request identities + +### Rule: lifecycle-evidence — error paths retain diagnostics and cleanup + +Scenario: Retain bounded stderr diagnostics + Test: + Package: ux-harness + Filter: stderr_diagnostics_are_bounded_and_retained + Given an owned child emitting stderr beyond the diagnostic capacity + When the harness records diagnostics + Then the newest error is retained and storage remains bounded + +Scenario: Reclaim owned process after early error + Test: + Package: ux-harness + Filter: owned_child_is_reaped_on_early_return + Given an owned test child and a failing startup operation + When the driver scope ends before explicit shutdown + Then the child is terminated and reaped + +Scenario: Generate bounded diagnostic streams + Test: + Package: ux-harness + Filter: prop_diagnostics_storage_never_exceeds_capacity + Given generated capacities and Unicode log streams + When each line is appended + Then retained bytes never exceed capacity + +### Rule: gui-evidence — business acceptance uses the actual UI + +Scenario: Preserve default directory and isolate explicit profiles + Test: data_directory_override_is_explicit + Given the default profile and an absolute test profile + When the override is absent or explicitly supplied + Then absence preserves the default and an absolute override selects only the test profile + And relative and empty overrides return an error + +Scenario: Reject ambiguous locator matches + Test: + Package: ux-harness + Filter: prop_locator_never_picks_an_ambiguous_widget + Given a generated number of visible matching widgets + When unique_match selects a target + Then selection succeeds if and only if the count is one + +Scenario: Correct independent-window coordinates + Test: + Package: ux-harness + Filter: prop_windowed_coordinates_are_translation_invariant + Given a target and window translated by generated offsets + When input_center converts the observed geometry + Then the window-local target remains unchanged + +Scenario: Send the event envelope decoded by the app + Test: + Package: ux-harness + Filter: studio_input_uses_the_apps_vector_envelope + Given a real local websocket connection + When a Chinese text event is sent + Then the application protocol decodes exactly one event with unchanged text + +Scenario: Scroll the observed container and record the resulting state + Test: + Package: ux-harness + Filter: studio_scroll_targets_observed_container_and_records_new_state + Given a uniquely located scrollable container in an independent window + When a scroll step runs + Then the official event targets its window-local center + And the step evidence records a new snapshot after the scroll + +Scenario: Reject mutations without an expected state + Test: + Package: ux-harness + Filter: studio_mutations_require_explicit_postconditions + Given click, scroll, type and key steps without an expect selector + When the step plan is decoded + Then each mutation is rejected before input + +Scenario: Reject a reused output directory without altering old evidence + Test: + Package: ux-harness + Filter: studio_rejects_existing_output_without_touching_its_evidence + Given an output directory containing a previous run's failure record + When a Studio run selects that directory + Then it fails before connecting + And the previous evidence remains unchanged + +Scenario: Validate later steps before any earlier mutation is sent + Test: + Package: ux-harness + Filter: studio_validates_the_complete_plan_before_any_input + Given a valid first mutation followed by an invalid deadline, selector or key + When the CLI validates the complete step plan + Then it identifies the invalid step before connecting or creating output + +Scenario: Fail when an ignored event never reaches its postcondition + Test: + Package: ux-harness + Filter: studio_ignored_input_cannot_pass_its_postcondition + Given a local Studio server that accepts an input event but returns unchanged snapshots + When a mutation waits for the expected new value + Then it fails within its bounded deadline and preserves failure evidence + And the input is sent exactly once + +Scenario: Wait for a delayed postcondition without replaying input + Test: + Package: ux-harness + Filter: studio_waits_for_delayed_postcondition_without_replaying_input + Given a local Studio server that returns old state before the expected new value + When a mutation runs + Then it records only the snapshot that satisfies the expected value as step evidence + And the input is sent exactly once + +Scenario: Studio drives real mentions and verifies the returned result + Test: manual_test_studio_local_three_layer_e2e + Given matching Studio and Robrix builds and local E2E agents + When Studio and Computer Use select the actual member candidate and send a unique task + Then the matching Matrix mention routes to the intended middle agent + And the middle independently monitors and verifies lower work + And task done, dispatch completed and the unique final same-thread reply have independent evidence + +## Out of Scope + +- AccessKit and system accessibility integration +- Claiming Windows/Linux GUI coverage from macOS execution +- Replacing Herdr or implementing a new agent orchestrator inside ux-harness diff --git a/src/app.rs b/src/app.rs index 40db799ea..cf9b52726 100644 --- a/src/app.rs +++ b/src/app.rs @@ -816,7 +816,9 @@ impl MatchEvent for App { // surfaces are light, so agent splash-card content rendered near-white // text on white — visually blank. Select the light theme for every // isolate before any splash card evaluates. - cx.set_splash_isolate_theme(live_id!(light)); + makepad_widgets::widget_async::set_splash_theme( + makepad_widgets::widget_async::SplashTheme::Light, + ); // only init logging/tracing once let _ = tracing_subscriber::fmt() diff --git a/src/data_directory.rs b/src/data_directory.rs new file mode 100644 index 000000000..d95dd33bb --- /dev/null +++ b/src/data_directory.rs @@ -0,0 +1,38 @@ +//! Explicit profile isolation for local UI regression tests. + +use std::path::{Path, PathBuf}; + +pub(crate) fn resolve_app_data_dir(override_dir: Option<&Path>, default: &Path) -> Result { + match override_dir { + None => Ok(default.to_path_buf()), + Some(path) if path.is_absolute() => Ok(path.to_path_buf()), + Some(_) => Err("ROBRIX_DATA_DIR must be a non-empty absolute path".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn data_directory_override_is_explicit() { + let base = std::env::temp_dir(); + let default = base.join("robrix-default"); + let isolated = base.join("robrix-isolated"); + assert_eq!(resolve_app_data_dir(None, &default).unwrap(), default); + assert_eq!(resolve_app_data_dir(Some(isolated.as_path()), &default).unwrap(), isolated); + assert!(resolve_app_data_dir(Some(Path::new("relative")), &default).is_err()); + assert!(resolve_app_data_dir(Some(Path::new("")), &default).is_err()); + } + + proptest::proptest! { + #[test] + fn prop_data_directory_never_implicitly_changes_default(name in "[a-zA-Z0-9_-]{1,32}") { + let base = std::env::temp_dir().join("robrix-default"); + let isolated = std::env::temp_dir().join(name); + proptest::prop_assert_eq!(resolve_app_data_dir(None, &base).unwrap(), base.clone()); + proptest::prop_assert_eq!(resolve_app_data_dir(Some(&isolated), &base).unwrap(), isolated); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 5b221ae43..d457aba29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub fn view_from_live_ptr( /// The top-level main application module. pub mod app; +mod data_directory; /// Function for loading and saving persistent application/session state. pub mod persistence; /// The settings screen and settings-related content/widgets. @@ -114,5 +115,15 @@ pub fn project_dir() -> &'static ProjectDirs { } pub fn app_data_dir() -> &'static Path { - project_dir().data_dir() + static DATA_DIR: OnceLock = OnceLock::new(); + DATA_DIR.get_or_init(|| { + let override_dir = std::env::var_os("ROBRIX_DATA_DIR"); + match data_directory::resolve_app_data_dir(override_dir.as_deref().map(Path::new), project_dir().data_dir()) { + Ok(path) => path, + Err(error) => { + eprintln!("Robrix configuration error: {error}"); + std::process::exit(2); + } + } + }).as_path() } diff --git a/tools/ux-harness/Cargo.toml b/tools/ux-harness/Cargo.toml index bd59f0ed4..12479662b 100644 --- a/tools/ux-harness/Cargo.toml +++ b/tools/ux-harness/Cargo.toml @@ -19,3 +19,9 @@ path = "src/main.rs" serde = { version = "1", features = ["derive"] } serde_json = "1" png = "0.18" +tungstenite = "=0.26.2" +makepad-studio-protocol = { git = "https://github.com/ZhangHanDong/makepad", branch = "dev" } +makepad-micro-serde = { git = "https://github.com/ZhangHanDong/makepad", branch = "dev" } + +[dev-dependencies] +proptest = { version = "1", default-features = false, features = ["std"] } diff --git a/tools/ux-harness/README.md b/tools/ux-harness/README.md index 9192b6fe3..e4fd6c02b 100644 --- a/tools/ux-harness/README.md +++ b/tools/ux-harness/README.md @@ -4,9 +4,10 @@ A UX audit harness for Robrix2. It drives the real app through **makepad's internal event channel**, captures the frames the app actually renders, and turns both into a scored report with per-finding evidence. -No screen recording, no accessibility permissions, no window server. The app -runs under makepad's headless CPU renderer and speaks newline-delimited JSON -over stdin/stdout, so the whole thing works over SSH and in CI. +The headless mode uses Makepad's CPU renderer over stdin/stdout and works over +SSH and in CI. The Studio mode attaches to a real running desktop build for +widget queries and event injection. Computer Use can independently check the +same window's focus, popup visibility and rendered appearance. ## What it measures @@ -88,6 +89,99 @@ cargo run -- contrast --fg '#687283' --bg '#FFFFFF' When a pair fails it prints the nearest compliant color along the same hue, so a palette fix keeps its hue relationships. +### Attach to a Studio build + +Build Studio and Robrix with the same Makepad revision. Launch an isolated +Studio hub on loopback and start the test Robrix with an absolute +`ROBRIX_DATA_DIR`. Use a unique application bundle ID when another Robrix +instance is running, so Computer Use selects the same instance as Studio. + +```bash +cargo build --release --locked -p ux-harness +target/release/ux-harness studio --address 127.0.0.1:18001 \ + --build-id 3 --app-mode windowed --steps steps.json --out target/ux-studio +``` + +`steps.json` is an array, for example: + +```json +[ + {"action":"wait","selector":{"id":"home_screen_view"},"timeout_ms":10000}, + {"action":"click","selector":{"text":"e2e project room","within":{"id":"rooms_list"}},"expect":{"id":"at_mention_button"}}, + {"action":"click","selector":{"id":"at_mention_button"},"expect":{"id":"popup"}}, + {"action":"wait","selector":{"id":"popup"},"timeout_ms":10000}, + {"action":"capture","label":"member-picker"} +] +``` + +Selectors combine exact `id`, `text`, `widget_type`, `value`, `window_index` +and optional `within` geometry. Missing targets wait to a bounded deadline; +ambiguous targets fail. `type` injects text; `key` supports navigation, Enter, +Escape, Backspace and `a` with optional `logo`/`shift` modifiers. Every `click`, +`scroll`, `type` and `key` requires an `expect` selector. The harness polls fresh, +correlated snapshots until that expected state appears, with `timeout_ms` in +1..60000 (default 10000), and saves the exact matching snapshot. Polling never +replays input. The whole plan is validated before connection or input. +Choose a changed value or newly visible control to test a transition: a +predicate that was already true proves only observed state, not event +acknowledgement or causality. Event delivery alone is not a business result. + +`--out` must name a new directory. Existing paths are rejected before connection +and are never cleared or overwritten. Keep step files outside that directory; +choose a different output path for every run so stale artifacts cannot mix with +current evidence. Re-running a step file can repeat its mutations; inspect the +current app state and prepare only the remaining steps after a failure. + +`windowed` converts desktop snapshot coordinates using the current Window +geometry; `embedded` uses desktop coordinates directly. The pinned Studio +revision ignores the input window index, so non-primary-window input fails +explicitly. The attachment never stops or clears builds. Screenshot responses +must match both query and build IDs, decode as PNG and persist successfully. +Failures save diagnostics and the last observed snapshot. Password input +values are redacted from snapshot artifacts; keep credential step files private +and remove them after use. + +Keep Studio snapshots/captures, Computer Use screenshots and independent +Matrix/HAFleet/artifact checks as separate evidence. A static UX score does not +prove GUI behavior, agent work, or final message delivery. + +### Combined desktop verification and current limits + +The September 9 macOS run used one uniquely bundled Robrix test process for +both transports. A Cargo binary with only a new compile-time name was not +sufficient for Computer Use discovery; an actual `.app` with a unique +`CFBundleIdentifier` and a Studio-launched executable wrapper worked. Keep its +profile separate from every existing Robrix process. + +Use Studio to find a unique visible control, inject text, assert its resulting +value and capture correlated evidence. Use Computer Use to activate the real +window, inspect visual layout and select candidates that the snapshot cannot +identify. Re-query Studio after each Computer Use action before injecting the +next event. For `@`, verify the chosen candidate in the composer before sending +and verify the structured Matrix mention afterward. + +Use a container selector for scrolling, for example +`{"action":"scroll","selector":{"id":"threads_list"},"sx":0,"sy":500,"expect":{"text":"Expected thread title"}}`. +The harness resolves fresh geometry, injects the scroll at that container's +center and waits for the expected visible thread before saving its snapshot. +Replace the example title with the actual expected result. Positive `sy` scrolls down. Inspect +the new state before choosing a thread; a long first entry can fill the pane. + +Observed limitations in the pinned fork: + +- Dynamic member rows were visible on screen but lacked usable text/geometry + in snapshots. Computer Use performed the real selection. +- Inactive Dock tabs retained `visible: true` input entries at identical + coordinates. Treat ambiguity as a failure; do not choose the first entry. +- Focus affected member-popup behavior. Successful event injection alone did + not establish that the popup remained visible. +- With the Mac locked, Computer Use returned `cgWindowNotFound` for both Chrome + and Robrix while Studio snapshots still responded. Continue protocol checks, + but keep real desktop/dashboard acceptance pending until an unlocked run. + +These are separate evidence limitations, not a claim of full accessibility or +cross-platform GUI coverage. Preserve failed attempts when a later retry works. + ## How it drives the app Makepad's headless backend (`MAKEPAD=headless`, run with `--stdin-loop`) reads @@ -96,8 +190,7 @@ channel Makepad Studio uses to remote-control a running app. - **Input**: `MouseDown/Up/Move`, `KeyDown/Up`, `TextInput`, `Scroll` are delivered as genuine `Event`s, through the same dispatch a real click takes. -- **Introspection**: `WidgetSnapshot` returns every widget's post-layout rect, - visibility, enabled state, and text — an accessibility-tree equivalent. +- **Introspection**: `WidgetSnapshot` exposes widget geometry, visibility, enabled state and text. Coverage depends on each widget implementation; validate it against the visible UI. - **Capture**: every draw cycle writes a PNG to `MAKEPAD_HEADLESS_OUT_DIR`. Two details are load-bearing: diff --git a/tools/ux-harness/src/driver.rs b/tools/ux-harness/src/driver.rs index 327af42b1..89f0ea74e 100644 --- a/tools/ux-harness/src/driver.rs +++ b/tools/ux-harness/src/driver.rs @@ -10,15 +10,17 @@ use std::collections::VecDeque; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; +use std::process::{ChildStdin, Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use crate::proto::{decode, Incoming, Key, Modifiers, Msg, WidgetSnapshot}; +use crate::evidence::{Diagnostics, OwnedChild, validate_capture, write_capture}; pub struct Driver { - child: Child, + child: OwnedChild, stdin: ChildStdin, rx: Receiver, pending: VecDeque, @@ -28,14 +30,12 @@ pub struct Driver { clock: f64, frames_dir: PathBuf, pub last_focus_rect: Option<(f64, f64, f64, f64)>, - pub logs: Vec, + diagnostics: Arc>, /// Set UX_HARNESS_TRACE=1 to mirror the whole protocol exchange to stderr. trace: bool, } const TICK_STEP: f64 = 1.0 / 60.0; -/// How long to wait for any single line from the app before declaring it hung. -const LINE_TIMEOUT: Duration = Duration::from_secs(60); /// How long a Tick may go unanswered before the UI counts as idle. Generous /// because the headless backend rasterises on the CPU. const IDLE_WINDOW: Duration = Duration::from_millis(2500); @@ -71,9 +71,9 @@ impl Driver { cmd.env(k, v); } - let mut child = cmd + let mut child = OwnedChild(cmd .spawn() - .map_err(|e| format!("cannot spawn {}: {e}", app_bin.display()))?; + .map_err(|e| format!("cannot spawn {}: {e}", app_bin.display()))?); let stdin = child.stdin.take().ok_or("no stdin on child")?; let stdout = child.stdout.take().ok_or("no stdout on child")?; @@ -86,7 +86,7 @@ impl Driver { for line in reader.lines() { let Ok(line) = line else { break }; if trace { - eprintln!("← {}", &line[..line.len().min(220)]); + eprintln!("← {}", line.chars().take(220).collect::()); } if let Some(msg) = decode(&line) { if tx.send(msg).is_err() { @@ -95,8 +95,9 @@ impl Driver { } } }); - // Drain stderr so a chatty app can never fill the pipe and deadlock. - let (etx, erx) = mpsc::channel(); + // Drain stderr into a bounded tail, including errors outside the protocol. + let diagnostics = Arc::new(Mutex::new(Diagnostics::new(64 * 1024))); + let stderr_diagnostics = diagnostics.clone(); thread::spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines() { @@ -104,15 +105,11 @@ impl Driver { if trace { eprintln!("!! {line}"); } - if etx.send(line).is_err() { - break; + if let Ok(mut log) = stderr_diagnostics.lock() { + log.push(format!("stderr: {line}")); } } }); - thread::spawn(move || { - // Keep the receiver alive; stderr is only surfaced on failure. - while erx.recv().is_ok() {} - }); Ok(Driver { child, @@ -123,15 +120,24 @@ impl Driver { clock: 0.0, frames_dir: frames_dir.to_path_buf(), last_focus_rect: None, - logs: Vec::new(), + diagnostics, trace: std::env::var("UX_HARNESS_TRACE").is_ok(), }) } + fn record_log(&self, line: String) { + if let Ok(mut log) = self.diagnostics.lock() { log.push(line); } + } + + pub fn diagnostic_text(&self) -> String { + self.diagnostics.lock().map(|log| log.text()).unwrap_or_else(|_| "diagnostics lock poisoned".into()) + } + fn send(&mut self, msg: Msg) -> Result<(), String> { let line = msg.to_line(); if self.trace { - eprintln!("→ {}", line.trim()); + if matches!(msg, Msg::TextInput { .. }) { eprintln!("→ TextInput [redacted]"); } + else { eprintln!("→ {}", line.trim()); } } self.stdin .write_all(line.as_bytes()) @@ -139,12 +145,15 @@ impl Driver { self.stdin.flush().map_err(|e| format!("flush failed: {e}")) } - fn recv(&mut self) -> Result { + fn recv_until(&mut self, deadline: Instant) -> Result { + if Instant::now() >= deadline { return Err("app response deadline expired".into()); } if let Some(msg) = self.pending.pop_front() { + if let Incoming::ProtocolError(error) = msg { return Err(error); } return Ok(msg); } - match self.rx.recv_timeout(LINE_TIMEOUT) { + match self.rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { Ok(msg) => { + if let Incoming::ProtocolError(error) = msg { return Err(error); } if let Incoming::KeyFocusRect { x, y, width, height } = &msg { self.last_focus_rect = match (x, y, width, height) { (Some(x), Some(y), Some(w), Some(h)) => Some((*x, *y, *w, *h)), @@ -153,7 +162,7 @@ impl Driver { } Ok(msg) } - Err(RecvTimeoutError::Timeout) => Err("app went silent (30s)".to_string()), + Err(RecvTimeoutError::Timeout) => Err("app response deadline expired".to_string()), Err(RecvTimeoutError::Disconnected) => Err("app exited".to_string()), } } @@ -165,9 +174,9 @@ impl Driver { if Instant::now() > deadline { return Err("timed out waiting for AfterStartup".to_string()); } - match self.recv()? { + match self.recv_until(deadline)? { Incoming::AfterStartup => return Ok(()), - Incoming::Log(l) => self.logs.push(l), + Incoming::Log(l) => self.record_log(l), _ => {} } } @@ -198,7 +207,7 @@ impl Driver { // instead of pipelining more Ticks — queueing them just makes the // app render frames nobody asked for. let mut wants_more = false; - let deadline = Instant::now() + IDLE_WINDOW; + let deadline = (Instant::now() + IDLE_WINDOW).min(budget); loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -209,7 +218,8 @@ impl Driver { wants_more = true; break; } - Ok(Incoming::Log(l)) => self.logs.push(l), + Ok(Incoming::Log(l)) => self.record_log(l), + Ok(Incoming::ProtocolError(error)) => return Err(error), Ok(Incoming::KeyFocusRect { x, y, width, height }) => { self.last_focus_rect = match (x, y, width, height) { (Some(x), Some(y), Some(w), Some(h)) => Some((x, y, w, h)), @@ -258,11 +268,7 @@ impl Driver { pub fn click(&mut self, x: f64, y: f64) -> Result { let time = self.clock; self.send(Msg::MouseMove { x, y, time, modifiers: Modifiers::default() })?; - self.settle(30)?; - let time = self.clock; self.send(Msg::MouseDown { x, y, time, modifiers: Modifiers::default() })?; - self.settle(30)?; - let time = self.clock; self.send(Msg::MouseUp { x, y, time, modifiers: Modifiers::default() })?; self.settle(24) } @@ -270,8 +276,6 @@ impl Driver { pub fn key(&mut self, key: Key, modifiers: Modifiers) -> Result { let time = self.clock; self.send(Msg::KeyDown { key, time, modifiers })?; - self.settle(60)?; - let time = self.clock; self.send(Msg::KeyUp { key, time, modifiers })?; self.settle(120) } @@ -321,7 +325,8 @@ impl Driver { Ok(Incoming::WidgetSnapshot { request_id, widgets }) if request_id == id => { return Ok(widgets) } - Ok(Incoming::Log(l)) => self.logs.push(l), + Ok(Incoming::Log(l)) => self.record_log(l), + Ok(Incoming::ProtocolError(error)) => return Err(error), Ok(other) => self.pending.push_back(other), Err(RecvTimeoutError::Timeout) => { return Err("no WidgetSnapshot response within 180s".to_string()) @@ -331,41 +336,24 @@ impl Driver { } } - /// Force a repaint and return the newest frame file on disk. - /// - /// The headless backend writes every drawn frame to `MAKEPAD_HEADLESS_OUT_DIR`, - /// so "capture" is really "make it draw, then take the latest file". + /// Save only the PNG returned for this request; old on-disk frames are not evidence. pub fn capture(&mut self, label: &str) -> Result, String> { let id = self.next_request_id; self.next_request_id += 1; - let before = latest_frame(&self.frames_dir); + let deadline = Instant::now() + Duration::from_secs(25); self.send(Msg::Screenshot { request_id: id })?; self.settle(120)?; - - // The frame is written by the render pass, which lands after the Tick - // that triggered it — and encoding a full-window PNG on the CPU is not - // instant. Wait for a file that is actually newer than the one we had, - // instead of assuming settle() outran the encoder. - let deadline = Instant::now() + Duration::from_secs(25); - let mut path = None; while Instant::now() < deadline { - match latest_frame(&self.frames_dir) { - Some(p) if Some(&p) != before.as_ref() => { - path = Some(p); - break; - } - Some(p) => { - path = Some(p); - thread::sleep(Duration::from_millis(150)); + match self.recv_until(deadline)? { + Incoming::Screenshot { request_ids, width, height, png } if request_ids.contains(&id) => { + validate_capture(id, &request_ids, width, height, &png)?; + return write_capture(&self.frames_dir, label, &png).map(Some); } - None => thread::sleep(Duration::from_millis(150)), + Incoming::Log(line) => self.record_log(line), + _ => {} } } - let Some(path) = path else { return Ok(None) }; - // Give the frame a stable, human-meaningful name next to the raw one. - let named = self.frames_dir.join(format!("scene_{label}.png")); - let _ = std::fs::copy(&path, &named); - Ok(Some(named)) + Err(format!("no screenshot response for request {id} before deadline")) } pub fn shutdown(mut self) { @@ -377,22 +365,41 @@ impl Driver { } } -/// Newest `window_*_frame_*.png` in a directory, by modified time. -pub fn latest_frame(dir: &Path) -> Option { - let mut best: Option<(std::time::SystemTime, PathBuf)> = None; - for entry in std::fs::read_dir(dir).ok()? { - let Ok(entry) = entry else { continue }; - let path = entry.path(); - let name = path.file_name()?.to_string_lossy().to_string(); - if !name.starts_with("window_") || !name.ends_with(".png") { - continue; - } - let Ok(meta) = entry.metadata() else { continue }; - let Ok(mtime) = meta.modified() else { continue }; - match &best { - Some((t, _)) if *t >= mtime => {} - _ => best = Some((mtime, path)), - } +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn test_driver(directory: &Path) -> (Driver, mpsc::Sender) { + let mut child = OwnedChild(Command::new("/bin/cat") + .stdin(Stdio::piped()).stdout(Stdio::null()).spawn().unwrap()); + let stdin = child.stdin.take().unwrap(); + let (tx, rx) = mpsc::channel(); + (Driver { child, stdin, rx, pending: VecDeque::new(), next_request_id: 18, + clock: 0.0, frames_dir: directory.into(), last_focus_rect: None, + diagnostics: Arc::new(Mutex::new(Diagnostics::new(1024))), trace: false }, tx) + } + + #[test] + fn expired_response_deadline_rejects_queued_frame() { + let (mut driver, _tx) = test_driver(Path::new(".")); + driver.pending.push_back(Incoming::Screenshot { request_ids: vec![18], width: 1, height: 1, png: vec![] }); + let result = driver.recv_until(Instant::now() - Duration::from_millis(1)); + assert!(result.is_err(), "an already queued frame must not defeat an expired deadline"); + } + + #[test] + fn capture_deadline_never_reuses_old_file_or_response() { + let directory = std::env::temp_dir().join(format!("ux-stale-capture-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let old_frame = directory.join("window_0_frame_1.png"); + std::fs::write(&old_frame, b"old frame must never become evidence").unwrap(); + let (mut driver, tx) = test_driver(&directory); + tx.send(Incoming::Screenshot { request_ids: vec![17], width: 1, height: 1, png: vec![] }).unwrap(); + let result = driver.capture("deadline"); + assert!(result.is_err()); + assert!(!directory.join("scene_deadline.png").exists()); + assert_eq!(std::fs::read(&old_frame).unwrap(), b"old frame must never become evidence"); + drop(driver); + std::fs::remove_dir_all(directory).unwrap(); } - best.map(|(_, p)| p) } diff --git a/tools/ux-harness/src/evidence.rs b/tools/ux-harness/src/evidence.rs new file mode 100644 index 000000000..a43223a8c --- /dev/null +++ b/tools/ux-harness/src/evidence.rs @@ -0,0 +1,158 @@ +//! Failure evidence and ownership rules shared by UI transports. + +use std::collections::VecDeque; +use std::io::Cursor; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Child; + +pub struct OwnedChild(pub Child); + +impl Deref for OwnedChild { + type Target = Child; + fn deref(&self) -> &Child { &self.0 } +} + +impl DerefMut for OwnedChild { + fn deref_mut(&mut self) -> &mut Child { &mut self.0 } +} + +impl Drop for OwnedChild { + fn drop(&mut self) { + if !matches!(self.0.try_wait(), Ok(Some(_))) { + let _ = self.0.kill(); + } + let _ = self.0.wait(); + } +} + +pub struct Diagnostics { + lines: VecDeque, + bytes: usize, + capacity: usize, +} + +impl Diagnostics { + pub fn new(capacity: usize) -> Self { + Self { lines: VecDeque::new(), bytes: 0, capacity } + } + + pub fn push(&mut self, mut line: String) { + if self.capacity == 0 { return; } + if line.len() >= self.capacity { + let mut start = line.len() - self.capacity + 1; + while !line.is_char_boundary(start) { start += 1; } + line = line[start..].to_string(); + } + let size = line.len() + 1; + while self.bytes + size > self.capacity { + let Some(old) = self.lines.pop_front() else { break }; + self.bytes -= old.len() + 1; + } + self.bytes += size; + self.lines.push_back(line); + } + + pub fn len_bytes(&self) -> usize { self.bytes } + pub fn text(&self) -> String { self.lines.iter().cloned().collect::>().join("\n") } +} + +pub fn validate_capture(request_id: u64, request_ids: &[u64], width: u32, height: u32, bytes: &[u8]) -> Result<(), String> { + if !request_ids.contains(&request_id) { + return Err(format!("stale screenshot: expected request {request_id}, received {request_ids:?}")); + } + let decoder = png::Decoder::new(Cursor::new(bytes)); + let mut reader = decoder.read_info().map_err(|e| format!("invalid capture PNG: {e}"))?; + if width == 0 || height == 0 || reader.info().width != width || reader.info().height != height { + return Err("capture dimensions do not match the response".to_string()); + } + let size = reader.output_buffer_size().filter(|size| *size <= 128 * 1024 * 1024) + .ok_or("capture exceeds the 128 MiB decoded image limit")?; + reader.next_frame(&mut vec![0; size]).map_err(|e| format!("incomplete capture PNG: {e}"))?; + Ok(()) +} + +pub fn write_capture(directory: &Path, label: &str, bytes: &[u8]) -> Result { + if label.is_empty() || !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err("capture label must contain only letters, digits, underscores or hyphens".into()); + } + std::fs::create_dir_all(directory).map_err(|e| format!("create capture directory: {e}"))?; + let path = directory.join(format!("scene_{label}.png")); + let temporary = directory.join(format!(".scene_{label}.png.tmp")); + std::fs::write(&temporary, bytes).map_err(|e| format!("write capture: {e}"))?; + if let Err(e) = std::fs::rename(&temporary, &path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!("publish capture: {e}")); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png() -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + bytes + } + + #[test] + fn stale_capture_is_error() { + assert!(validate_capture(18, &[17], 1, 1, &png()).is_err()); + assert!(validate_capture(18, &[18], 1, 1, &png()).is_ok()); + assert!(validate_capture(18, &[18], 2, 1, &png()).is_err()); + } + + proptest::proptest! { + #[test] + fn prop_capture_requires_matching_request(request in 0u64..1000, received in 0u64..1000) { + proptest::prop_assert_eq!(validate_capture(request, &[received], 1, 1, &png()).is_ok(), request == received); + } + + #[test] + fn prop_diagnostics_storage_never_exceeds_capacity(capacity in 0usize..1000, lines in proptest::collection::vec(".{0,100}", 0..40)) { + let mut diagnostics = Diagnostics::new(capacity); + for line in lines { + diagnostics.push(line); + proptest::prop_assert!(diagnostics.len_bytes() <= capacity); + proptest::prop_assert!(diagnostics.text().len() <= capacity); + } + } + } + + #[test] + fn capture_write_failure_is_error() { + let path = std::env::temp_dir().join(format!("ux-capture-test-{}", std::process::id())); + std::fs::write(&path, b"a file cannot be an output directory").unwrap(); + let result = write_capture(&path, "frame", &png()); + std::fs::remove_file(path).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn stderr_diagnostics_are_bounded_and_retained() { + let mut log = Diagnostics::new(128); + for i in 0..100 { log.push(format!("stderr line {i}: {}", "测试".repeat(40))); } + log.push("stderr: final failure".into()); + assert!(log.text().contains("stderr: final failure")); + assert!(log.len_bytes() <= 128); + } + + #[cfg(unix)] + #[test] + fn owned_child_is_reaped_on_early_return() { + let child = std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap(); + let pid = child.id(); + let owned = OwnedChild(child); + drop(owned); + let status = std::process::Command::new("/bin/kill") + .args(["-0", &pid.to_string()]).stderr(std::process::Stdio::null()).status().unwrap(); + assert!(!status.success(), "owned child {pid} survived driver scope"); + } +} diff --git a/tools/ux-harness/src/locator.rs b/tools/ux-harness/src/locator.rs new file mode 100644 index 000000000..911e5c98b --- /dev/null +++ b/tools/ux-harness/src/locator.rs @@ -0,0 +1,113 @@ +//! Strict, post-layout selectors. No coordinate fallback on missing or ambiguous state. + +use serde::Deserialize; +use crate::proto::WidgetSnapshot; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Selector { + pub id: Option, + pub text: Option, + pub widget_type: Option, + pub window_index: Option, + pub value: Option, + pub within: Option>, +} + +impl Selector { + pub fn validate(&self) -> Result<(), String> { + if self.id.is_none() && self.text.is_none() && self.widget_type.is_none() && self.value.is_none() { + return Err("selector needs id, text, widget_type or value".into()); + } + if let Some(parent) = &self.within { parent.validate()?; } + Ok(()) + } +} + +pub fn unique_match<'a>(widgets: &'a [WidgetSnapshot], selector: &Selector) -> Result<&'a WidgetSnapshot, String> { + selector.validate()?; + let region = selector.within.as_ref().map(|parent| unique_match(widgets, parent)).transpose()?; + let matches: Vec<_> = widgets.iter().filter(|widget| { + widget.visible && widget.width > 0 && widget.height > 0 + && selector.id.as_ref().is_none_or(|v| &widget.id == v) + && selector.text.as_ref().is_none_or(|v| widget.text.as_ref() == Some(v)) + && selector.widget_type.as_ref().is_none_or(|v| &widget.widget_type == v) + && selector.window_index.is_none_or(|v| widget.window_index == v) + && selector.value.as_ref().is_none_or(|v| widget.value.as_ref() == Some(v)) + && region.is_none_or(|r| widget.window_index == r.window_index + && widget.x >= r.x && widget.y >= r.y + && widget.x as i128 + widget.width as i128 <= r.x as i128 + r.width as i128 + && widget.y as i128 + widget.height as i128 <= r.y as i128 + r.height as i128) + }).collect(); + match matches.as_slice() { + [widget] => Ok(widget), + [] => Err(format!("no visible match for {selector:?}")), + _ => Err(format!("ambiguous selector {selector:?}: {} matches", matches.len())), + } +} + +pub fn input_center(widgets: &[WidgetSnapshot], target: &WidgetSnapshot, windowed: bool) -> Result<(f64, f64), String> { + let (mut x, mut y) = (target.x as f64 + target.width as f64 / 2.0, target.y as f64 + target.height as f64 / 2.0); + if windowed { + let windows: Vec<_> = widgets.iter().filter(|w| w.widget_type == "Window" && w.visible && w.window_index == target.window_index).collect(); + let [window] = windows.as_slice() else { return Err("windowed input requires exactly one matching Window geometry".into()); }; + x -= window.x as f64; + y -= window.y as f64; + } + Ok((x, y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn widget() -> WidgetSnapshot { + WidgetSnapshot { id: "send".into(), text: Some("发送".into()), visible: true, enabled: true, width: 80, height: 40, ..Default::default() } + } + + #[test] + fn locator_requires_one_visible_match() { + let selector: Selector = serde_json::from_str(r#"{"text":"发送"}"#).unwrap(); + assert!(unique_match(&[], &selector).is_err()); + assert!(unique_match(&[widget(), widget()], &selector).is_err()); + assert!(unique_match(&[widget()], &selector).is_ok()); + let mut hidden = widget(); hidden.visible = false; + assert!(unique_match(&[hidden], &selector).is_err()); + let empty: Selector = serde_json::from_str("{}").unwrap(); + assert!(unique_match(&[widget()], &empty).is_err()); + } + + #[test] + fn standalone_coordinates_are_relative_to_the_observed_window() { + let window = WidgetSnapshot { widget_type: "Window".into(), visible: true, x: 1080, y: 457, ..Default::default() }; + let target = WidgetSnapshot { x: 1525, y: 741, width: 390, height: 44, ..widget() }; + assert_eq!(input_center(&[window.clone()], &target, true).unwrap(), (640.0, 306.0)); + assert_eq!(input_center(&[window], &target, false).unwrap(), (1720.0, 763.0)); + assert!(input_center(&[], &target, true).is_err()); + } + + #[test] + fn region_selector_distinguishes_sidebar_from_recent_rooms() { + let sidebar = WidgetSnapshot { id: "rooms_list".into(), width: 300, height: 800, ..widget() }; + let left = WidgetSnapshot { id: "room_name".into(), text: Some("project 2".into()), x: 20, y: 40, ..widget() }; + let right = WidgetSnapshot { x: 400, ..left.clone() }; + let widgets = [sidebar, left, right]; + let selector: Selector = serde_json::from_str(r#"{"text":"project 2","within":{"id":"rooms_list"}}"#).unwrap(); + assert_eq!(unique_match(&widgets, &selector).unwrap().x, 20); + } + + proptest::proptest! { + #[test] + fn prop_locator_never_picks_an_ambiguous_widget(count in 0usize..20) { + let selector: Selector = serde_json::from_str(r#"{"id":"send"}"#).unwrap(); + proptest::prop_assert_eq!(unique_match(&vec![widget(); count], &selector).is_ok(), count == 1); + } + + #[test] + fn prop_windowed_coordinates_are_translation_invariant(dx in -10000i64..10000, dy in -10000i64..10000) { + let window = WidgetSnapshot { widget_type: "Window".into(), visible: true, x: dx, y: dy, ..Default::default() }; + let target = WidgetSnapshot { x: dx + 20, y: dy + 30, ..widget() }; + proptest::prop_assert_eq!(input_center(&[window], &target, true).unwrap(), (60.0, 50.0)); + } + } +} diff --git a/tools/ux-harness/src/main.rs b/tools/ux-harness/src/main.rs index 2f8051c65..f3abb683d 100644 --- a/tools/ux-harness/src/main.rs +++ b/tools/ux-harness/src/main.rs @@ -18,13 +18,16 @@ //! See README.md for the full loop. mod driver; +mod evidence; mod findings; mod frame; mod gate; +mod locator; mod proto; mod rules_runtime; mod rules_static; mod scenes; +mod studio; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -35,6 +38,13 @@ use frame::{contrast_hex, contrast_ratio, parse_hex, relative_luminance}; fn main() { let args: Vec = std::env::args().collect(); let mode = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if mode == "studio" { + if let Err(error) = studio::run_cli(&args[2..]) { + eprintln!("error: {error}"); + std::process::exit(1); + } + return; + } let mut repo = PathBuf::from("."); let mut app: Option = None; @@ -211,20 +221,26 @@ fn run(repo: &Path, app: Option<&Path>, out: &Path, gate_path: Option<&Path>) -> } Some(app_bin) => { let frames_dir = out.join("frames"); - // Point the app at a throwaway HOME so an audit can never read or - // overwrite the developer's real Matrix session, and give the - // headless shader JIT its own cache directory (it shells out to - // rustc, so it needs the toolchain on PATH — inherited). - let home = out.join("home"); + // Isolate application data without changing toolchain or OS homes. + let profile = out.join("profile"); let jit = out.join("jit"); - std::fs::create_dir_all(&home).map_err(|e| format!("create {}: {e}", home.display()))?; + std::fs::create_dir_all(&profile).map_err(|e| format!("create {}: {e}", profile.display()))?; + let profile = profile.canonicalize().map_err(|e| format!("resolve profile: {e}"))?; let env = vec![ - ("HOME".to_string(), home.to_string_lossy().to_string()), + ("ROBRIX_DATA_DIR".to_string(), profile.to_string_lossy().to_string()), ("MAKEPAD_HEADLESS_JIT_DIR".to_string(), jit.to_string_lossy().to_string()), ]; let mut driver = driver::Driver::launch(app_bin, &frames_dir, &env)?; - driver.wait_for_startup()?; - let outcome = scenes::run_all(&mut driver, &frames_dir)?; + let result = driver.wait_for_startup().and_then(|()| scenes::run_all(&mut driver, &frames_dir)); + let diagnostics = driver.diagnostic_text(); + std::fs::write(out.join("app.log"), &diagnostics).map_err(|e| format!("write app diagnostics: {e}"))?; + let outcome = result.map_err(|error| { + let failure = format!("{error}\n\n{diagnostics}"); + if let Err(write_error) = std::fs::write(out.join("failure.txt"), &failure) { + return format!("{failure}\nwrite failure evidence: {write_error}"); + } + failure + })?; driver.shutdown(); all.extend(outcome.findings); scenes_run.extend(outcome.records); diff --git a/tools/ux-harness/src/proto.rs b/tools/ux-harness/src/proto.rs index a623ccfb4..06c8e9f4c 100644 --- a/tools/ux-harness/src/proto.rs +++ b/tools/ux-harness/src/proto.rs @@ -14,7 +14,7 @@ //! We hand-write the subset instead of depending on `makepad_studio_protocol` //! so the harness stays a standalone binary that builds in seconds. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; /// Mouse button bits as makepad's `MouseButton::from_bits_retain` expects @@ -28,6 +28,7 @@ pub const MOUSE_PRIMARY: u32 = 1; /// a UX scenario actually needs are mapped here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Key { + A, Escape, Tab, Return, @@ -42,6 +43,7 @@ pub enum Key { impl Key { pub fn wire_index(self) -> u32 { match self { + Key::A => 30, Key::Escape => 0, Key::Backspace => 15, Key::Tab => 16, @@ -56,6 +58,7 @@ impl Key { pub fn parse(name: &str) -> Option { Some(match name.to_ascii_lowercase().as_str() { + "a" => Key::A, "escape" | "esc" => Key::Escape, "tab" => Key::Tab, "return" | "enter" => Key::Return, @@ -190,14 +193,14 @@ impl Msg { /// parsers do not. `WidgetSnapshot` ends in four optional fields, so nearly /// every snapshot line needs this. Quoted strings are skipped so a comma /// inside a label is never touched. -fn strip_trailing_commas(input: &str) -> String { +pub(crate) fn strip_trailing_commas(input: &str) -> String { let mut out = String::with_capacity(input.len()); - let bytes = input.as_bytes(); + let chars: Vec = input.chars().collect(); let mut in_string = false; let mut escaped = false; let mut i = 0; - while i < bytes.len() { - let c = bytes[i] as char; + while i < chars.len() { + let c = chars[i]; if in_string { out.push(c); if escaped { @@ -220,10 +223,10 @@ fn strip_trailing_commas(input: &str) -> String { // Look ahead past whitespace: a comma right before a closer is the // artifact we are removing. let mut j = i + 1; - while j < bytes.len() && (bytes[j] as char).is_ascii_whitespace() { + while j < chars.len() && chars[j].is_ascii_whitespace() { j += 1; } - if j < bytes.len() && (bytes[j] == b'}' || bytes[j] == b']') { + if j < chars.len() && (chars[j] == '}' || chars[j] == ']') { i += 1; continue; } @@ -237,7 +240,7 @@ fn strip_trailing_commas(input: &str) -> String { /// One widget as reported by makepad's `WidgetSnapshot`. This is the /// accessibility-tree-equivalent the harness reasons over: real post-layout /// geometry plus the semantics each widget chooses to expose. -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Serialize, Default)] pub struct WidgetSnapshot { pub id: String, pub widget_type: String, @@ -282,11 +285,26 @@ pub enum Incoming { WidgetSnapshot { request_id: u64, widgets: Vec }, WidgetTreeDump { request_id: u64, dump: String }, KeyFocusRect { x: Option, y: Option, width: Option, height: Option }, - Screenshot { request_ids: Vec, width: u32, height: u32 }, + Screenshot { request_ids: Vec, width: u32, height: u32, png: Vec }, Log(String), + ProtocolError(String), Other(String), } +#[derive(Deserialize)] +struct SnapshotResponse { + request_id: u64, + widgets: Vec, +} + +#[derive(Deserialize)] +struct ScreenshotResponse { + request_ids: Vec, + width: u32, + height: u32, + png: Vec, +} + /// Decode one stdout line. Unknown variants collapse to `Other` — makepad emits /// a lot of chatter (logs, cursor changes, flips) the harness does not care /// about, and new variants must not break an audit run. @@ -295,14 +313,11 @@ pub fn decode(line: &str) -> Option { if line.is_empty() { return None; } - // A Screenshot response inlines the PNG as a JSON array of bytes, so the - // line can run to megabytes. We never read those bytes — the headless - // backend has already written the same frame to MAKEPAD_HEADLESS_OUT_DIR — - // so skip the parse entirely rather than burn time on it. - if line.len() > 64 * 1024 && line.starts_with("{\"Screenshot\"") { - return Some(Incoming::Screenshot { request_ids: Vec::new(), width: 0, height: 0 }); - } - let v: Value = serde_json::from_str(&strip_trailing_commas(line)).ok()?; + let v: Value = match serde_json::from_str(&strip_trailing_commas(line)) { + Ok(value) => value, + Err(error) if line.starts_with('{') => return Some(Incoming::ProtocolError(format!("invalid Studio JSON: {error}"))), + Err(_) => return Some(Incoming::Log(line.to_string())), + }; let obj = v.as_object()?; let (tag, body) = obj.iter().next()?; Some(match tag.as_str() { @@ -310,13 +325,10 @@ pub fn decode(line: &str) -> Option { "AfterStartup" => Incoming::AfterStartup, "RequestAnimationFrame" => Incoming::RequestAnimationFrame, "WidgetSnapshot" => { - let inner = body.get(0)?; - let request_id = inner.get("request_id").and_then(|v| v.as_u64()).unwrap_or(0); - let widgets = inner - .get("widgets") - .and_then(|w| serde_json::from_value::>(w.clone()).ok()) - .unwrap_or_default(); - Incoming::WidgetSnapshot { request_id, widgets } + match serde_json::from_value::(body.get(0).cloned().unwrap_or(Value::Null)) { + Ok(response) => Incoming::WidgetSnapshot { request_id: response.request_id, widgets: response.widgets }, + Err(error) => Incoming::ProtocolError(format!("invalid WidgetSnapshot: {error}")), + } } "WidgetTreeDump" => { let inner = body.get(0)?; @@ -336,19 +348,51 @@ pub fn decode(line: &str) -> Option { } } "Screenshot" => { - let inner = body.get(0)?; - let request_ids = inner - .get("request_ids") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|x| x.as_u64()).collect()) - .unwrap_or_default(); - Incoming::Screenshot { - request_ids, - width: inner.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32, - height: inner.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + match serde_json::from_value::(body.get(0).cloned().unwrap_or(Value::Null)) { + Ok(response) => Incoming::Screenshot { request_ids: response.request_ids, width: response.width, height: response.height, png: response.png }, + Err(error) => Incoming::ProtocolError(format!("invalid Screenshot: {error}")), } } "LogItem" => Incoming::Log(body.to_string()), other => Incoming::Other(other.to_string()), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trailing_comma_cleanup_preserves_chinese() { + let input = "{\"text\":\"项目二,选择 agent\",}"; + assert_eq!(strip_trailing_commas(input), "{\"text\":\"项目二,选择 agent\"}"); + } + + proptest::proptest! { + #[test] + fn prop_protocol_cleanup_preserves_text(text in ".{0,100}") { + let encoded = serde_json::json!({"text":text}).to_string(); + proptest::prop_assert_eq!(strip_trailing_commas(&encoded), encoded); + } + } + + #[test] + fn malformed_widget_snapshot_is_error() { + let message = decode(r#"{"WidgetSnapshot":[{"request_id":17,"widgets":{"invalid":true}}]}"#); + assert!(!matches!(message, Some(Incoming::WidgetSnapshot { .. })), "malformed data must not be accepted as an empty tree"); + assert!(matches!(message, Some(Incoming::ProtocolError(_)))); + } + + #[test] + fn missing_snapshot_request_id_is_error() { + let message = decode(r#"{"WidgetSnapshot":[{"widgets":[]}]}"#); + assert!(!matches!(message, Some(Incoming::WidgetSnapshot { .. })), "missing request identity must not become zero"); + assert!(matches!(message, Some(Incoming::ProtocolError(_)))); + } + + #[test] + fn valid_empty_widget_snapshot_is_preserved() { + let message = decode(r#"{"WidgetSnapshot":[{"request_id":17,"widgets":[]}]}"#); + assert!(matches!(message, Some(Incoming::WidgetSnapshot { request_id: 17, widgets }) if widgets.is_empty())); + } +} diff --git a/tools/ux-harness/src/studio.rs b/tools/ux-harness/src/studio.rs new file mode 100644 index 000000000..151c43f7a --- /dev/null +++ b/tools/ux-harness/src/studio.rs @@ -0,0 +1,577 @@ +//! Attach to an explicitly selected local Studio build. This transport does not +//! own that build and never stops, replaces or clears it on disconnect. + +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use makepad_micro_serde::{DeBin, DeJson, SerBin, SerJson}; +use makepad_studio_protocol::{StudioToApp, StudioToAppVec, hub_protocol::{ClientId, QueryId, ClientToHub, ClientToHubEnvelope, HubToClient}}; +use serde::Deserialize; +use tungstenite::{Message, WebSocket}; +use crate::{evidence::{Diagnostics, validate_capture, write_capture}, locator::{Selector, unique_match, input_center}, proto::{Key, Modifiers, Msg, WidgetSnapshot, strip_trailing_commas}}; + +pub struct StudioDriver { + socket: WebSocket, + client_id: ClientId, + counter: u64, + build_id: QueryId, + diagnostics: Diagnostics, + last_snapshot: Vec, + windowed: bool, +} + +fn snapshot_evidence(widgets: &[WidgetSnapshot]) -> Result, String> { + let mut widgets = widgets.to_vec(); + for widget in &mut widgets { + if widget.id.to_ascii_lowercase().contains("password") && widget.widget_type.contains("Input") { + widget.text = Some("[redacted]".into()); + widget.value = Some("[redacted]".into()); + } + } + serde_json::to_vec_pretty(&widgets).map_err(|e| e.to_string()) +} + +impl StudioDriver { + pub fn connect(address: &str, build_id: u64) -> Result { + let address: SocketAddr = address.parse().map_err(|e| format!("Studio address must be IP:port: {e}"))?; + if !address.ip().is_loopback() { return Err("Studio attachment requires a loopback address".into()); } + let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5)).map_err(|e| format!("connect Studio: {e}"))?; + stream.set_read_timeout(Some(Duration::from_secs(5))).map_err(|e| e.to_string())?; + stream.set_write_timeout(Some(Duration::from_secs(5))).map_err(|e| e.to_string())?; + let (socket, _) = tungstenite::client(format!("ws://{address}/ui"), stream).map_err(|e| format!("Studio handshake: {e}"))?; + let mut driver = Self { socket, client_id: ClientId(0), counter: 1, build_id: QueryId(build_id), diagnostics: Diagnostics::new(64 * 1024), last_snapshot: Vec::new(), windowed: false }; + match driver.receive(Instant::now() + Duration::from_secs(5))? { + HubToClient::Hello { client_id } => driver.client_id = client_id, + _ => return Err("Studio did not start with Hello".into()), + } + Ok(driver) + } + + fn receive(&mut self, deadline: Instant) -> Result { + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { return Err("Studio response deadline expired".into()); } + self.socket.get_mut().set_read_timeout(Some(remaining)).map_err(|e| e.to_string())?; + let message = match self.socket.read().map_err(|e| format!("Studio read: {e}"))? { + Message::Binary(bytes) => HubToClient::deserialize_bin(&bytes).map_err(|e| format!("invalid Studio binary response: {e:?}"))?, + Message::Text(text) => HubToClient::deserialize_json(&text).map_err(|e| format!("invalid Studio JSON response: {e:?}"))?, + Message::Ping(_) | Message::Pong(_) => continue, + Message::Close(_) => return Err("Studio connection closed".into()), + _ => return Err("unsupported Studio websocket frame".into()), + }; + if let HubToClient::Error { message } = message { return Err(format!("Studio: {message}")); } + return Ok(message); + } + } + + fn send(&mut self, msg: ClientToHub) -> Result { + let query_id = QueryId::new(self.client_id, self.counter); + self.counter += 1; + let envelope = ClientToHubEnvelope { query_id, msg }; + self.socket.send(Message::Binary(envelope.serialize_bin().into())).map_err(|e| format!("Studio write: {e}"))?; + Ok(query_id) + } + + fn record_unrelated(&mut self, message: HubToClient) { + // A shared hub can send another build's widgets, files or frames. + // Keep correlation metadata, never that build's content in our log. + let line = match message { + HubToClient::WidgetSnapshot { query_id, build_id, widgets } => + format!("ignored snapshot query={} build={} widgets={}", query_id.0, build_id.0, widgets.len()), + HubToClient::Screenshot { query_id, build_id, width, height, .. } => + format!("ignored screenshot query={} build={} size={width}x{height}", query_id.0, build_id.0), + HubToClient::BuildStopped { build_id, exit_code } => + format!("build {} stopped: {exit_code:?}", build_id.0), + _ => "ignored unrelated Studio event".into(), + }; + self.diagnostics.push(line); + } + + fn snapshot_until(&mut self, deadline: Instant) -> Result, String> { + let query = self.send(ClientToHub::WidgetSnapshot { build_id: self.build_id })?; + loop { + match self.receive(deadline)? { + HubToClient::WidgetSnapshot { query_id, build_id, widgets } if query_id == query && build_id == self.build_id => { + let json = strip_trailing_commas(&widgets.serialize_json()); + let widgets: Vec = serde_json::from_str(&json).map_err(|e| format!("invalid widget snapshot: {e}"))?; + self.last_snapshot = widgets.clone(); + return Ok(widgets); + } + HubToClient::BuildStopped { build_id, exit_code } if build_id == self.build_id => return Err(format!("selected build stopped: {exit_code:?}")), + other => self.record_unrelated(other), + } + } + } + + fn input(&mut self, window: usize, msg: Msg) -> Result<(), String> { + // This fork's hub ignores RunViewInput.window_id. Until it routes that + // field, reject non-primary windows rather than misdirecting events. + if window != 0 { return Err("this Studio revision cannot route input to a non-primary window".into()); } + let msg = StudioToApp::deserialize_json(&msg.to_line()).map_err(|e| format!("incompatible input protocol: {e:?}"))?; + self.send(ClientToHub::RunViewInput { build_id: self.build_id, window_id: window, msg_bin: StudioToAppVec(vec![msg]).serialize_bin() })?; + Ok(()) + } + + fn locate(&mut self, selector: &Selector, timeout_ms: u64) -> Result { + if !(1..=60_000).contains(&timeout_ms) { return Err("locator timeout must be 1..60000 ms".into()); } + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + let widgets = self.snapshot_until(deadline)?; + match unique_match(&widgets, selector) { + Ok(widget) => return Ok(widget.clone()), + Err(error) => { + if !error.starts_with("no visible match") || Instant::now() >= deadline { return Err(error); } + self.diagnostics.push(error); + } + } + std::thread::sleep(Duration::from_millis(100).min(deadline.saturating_duration_since(Instant::now()))); + } + } + + fn click(&mut self, selector: &Selector) -> Result<(), String> { + let widget = self.locate(selector, 10_000)?; + if !widget.enabled { return Err(format!("widget {} is disabled", widget.id)); } + let (x, y) = input_center(&self.last_snapshot, &widget, self.windowed)?; + let time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_secs_f64(); + self.input(widget.window_index, Msg::MouseMove { x, y, time, modifiers: Modifiers::default() })?; + self.input(widget.window_index, Msg::MouseDown { x, y, time, modifiers: Modifiers::default() })?; + self.input(widget.window_index, Msg::MouseUp { x, y, time, modifiers: Modifiers::default() }) + } + + fn scroll(&mut self, selector: &Selector, sx: f64, sy: f64) -> Result<(), String> { + if !sx.is_finite() || !sy.is_finite() { return Err("scroll deltas must be finite".into()); } + let widget = self.locate(selector, 10_000)?; + if !widget.enabled { return Err(format!("widget {} is disabled", widget.id)); } + let (x, y) = input_center(&self.last_snapshot, &widget, self.windowed)?; + let time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_secs_f64(); + self.input(widget.window_index, Msg::Scroll { x, y, sx, sy, time }) + } + + fn capture(&mut self, out: &Path, label: &str) -> Result { + let query = self.send(ClientToHub::Screenshot { build_id: self.build_id, kind_id: Some(0) })?; + let deadline = Instant::now() + Duration::from_secs(25); + loop { + match self.receive(deadline)? { + HubToClient::Screenshot { query_id, build_id, path, width, height, .. } if query_id == query && build_id == self.build_id => { + let bytes = std::fs::read(&path).map_err(|e| format!("read correlated Studio capture: {e}"))?; + validate_capture(query.0, &[query_id.0], width, height, &bytes)?; + return write_capture(out, label, &bytes); + } + other => self.record_unrelated(other), + } + } + } +} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +enum Step { + Snapshot, + Click { selector: Selector, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Scroll { selector: Selector, sx: f64, sy: f64, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Type { text: String, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Key { key: String, #[serde(default)] logo: bool, #[serde(default)] shift: bool, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Wait { selector: Selector, timeout_ms: u64 }, + Capture { label: String }, +} + +fn default_step_timeout() -> u64 { 10_000 } + +impl Step { + fn validate(&self) -> Result<(), String> { + match self { + Self::Click { expect, timeout_ms, .. } | Self::Scroll { expect, timeout_ms, .. } + | Self::Type { expect, timeout_ms, .. } | Self::Key { expect, timeout_ms, .. } + | Self::Wait { selector: expect, timeout_ms } => { + expect.validate()?; + if !(1..=60_000).contains(timeout_ms) { return Err("timeout must be 1..60000 ms".into()); } + } + _ => {} + } + match self { + Self::Click { selector, .. } => selector.validate()?, + Self::Scroll { selector, sx, sy, .. } => { + selector.validate()?; + if !sx.is_finite() || !sy.is_finite() { return Err("scroll deltas must be finite".into()); } + } + Self::Key { key, .. } => { + if Key::parse(key).is_none() { return Err(format!("unsupported key: {key}")); } + } + Self::Capture { label } => { + if label.is_empty() || !label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') { + return Err("capture label must contain only letters, digits, '-' or '_'".into()); + } + } + _ => {} + } + Ok(()) + } +} + +pub fn run_cli(args: &[String]) -> Result<(), String> { + let mut address = "127.0.0.1:18001".to_string(); + let mut build = None; + let mut steps = None; + let mut windowed = None; + let mut out = PathBuf::from("target/ux-studio"); + let mut i = 0; + while i < args.len() { + let value = args.get(i + 1).ok_or_else(|| format!("missing value for {}", args[i]))?; + match args[i].as_str() { + "--address" => address = value.clone(), + "--app-mode" => windowed = Some(match value.as_str() { "windowed" => true, "embedded" => false, _ => return Err("--app-mode must be windowed or embedded".into()) }), + "--build-id" => build = Some(value.parse().map_err(|_| "invalid build ID")?), + "--steps" => steps = Some(PathBuf::from(value)), + "--out" => out = PathBuf::from(value), + other => return Err(format!("unknown Studio argument: {other}")), + } + i += 2; + } + let build = build.ok_or("Studio requires explicit --build-id")?; + let windowed = windowed.ok_or("Studio requires --app-mode windowed|embedded for explicit coordinate semantics")?; + let steps = steps.ok_or("Studio requires --steps ")?; + let steps: Vec = serde_json::from_slice(&std::fs::read(steps).map_err(|e| e.to_string())?).map_err(|e| format!("invalid Studio steps: {e}"))?; + if steps.is_empty() { return Err("Studio steps must not be empty".into()); } + for (index, step) in steps.iter().enumerate() { + step.validate().map_err(|e| format!("invalid Studio step {index}: {e}"))?; + } + if let Some(parent) = out.parent().filter(|parent| !parent.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::create_dir(&out).map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + "Studio output directory already exists; choose a new path for this run".to_string() + } else { format!("create Studio output directory: {e}") } + })?; + let mut driver = match StudioDriver::connect(&address, build) { + Ok(driver) => driver, + Err(error) => { + std::fs::write(out.join("failure.txt"), &error) + .map_err(|e| format!("{error}; evidence write failed: {e}"))?; + return Err(error); + } + }; + driver.windowed = windowed; + let result = (|| { + for (index, step) in steps.into_iter().enumerate() { + let start = Instant::now(); + match step { + Step::Snapshot => { driver.snapshot_until(Instant::now() + Duration::from_secs(10))?; } + Step::Click { selector, expect, timeout_ms } => { + driver.click(&selector)?; + driver.locate(&expect, timeout_ms)?; + } + Step::Scroll { selector, sx, sy, expect, timeout_ms } => { + driver.scroll(&selector, sx, sy)?; + driver.locate(&expect, timeout_ms)?; + } + Step::Type { text, expect, timeout_ms } => { + driver.input(0, Msg::TextInput { input: text })?; + driver.locate(&expect, timeout_ms)?; + } + Step::Key { key, logo, shift, expect, timeout_ms } => { + let key = Key::parse(&key).ok_or_else(|| format!("unsupported key: {key}"))?; + let modifiers = Modifiers { logo, shift, ..Default::default() }; + driver.input(0, Msg::KeyDown { key, time: 0.0, modifiers })?; + driver.input(0, Msg::KeyUp { key, time: 0.0, modifiers })?; + driver.locate(&expect, timeout_ms)?; + } + Step::Wait { selector, timeout_ms } => { driver.locate(&selector, timeout_ms)?; } + Step::Capture { label } => { + driver.capture(&out, &label)?; + driver.snapshot_until(Instant::now() + Duration::from_secs(10))?; + } + } + // Keep the exact correlated snapshot that satisfied the expected + // state. A later frame could already have moved beyond that state. + let widgets = &driver.last_snapshot; + let path = out.join(format!("step-{index:03}.json")); + std::fs::write(path, snapshot_evidence(widgets)?).map_err(|e| e.to_string())?; + eprintln!("Studio step {index}: observed {} widgets in {} ms", widgets.len(), start.elapsed().as_millis()); + } + Ok(()) + })(); + std::fs::write(out.join("studio.log"), driver.diagnostics.text()).map_err(|e| e.to_string())?; + if let Err(error) = &result { + std::fs::write(out.join("failure.txt"), error).map_err(|e| format!("{error}; evidence write failed: {e}"))?; + std::fs::write(out.join("last-snapshot.json"), snapshot_evidence(&driver.last_snapshot)?).map_err(|e| e.to_string())?; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn studio_mutations_require_explicit_postconditions() { + for json in [ + r#"{"action":"click","selector":{"id":"send"}}"#, + r#"{"action":"scroll","selector":{"id":"threads_list"},"sx":0,"sy":420}"#, + r#"{"action":"type","text":"hello"}"#, + r#"{"action":"key","key":"Return"}"#, + ] { + assert!(serde_json::from_str::(json).is_err(), + "a mutation without an observable postcondition must be rejected: {json}"); + } + } + + #[test] + fn studio_validates_the_complete_plan_before_any_input() { + let directory = std::env::temp_dir().join(format!("ux-studio-invalid-plan-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + for invalid in [ + r#"{"action":"type","text":"hello","expect":{"id":"composer"},"timeout_ms":0}"#, + r#"{"action":"click","selector":{"id":"send"},"expect":{"id":"sent"},"timeout_ms":60001}"#, + r#"{"action":"type","text":"hello","expect":{}}"#, + r#"{"action":"click","selector":{"id":"send","within":{}},"expect":{"id":"sent"}}"#, + r#"{"action":"key","key":"unsupported-key","expect":{"id":"sent"}}"#, + ] { + let steps = directory.join("steps.json"); + std::fs::write(&steps, format!(r#"[{{"action":"type","text":"first","expect":{{"id":"composer","value":"first"}}}},{invalid}]"#)).unwrap(); + let error = run_cli(&["--address".into(), "invalid-address".into(), "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]).unwrap_err(); + assert!(error.starts_with("invalid Studio step 1:"), "validate later invalid step before connecting or sending the first input: {error}"); + assert!(!directory.join("output").exists()); + } + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_rejects_existing_output_without_touching_its_evidence() { + let directory = std::env::temp_dir().join(format!("ux-studio-existing-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"snapshot"}]"#).unwrap(); + let old_failure = directory.join("failure.txt"); + std::fs::write(&old_failure, "previous run failure").unwrap(); + let result = run_cli(&["--address".into(), "invalid-address".into(), "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.display().to_string()]); + let error = result.unwrap_err(); + assert!(error.contains("output directory already exists"), "must reject output before connecting: {error}"); + assert_eq!(std::fs::read_to_string(&old_failure).unwrap(), "previous run failure"); + std::fs::remove_dir_all(directory).unwrap(); + } + + fn postcondition_probe(delayed: bool) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-postcondition-{}-{delayed}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"type","text":"new value","expect":{"id":"composer","value":"new value"},"timeout_ms":1000}]"#).unwrap(); + let server = std::thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + Err(error) => panic!("no Studio client: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let (mut inputs, mut snapshots) = (0, 0); + while let Ok(Message::Binary(bytes)) = ws.read() { + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + match envelope.msg { + ClientToHub::RunViewInput { build_id, msg_bin, .. } => { + assert_eq!(build_id, QueryId(42)); + let events = StudioToAppVec::deserialize_bin(&msg_bin).unwrap(); + assert!(matches!(events.0.as_slice(), [StudioToApp::TextInput(event)] if event.input == "new value")); + inputs += 1; + } + ClientToHub::WidgetSnapshot { build_id } => { + assert_eq!(build_id, QueryId(42)); + snapshots += 1; + let value = if delayed && snapshots >= 3 { "new value" } else { "old value" }; + let widgets = vec![makepad_studio_protocol::WidgetSnapshot { + id: "composer".into(), widget_type: "TextInput".into(), value: Some(value.into()), + visible: true, enabled: true, width: 200, height: 40, ..Default::default() + }]; + if ws.send(Message::Binary(HubToClient::WidgetSnapshot { query_id: envelope.query_id, build_id, widgets }.serialize_bin().into())).is_err() { break; } + } + _ => panic!("unexpected Studio request"), + } + } + (inputs, snapshots) + }); + let started = Instant::now(); + let result = run_cli(&["--address".into(), address, "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]); + let (inputs, snapshots) = server.join().unwrap(); + assert_eq!(inputs, 1, "polling a postcondition must never replay input"); + if delayed { + assert!(result.is_ok(), "delayed expected state must pass: {result:?}"); + let widgets: Vec = serde_json::from_slice(&std::fs::read(directory.join("output/step-000.json")).unwrap()).unwrap(); + assert_eq!(widgets[0].value.as_deref(), Some("new value")); + assert!(snapshots >= 3); + } else { + assert!(result.is_err(), "ignored input must not pass on an unchanged snapshot"); + assert!(started.elapsed() < Duration::from_secs(5), "postcondition must have a bounded deadline"); + assert!(directory.join("output/failure.txt").is_file()); + assert!(directory.join("output/last-snapshot.json").is_file()); + assert!(!directory.join("output/step-000.json").exists()); + } + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_ignored_input_cannot_pass_its_postcondition() { postcondition_probe(false); } + + #[test] + fn studio_waits_for_delayed_postcondition_without_replaying_input() { postcondition_probe(true); } + + #[test] + fn studio_scroll_targets_observed_container_and_records_new_state() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-scroll-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"scroll","selector":{"id":"threads_list"},"sx":0,"sy":420,"expect":{"id":"threads_list","text":"new thread"}}]"#).unwrap(); + let server = std::thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + Err(error) => panic!("no Studio client: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + for scrolled in [false, true] { + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected snapshot request"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + assert!(matches!(envelope.msg, ClientToHub::WidgetSnapshot { build_id: QueryId(42) })); + let widgets = vec![ + makepad_studio_protocol::WidgetSnapshot { widget_type: "Window".into(), visible: true, x: 100, y: 200, width: 800, height: 600, ..Default::default() }, + makepad_studio_protocol::WidgetSnapshot { id: "threads_list".into(), visible: true, enabled: true, x: 600, y: 250, width: 200, height: 400, text: Some(if scrolled { "new thread" } else { "old thread" }.into()), ..Default::default() }, + ]; + ws.send(Message::Binary(HubToClient::WidgetSnapshot { query_id: envelope.query_id, build_id: QueryId(42), widgets }.serialize_bin().into())).unwrap(); + if !scrolled { + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected scroll input"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + let ClientToHub::RunViewInput { build_id, window_id, msg_bin } = envelope.msg else { panic!("expected RunViewInput"); }; + assert_eq!(build_id, QueryId(42)); + assert_eq!(window_id, 0); + let events = StudioToAppVec::deserialize_bin(&msg_bin).unwrap(); + assert!(matches!(events.0.as_slice(), [StudioToApp::Scroll(event)] if event.x == 600.0 && event.y == 250.0 && event.sx == 0.0 && event.sy == 420.0)); + } + } + }); + let result = run_cli(&["--address".into(), address, "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]); + assert!(result.is_ok(), "scroll must be supported: {result:?}"); + server.join().unwrap(); + let snapshot = std::fs::read_to_string(directory.join("output/step-000.json")).unwrap(); + assert!(snapshot.contains("new thread")); + assert!(!snapshot.contains("old thread")); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_connection_failure_preserves_cli_evidence() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-connect-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"snapshot"}]"#).unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.close(None).unwrap(); + }); + let args = vec!["--address".into(), address, "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]; + let error = run_cli(&args).unwrap_err(); + server.join().unwrap(); + assert_eq!(std::fs::read_to_string(directory.join("output/failure.txt")).ok().as_deref(), Some(error.as_str()), + "connection failures must remain reviewable in the output directory"); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_snapshot_requires_matching_build_and_query() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected binary request"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + assert_eq!(envelope.query_id.client_id(), ClientId(3)); + assert!(matches!(envelope.msg, ClientToHub::WidgetSnapshot { build_id: QueryId(42) })); + for (query_id, build_id, label) in [ + (QueryId(envelope.query_id.0 + 16), QueryId(42), "stale request"), + (envelope.query_id, QueryId(99), "wrong build"), + (envelope.query_id, QueryId(42), "项目二"), + ] { + let widgets = vec![makepad_studio_protocol::WidgetSnapshot { text: Some(label.into()), ..Default::default() }]; + let reply = HubToClient::WidgetSnapshot { query_id, build_id, widgets }; + ws.send(Message::Binary(reply.serialize_bin().into())).unwrap(); + } + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let snapshot = driver.snapshot_until(Instant::now() + Duration::from_secs(5)).unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].text.as_deref(), Some("项目二")); + assert!(!driver.diagnostics.text().contains("wrong build"), "foreign widget text must not leak into diagnostics"); + server.join().unwrap(); + } + + #[test] + fn studio_disconnect_is_error_not_empty_state() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + ws.read().unwrap(); + ws.close(None).unwrap(); + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + assert!(driver.snapshot_until(Instant::now() + Duration::from_secs(5)).is_err()); + server.join().unwrap(); + } + + #[test] + fn studio_input_uses_the_apps_vector_envelope() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected binary input"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + let ClientToHub::RunViewInput { build_id, window_id, msg_bin } = envelope.msg else { panic!("expected RunViewInput"); }; + assert_eq!(build_id, QueryId(42)); + assert_eq!(window_id, 0); + let events = StudioToAppVec::deserialize_bin(&msg_bin).expect("application decodes event vectors"); + assert!(matches!(events.0.as_slice(), [StudioToApp::TextInput(event)] if event.input == "项目二")); + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + driver.input(0, Msg::TextInput { input: "项目二".into() }).unwrap(); + server.join().unwrap(); + } +}