You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/23 07:51:53 UTC
(maka) branch rust-preview updated: feat(nsh): sync paced lists and embedding host capabilities
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/rust-preview by this push:
new 32fc601cbb feat(nsh): sync paced lists and embedding host capabilities
32fc601cbb is described below
commit 32fc601cbb80880ed0469dbdc605c6db65891846
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Fri Oct 23 16:51:38 2026 +0900
feat(nsh): sync paced lists and embedding host capabilities
Integrate longbridge/nsh 7ed244990097dbbec6b177e26dbc357cb0dba654 by vendor-directory three-way merge. Add visible-first paced list binding, mobile theme overrides and hardware shortcuts, and the opt-in nsh/host API.
Preserve terminal keyboard behavior, scroll-intent event identity, sticky/source anchoring, and the list source-publication fence. Keep Maka on its existing typed maka/host registry with generic NSH host capabilities disabled. Include red/green source-change regressions and retain settled test coverage.
Refresh source provenance and only affected NSH notice inputs. Generated source hashes remain upstream bytes rather than local adaptations.
Generated-by: OpenAI Codex
---
products/maka/cli/notices.json | 269 +++----
third-party/nsh/AGENTS.md | 6 +-
third-party/nsh/Cargo.lock | 1 +
third-party/nsh/SOURCE.md | 6 +-
third-party/nsh/crates/nsh-base/src/lib.rs | 2 +-
third-party/nsh/crates/nsh-base/src/theme.rs | 53 +-
third-party/nsh/crates/nsh-bundler/src/bundle.rs | 40 +
third-party/nsh/crates/nsh-cli/src/dev.rs | 3 +-
third-party/nsh/crates/nsh-cli/src/main.rs | 7 +
third-party/nsh/crates/nsh-cli/src/package.rs | 76 ++
third-party/nsh/crates/nsh-cli/src/test.rs | 26 +
third-party/nsh/crates/nsh-cli/src/test_web.rs | 4 +-
third-party/nsh/crates/nsh-cli/src/web.rs | 141 +++-
third-party/nsh/crates/nsh-cli/src/web/dev.html | 14 +
third-party/nsh/crates/nsh-cli/src/web/index.html | 13 +-
third-party/nsh/crates/nsh-core/src/access.rs | 137 +++-
.../nsh/crates/nsh-core/src/core/components.rs | 84 +++
third-party/nsh/crates/nsh-core/src/core/list.rs | 295 +++++++-
third-party/nsh/crates/nsh-core/src/core/mod.rs | 95 ++-
third-party/nsh/crates/nsh-core/src/core/tests.rs | 6 +-
third-party/nsh/crates/nsh-core/src/js/host.rs | 3 +-
third-party/nsh/crates/nsh-core/src/js/mod.rs | 1 +
third-party/nsh/crates/nsh-core/src/keymap.rs | 182 +++++
third-party/nsh/crates/nsh-core/src/ops.rs | 17 +
third-party/nsh/crates/nsh-core/src/platform.rs | 4 +
.../nsh/crates/nsh-core/src/runtime/host.rs | 89 +++
third-party/nsh/crates/nsh-core/src/runtime/mod.rs | 7 +-
third-party/nsh/crates/nsh-core/src/ui/ambient.rs | 16 +
third-party/nsh/crates/nsh-core/src/ui/env.rs | 4 +
third-party/nsh/crates/nsh-core/src/ui/services.rs | 16 +
third-party/nsh/crates/nsh-core/src/ui/task.rs | 10 +
third-party/nsh/crates/nsh-engine/src/lib.rs | 151 +++-
.../nsh-engine/tests/engine/component_bench.rs | 1 +
.../crates/nsh-engine/tests/engine/components.rs | 9 +-
.../nsh/crates/nsh-engine/tests/engine/host.rs | 476 ++++++++++++
.../nsh/crates/nsh-engine/tests/engine/list.rs | 357 ++++++++-
.../crates/nsh-engine/tests/engine/list_bench.rs | 5 +
.../nsh/crates/nsh-engine/tests/engine/logs.rs | 11 +
.../nsh/crates/nsh-engine/tests/engine/main.rs | 1 +
.../nsh/crates/nsh-engine/tests/engine/theme.rs | 108 ++-
third-party/nsh/crates/nsh-js/src/host.rs | 1 +
third-party/nsh/crates/nsh-js/src/keymap.rs | 4 +-
third-party/nsh/crates/nsh-js/src/modules.rs | 7 +
.../nsh/crates/nsh-js/src/platform_modules.rs | 213 +++++-
.../java/com/longbridge/nsh/Native.java | 17 +
.../java/com/longbridge/nsh/NshView.java | 61 ++
.../java/com/longbridge/nsh/TestAgent.java | 19 +
.../nsh/crates/nsh-platform-android/src/agent.rs | 34 +
.../nsh/crates/nsh-platform-android/src/encode.rs | 11 +-
.../nsh/crates/nsh-platform-android/src/host.rs | 79 +-
.../nsh/crates/nsh-platform-android/src/keymap.rs | 161 ++++
.../nsh/crates/nsh-platform-android/src/lib.rs | 1 +
.../nsh/crates/nsh-platform-ios/include/nsh.h | 8 +
.../nsh/crates/nsh-platform-ios/src/agent.rs | 39 +
.../nsh/crates/nsh-platform-ios/src/embed.rs | 32 +
.../nsh/crates/nsh-platform-ios/src/input.rs | 52 ++
.../nsh/crates/nsh-platform-ios/src/keymap.rs | 288 ++++++++
third-party/nsh/crates/nsh-platform-ios/src/lib.rs | 1 +
.../nsh/crates/nsh-platform-ios/src/mount.rs | 7 +-
.../nsh/crates/nsh-platform-ios/src/surface.rs | 75 +-
.../nsh/crates/nsh-platform-ios/src/synth.rs | 35 +
.../nsh/crates/nsh-platform-ios/src/target.rs | 10 +-
.../nsh/crates/nsh-platform-ios/src/view.rs | 22 +
.../nsh-platform-ios/swift/NativeShell.swift | 14 +
.../nsh/crates/nsh-platform-linux/src/app.rs | 15 +
.../nsh/crates/nsh-platform-linux/src/surface.rs | 14 +-
.../nsh/crates/nsh-platform-macos/src/app.rs | 15 +
.../nsh/crates/nsh-platform-macos/src/surface.rs | 95 ++-
.../nsh/crates/nsh-platform-tui/src/surface.rs | 1 +
.../nsh/crates/nsh-platform-web/src/encode.rs | 15 +
third-party/nsh/crates/nsh-platform-web/src/lib.rs | 9 +-
third-party/nsh/crates/nsh-platform-web/web/nsh.js | 67 +-
.../nsh/crates/nsh-platform-web/web/nsh.test.mjs | 167 +++++
.../nsh/crates/nsh-platform-windows/src/app.rs | 15 +
.../nsh/crates/nsh-platform-windows/src/surface.rs | 14 +-
third-party/nsh/crates/nsh-testing/Cargo.toml | 7 +-
third-party/nsh/crates/nsh-testing/src/agent.rs | 10 +-
.../crates/nsh-testing/src/headless/keyboard.rs | 18 +-
.../nsh/crates/nsh-testing/src/headless/mod.rs | 47 +-
third-party/nsh/crates/nsh-testing/src/lib.rs | 2 +
.../nsh/crates/nsh-testing/src/page_host.rs | 332 +++++++++
third-party/nsh/crates/nsh-testing/src/run.rs | 20 +
third-party/nsh/crates/nsh-testing/src/runner.rs | 5 +-
.../crates/nsh-testing/tests/testing/host/App.tsx | 40 +
.../nsh-testing/tests/testing/host/Broken.test.tsx | 5 +
.../nsh-testing/tests/testing/host/Host.test.tsx | 51 ++
.../nsh-testing/tests/testing/host/NoHost.test.tsx | 8 +
.../crates/nsh-testing/tests/testing/host/host.js | 17 +
.../crates/nsh-testing/tests/testing/host/none.js | 1 +
.../crates/nsh-testing/tests/testing/host/nsh.json | 5 +
.../nsh/crates/nsh-testing/tests/testing/main.rs | 19 +
third-party/nsh/docs/dev/elements.md | 7 +-
third-party/nsh/docs/dev/embedding.md | 16 +
third-party/nsh/docs/dev/js-modules.md | 1 +
third-party/nsh/docs/dev/macos.md | 6 +
third-party/nsh/docs/dev/performance.md | 23 +
third-party/nsh/docs/dev/pitfalls.md | 14 +
third-party/nsh/docs/dev/sandbox.md | 14 +-
third-party/nsh/docs/dev/scrolling-and-lists.md | 8 +
third-party/nsh/docs/dev/tooling.md | 17 +-
third-party/nsh/docs/dev/web.md | 34 +-
third-party/nsh/examples/embed-android/README.md | 8 +
.../com/longbridge/nsh/embeddemo/MainActivity.java | 15 +-
third-party/nsh/examples/embed-ios/README.md | 8 +
third-party/nsh/examples/embed-ios/main.swift | 29 +-
third-party/nsh/examples/embedded/App.tsx | 7 +-
.../examples/gallery/tests/fixtures/mention.tsx | 39 +
.../nsh/examples/gallery/tests/mention.test.tsx | 15 +
third-party/nsh/examples/host-echo/App.test.tsx | 17 +
third-party/nsh/examples/host-echo/App.tsx | 34 +
third-party/nsh/examples/host-echo/host.js | 13 +
third-party/nsh/examples/host-echo/nsh.json | 10 +
third-party/nsh/examples/host-echo/tsconfig.json | 4 +
.../nsh/tests/compat/cases/core/core-list.tsx | 11 +-
third-party/nsh/tests/compat/cases/core/script.txt | 5 +
third-party/nsh/tests/compat/cases/modules/App.tsx | 2 +
.../nsh/tests/compat/cases/modules/mod-host.tsx | 35 +
.../nsh/tests/compat/cases/modules/script.txt | 11 +
third-party/nsh/upstream.json | 813 +++++++++++++++++----
third-party/nsh/website/docs/android.md | 19 +
third-party/nsh/website/docs/cli.md | 3 +-
third-party/nsh/website/docs/ios.md | 22 +
third-party/nsh/website/docs/linux.md | 5 +
third-party/nsh/website/docs/macos.md | 5 +
third-party/nsh/website/docs/project-structure.md | 1 +
.../nsh/website/docs/sandbox-and-capabilities.md | 30 +-
third-party/nsh/website/docs/tailwind.md | 2 +-
third-party/nsh/website/docs/testing-and-trace.md | 30 +-
third-party/nsh/website/docs/web.md | 60 ++
third-party/nsh/website/docs/windows.md | 5 +
third-party/nsh/website/modules/index.md | 1 +
third-party/nsh/website/modules/nsh-host.md | 91 +++
third-party/nsh/website/modules/nsh-keymap.md | 8 +-
third-party/nsh/website/zh-CN/docs/android.md | 15 +
third-party/nsh/website/zh-CN/docs/cli.md | 3 +-
third-party/nsh/website/zh-CN/docs/ios.md | 18 +
third-party/nsh/website/zh-CN/docs/linux.md | 2 +
third-party/nsh/website/zh-CN/docs/macos.md | 2 +
.../nsh/website/zh-CN/docs/project-structure.md | 1 +
.../website/zh-CN/docs/sandbox-and-capabilities.md | 29 +-
third-party/nsh/website/zh-CN/docs/tailwind.md | 2 +-
.../nsh/website/zh-CN/docs/testing-and-trace.md | 26 +-
third-party/nsh/website/zh-CN/docs/web.md | 61 +-
third-party/nsh/website/zh-CN/docs/windows.md | 2 +
third-party/nsh/website/zh-CN/modules/index.md | 1 +
third-party/nsh/website/zh-CN/modules/nsh-host.md | 91 +++
.../nsh/website/zh-CN/modules/nsh-keymap.md | 8 +-
147 files changed, 6290 insertions(+), 420 deletions(-)
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index ddce7829e3..e6b5a53f88 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1022,14 +1022,14 @@
"third-party/llrt-crypto/src/subtle/verify.rs": "50b94c3f7476085fa15dfee704c1ae86b45746a715ce31fee0a8a3568562e3a5",
"third-party/llrt-crypto/src/subtle/wrapping.rs": "438a0b83ff2309e3c040bb3d4d875b8bdd847f338455323427eeb1d1b07b2db6",
"third-party/llrt-crypto/upstream.json": "eaf8f0a0b7b6027ea1656c21bf021885168681479b03ef925a622dfa788aa7bf",
- "third-party/nsh/AGENTS.md": "6670fd3b737cba0e6a8afe4fa5cab194a88192dcb41a00a67fd1753893bcbd88",
+ "third-party/nsh/AGENTS.md": "b5c12bc94c1466fd6f39a0c34e895fb306724303ca2ba095e3309f5d58f95899",
"third-party/nsh/CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49",
"third-party/nsh/CONTRIBUTING.md": "fb27100fec01ba22dde6da1546f9cd761b8e23bec7d47eca87146c7991e85de2",
- "third-party/nsh/Cargo.lock": "f1765287f4b60baad0fe758919f878f9da61b6ac0f3e8a17ed9488b3608955c6",
+ "third-party/nsh/Cargo.lock": "6c2b50eeed7acb3bee579b0a5ea55db19f468b6d542c7ed6682ef80c18d6a2e8",
"third-party/nsh/Cargo.toml": "8b8808f82642ac958bb922917b2d986b485c12fcc7667997ca2123c83ec06a92",
"third-party/nsh/LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38",
"third-party/nsh/README.md": "ff632ba97e2af5f8f0d69ed03250dd2a7d90f53da48b7a07976394d37f85aacb",
- "third-party/nsh/SOURCE.md": "031818282de156628cee8236b5ebb455a5afa6e29aa294f7ab653d5622f2ad33",
+ "third-party/nsh/SOURCE.md": "408a39295eab27b5ec27183b4b83bf65fd8ddba622a343d9d591f284432908d4",
"third-party/nsh/crates/gpui/Cargo.lock": "2b8a4fd17ecabf2cb11520392322bf5aaa2736a8f79989d9e55ab8795360487b",
"third-party/nsh/crates/gpui/Cargo.toml": "1d53eec1242cd480700a9e2ffd5c013f21c8b0c3207cb9544733fd5ea375f0f1",
"third-party/nsh/crates/gpui/nsh-gpui-host/Cargo.toml": "f36b982a17b38bd1d91760a2d8d9db06b42db949c227878cef294a5ff2100844",
@@ -1114,7 +1114,7 @@
"third-party/nsh/crates/nsh-base/src/dock/value.rs": "a8f75f77b4b770755247eac7c07d4340e49541bc3dfd164fbc189fa27d09771e",
"third-party/nsh/crates/nsh-base/src/dock/value/tests.rs": "3c730d09df8502d04211f7f3f00ede95be06ff1efac11deebc3d802ef8993449",
"third-party/nsh/crates/nsh-base/src/keyboard.rs": "797de960194d5a7c29c0f88c1e14429895067d68ccd00987777615f73673e856",
- "third-party/nsh/crates/nsh-base/src/lib.rs": "de0c6516d558654521318e20b97ad0d1609de40d0801a13347903f6aef629f8d",
+ "third-party/nsh/crates/nsh-base/src/lib.rs": "290ef42b9145ceffe02cf046d72ed4d007068ff7690f9891e7816460a2106dde",
"third-party/nsh/crates/nsh-base/src/menu.rs": "ba6339940ab1f77de454cd32a3d01fcaa370f2234bf44352a8ade0d043f1cd8a",
"third-party/nsh/crates/nsh-base/src/navigation.rs": "0fa4be89464b3feffacb521991192ca5d71db53a0021f90461c4e37df77c1278",
"third-party/nsh/crates/nsh-base/src/popover.rs": "d42902bb2396513bc9eeb98b7e584cee2b99647523779e2e888bf4b0fbbf1063",
@@ -1144,14 +1144,14 @@
"third-party/nsh/crates/nsh-base/src/text_view/mod.rs": "f6d200e37c21aece2cf3355781577539a9830118d3e55d34356a55776d3b5a7b",
"third-party/nsh/crates/nsh-base/src/text_view/parts.rs": "1cc12fdfdb815ea5eb44ceaa37467849b9005d99353809150379708f4b7c1add",
"third-party/nsh/crates/nsh-base/src/text_view/view.rs": "e1162a8a0e79876951ffc30fd3f930346f2cfb68969d03c284ca350c9e5e4105",
- "third-party/nsh/crates/nsh-base/src/theme.rs": "cf2c7a0d55987301434e8c61a96d4190c0945fed9c52632076c50812aef8b432",
+ "third-party/nsh/crates/nsh-base/src/theme.rs": "2eedb3df1738c45b6c295f18cd5a8a2f4959195c87d9c304fa2d8e5ecd8bb9c5",
"third-party/nsh/crates/nsh-base/src/tooltip.rs": "5ee4deb7358e62d9e88b0287d06624e538c21619229b4cace4ecda09c214c5a0",
"third-party/nsh/crates/nsh-base/tests/base/dock.rs": "d6657e7a078fec7ee3f3640540b8f2935255d68a176af79444713574d8177c27",
"third-party/nsh/crates/nsh-base/tests/base/main.rs": "d4bd54e22c71fee7a6ea0f4c242b77aaafd24c7af9773b86db6d26b6e1f9b699",
"third-party/nsh/crates/nsh-base/tests/base/resizable.rs": "77d40746a511c641b8332fa3187ecc890f278cac1582a4c5a613476fbcf200b7",
"third-party/nsh/crates/nsh-base/tests/base/sheet.rs": "2a7cfda4b7087d85df7f4e65e23c2386fe3b91a868339aa7dba159e42e27f56a",
"third-party/nsh/crates/nsh-bundler/Cargo.toml": "8284a211f8f7fbf7fc14de5ac6739a5b837ad2db30729bdbffd70001df95000f",
- "third-party/nsh/crates/nsh-bundler/src/bundle.rs": "812e38f4d8522267f832f4381d388c2ee8226729beb9605ceeea610c35600721",
+ "third-party/nsh/crates/nsh-bundler/src/bundle.rs": "19e06f32851a6f70a1ae37b709bf53c26d2165d08d9692654bac63c15e321668",
"third-party/nsh/crates/nsh-bundler/src/classes.rs": "b6fc4affe5ca9ee0dc38b0b00579375dd2c6d6684424e4b6aa08371ab965fb40",
"third-party/nsh/crates/nsh-bundler/src/compile.rs": "a8c3881697eedf15074495c7bee24c5bf79af9667ecf5a99fb6089dabdeb6445",
"third-party/nsh/crates/nsh-bundler/src/hot.rs": "c3655c9dc2c1c561831bf33d0a7915bfa596063ac130cca4b79e37a021d76c10",
@@ -1171,12 +1171,12 @@
"third-party/nsh/crates/nsh-cli/src/build.rs": "930e9e61189b299f9b4121db7810c48a463f50d2e45d3dae2a88d820ab08d309",
"third-party/nsh/crates/nsh-cli/src/cdp.rs": "a7df2735afb1013b4946b9d21393c9d72e1afa2e28e23667eaa242eb5878681d",
"third-party/nsh/crates/nsh-cli/src/check.rs": "4caecd505d9a8464a99eb6ebc3a34e047aafa205c86e794524f865b4761677ff",
- "third-party/nsh/crates/nsh-cli/src/dev.rs": "e25c40cf05389b1c2eb8d0de22a5d4fd33492eecd1ef0c0a5ec96d36421dc4f3",
+ "third-party/nsh/crates/nsh-cli/src/dev.rs": "ec8d33fe7cf391b26e6425877ad568f45fdeaf213b55268961e9fc5aff0171bb",
"third-party/nsh/crates/nsh-cli/src/entry.rs": "8880023842ca765687e3ce6f356a9f8498881fa8d90f459c025702964098ea41",
"third-party/nsh/crates/nsh-cli/src/init.rs": "953623491dc2fc64dc1f72c113b8d18edb564271b54a3e62ca7ff8648bb41f08",
"third-party/nsh/crates/nsh-cli/src/logger.rs": "09574fa1381d8dc650d820f6953541601180698657cf3eda2f1429165122840d",
- "third-party/nsh/crates/nsh-cli/src/main.rs": "feb8ecbb8f3e4b33cd29e3df840198f28cead2c3341bce22407d234b734ed46e",
- "third-party/nsh/crates/nsh-cli/src/package.rs": "40c91f4ec6e23cacd7fc256c3bed159a35a52e833de67f21797fb9917fbd542d",
+ "third-party/nsh/crates/nsh-cli/src/main.rs": "95ad9f71238edcb12aedad8c615201368147605c18731ea064e72dbdc036c985",
+ "third-party/nsh/crates/nsh-cli/src/package.rs": "cf70454089f44c682f7e30ea6ac93a8fa2219956f37be0ebf57a8453dd0585ee",
"third-party/nsh/crates/nsh-cli/src/run.rs": "077ebada325b5ac5470a21a4be510a2507c28f47711be42844e53914cf8f9f51",
"third-party/nsh/crates/nsh-cli/src/runtime.rs": "5c0c87e5f3793083bc66dcd67c0faff2362de0ee788145fa00198cd29c5b9628",
"third-party/nsh/crates/nsh-cli/src/rust_app.rs": "ada2a98a27628f508eea1fe818a2dc4252a7c60b1d73179dd3eeceba076ddd8e",
@@ -1194,19 +1194,19 @@
"third-party/nsh/crates/nsh-cli/src/templates/tsconfig/3.json": "832b63d1c87c6381f7d49d7f55394ce4ed2503d3cf0d0124b81a88ec9621a330",
"third-party/nsh/crates/nsh-cli/src/templates/tsconfig/4.json": "d7972c656ce45ce90df97fd5370c26c660c5fe8fca5355cf24822498039685ee",
"third-party/nsh/crates/nsh-cli/src/templates/tsconfig/5.json": "5d6ee953f0366848a5cbe9fe9d0a6b31637d14e8dfb20761e7730c9228753b1a",
- "third-party/nsh/crates/nsh-cli/src/test.rs": "ebf44d8fa58c05c0375a0cd7586a3f0ee38968f9c2897682c7411bae8599bce4",
+ "third-party/nsh/crates/nsh-cli/src/test.rs": "a938625c17784be9648ab3525bb76b3e549c73c8f3173a4028db531f0fba2867",
"third-party/nsh/crates/nsh-cli/src/test_android.rs": "6f3ca5c6caf8e46d48fa7d3046e82a9d1d5f50efd1a6a68a08f3c0472295c48c",
"third-party/nsh/crates/nsh-cli/src/test_host.rs": "1d180f234bed65f2e53fbae83e3b9547644e17d3d5bfeb165a12e15b874cf892",
"third-party/nsh/crates/nsh-cli/src/test_ios.rs": "aa5e20afee9e655471d7971c31f94ed9d3973784dc13edc20019f3ef45de3ef5",
- "third-party/nsh/crates/nsh-cli/src/test_web.rs": "0f4d0ce39f86ffd4a4de2e6d2d6b9d6f69427aaf10e7c1a3dfa397fc440bdba9",
+ "third-party/nsh/crates/nsh-cli/src/test_web.rs": "cedb081afca9ece1830142ce344f80cfcc08f217ae6d92dd05ebb7a260fc1748",
"third-party/nsh/crates/nsh-cli/src/tui.rs": "edadbdd4996dce76cf87b4e8eb4e4101fa6ed338f2ebb7f0725a6c61d2bde6c0",
"third-party/nsh/crates/nsh-cli/src/ui.rs": "b76573d4d63e976026013526e661b0f32d74f9da995fc1facc7f3f48c040735e",
- "third-party/nsh/crates/nsh-cli/src/web.rs": "736b5930852cca6f86b5c7ceb376308607a1804dc42ca092869c8c67cfcffcfb",
- "third-party/nsh/crates/nsh-cli/src/web/dev.html": "6ee194e1e1eeae680741635ff7c4f1f2e8a24613bf190d2ffa81a718dba27bf0",
- "third-party/nsh/crates/nsh-cli/src/web/index.html": "e31fd8c3e4d72b3b82cf47e79c3dc5fc8405321b9c26558ec86904a90703e49a",
+ "third-party/nsh/crates/nsh-cli/src/web.rs": "9990b4ddd89ef561c10da236f9613ce731b2aca9bd91c4b867452012c34332e0",
+ "third-party/nsh/crates/nsh-cli/src/web/dev.html": "e4ff740d6776686ce3fea4f9520f4e811b4adfdd04fe76dbbf3ed167fbf31f06",
+ "third-party/nsh/crates/nsh-cli/src/web/index.html": "706946ee021a1b0e62c11200242b526d351fb1cde61711fdc449512ad9610dbc",
"third-party/nsh/crates/nsh-cli/src/windows.rs": "81b1e8af3c91b3d45f7c0d05ac2abef4ca426403e30f7c71c8029d74bfde6b24",
"third-party/nsh/crates/nsh-core/Cargo.toml": "9e5e298894de41f52334ba29bec2c3cbc37ce8676f754701c9f4680f99f7a7de",
- "third-party/nsh/crates/nsh-core/src/access.rs": "5e880fb21cc43c8c28fe42bd06a08f56520d4eca33c8e404b199cdc83ac3d764",
+ "third-party/nsh/crates/nsh-core/src/access.rs": "9b45558ea450cab93f570d28de3d9ec521b2f6f5f82700bdcc63188202a4e3ec",
"third-party/nsh/crates/nsh-core/src/animation/animator.rs": "a5ccd0ab2bf2f353bf7e31c0e8ef11151f443d3a551ee63ee7487f77b87a7506",
"third-party/nsh/crates/nsh-core/src/animation/driver.rs": "68f951a4572f81335911c19b65329b3f920a85d9ad402d566716f84c1372311e",
"third-party/nsh/crates/nsh-core/src/animation/graph.rs": "a4fe8ca39d20fffce8f2114ee8d834c82044053ad5ef34c17e3bfa037c7e1fe2",
@@ -1218,18 +1218,18 @@
"third-party/nsh/crates/nsh-core/src/clipboard.rs": "36b1c87f8762288ba9f8282daa03a5deb5541769afe75d58a7ba0cf8fe7defb1",
"third-party/nsh/crates/nsh-core/src/color.rs": "93afe9bf38e8f7b12108fedc4863f8e1bee3a55ae7d0c75e788b79b02fb72a93",
"third-party/nsh/crates/nsh-core/src/core/classes.rs": "eeea690ba30e01bd1b74aebb83358475a62265fe2d902b119cb97389b84d8765",
- "third-party/nsh/crates/nsh-core/src/core/components.rs": "93f3cb23f3a570c620d8204bf46a125885f35cebe71e56e637eaafcb673dd057",
+ "third-party/nsh/crates/nsh-core/src/core/components.rs": "26fff1f2071b57c68ef227ea1ef544a4327259cdb43cd72622ac335fc929b628",
"third-party/nsh/crates/nsh-core/src/core/layout_tree.rs": "d28e3c5657e308c9cc254529457a025b057735d743e529bcf5e11734b8b91109",
- "third-party/nsh/crates/nsh-core/src/core/list.rs": "e709dcf029ac3d8d0c4bc87b4e1984ce07f50e18266d0759eaed773de1902684",
+ "third-party/nsh/crates/nsh-core/src/core/list.rs": "c9876f0abeeeed004070f6eddcdbf7b46c830c95d2d4418a9e1a802fd409a25a",
"third-party/nsh/crates/nsh-core/src/core/measure.rs": "c6692abd3002576afef5b6d777d7694e28e4754bf7ada0adf8ed1c92419289c1",
- "third-party/nsh/crates/nsh-core/src/core/mod.rs": "396f2523801a88fcf050ef3e80a6e0fc8eea15b19315049e88166852f9a2d3fe",
+ "third-party/nsh/crates/nsh-core/src/core/mod.rs": "46bd7396e508dc399a6e84d9d272627be10f05258ff9900da58f97889e574109",
"third-party/nsh/crates/nsh-core/src/core/overlay.rs": "4a65103b3cf7a35e11036532866b171dafd0bddbfe6517849520010e39d01239",
"third-party/nsh/crates/nsh-core/src/core/parent_viewport.rs": "af5798fb792c837a9cbd0654bb04aeb867ecf784d140ea159ebce326cbaf6e7d",
"third-party/nsh/crates/nsh-core/src/core/reading.rs": "3bb3d25dc8b97f35eb73dcbaf2d6f1b5dff8cdbdd012a8edcef7fab6661f3d9f",
"third-party/nsh/crates/nsh-core/src/core/renders.rs": "eba08379b95272762630258bba8c9d1908f8e8ec94d9518bfd59470edf49b5cd",
"third-party/nsh/crates/nsh-core/src/core/services.rs": "8812eefd00661841ff2b1de5d4f8cf68dd667da4151b4c79ed966cc940934752",
"third-party/nsh/crates/nsh-core/src/core/sticky.rs": "2c508038c1677fc10f11b5e6c1bf245cb78a75a02beaac24d0b09b2033a4beaa",
- "third-party/nsh/crates/nsh-core/src/core/tests.rs": "9df804c36eecc30b8f8efad2c3b60ef67e1401c17f81b1ead276d9c504b6dd9c",
+ "third-party/nsh/crates/nsh-core/src/core/tests.rs": "c37326de457e69cc2e5a804342c1b286df9e1df0b6b4c1a86236841174da3b06",
"third-party/nsh/crates/nsh-core/src/core/touch.rs": "a8559c023a443a6defad135af2f319e60130e7df23af2a8353239b0862e494a8",
"third-party/nsh/crates/nsh-core/src/core/views.rs": "c32c59533d5ee33bc16677b0459816f7670c224f3f8de3507c72029989660834",
"third-party/nsh/crates/nsh-core/src/css.rs": "56760520c706f64505849d5a32a1eada2d9fd243a5c70e07f21bc8ed0105217f",
@@ -1252,20 +1252,20 @@
"third-party/nsh/crates/nsh-core/src/input.rs": "9ef67d528e7a00bb4b325a425aa50e20ee3ebbf7f62325201520b6a3f4ffab07",
"third-party/nsh/crates/nsh-core/src/js/animation.rs": "e2bb991a9e414e81f1d4e9449b4c017d42a5a54f529f4713651e1c5badca32d8",
"third-party/nsh/crates/nsh-core/src/js/canvas.rs": "348900df6c3d12e2061e09d7d7c1d50fc409cb1f42fe4da2e1400e89fab93679",
- "third-party/nsh/crates/nsh-core/src/js/host.rs": "3ff652f77711cb48a1255805fdd51d34d5c514e14ba9d1d4ad3cb8c192c97613",
+ "third-party/nsh/crates/nsh-core/src/js/host.rs": "04e7899db07ffd3b5d8f7a5f3b33b43fc8a36af3b044cd3d229a2fe44c301317",
"third-party/nsh/crates/nsh-core/src/js/host_values.rs": "f8da8a7af5cf78c4e514357c7d15b5cec5d32b60dfdfd103a28a32bbc89dac09",
"third-party/nsh/crates/nsh-core/src/js/limits.rs": "44bafb43b6fc14a6899f7f7219fb8d7505485179bc0ec470f76d9266d9b35fb3",
- "third-party/nsh/crates/nsh-core/src/js/mod.rs": "0377b9d8f67c77acc47a467dc709090d3c5951a3f4ae6118abe680e97bfef9ca",
+ "third-party/nsh/crates/nsh-core/src/js/mod.rs": "33d6ee5852f3727ceb666f28f823cca49ec50a01b7f8c0e7877f98f63539079a",
"third-party/nsh/crates/nsh-core/src/js/ops.rs": "c43398b95c3d9a6d58027b19bd3fb4d073287986939d9b894e9d4d1c905c4cd4",
"third-party/nsh/crates/nsh-core/src/js/protocol.rs": "19c4c34368d2a05a196e580d5813159f44e3d8183b9c6e9168f9b3ab3db4f91a",
"third-party/nsh/crates/nsh-core/src/js/style.rs": "8b80f7b45468d22da5403a25092087d42340ca119e6940309e38f22ef1651926",
- "third-party/nsh/crates/nsh-core/src/keymap.rs": "c55d58813d5d84d2c33dc9a9cc9d9ef95e3e5eaef4e03a1af42c863674281685",
+ "third-party/nsh/crates/nsh-core/src/keymap.rs": "826371d8465de044dae0cf0cd7fba4f0e6928e38e228089813d010c87eefeaa6",
"third-party/nsh/crates/nsh-core/src/lib.rs": "5106d92ca05d9f46573efa426d1abeef8d7b5cddf71d4b1f7e3f7beda339c82f",
"third-party/nsh/crates/nsh-core/src/mount.rs": "d8b4f34e10db29189b29538041345b7c3cc90f2d743e48f6d20ad453d51471d3",
"third-party/nsh/crates/nsh-core/src/navigation.rs": "d79dd1d10a04157cbc5a383dc7b01aee820e6a33a16c682d3e43dc5d4909f5ea",
- "third-party/nsh/crates/nsh-core/src/ops.rs": "cc2c363638171332617f74ea3534c6da6180c4cfb6ab1ef2cf065b79ccc43d2e",
+ "third-party/nsh/crates/nsh-core/src/ops.rs": "65377dc000c3b54f93a49ffa18c1e870ddf435801206f02847d5793a8721c6eb",
"third-party/nsh/crates/nsh-core/src/overlay.rs": "e3029e8e8827a4365a4ac91162c77baa2a8e76fcbf22ded7c46cafac8cc33b13",
- "third-party/nsh/crates/nsh-core/src/platform.rs": "d0acf7489905e456a648aac2cd2e892a0a7b0a34ead24de921a7cb33bd0029e3",
+ "third-party/nsh/crates/nsh-core/src/platform.rs": "8a0a76a5d9d94d1a991a4a72b60b2bec90265d8dff3ba6b3e5b6240ad90472b5",
"third-party/nsh/crates/nsh-core/src/platform/clipboard.rs": "42bad81067d3339c38fbd45de16611c92bb6dcbf0c400c5486962ca19e70f282",
"third-party/nsh/crates/nsh-core/src/props.rs": "88d70945adcdbcf384e6b13ed1105d6f683b877e7a429a9b0804b9b356415963",
"third-party/nsh/crates/nsh-core/src/reactive.rs": "0d2d4c952be9b7f1c6c9c12898aa5d63b30e9401e8621194fda80904470e41df",
@@ -1276,8 +1276,9 @@
"third-party/nsh/crates/nsh-core/src/runtime/clipboard.rs": "916e9bfcf05cc6b8702f0fccaa20e7e1062a71dcacc820d625fc34ecfccecab8",
"third-party/nsh/crates/nsh-core/src/runtime/desktop.rs": "9f178654e9ddb48d258ccf198b5faac58f68cee3c3ef9fbeb079ed9523b87b46",
"third-party/nsh/crates/nsh-core/src/runtime/fs.rs": "b7f6770ccaefe5a04b034d649ef639601876651bece06c03b5ed4e5265d05d97",
+ "third-party/nsh/crates/nsh-core/src/runtime/host.rs": "e66077432dc0ec52f61f3b3b1b8e3a0a80779e22323dc16f539ee8d7498060d8",
"third-party/nsh/crates/nsh-core/src/runtime/image.rs": "df044f7d2f061309859de8b1c0f293747c85721eaae7ee265805da8c0517b811",
- "third-party/nsh/crates/nsh-core/src/runtime/mod.rs": "6bb4ba9ac5272e343f2b3453c56151388bc2a49a108a53d11f8b7b12449de0bc",
+ "third-party/nsh/crates/nsh-core/src/runtime/mod.rs": "e16f88133118e0c7152d757929636d3775da374f9546a085e9574583d13b5642",
"third-party/nsh/crates/nsh-core/src/runtime/net.rs": "698fadd3e298e8da089e4d4c88ed5f9a714028b021e5155c89fc6f5e86bf143c",
"third-party/nsh/crates/nsh-core/src/runtime/os.rs": "741a77c8a93dc6343873aea7b90a951f9340bd9a61ddaf185e6e6a3108bb81ea",
"third-party/nsh/crates/nsh-core/src/runtime/process.rs": "53eaea00026fce5689ee9d500aba7508f1e79d9ea3e4867232f9fdc281978efb",
@@ -1307,7 +1308,7 @@
"third-party/nsh/crates/nsh-core/src/time.rs": "c342c2e1d620cd2e8d44691e10064e56505253b696f543c1223c6ca4e34a137d",
"third-party/nsh/crates/nsh-core/src/transform.rs": "7a5fef9eb9b544d114ccf0f3c11e272aaed77eea1124da06bf247ef354e3b07c",
"third-party/nsh/crates/nsh-core/src/transition.rs": "f468b6d0a6e07d12ff76d7abbfef4c00233f69484fdfa4bb6eb7f288116375d9",
- "third-party/nsh/crates/nsh-core/src/ui/ambient.rs": "864367f4f2d4999056e9d804ea0584807c37b377dfada1f5864756817d6bea7a",
+ "third-party/nsh/crates/nsh-core/src/ui/ambient.rs": "5c2307a880fa1750f6dce07b1a3fa1008adc96f47303f11f40a7456ce59791a5",
"third-party/nsh/crates/nsh-core/src/ui/animated.rs": "b5e802d1f29181f663e375cbf6d6f09cb1706e4141ebf0b9289f25cc48579344",
"third-party/nsh/crates/nsh-core/src/ui/asset.rs": "ecc4b40dbe52c239f69de438eab9444768bb5d7e4069aa15a51c3adc7477702e",
"third-party/nsh/crates/nsh-core/src/ui/back.rs": "ef98d58af2314de48ec9fef54677a69d663a6fbd6fcbe90ba5f80f8c253bbce7",
@@ -1320,7 +1321,7 @@
"third-party/nsh/crates/nsh-core/src/ui/element.rs": "0caf17597339f476132700eb31fba3a992ab4e43818b83e51b10113801daa535",
"third-party/nsh/crates/nsh-core/src/ui/element_ref.rs": "18505f479859e82aeaabe2522318adf0004f2301ba9d7119bfa032f1fc5bc233",
"third-party/nsh/crates/nsh-core/src/ui/engine_context.rs": "fc9f4487a2ec47939119d2c6f5c75dc081ede1feb0422be8127b19fcb129fa1a",
- "third-party/nsh/crates/nsh-core/src/ui/env.rs": "6560f20704070b7bb08c1e8e7c58f63c86afc53c150aa41fc571460a0c8e6b37",
+ "third-party/nsh/crates/nsh-core/src/ui/env.rs": "86521fc681a7fc9e0dafb8bc76e38e675f11ed8c61605e8cd75e470650fad203",
"third-party/nsh/crates/nsh-core/src/ui/events.rs": "40d4d1b562781ac13732685d5decdffb81860010790469c9b4219cb3df9736fa",
"third-party/nsh/crates/nsh-core/src/ui/flow.rs": "3984b86ad1aaba5a1dd8fc32f93c139dcf3260d86500f6791ac8dd15d6de756d",
"third-party/nsh/crates/nsh-core/src/ui/host.rs": "03366afe31db0cef32adf10f5bd9eccb2406365bf1f5df711ad0e063ace1f408",
@@ -1339,11 +1340,11 @@
"third-party/nsh/crates/nsh-core/src/ui/responder.rs": "b5e419ced70b1457743c4c9fd61e3cc51c93db348581cc9bd14b61fe695e0e43",
"third-party/nsh/crates/nsh-core/src/ui/rows.rs": "a5fd4db97517c39b1e4d898c19cb4a8e60429ffc6de1021527df0f2cd53bb365",
"third-party/nsh/crates/nsh-core/src/ui/scrollable.rs": "704ae082a1f10e2b344e20299e52177ee77990bacf83eb71c6523e0419f8ec6a",
- "third-party/nsh/crates/nsh-core/src/ui/services.rs": "df1e10972fbdea7b4af78016948c214eb393c49ef8d4e023e9f55d022e91a8d6",
+ "third-party/nsh/crates/nsh-core/src/ui/services.rs": "3009cbdcb6dd76d922d1295759c019bd200e084b3112c944d904b2b1a9e8b498",
"third-party/nsh/crates/nsh-core/src/ui/status_bar.rs": "2fff306388c47bfee440e1042f9143251fe0b210cd57a370e5e8a8247c537acc",
"third-party/nsh/crates/nsh-core/src/ui/style.rs": "3a39cfbd53c8a754d59685e73ccb7cf1da431063c8b7a4ec0379bb9fd039498c",
"third-party/nsh/crates/nsh-core/src/ui/system.rs": "077e9dada158047e159eae15a86eadbdff62875e473e11255afacf32a20f9607",
- "third-party/nsh/crates/nsh-core/src/ui/task.rs": "e19a244fd39fb716a71ad33f653f3f24cc3e390c40159224ad341aa51c37e174",
+ "third-party/nsh/crates/nsh-core/src/ui/task.rs": "9416e77ea03b568807b95a3c84bd85b3353b7d5788adf6d4bc605be3d17404d7",
"third-party/nsh/crates/nsh-core/src/ui/tokens.rs": "c95b983afc27c65290b22f5db7e9ece7b48d05e7f4c769c2e882e81695ab4756",
"third-party/nsh/crates/nsh-core/src/ui/value.rs": "81804c80fb2a357b40f55fa4586c55a818370cb641e8c074e6f3f8f6046ab0b8",
"third-party/nsh/crates/nsh-core/src/ui/virtual_list.rs": "e3d0ae1417072a65323d978a8fdf7911d936d82f4304993e5ed74b2589335abe",
@@ -1362,7 +1363,7 @@
"third-party/nsh/crates/nsh-engine/src/access.rs": "37cb4b18aa2f1d47325969b035bd08f5d6cb9db6c5b437a6a9aa7bd2677f6707",
"third-party/nsh/crates/nsh-engine/src/app_directory.rs": "bf99d20949b392e5b919f8185a6e234308d53ae7db02c29b0d18ce678b74986f",
"third-party/nsh/crates/nsh-engine/src/error.rs": "54bce4d455c05a1c37b3e0ce8e46271492369855482283ffae3851605b44bc51",
- "third-party/nsh/crates/nsh-engine/src/lib.rs": "e443d068bc5197bbc89df2e6bb648a3ba1c2cc679baeaef15eb110f43bc04252",
+ "third-party/nsh/crates/nsh-engine/src/lib.rs": "d25fb01cb74b95291649adf6abe7c1a7237433f4f6e2da2817af01ad0b85d579",
"third-party/nsh/crates/nsh-engine/src/local_storage.rs": "bf75d06d4d917a14b6d4f08abb10d385c68ed4ab50b6c70c4851cbc72ad3f631",
"third-party/nsh/crates/nsh-engine/src/net.rs": "3a1fe7de92639eb17aac9e97156b7a9c6cf9384bbd9a973548c12434b48910f9",
"third-party/nsh/crates/nsh-engine/src/perf.rs": "29aeede364da40ee8cbdf2acb750abaab7cee523b2df34870b7bbeaf291f1a33",
@@ -1373,24 +1374,25 @@
"third-party/nsh/crates/nsh-engine/tests/engine/class_bench.rs": "48429824b5efc1a8a8a08776fdf3280139c9a2b450aacc7437a663bd16bf4ba6",
"third-party/nsh/crates/nsh-engine/tests/engine/clock.rs": "b11f9c4240f2787b39dc819bdbe90b2d3666b4ff3f89d267ec90d5356780495f",
"third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs": "ed35fbe5be966d3bdd3d3cf6f7d090dce664e1312a8484b36cdab2dbbbeaaa54",
- "third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs": "3d9625ac6a683620aff4c277addb251f724ce1fdc20fc1dfd228a7b3fbf17aa4",
- "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "8bc03ffd81b3ef86d101cac1d2fbcab88df98aa198bb8d55fe01a1e65653a21e",
+ "third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs": "18b53b2ebb2459d48f799c7b1a1850527f97381fbd97f05748d347db945293f2",
+ "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "9096a7c4c447ed32dc3db53530daba1f4d0f2bffea872f08399a1ab681189d22",
"third-party/nsh/crates/nsh-engine/tests/engine/counter.rs": "1b0f9981461d121290e72789c2efe495c46dd19df138a9dc908977fa7364aa05",
"third-party/nsh/crates/nsh-engine/tests/engine/errors.rs": "a050ab6f85a865b7701563de2230c2c316cf94a40eea15a17702a020d2657c42",
"third-party/nsh/crates/nsh-engine/tests/engine/external_wake.rs": "4c114be3b7489e607e9826c8264bbf9bdd161479fbda8f598da6595661ab09ff",
"third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs": "30a4ad7fea1aa1f3e481d9774ed3aefcd92832f36f3347841a8ead672e05368d",
"third-party/nsh/crates/nsh-engine/tests/engine/fs.rs": "71f693539ff71b4ddccddd925739a52a695e2fd22335cfad396304395147b01f",
"third-party/nsh/crates/nsh-engine/tests/engine/handle_bench.rs": "fdfb1c00cbae1dfa73b3b5fcd373bd839774d2f137642a5dd1b09bbc8ad12675",
+ "third-party/nsh/crates/nsh-engine/tests/engine/host.rs": "b9d3364dd822cb84fae74a159fcb1d51ef13279907d1e86ab527d6b26124a498",
"third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs": "261558f3b5badd08e89586af65893603f195588508b99e3d6164f572bc045cba",
"third-party/nsh/crates/nsh-engine/tests/engine/hot.rs": "98e44b2518e746f86a61ef503af030e7d460becfa3b66891d3a98228bcf8be46",
"third-party/nsh/crates/nsh-engine/tests/engine/hover.rs": "555c2bf8043e52d181dae2e4a35d01182fe44258d5051be1c5412ad1613b7482",
"third-party/nsh/crates/nsh-engine/tests/engine/images.rs": "775dad08ac5ca247b8cb5388e40c9a70b3583636bcc1bf8bc4d09551aacad372",
"third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs": "dee2b2ac3299459a90ef785e24081e0fd280dc9aa91d650de69e8c938ce61888",
"third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs": "85fe9f59f13bc07c66148a1d2cf8ef62858e914a83d84dd451c4d6af7d8ad1a4",
- "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "757a325fb32271922cf977d35307431e6c8fb6e1db19d4991cc9cb783e06e1bd",
- "third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs": "df4cb2074bf17123776168d8650ce2575595a3b7162796b0dfe793c880e022c6",
- "third-party/nsh/crates/nsh-engine/tests/engine/logs.rs": "e40d974c261a3b025b80dbbbb2be83e2d58f8ccd07ef9a77b82f740d5795bd9b",
- "third-party/nsh/crates/nsh-engine/tests/engine/main.rs": "0db1654e25ae62e4d496446e38e8c0b369fc82d1435ea8b791ed5c0e8ab703f1",
+ "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "0b46510028a25d574cc3cebf014b85c85d1975d85215a5c834276139772c6961",
+ "third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs": "54cb3ab6e5b415e02cc7f8fb6e79f0a2c3ca2f3fc022fcb5f4a545f25c179080",
+ "third-party/nsh/crates/nsh-engine/tests/engine/logs.rs": "22201bd9432b8e3801064aba2a5b77d3a64f4a4b148133af5a39e2e26a33c0e2",
+ "third-party/nsh/crates/nsh-engine/tests/engine/main.rs": "cd4bd38e3eee7064bbad3d25eb8a72f9db14bcb1d0d0d4883c319a343cd09249",
"third-party/nsh/crates/nsh-engine/tests/engine/native.rs": "91e9f9255e84e0f4f93e40e72fe1953d4bf305818cee95b102be715ba9bf6fc3",
"third-party/nsh/crates/nsh-engine/tests/engine/net.rs": "d6b6b5cb734f7a0ded0063d193919ea8ca1d783511102d5078bb4e8e6c07ceb7",
"third-party/nsh/crates/nsh-engine/tests/engine/package.rs": "5dd7e22c6d63c010b1ed3d701810442d112120b143fef6481cd44d33c43f09fa",
@@ -1414,7 +1416,7 @@
"third-party/nsh/crates/nsh-engine/tests/engine/storage.rs": "271620e86942712de84bbc3c0ed71763f38d8e9f56bee3717fa38ab6494131f2",
"third-party/nsh/crates/nsh-engine/tests/engine/system.rs": "fc1b5c7d5417d256cdeb51451110b728f31b85862aab377e3730540df13d4a63",
"third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs": "e5e6f8b9acd4124668deec4fc8860ab1849c1f3be82c1244330ace6e4599b5fb",
- "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "fb247e36f8ebf78c568fa6319e4dd4b5532f2f973783737f6c8778246be97c08",
+ "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "32eaa004b7b5ace88ec4b855d7734f6e9dddf5dbc0db170058f00cdd1e34cf55",
"third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs": "d598ae8ba425749118c9269d6cde057c38fae0f029758b58538d8e79368cf133",
"third-party/nsh/crates/nsh-engine/unitless.txt": "608551ec4de1da091099bad8e64d961a352681da134c4af9b58b9a29a9e43018",
"third-party/nsh/crates/nsh-engine/untyped.txt": "0e94c66fb47844aa29a7b5deab131001f3ab89ea46e79c1d75679bf618788c10",
@@ -1438,16 +1440,16 @@
"third-party/nsh/crates/nsh-js/src/dom.rs": "b75190400a363ed60f5d6737be4cb4d14f8d69e819201bb090bfc4dedb98d56d",
"third-party/nsh/crates/nsh-js/src/env.rs": "571522044cb3d43799d185e363edf87622a03580487db85e5827ea59229d0ac5",
"third-party/nsh/crates/nsh-js/src/error.rs": "01a5f911656faa39aa30dbcd71789ce30c35b3e740c5f3ec5125c87b85282bd5",
- "third-party/nsh/crates/nsh-js/src/host.rs": "3740d306e43b9eb5c81f30623044f44ee166828c9100425f691bc3d244e94931",
+ "third-party/nsh/crates/nsh-js/src/host.rs": "e0f22e5685dc0a3ef8dfb5bb6c03d2e51bbf2ccb0c1af5398e596390d5648789",
"third-party/nsh/crates/nsh-js/src/hot.rs": "6286e13ee994063c1c72292bfdb44c964dad45b127edb24a2d76461bd163d621",
"third-party/nsh/crates/nsh-js/src/input_action.rs": "eb70b0792eff82ccda139f63700656167ac04753872fca2aa63485673d9ab047",
- "third-party/nsh/crates/nsh-js/src/keymap.rs": "cf6a81a283c6f3ea77b07dabc1ab8ed3d64b88cdc3d51e8889cff6cc757806a3",
+ "third-party/nsh/crates/nsh-js/src/keymap.rs": "898484cb0e28f040f1dc95fe6dfe4675cf2acdba5d31aca5094391cacfff580f",
"third-party/nsh/crates/nsh-js/src/lib.rs": "396374fe32e378c38d5ad936b6146569fcf183c16f5133e6ba8d46b231ba29d1",
"third-party/nsh/crates/nsh-js/src/list.rs": "dafec1262d993ed59e80067ed925ba3e1ee4481a68a1a53dd604126e4e81a998",
- "third-party/nsh/crates/nsh-js/src/modules.rs": "c23d93fbf0bd5d5680fa02e402ed2c95bd574f6c3d6a900e9722bdf55230362e",
+ "third-party/nsh/crates/nsh-js/src/modules.rs": "9890faad1da6d6077f8bb0ef953ac7903176fd1282c14d04f7a3ba21217e1871",
"third-party/nsh/crates/nsh-js/src/navigation.rs": "23d6bce4611eff5da1516561caf7d89082059b2aaae0cec47f72efe955998401",
"third-party/nsh/crates/nsh-js/src/net.rs": "2c1e993f6e58ecb095a78ceac05357ab747cf8d21769507ebbc3c74c63fe6950",
- "third-party/nsh/crates/nsh-js/src/platform_modules.rs": "26c25867b012588b1d5fc0783a99b0c650915a9a48d797a57f73ecae34008d09",
+ "third-party/nsh/crates/nsh-js/src/platform_modules.rs": "223ab3c2389e33d3ce4460588de838234e177b6f5389d02cdd9b7310d821fcbd",
"third-party/nsh/crates/nsh-js/src/protocol.rs": "058fa87197c16aa0bbc76345244eddef44f0fb7a8c669ff974486889648ac4dd",
"third-party/nsh/crates/nsh-js/src/reactive.rs": "36773de3b3e8e1368cb9960fe3925d7d15e746dc85df03c659c55527d7683e67",
"third-party/nsh/crates/nsh-js/src/rejections.rs": "7873bcd5daa9b755951b0ded491e72ba9c9a82f77526dfc41df829a998fda7da",
@@ -1556,34 +1558,35 @@
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Json.java": "80d814fa3a0357463394fb0d84d98ec02687abd42b2d3521037db2e1ad20db27",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ModalHost.java": "a40b13eadf1061ae52d9ef92ea55352d836af64e0d846a48a3e9856c56a4ef79",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "6ae84283cfb51b9976a3c1c59550e826c13add4c0c7f00ae4ed59b85dea69465",
- "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "b97b3c79261f9362e07c70cf73c288fe27ea20e7a5f66e9a48a6bf4a8e1017a3",
+ "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "0148c6c03a8da5b1fed7a4dbdd5bd41a50c4a6c5ecc1ca968200aae0314c1fac",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Net.java": "891adea10cb4f8cf0020ed3854eac241572b00b32506e129987715db2a183149",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Node.java": "d16e8e7d2375e3633c3cff5edeba08cf38d08726801f67721b12ba51bcce4e5c",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshComponent.java": "70005073c746cc06ea2421a788443abd20447dacbdbe62a9cd4e8b2cbb4b3011",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshComponentContext.java": "2c1a1fcb84cb5c9742ff2a51021be1c304a8dd7db5e1841a6078bc3a0fab0d97",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshPackage.java": "8f15ba9b542631a20ff0edba6c2a159ff547fe713ea98e1a160e8b52c0c76474",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshTrust.java": "9a9027f5eab16e5f54641c72914b319c53e0de2f2502a1baaf3eec941f2ea527",
- "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "f9e25b8234d2786c8bb13073f02b958b7739d8a38db4f5c122450e2b4a29ebb6",
+ "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "5f6259c51630cc857ce95526d433f5d5eda3fc8e54ae3dba772a1a6c1fdb059e",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Pressable.java": "5bb17da3a0e1fd7519d34498752183f4e86fdc6e8d17102cd2e5a31fb3bdf98d",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java": "9fa43f2b9a68c711fbafde8e7c7eda2ac44782838288b1e6ca42c05472d6f9d3",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectionArea.java": "376a3abe901a71c03ca95edd4189a7fce1b3c620b5b4830b269ff68c99de213e",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectionHandle.java": "5c0ad0957390540d21c4b9e351c343a2c7f0532934f6ed54b8cb621102d169a8",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/StackBox.java": "f712333f289b2eba41611a7ef36b677d1f12a7850d69a9fa389e77959ec5eb1c",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SystemUi.java": "6001517437d9828a2b5cf65f9b4c7bea96380decf5f5f02c81de4991934dc208",
- "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "aebaf9af4985c6502a043f4dad054b0453b84cc4a2f772a031ae00a653c917cf",
+ "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "26b3bcdfcd9d872ebe55429f561026d8361fc8a7a9f5ceb4f63c78613d14a795",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextBox.java": "6ad8195352f29338e96c144985a4797a8894e3b0fcbf473bf7a1ea297c3ec5a5",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextLayouts.java": "bc02e66bdb1408a904d3c799f2d228487742cfa7ceb9fe24391c80fb4e368c42",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TouchTracker.java": "29d218455c922e29bf69e58d48cc797637e5030c768dfb846e6cb7969cc99621",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/VideoBox.java": "2e7c5fed70f1227353f2f6363dcd6c4fa4d5f277a2739515fe13bba2f675e3f7",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WebBox.java": "5f49a4b3748f8df0180ec45cb3b3c296694ca0ba3fdf2c7d117cb4e021b39965",
"third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WidgetBox.java": "51340bdf442b737f2271e091ec2114e0550b005e8a5eba02ad0947c53c574120",
- "third-party/nsh/crates/nsh-platform-android/src/agent.rs": "7ef85fa373ab3d4516afa3b9b6fc221de133edd12d092366d0ec37a372f3e6b5",
+ "third-party/nsh/crates/nsh-platform-android/src/agent.rs": "c67465629a6d49e13dad3c12bc44550d1d500bcea1222905634df8b39c68b97b",
"third-party/nsh/crates/nsh-platform-android/src/animation.rs": "c1fef8de76e942b48c05e108723effc49ed4a57cbe726d9b73037b77e6704a26",
"third-party/nsh/crates/nsh-platform-android/src/dev.rs": "fc60e6257001b3e5c27802d4320e138bebf21f66a561928297a68bbc5de0d5da",
- "third-party/nsh/crates/nsh-platform-android/src/encode.rs": "1f7b9bb75a90b6c882577af186dd72c41b342f358262f3453100187a07f4b115",
- "third-party/nsh/crates/nsh-platform-android/src/host.rs": "15101195191e4d8b60fcb04f0a7ae1b02f488dd368c216908256f89151c60b3f",
+ "third-party/nsh/crates/nsh-platform-android/src/encode.rs": "053353b68306cc26811df3a0e8d63f607efca8e47e80b943f167619befe37c0c",
+ "third-party/nsh/crates/nsh-platform-android/src/host.rs": "9da0fb7d5ecf1729bc42127f7007c749c80321599ef723ab88864d8b030dc034",
"third-party/nsh/crates/nsh-platform-android/src/java.rs": "342ea89f068da2261731704ba4cc3628835a1c06f5b8db07e071abcac8d4ef3f",
- "third-party/nsh/crates/nsh-platform-android/src/lib.rs": "f7dd6f6e125ff9a6c488fb9322cea08da0fa84a234e7e03a9e251007b874a352",
+ "third-party/nsh/crates/nsh-platform-android/src/keymap.rs": "7710df8e27d117d3902ab7788a425406cc6b2b2226f3a42abbc60f16239b4f6d",
+ "third-party/nsh/crates/nsh-platform-android/src/lib.rs": "10282c14a4d56f60291ae8bb6c07593275592ad7ce034b63ec91c96d5d41660c",
"third-party/nsh/crates/nsh-platform-android/src/logger.rs": "7fe95c849533e54aa753757090b5747c1131e0523f2f5b24f260fb77bd0aa2f8",
"third-party/nsh/crates/nsh-platform-android/src/net.rs": "e51b9e715af5652a6d0697beaa707d021935128c06751010e773c0bda9baaa49",
"third-party/nsh/crates/nsh-platform-android/src/package.rs": "f69038eb79ac0e6885cf831fce67572e4b068fb164c21ff64abd40c82ef56fa0",
@@ -1592,8 +1595,8 @@
"third-party/nsh/crates/nsh-platform-android/src/text.rs": "5ca4425a2ab7a7030667c0b012ba3c85b3a126f3f015fafa785c5343cc3b6b8a",
"third-party/nsh/crates/nsh-platform-ios/Cargo.toml": "0fe3eabb15eceffb19a896318ce623e6f0752da8b8abb174dfd517162c5dd3c6",
"third-party/nsh/crates/nsh-platform-ios/include/module.modulemap": "687c700460603d8f9ca214ff058f91d0d6edc9e3a7350d981015c73872e5cea1",
- "third-party/nsh/crates/nsh-platform-ios/include/nsh.h": "b8505a1f2a0cb882be5dc798d475249b7bb2ae2850cf5575137a8c9e1c5d174f",
- "third-party/nsh/crates/nsh-platform-ios/src/agent.rs": "adb7c1b931ad01ccdb88dd42aa6ba9f27e407d7dbbecb34181ef4f4e5ee1e45a",
+ "third-party/nsh/crates/nsh-platform-ios/include/nsh.h": "20924d096a81b7a91ee4ccdafad44d0ce76b13eaa3f17c59046c911a0cd60104",
+ "third-party/nsh/crates/nsh-platform-ios/src/agent.rs": "a94f761b87e51ce68ebd13fa24477a452f04a6ad9b96005da6851aa52c526be3",
"third-party/nsh/crates/nsh-platform-ios/src/animation.rs": "662ffef6013aa336d05648576e149e7736a35c059ab549cc50f26cbfd75f966f",
"third-party/nsh/crates/nsh-platform-ios/src/app.rs": "b307e39e915d38961129c41a5a5c3858198a38b2f9e0e28655d2c020658e513d",
"third-party/nsh/crates/nsh-platform-ios/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
@@ -1601,36 +1604,37 @@
"third-party/nsh/crates/nsh-platform-ios/src/component.rs": "b53803ac0876795ea7b574dab2cfa771c18f30360ebcb2ad2989d716d52e9e66",
"third-party/nsh/crates/nsh-platform-ios/src/decor.rs": "bf54501f9a913cd17d03eea457fa3e20a90a765ff7171208bbee11136e5c7249",
"third-party/nsh/crates/nsh-platform-ios/src/dev.rs": "6a4d83df5efd1b0690558d817a37d2404459f855c389e7103463e06dc08e46c3",
- "third-party/nsh/crates/nsh-platform-ios/src/embed.rs": "3e9aa5fbcc4996305604aeee1a90863303e09405864968506205034fadddc64d",
+ "third-party/nsh/crates/nsh-platform-ios/src/embed.rs": "6b45045a29a9c5cf78b4754943f618df68b3ef957b0b6faa712f36876930214a",
"third-party/nsh/crates/nsh-platform-ios/src/image.rs": "67dab1f77c0809b63ffb798989f51b595b0663d3ef1bd3f6ffa93cc2dc982b34",
- "third-party/nsh/crates/nsh-platform-ios/src/input.rs": "5d6f6d43a3d011c8a513f9208590789408f31b0eed2067845d6ccbd7851712b2",
+ "third-party/nsh/crates/nsh-platform-ios/src/input.rs": "d32418441866e41b8a38df75beba6060acd18acc13ce8da11c3c6a6087a1e38a",
"third-party/nsh/crates/nsh-platform-ios/src/interaction.rs": "10b85e580a1c507a884d62c222c3f0b115ff82eac0f38e944954e17f9219d0ec",
- "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "25c0ad2a5615c46eca35d5420d02c3e24d869b092533023569d5599c5a32f6a9",
- "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "8c85c83c95e1e3eb9a29b79ea49820ec37b11955ee4998175d6c293b102eace2",
+ "third-party/nsh/crates/nsh-platform-ios/src/keymap.rs": "578fb8e85781ffa08f53ff1aaf65baa1ea80a08dc6892f4d6f180d649aaf54d3",
+ "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "57025f16e8c3081ad070eaad14458e64bf0059f8c195e4e6653faf0dd8c7f901",
+ "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "4eda9f5a722b53e6ac26cfa14b2adee1895081cdf09a6acc26679c0297b5ac19",
"third-party/nsh/crates/nsh-platform-ios/src/native_view.rs": "e73867cf00a330e184a6be6830d7b25894db62c1149e85fbe97b316f374831e6",
"third-party/nsh/crates/nsh-platform-ios/src/navigation.rs": "f881a30c8db5b5b01d360b1c497547dd1cbaa228ecfee7c55ccccece87d2a3bb",
"third-party/nsh/crates/nsh-platform-ios/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
"third-party/nsh/crates/nsh-platform-ios/src/press.rs": "3a68966f15bb361d8eba789b9a3cce2b4877c7fa9a7e8aca4f9868e2faeb2536",
"third-party/nsh/crates/nsh-platform-ios/src/selection_area.rs": "d492cfabf02baaeac68898eaf8a231141f635c5875d8336a401414346edbdfc8",
- "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "554efab1eafd60337541b6fca2278807c182ab525533169717ab33f2fbeb8efa",
- "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "db7aef0e5815132a49d334f2f3bc5324ffe00a3913825672af87936854321c8b",
+ "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "93f4e78ceddc9142713ac7bb7157e6b5308e59026a296247c268ba7476c8e7f1",
+ "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "5ed5e9e78a632820a05ae9114d275283a5b7bccb782d1b1f80031ca013c69a52",
"third-party/nsh/crates/nsh-platform-ios/src/system.rs": "3c96018a1a03e54ccad60d4b3cdb3848d4a687864e865558c257ff4a4e4abd2f",
- "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "994f0a282f57b553a1c392b191a2853dbd50b453b7155fa4e13d7c771e810545",
+ "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "6dae6b295faeb46f663b167c0b45c70dcd418c4e2013a9a3058cc6a602fa8c59",
"third-party/nsh/crates/nsh-platform-ios/src/text.rs": "142376f7efed62e4d4c2ed8059d7ab0642356ce95d32db4767cb4f756905377c",
"third-party/nsh/crates/nsh-platform-ios/src/text_selection.rs": "cd68c073fed1d2baed7121fd72d2e703e7c14e5b9963fbc4f75098f38a00b6b2",
"third-party/nsh/crates/nsh-platform-ios/src/text_view.rs": "b7bd9d38bab972227360ba7bee4050d895283e406de2bc5e153f29dcf2465d1b",
"third-party/nsh/crates/nsh-platform-ios/src/touch.rs": "cb910d75ca29435063749c0620df765b116f739e19d79889497572a5c9461f42",
"third-party/nsh/crates/nsh-platform-ios/src/video.rs": "aad3d01262fc4520b9523eabff0a68fa6373c71433f0331bf6200e69b6907bf1",
- "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "042e66b4200c4713809decb8bc0833d15dfffd6b8a2f0f77581732463b2f071a",
+ "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "1807d29d3965e290e0c2d94751acc04f2e27e8833ebe9978c2c31de19fc607b5",
"third-party/nsh/crates/nsh-platform-ios/src/visual.rs": "dc968d6bbfa977b5791ae1f9cf3d859d3aef5567799f02ac3588b3f280744480",
"third-party/nsh/crates/nsh-platform-ios/src/web.rs": "2bf4ec4a10212c735585347f72175af0e784b7fe50b7e44037093c48e56c2844",
"third-party/nsh/crates/nsh-platform-ios/src/zoom.rs": "274010fd16832c2c93bde502c2c1d6ad308d59e594cdf859d28d87b5ec6ab865",
- "third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift": "052e146f7bc8c99509f459fd908efa4718ce1e9a0dc5a9c0948786598d162df7",
+ "third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift": "6058b673239941e863b8cc97180d09b4fbd8a4957cd32cedfb1e2d1ae6104d87",
"third-party/nsh/crates/nsh-platform-linux/Cargo.toml": "2ea41863aacd26d6bd568126f0c6f089f682555b4adc8fac5e2a7bd49dde007c",
"third-party/nsh/crates/nsh-platform-linux/src/access.rs": "9b9aa4d728b4451dfd80e0c3306deca2d7abf1308bb3acc3c2f0957257ed06b0",
"third-party/nsh/crates/nsh-platform-linux/src/agent.rs": "433d0a4d9b12c7373a2941d255c5f65103a34639d27b9c60caf9e6799c3c23d8",
"third-party/nsh/crates/nsh-platform-linux/src/animation.rs": "7fc1b4f7ec5b1f2010216bfbe8f4a773b7ac08549b326d439e266f0188be514b",
- "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "bdc83b899efa9d5aed4ede07303e7ef82803b0ce7b4648f4fa6e0c5edc2b3e52",
+ "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "3a1acad9886b53f25f263cd39cf29d9c2ad12c02f254b12aae0088b25fb891a6",
"third-party/nsh/crates/nsh-platform-linux/src/canvas.rs": "555850bb5a9cb0b9f09438ce360f460fb9f2144c4701001ba0bfd7dd91726dfe",
"third-party/nsh/crates/nsh-platform-linux/src/chrome.rs": "b0b1ba6b3e5280a5ede9afe7cc629738c1f99b3f57af3a5864c41ca5038e309e",
"third-party/nsh/crates/nsh-platform-linux/src/decor.rs": "9250081c55b8248278ccdcc8971184d6bfb820f2e6893d407ab76e84c21f37c1",
@@ -1646,7 +1650,7 @@
"third-party/nsh/crates/nsh-platform-linux/src/navigation.rs": "77be5eef778948105eeea4d91306fbd70f2afede4d43d5432bff377db6b49d93",
"third-party/nsh/crates/nsh-platform-linux/src/scroll.rs": "fb7b5cf5843a20d67f1a0550bbb53a1ac807684660ae691367d9bcbef2894310",
"third-party/nsh/crates/nsh-platform-linux/src/selection_area.rs": "f38e30a766c3d7f52954c6b0c8aca3a52dbdee94c90c4ad6f21b0b6aff66ea4d",
- "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "3182b74b110d3a684fad43616c7542bc61e6bd0017a2932fe974f101045833cd",
+ "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "f2087a9d1df07a1f5a94a49037fa0acd3409a873c334b66a858cff67161a617c",
"third-party/nsh/crates/nsh-platform-linux/src/synth.rs": "ca932e93904d5b3b734a874be5337c2f6eeeea72c35e4c5bf57c04d864c30df2",
"third-party/nsh/crates/nsh-platform-linux/src/system.rs": "fea65629cfbcf37eb050fdf9bb9f736e092af0375bfa8dd4f6ea425390f378be",
"third-party/nsh/crates/nsh-platform-linux/src/text.rs": "87d1b461b411da98a59e120e13290cc41d665bbd9c7eeb65d62db58c81fcc6ff",
@@ -1657,7 +1661,7 @@
"third-party/nsh/crates/nsh-platform-macos/src/access.rs": "5bafcc7cb9aa39c5d931f5c0fa5fef547b3d32ee6bcb1b38212cd7f1915c0b25",
"third-party/nsh/crates/nsh-platform-macos/src/agent.rs": "db50231031040a91216b29a9b41b6a936016d6ff2d1c8cac2399dc08d8f9b6e7",
"third-party/nsh/crates/nsh-platform-macos/src/animation.rs": "2458ba49973e39a739ad031482ef4c158a29e80c69413cf06bc615add190ad29",
- "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "c1562689dc7c467a0e763a39b874babfebd92b0201b6709ce7c3dadc08af60c3",
+ "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "f4866d3554882d2e91c78725a8da7191d9a1f52e743c975f97f713862bcb6580",
"third-party/nsh/crates/nsh-platform-macos/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
"third-party/nsh/crates/nsh-platform-macos/src/canvas.rs": "6134e252a2751484fb7a6db63f9c5ed95364126f38f988fc016a846de3a54963",
"third-party/nsh/crates/nsh-platform-macos/src/chrome.rs": "83129a8a83998c110487f496c04ad9c5e8363c6d6ea6f64666789ac13d1fbb6c",
@@ -1676,7 +1680,7 @@
"third-party/nsh/crates/nsh-platform-macos/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
"third-party/nsh/crates/nsh-platform-macos/src/scroll.rs": "824c167ffa1e9b6cfee91ab7aa1504b557da5c71003b18f736ab96f0872e1bdc",
"third-party/nsh/crates/nsh-platform-macos/src/selection_area.rs": "0425f31d882802bdee2f7bc2275dfcffa8274f194c2fe2a5a096efc8b91df4e6",
- "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "3068eae4156da32035457523a212c0f03a40096043d6972ca6bcd97fd79fbbca",
+ "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "52cb73ba0201bcb9fef631535a07144b9ca32f87bad03265070732404e75d054",
"third-party/nsh/crates/nsh-platform-macos/src/synth.rs": "c6510dcd66cb600e382e74d2d86352196188303bd86a85f12afb21213cf51776",
"third-party/nsh/crates/nsh-platform-macos/src/system.rs": "1e7be08090cecf5741c52799b7770a0b0dc82ffe78a752abee8ff98f2321577f",
"third-party/nsh/crates/nsh-platform-macos/src/text.rs": "ff5de5d8db0099f156a8dd643f7d85a804325f78bb9d3b7ea965e36c97fbaabf",
@@ -1708,17 +1712,17 @@
"third-party/nsh/crates/nsh-platform-tui/src/script.rs": "a0fb944fabd26af711306bdee37342b13fdba26c5066e5752e180e4e916d09a6",
"third-party/nsh/crates/nsh-platform-tui/src/scroll.rs": "3a5f2de17609b5e93aecaf3ba89a403c35dc4046d2814106a40c0c00a31caaea",
"third-party/nsh/crates/nsh-platform-tui/src/services.rs": "e871114faa1c56ae24ab047ff2da7ae5df4a728cff17797c8bb5e3009904ec1a",
- "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "db4cfb9f6bb0cbff8fdb346b0810762a7975a7b1d8d82f0529c2268c6a55abc5",
+ "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "bc47eabd0c826400c3469054752369862a4dd10c0e6c2da021f8ff992bd62c68",
"third-party/nsh/crates/nsh-platform-tui/src/text.rs": "c1e2a055250529ed1cc2888cfe444e306e1ea13c62b3e52f36f1afc1586f5ee1",
"third-party/nsh/crates/nsh-platform-tui/src/worker.rs": "0eef61c1cc330015df84793e604d984af732f10fb14e1520c7f989253d354bb5",
"third-party/nsh/crates/nsh-platform-web/Cargo.toml": "d49263fc6c477f87e42e5e6c8852c792bf79068c411b40c98055f6415b616167",
"third-party/nsh/crates/nsh-platform-web/build.rs": "6e60cd4d9737c2766e2631ff775410cc0aca1fc53d8463356ccce49f3a11f803",
"third-party/nsh/crates/nsh-platform-web/src/animation.rs": "b19491130e0b1009a483abccfd0a335684973072137c8e14d500827f7acf65b0",
"third-party/nsh/crates/nsh-platform-web/src/canvas.rs": "9fc4c10800eaa2b69ae34903fbc8aa0a3e4a65e53b8151d3390ee883bd8cb4b9",
- "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "b776129aee4d45596a5d6c1a6c2631a055e6aceb2a8161a96e96dd870a3c8a30",
+ "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "ee16f1f491eb67549e5f0479fffdf762e1f4e1d16fb556e66237c00a169e4a07",
"third-party/nsh/crates/nsh-platform-web/src/ffi.rs": "1ef25ae33cc562695438e574d7fead4a8b9d1536ce9ed78e8619d73c4c616d23",
"third-party/nsh/crates/nsh-platform-web/src/filesystem.rs": "34cb25848b20fe11967437368e5f5feda3f1297982232eb54aa27ab2611cfccb",
- "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "4470f61a9480768ceea2654edc371997e558cdd6ca2001e1696deca8480dc778",
+ "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "1b862bfb6b6f6eccdaa4460ff5d0528469aa22ebbf13974422bcee39fc50306f",
"third-party/nsh/crates/nsh-platform-web/src/local_time.rs": "63f2781b1a5364c3914bbefb1bf0538d77876a1324a2e7a9548231711b8241f4",
"third-party/nsh/crates/nsh-platform-web/src/net.rs": "6ad23669ffb7f920da1d4c0acd9cdf6bf6a1b8f33317dfd27eb40ce0b133fe9e",
"third-party/nsh/crates/nsh-platform-web/src/storage.rs": "fdff261938b0d75b6b1ff633b4788cc386de23aede2b563fb226508042a3c27a",
@@ -1728,7 +1732,8 @@
"third-party/nsh/crates/nsh-platform-web/web/local-time.test.mjs": "e8b62dde3208d979eeb281bad19fcb7c19566732500e969ab2ae5c4cf373c9b2",
"third-party/nsh/crates/nsh-platform-web/web/mouse-browser.test.mjs": "8f3e5d180558a4d4553d01be86dd080ae80638734c83095d90b5507e2ee65420",
"third-party/nsh/crates/nsh-platform-web/web/mouse.test.mjs": "b6db2b6bb88c0e5b13bab082027c0d84162156350e1c86bde91cbf1f98f63644",
- "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "55bfc70c093b86520a668cb46db9082265fa534b2965363f7c782787f2157cf0",
+ "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "2ef82397994e5a5ee1fae4e08ed8c8c0e964f014411335a5a324e2608ed97adc",
+ "third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs": "f67b256bcb312be5ac46a7e0c11e07ee52fdb4e467f41551db460def709062f0",
"third-party/nsh/crates/nsh-platform-web/web/scroll-browser.test.mjs": "3f8150ebd2b69d7ab306307868b938b99435308ff37c2c999cf297a0f94fd30d",
"third-party/nsh/crates/nsh-platform-web/web/scroll.test.mjs": "4ea1e845ddd2e480a284caef06f2fcb4dd26f85d0d0808ac8216977cfaff0b30",
"third-party/nsh/crates/nsh-platform-windows/Cargo.toml": "ca891951f7e5d38889c956e0f00aacffa11da045ed4bb849065c781950c75d9f",
@@ -1736,7 +1741,7 @@
"third-party/nsh/crates/nsh-platform-windows/src/access/uia.rs": "9b37dace78dd3ec658728ff71c73b61d79da7e7852dbaa573a8588d02769ea9c",
"third-party/nsh/crates/nsh-platform-windows/src/agent.rs": "a4094f8b42c1096461dd13e2b6145fd497f2dfbf7b171ad35cda3d349ce45e99",
"third-party/nsh/crates/nsh-platform-windows/src/animation.rs": "5e849ee6e81ef77bfcd70e255228431f3ea2b67266530a10d962582541b78c29",
- "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "5f98ffac0f08e7dfc58d21f90cf6d4f7e5f4a346b4456cfe47b76d99c6899ff6",
+ "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "29fe32d6642335242fee5afda1859f905947a55c4af8cb979e350df1af877072",
"third-party/nsh/crates/nsh-platform-windows/src/canvas.rs": "ada51da124bade5308b0dbdc3301b5001c0ae0963ec47164800b316174c21f17",
"third-party/nsh/crates/nsh-platform-windows/src/chrome.rs": "1d7cddfb2cbcb9e6fd0c02e4ff72ee468caf89871142c3a6a8ffb5207e9717e0",
"third-party/nsh/crates/nsh-platform-windows/src/compose.rs": "fb996e3c8fa97934361aa72c27f599255f242fc98c79ee1c6226b4ee94c314e9",
@@ -1760,7 +1765,7 @@
"third-party/nsh/crates/nsh-platform-windows/src/render.rs": "50cc1fd242bd15fc495a77ca2383251ce5ed712688a552de908e3fc268d3d9f8",
"third-party/nsh/crates/nsh-platform-windows/src/scroll.rs": "28279bf52c6a8e36f173e9e3501470f1c7a17d5b7899390905a08f66202da461",
"third-party/nsh/crates/nsh-platform-windows/src/selection_area.rs": "79cc6116c2cfd1d33dd5b5e6bdc15222c3460aeb6eddf288fccc09489417b3fe",
- "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "680f0210bcfa893a1f4c182a097b4a96dd18d4095c5ad67f27ba0ff539fdae63",
+ "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "2477b05ae6b5711c8614c240251f04b7d5b7799aa012267341026714b7107808",
"third-party/nsh/crates/nsh-platform-windows/src/synth.rs": "27fc28c8d4d0b54dec35abfaeaa283f39bc3fc33eeb9d5b1cd9c280a6511966a",
"third-party/nsh/crates/nsh-platform-windows/src/system.rs": "0466f5714b717e08d7fbaec35cc7109be0bac8d30d2a997db13420c7eb23436b",
"third-party/nsh/crates/nsh-platform-windows/src/text.rs": "da82b3aba6af2b92c2615dbd27bc40ac7cdda0009027a7190faa349a8d2070e7",
@@ -1768,24 +1773,25 @@
"third-party/nsh/crates/nsh-platform-windows/src/transition.rs": "dac21868ca439f92c4ecb41197fec60f4b8cf54e0364213dbcf4b8ae9e01cc24",
"third-party/nsh/crates/nsh-platform-windows/src/view.rs": "064cccd47c5b13724fafe4082dc86954b676b746186c01100e466ad764294ea1",
"third-party/nsh/crates/nsh-platform-windows/src/window.rs": "84b85a4d05b9194198b647a7244f4dbeec4323df9a85ff6475f73c3ffec46ce3",
- "third-party/nsh/crates/nsh-testing/Cargo.toml": "a32d7aa17b13e244f7e9c2c6c8a8859cbdec3e878c1ddf1655fdd4b5b7113c0e",
- "third-party/nsh/crates/nsh-testing/src/agent.rs": "dadd07658ee16f5d6e57f89629ad14233e380ca4b614645d3816df7c6755aee3",
+ "third-party/nsh/crates/nsh-testing/Cargo.toml": "8cab49bdd6a4fce1584e25482263ccb82327a95fde6a2b83b32d9f9447fd974a",
+ "third-party/nsh/crates/nsh-testing/src/agent.rs": "be0bc6365aa19cbe444fbd8522675d99d6e907ef12ec71a5de86c8228fe86961",
"third-party/nsh/crates/nsh-testing/src/agent/forward.rs": "6e62c09570ba63b910caa280eba059fd2ef87f056bca59d2e5413b4c313ad4b2",
"third-party/nsh/crates/nsh-testing/src/driver.rs": "9a97ff5d9f7796965e7659298c2b885f214e826ff218c946d64f25f3d7712895",
"third-party/nsh/crates/nsh-testing/src/headless/animation.rs": "a38102dc120bd208331b64d94c28cfb2b1aba52845bf31d7f387fa552a6ad18a",
- "third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs": "986176b5894e26d549dc9d6a1db119e06a4c7b224ff32e4e13dcb77f73e4c44e",
+ "third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs": "57f9729e15ab46479abbac9926d81f3c751cacd3be3e2b6136009a11b6dfb34d",
"third-party/nsh/crates/nsh-testing/src/headless/live.rs": "97e9ebda01076dbbc525618303316f379397af8f07a4e0fcc3372a89096f6597",
"third-party/nsh/crates/nsh-testing/src/headless/menus.rs": "79276a0066e4505bcf3e5043f2bb800415a84e537415633f79815235b122461f",
- "third-party/nsh/crates/nsh-testing/src/headless/mod.rs": "fc6ed20e9dd3f5ebb55d03ea8129f43d213934c6ba3adf307308c348fa12bdf8",
+ "third-party/nsh/crates/nsh-testing/src/headless/mod.rs": "7d91786f0375f981748d58887bd81c1b41ea24f835a7ed16cd6986afdfc17df1",
"third-party/nsh/crates/nsh-testing/src/headless/net.rs": "05b51b76f89a71f11b085001950812cd581bc9f13f9111a867b6cb3dc608f3c4",
"third-party/nsh/crates/nsh-testing/src/headless/net_fetch_tests.rs": "798c72ea94333a2ce720d313a95b7e5612cf73c1830c9c8aed4ea5550e52f6e9",
"third-party/nsh/crates/nsh-testing/src/headless/pointer.rs": "b535b3b01c7d7803b8c92aca81258db65caedeff7d13b59d28f8874def6f6087",
"third-party/nsh/crates/nsh-testing/src/headless/stub.rs": "4f6a52cea583a94614c5552ec02b188d40878e69e65cd5b04ff8904a9f73ff3b",
- "third-party/nsh/crates/nsh-testing/src/lib.rs": "ac12cd696637cae87b62c08396b334a01b565ae9d0698383ebb2623e1f1eb160",
+ "third-party/nsh/crates/nsh-testing/src/lib.rs": "f2ba1344b98370030cd612d5c03c7a8cdfed8c5d346fd1f9005eb091e66cc837",
"third-party/nsh/crates/nsh-testing/src/menu.rs": "4bc761c4ef832b71e25630b3232d0b0a5367394b6632b2745edabb807248486e",
"third-party/nsh/crates/nsh-testing/src/module.rs": "209147852295109003570770a3383fd938a10175849fb8e2be80823c6a660394",
- "third-party/nsh/crates/nsh-testing/src/run.rs": "69b39dc5e84d927b39269e73b921c7c497b88db2bd073f7475c7ad578e5794c5",
- "third-party/nsh/crates/nsh-testing/src/runner.rs": "9ac5462e7834dacf12916681b65e2edb7b482f350dba658d8ee338594b220447",
+ "third-party/nsh/crates/nsh-testing/src/page_host.rs": "6c8c8d516a491dc41568cde09fda8bc7f5974bcbce3350b4d907260eaf3eb2e8",
+ "third-party/nsh/crates/nsh-testing/src/run.rs": "0b1c17a4059417d52ca40aa080311db5df08c95bca4f95cd0fd05abfe77dd730",
+ "third-party/nsh/crates/nsh-testing/src/runner.rs": "320e1368ddaf6e60da66ff7faef74f753bcdc5953d4cbff0dc07170dbb7a2534",
"third-party/nsh/crates/nsh-testing/src/screen.rs": "04cd1b008006e1b5aae501c31ac139443bdbff1b901b21038033fa746fb20895",
"third-party/nsh/crates/nsh-testing/src/selector.rs": "9567a7d5ecba869684cbe4a9b4d7441c074dab2e6d564ca6463302da0dd40599",
"third-party/nsh/crates/nsh-testing/src/storage.rs": "72eb5c29dec32f9a8d9afaa3b8d4053a8fc8cc76b0f2bf31f1f76cfd5ad4020e",
@@ -1797,7 +1803,14 @@
"third-party/nsh/crates/nsh-testing/tests/testing/clock/App.tsx": "88a13af9015a64565d9d5a2b208e29a3f92bf5ad42c26f30fb6b52fa588b7e8d",
"third-party/nsh/crates/nsh-testing/tests/testing/environment/App.test.tsx": "187831fa2adc650723de6e17ed53e1bd88f80169e91daa946c2b0277c3714077",
"third-party/nsh/crates/nsh-testing/tests/testing/environment/App.tsx": "84583720c58d63385b056e0f2145d35b22b6facc1d5eeb6a49ddff86d83cbefb",
- "third-party/nsh/crates/nsh-testing/tests/testing/main.rs": "9be3af39d046d30e1ca4c5ec969292480178bb1f033037bd605ab7e604941c8b",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx": "4e0cbba5c962cd49fc8d4133296be0dbcc25eead3401fa2ec25b93e0409a0c7f",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx": "38fc46c28004adf81e13f73e8e47322ba73c0b8b20fae40b4e221210a3037c7b",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx": "af3b65cc75680b47ea1c1cb73f53a34842c0bbb4ac693042652a5aa8f5206cb7",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx": "57da9b0c7f7dede954ad2def033eb98936f3133ad436d7ae49b695b841793990",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/host.js": "8d127bcc8a8bc959cb23c48a53fedc83f106dbfd0fd575b8812a1095bffeb1b7",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/none.js": "56c2d2a72379cc68ea04dbf5acb31d79a2c7673d3c272b2fb6a4ad6965a74ad6",
+ "third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json": "76e6426bcd425eb7673cb1e68d59e91acbb2f055e8c13ffaf9b05e21d727589c",
+ "third-party/nsh/crates/nsh-testing/tests/testing/main.rs": "31dffcf89c9c0efdff611143476e2ea6ff31fdec888e94d90a65f143ab6a9576",
"third-party/nsh/crates/nsh-testing/tests/testing/platform/App.test.tsx": "0126c606f69d8fd7b880b0a4c046f0da6187db574ab492a21715fedcc65d8277",
"third-party/nsh/crates/nsh-testing/tests/testing/platform/App.tsx": "d0a26d35006662ae6f6410493a647defeb5a01dbed7f7fdf78ed9b573b72b956",
"third-party/nsh/crates/nsh-testing/tests/testing/queries/App.test.tsx": "cfc603ff619f179002576cd89994cd09c3f07cec4782ebf33538aef55c687449",
@@ -1837,31 +1850,31 @@
"third-party/nsh/docs/dev/compatibility.md": "26336d6349cbedd0a70990dd5a4d2e7e48c604242974ac164e68bbeb4ad1cddd",
"third-party/nsh/docs/dev/components.md": "9f4a41c405a016275002fc9d850a222dcf941eb47f96201a1a384ba3f8a34914",
"third-party/nsh/docs/dev/dock.md": "51527992aeec0387fe7ebdb82b7d24ed4ab6f0dc19b3332e23236cb7a3ad1041",
- "third-party/nsh/docs/dev/elements.md": "94da584a4b2a7dfbd54c83799329ed05220ce8f2c86107df1e556148dd2b790f",
- "third-party/nsh/docs/dev/embedding.md": "db9490c56c428005722ff46a5c1a2e632b6f6dfd4a1283b99b766629cca58c5f",
+ "third-party/nsh/docs/dev/elements.md": "e0e23205ae83d711e9a67e5203e421af27ddd2d0d992dc2f87d54a4d8e828f45",
+ "third-party/nsh/docs/dev/embedding.md": "b833df433ce8eef19587bde3d2805abe41859811c6c0794b99e37499227fcd2f",
"third-party/nsh/docs/dev/file-drop.md": "ee9e7e263e32789d03893108e68b47d2447a0fa19b29be6b348f269884cb81b0",
"third-party/nsh/docs/dev/geometry.md": "cc747bc4cea9d983b1ac3d634be58a8eac27254ecb912c2940173f168de8f9a9",
"third-party/nsh/docs/dev/gpui.md": "15cb649e427c84858f177b0d85ae0540dcd4ae23a3b8b3df414a9f1dab6b0284",
"third-party/nsh/docs/dev/images.md": "9e797fd623bf317a5e96fdc7d04ac1bf85f2fc82d267a2d220e3e39c9a6f187a",
- "third-party/nsh/docs/dev/js-modules.md": "991718901c2e0ee19c3776a7f806d30717c0a1842a5f4c5f1bfb18343795ebb8",
+ "third-party/nsh/docs/dev/js-modules.md": "f142385e5d7e0bd8eac89814f0c3ca6d539d86b9d9acc58bf5717ed43470974e",
"third-party/nsh/docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b",
"third-party/nsh/docs/dev/linux.md": "4441ec64b6edc5c03084bd203aa5114c066f9e2c0541ee320be8f17b6f82c3fe",
- "third-party/nsh/docs/dev/macos.md": "827c809b5774681c77daf05ac6603342c027dfd459e78f55558044861711dcd3",
+ "third-party/nsh/docs/dev/macos.md": "c2b332f4a6e72b60520ebf34d8875a6776662ddc9d1ffa62963744a62961c988",
"third-party/nsh/docs/dev/native-pipeline.md": "07f803365af6ac90d5247c70d2e85ce9e03393f0e9e122c46690f85e381a0c55",
- "third-party/nsh/docs/dev/performance.md": "6d3f36e2e44b5ccba8e1110b0b6fdfe315768444af325451bcebf019734e6d11",
- "third-party/nsh/docs/dev/pitfalls.md": "80fc820be5136534546eee9790dbc3b824cd1053fe45432f0187f4d25641bdb3",
+ "third-party/nsh/docs/dev/performance.md": "4a49973e31848b26dbb8a8e238bf8417bd7015a6b323f75e2551645078651f76",
+ "third-party/nsh/docs/dev/pitfalls.md": "ec02fe363e800a5f338afed88e16a83d21971549fe47c4da553d4e865cb84d21",
"third-party/nsh/docs/dev/reactive-components.md": "cd447fe8cfbe9026d7cadd5fa42d9c387e4c1b1b7e1c5adddd55c3f1beb94a4d",
"third-party/nsh/docs/dev/rust-classes.md": "1f70015213dbcf56e6d6e4641019092003c22eb57ca324d9c4a85578b4468139",
"third-party/nsh/docs/dev/rust-modules.md": "f793bfb12272ded93e80876fd2c72ff8dc0f1bd0337977f39b750af495a83592",
- "third-party/nsh/docs/dev/sandbox.md": "4dd889e136fc64a6024a60e07372868e9b25753128a0cf3925966f083daf7f27",
- "third-party/nsh/docs/dev/scrolling-and-lists.md": "158f7fc20773ad96683db9f6c6b2588f97274e7c9ed4e2958dc925b1ecf0e750",
+ "third-party/nsh/docs/dev/sandbox.md": "86768ef1e0d3aeb6468fa075eb9a3a00adbe4ac4f576586d82fc495ca398fe5b",
+ "third-party/nsh/docs/dev/scrolling-and-lists.md": "23c10d81c623a3b2271b240843080c5cc9da0a5a9ef85c79411570fd6f2cba69",
"third-party/nsh/docs/dev/signed-packages.md": "6e762f506428d53d347106e3619921a5595391105128305090da8eb8a20c220e",
"third-party/nsh/docs/dev/styles.md": "fcb5410f36c3d0ec08c300a7d624b19ad9d62404f665e9f4a2c61dda001ae532",
"third-party/nsh/docs/dev/text-view.md": "ef89f0a8af990356b33b5bfdd0f79e9d86342024e04ad8e08aba9e9ab13c6693",
"third-party/nsh/docs/dev/text.md": "7a4f09386899d5e6a5c3dd7c886063db73fcb5b288b47d0ff37cbfc8b4911dfd",
- "third-party/nsh/docs/dev/tooling.md": "1e605160ca6bb185136e1ef28f1cb703c4db5ac24647570c2c7b5a7c80f65fee",
+ "third-party/nsh/docs/dev/tooling.md": "265562522caf036982b8494712949387f8edbd90dfabd6e5355efa1f5624aae2",
"third-party/nsh/docs/dev/tui.md": "47b56095f90ba9caa3efc65acc10bb3e255b674d9cf05d71fd5a30f7a43b1770",
- "third-party/nsh/docs/dev/web.md": "7c6f900d89b7f46f840cbaf271664f5979130ec9175b5695eed8afc30cccbbf4",
+ "third-party/nsh/docs/dev/web.md": "fd9039c76d0f8259cfdcb894add27d2cbcd04eda67d06c57d614b9f0ce6c1ade",
"third-party/nsh/docs/dev/windows.md": "099245b28187723d0ce93f9b6f0b4775bb19f25c625e8b3db558fe175dd0bb6e",
"third-party/nsh/examples/bench/GridTs.tsx": "e1388190424c85d044232f9dfdc897acc69fc4464b23b57db149f9276336e3b4",
"third-party/nsh/examples/bench/README.md": "75d32dc5f73955e42cd171baaeb7df39b19cbdd25e67e661df7976a887ca3506",
@@ -1875,8 +1888,8 @@
"third-party/nsh/examples/counter/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"third-party/nsh/examples/desktop/App.tsx": "069c5a14642c56403f8786b8b96187d226f08e4078f37a7551f253433c65b49d",
"third-party/nsh/examples/desktop/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
- "third-party/nsh/examples/embed-android/README.md": "2694fb610d63a1e2bd816533240fb0bac3c66d203a0be7e076d06098c75f2573",
- "third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "482e4f1f734450f2a4acb9dfacee0995ef69947e70ff25e7fa2ce93c6cb2c782",
+ "third-party/nsh/examples/embed-android/README.md": "d74d883aa1e65a564c01e897a0781adc8279aa5c6e13f5e247b117bcf2f223ab",
+ "third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "ce94b18d9bb436c129cc38ee579c8823b1bbaa216bc7e11a5ff6d344e732dcbf",
"third-party/nsh/examples/embed-gpui/Cargo.lock": "16bedb298e480edba2cf2548b07cd3583c7471c3ef7aac852c86da70da7b2886",
"third-party/nsh/examples/embed-gpui/Cargo.toml": "a1f4727ec5fbc760f3678bcabfb14e3d34d37d71fb0b243e40920e55b8ed1f21",
"third-party/nsh/examples/embed-gpui/README.md": "bf86ae2881ad77ff9bbd0292d64e2572927b0b197461fdb52c67a049f99c9dea",
@@ -1894,9 +1907,9 @@
"third-party/nsh/examples/embed-gpui/src/trading.rs": "d74b621e04b6a04a622033c319574c8eca80051bb7bb2619a872377c42ebdefd",
"third-party/nsh/examples/embed-gpui/src/watchlist.rs": "7d46e105a6f00e31a3d225e5b1430616f0360cac5b7ac8a63add0ebdb6397ce2",
"third-party/nsh/examples/embed-gpui/themes/violet.json": "7da650a48cebde43683ef6f50bbb27a5000dd79911763e46982688160a59e2ab",
- "third-party/nsh/examples/embed-ios/README.md": "a5f2a52371ad660b6c5e24333f7282475e4b544a564770035f10efbcb1a6937d",
- "third-party/nsh/examples/embed-ios/main.swift": "6c2bb633521fc06e45351115ff8b34d5b182106e7171e5ab4c8d376585962020",
- "third-party/nsh/examples/embedded/App.tsx": "11c06c478613046c7ebb3b936366d08abd66e4f82e3b45037ea1648d01cacb27",
+ "third-party/nsh/examples/embed-ios/README.md": "99c5f76f3c3a2acb2008796fb71ca864ac853a293583005acb82ca155d5da527",
+ "third-party/nsh/examples/embed-ios/main.swift": "fbb648c731f509cdb9ce20a70c91090291863a8f643482ab8727a32324a0e3f7",
+ "third-party/nsh/examples/embedded/App.tsx": "416fbcb5da5df7d68448da5e3a620efec9afa8f730efc432345e9fe7ebdbf26b",
"third-party/nsh/examples/embedded/nsh.json": "3ee671a3404ac47b07142fc0803df84417f265279c139a0616c37f772580c8ce",
"third-party/nsh/examples/embedded/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"third-party/nsh/examples/gallery-rs/App.test.tsx": "7aaa088af8ed0749b5dda6bb90e7c8746db0735d0706b7475ff0b38e1d8bc27e",
@@ -2042,12 +2055,14 @@
"third-party/nsh/examples/gallery/tests/error-boundary.test.tsx": "f71d9c316fcbea37f15a7b3be31b7e82aacb09c6b550553f3636dccd9534e94d",
"third-party/nsh/examples/gallery/tests/fixtures/error-boundary.tsx": "94516c85cc83f4824d71bcfac24ba972d5dc3bf6ea39060752a5d6e097ecca7a",
"third-party/nsh/examples/gallery/tests/fixtures/list-search.tsx": "ead5f110c6bf3885ea230b10c2a80fb0891d26c69ba46e33a437c6e89c381143",
+ "third-party/nsh/examples/gallery/tests/fixtures/mention.tsx": "329706d1ac88a20f6f60fd82bc4a8ba97dd0092838110355463588036c7420af",
"third-party/nsh/examples/gallery/tests/fixtures/press.tsx": "a91869ba8b693abaa3ca130a46bea568a4c251f20e4cfa7db6965a45a456181e",
"third-party/nsh/examples/gallery/tests/form.test.tsx": "8a82127b536f73bf3fbcf23881cf802b30d0346314d2783bcf9da52d45e3a49b",
"third-party/nsh/examples/gallery/tests/gestures.test.tsx": "adf7fcf2afe5029f2441eaf048251a1d33530467df91747f170968fbdc6f3a08",
"third-party/nsh/examples/gallery/tests/hover.test.tsx": "dae609cc2f03243b33ef3d46f58b1dadef73645b1223f1796c3f246606baa5bb",
"third-party/nsh/examples/gallery/tests/links.test.tsx": "db1b97338bc81b437228d2015926f55a469fb1ffef35cdd57961977952003963",
"third-party/nsh/examples/gallery/tests/list-search.test.tsx": "80eaf7e15d7f285662f1c29c662ac075722f04a0cb44220f8a1a5c55033f1ac6",
+ "third-party/nsh/examples/gallery/tests/mention.test.tsx": "ca76505adb487f51a89b7272bcd8afa4a750807df1a9c10d8c006f780446476f",
"third-party/nsh/examples/gallery/tests/menus.test.tsx": "2db06b0b97d2a3dfcaae959fcec37452980158e93a97a94ceda2e318a2f39313",
"third-party/nsh/examples/gallery/tests/navigation.test.tsx": "76fcd06532791d18abb88afef2c951ab950adf148f10ecd1e8fe32cc3a718569",
"third-party/nsh/examples/gallery/tests/network.test.tsx": "aaab1989a62f65e9c92c69ef8fe56fdba65677db382a00796b37b21199bd7d15",
@@ -2077,6 +2092,11 @@
"third-party/nsh/examples/hopper/theme.ts": "635f518b81e7408e5969ca59dbd25860381857f37e9980d225a1a54ba373a38b",
"third-party/nsh/examples/hopper/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"third-party/nsh/examples/hopper/ui.tsx": "d896dfce94d4c96585275f3b47e2e418e2867ef8aeec5c6148adf3401155f8a0",
+ "third-party/nsh/examples/host-echo/App.test.tsx": "ccd51aa627dc36087d36614b02c97dd1f08513146261b2b282a7997d1594d8f0",
+ "third-party/nsh/examples/host-echo/App.tsx": "b71e5c39233a5bd6891ced4f713af67e414607daa119bd3e28bff38fc0597a54",
+ "third-party/nsh/examples/host-echo/host.js": "da4dfc0d7be297fdb12ad686bcd251d9da4036ecd2aa5b2058d685f4b7ebfa9b",
+ "third-party/nsh/examples/host-echo/nsh.json": "c0059e3ecaeb57be9f29502f727184234d7166e1e0e1f9fb7c22a80f97000430",
+ "third-party/nsh/examples/host-echo/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"third-party/nsh/examples/rust-exports/App.test.tsx": "98df2fa881eb3b6d1c383c0b6f77d6753d178e93df556111258dbdfc66db7cec",
"third-party/nsh/examples/rust-exports/App.tsx": "3c64df906f96cc4495f481c133713d33e9dd95c493f449a0e7aa7342a34b315b",
"third-party/nsh/examples/rust-exports/README.md": "6e6e2798773876a6c586ce914d31680f2a2890c0d65c3067c794dd946d86c2c8",
@@ -2147,7 +2167,7 @@
"third-party/nsh/tests/compat/cases/core/core-error-boundary.tsx": "c2de89bed501a5483446830f8ec19e0a1fe7131fe0af8c0fbf8dcb6b9942979a",
"third-party/nsh/tests/compat/cases/core/core-input-action.tsx": "9e6c67b4c7cad5580219045666bd1f5d91ea377c3a371717adb9d25950194a59",
"third-party/nsh/tests/compat/cases/core/core-list-binding.tsx": "b9e52b8e5f875188a98b09226d410410318846c3df9f97b1852365e063fe7dbd",
- "third-party/nsh/tests/compat/cases/core/core-list.tsx": "1851cf6e7c7b0bdc7794eb2fa12b316b8e0d96fea3dd1e99398e54d1c013ec9e",
+ "third-party/nsh/tests/compat/cases/core/core-list.tsx": "360ac05ea133de4ba541e4147b220e350d6c081eeccf8b15c2a10b290ad5b626",
"third-party/nsh/tests/compat/cases/core/core-overlay.tsx": "96a006df0796850f1b9dfa68cc4fe5ed6868988635ec7f47713523c47fa303ea",
"third-party/nsh/tests/compat/cases/core/core-primitives.tsx": "2dc25a3b00f0f6c3450128d85b52c6354e42618f0ce6599cd7b3196bea81df12",
"third-party/nsh/tests/compat/cases/core/core-reactive.tsx": "bcde98c407709730db5a6d2a620f01f322804d9184f0f727626034660ad2f5d3",
@@ -2155,7 +2175,7 @@
"third-party/nsh/tests/compat/cases/core/core-resource.tsx": "cc3d33c1321a04615b7fef7ac0d759fa5d76e7a3f5aae671c5ebff050a2769b5",
"third-party/nsh/tests/compat/cases/core/module-url.tsx": "8bf4a7a606496d4d5f321a8f722892de01e845d56bec5f42d9767ab3c516bb1d",
"third-party/nsh/tests/compat/cases/core/nsh.json": "c4354909c4780abb723666fe31c2c82d50cbe3839f62c169dc0ec2f145d8a728",
- "third-party/nsh/tests/compat/cases/core/script.txt": "46009ab58c932240d2aa0997b14d47eac6a9d01c92cbe845b73df3abcd7d0bd2",
+ "third-party/nsh/tests/compat/cases/core/script.txt": "819fa77036fdaeb5fbaf17e1d2d47317e88c7196396031212486c8e49d20f873",
"third-party/nsh/tests/compat/cases/core/steps.tsx": "bcfbdf96ffeb91d9d4cc5abd0a562cf612fe73c04ddf9d8bfdeb5a7d14aacc37",
"third-party/nsh/tests/compat/cases/core/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"third-party/nsh/tests/compat/cases/events/App.tsx": "cec0a2e9ebfbf3c0c0b1d6e05760023d66bad7b30ec1e27b0213ad6752eeee38",
@@ -2189,18 +2209,19 @@
"third-party/nsh/tests/compat/cases/macos/mod-standard-system.tsx": "17363588e50524931b2a21b06b7319c39864ef77c35d65cd3708524c185337e5",
"third-party/nsh/tests/compat/cases/macos/nsh.json": "81592a22d477fd7aa971a58119b710cd6b49d58517f6dac65e2f091567c4c020",
"third-party/nsh/tests/compat/cases/macos/script.txt": "adfe51bb062be578136db927b767640d286ee3b4e8847f3446b0cb47a5847514",
- "third-party/nsh/tests/compat/cases/modules/App.tsx": "a96f5194e1ccd1fbfe3c40d03ab18d4edc78465ffd15c00e768bf041372d40da",
+ "third-party/nsh/tests/compat/cases/modules/App.tsx": "48cdf7cfd2b54f36c5b3b920879411583c898e4656c2c96443b962cbc899503a",
"third-party/nsh/tests/compat/cases/modules/mod-accessibility.tsx": "139148dc45f9402dee9aaaf78587690fa4ede4a209ec39c23c2e3eb379ae1e6b",
"third-party/nsh/tests/compat/cases/modules/mod-android.tsx": "0bc2aca9677c91fa0d1e3693110581c5f76fe32ea2e0eb3f8fe5d4470395c8f9",
"third-party/nsh/tests/compat/cases/modules/mod-clipboard.tsx": "0ca63a3ad7e0b77672e688fb3e5ba3d1fe79904fbd5640646074c1ca22d4951c",
"third-party/nsh/tests/compat/cases/modules/mod-desktop.tsx": "23c2fa95911c0ac2be36fd1fddf78c1c7aacfcf7e79e66f0df89b416e8cc3909",
"third-party/nsh/tests/compat/cases/modules/mod-device.tsx": "6b384f6830bb0da03344986ec02a1e394d307a5b5ef13dc1fd53ba272d04de34",
+ "third-party/nsh/tests/compat/cases/modules/mod-host.tsx": "6fb70e966bb0208e1fb9c2dc847146ad720227a3fb421e4b5695a2d6c525037c",
"third-party/nsh/tests/compat/cases/modules/mod-ios.tsx": "15398c7e334c6067bce708ef6303d534380b1b2d3cf9ca90c7fdd6423fa85b3f",
"third-party/nsh/tests/compat/cases/modules/mod-keymap.tsx": "d3d40c3ec2a9d72a42a33335255b93703e1b87f6c94a693837ab79b69ff16ae1",
"third-party/nsh/tests/compat/cases/modules/mod-standard-access.tsx": "c6f8c9d94ac06655950666e356af86d219379f95b4d6978c9b81a07077f5fd5d",
"third-party/nsh/tests/compat/cases/modules/mod-storage.tsx": "cfa8ffaf50301e957f0da33a075c6f4a8be93e758555d720dab07f7ae473410d",
"third-party/nsh/tests/compat/cases/modules/nsh.json": "8aded49d92a6f2cb8db2e005d097aa70f60f26e5e6639b98f45cd89154f3feb2",
- "third-party/nsh/tests/compat/cases/modules/script.txt": "25dcfb68b52245396ca162aa57b002b0ce100c6c10c5fd48b472de9f9b9bb8aa",
+ "third-party/nsh/tests/compat/cases/modules/script.txt": "8deea2cbd8b57c68f4be2b9cbc53198aa2229ffcdc05fcd132fdc310aca3c037",
"third-party/nsh/tests/compat/cases/paint/App.tsx": "c8bcbb4b62d5ecabe0fc98c7e6524e451193952701215ebe614a92168107c316",
"third-party/nsh/tests/compat/cases/paint/nsh.json": "56f891ce05e7aefa7949e3445a603cb477313be037897c75acb12f136c1a85a8",
"third-party/nsh/tests/compat/cases/paint/paint-borders.tsx": "388fb9d77dced948c4a014ce42af65f3509153347ad574778983b0263277a428",
@@ -2302,7 +2323,7 @@
"third-party/nsh/third-party/icy_sixel/src/encoder.rs": "9f2941a073f3df5d1216bb4f71487a3c43b5ceded1ec88cd13a804b4d7e31999",
"third-party/nsh/third-party/icy_sixel/src/lib.rs": "126c4edada7c430377a73839c9da93cae95f9191be69c1401fb4ac1cad9cbf5e",
"third-party/nsh/third-party/icy_sixel/src/sixel_image.rs": "1e968370dd0ff8c69751213a1ed11cfce6783feabb30c2b5074ac1c4ba3d8503",
- "third-party/nsh/upstream.json": "af929e730cc5f51898817f8524d59ae37b193bb961c02a8fcab3c21f06958b49",
+ "third-party/nsh/upstream.json": "ec5aafbfe7590e0a6e37af8d64eb8f554dd32c91cd3c830e8c3a0334fa352580",
"third-party/nsh/website/DESIGN.md": "484ab5b78233c1c57b0d46e631d2341031b2b34a74740abbb88a275b4c8134b7",
"third-party/nsh/website/README.md": "50de9faed8cd3b0751991d6168ac6e33a6437dfd36aa15dc30d35b13dfb94284",
"third-party/nsh/website/astro.config.mjs": "0cfae54c7791bd915865dc535669b311e4f76001efed09fdb2772d332812924f",
@@ -2368,11 +2389,11 @@
"third-party/nsh/website/demos/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"third-party/nsh/website/demos/ui-navigation.tsx": "0ede3abc087bc9a66a69d3efb9f1997dfc0da76008e7c88311b5a13ee4c2e01d",
"third-party/nsh/website/docs/accessibility.md": "acc7f899e192a16af5d3a07395a26c87e3ada2e05dfbad8bf453779e461fcb51",
- "third-party/nsh/website/docs/android.md": "6056ebd29e675f8dce7acd16d711b9a7c66c5860bf3665d20b6125e31103817d",
+ "third-party/nsh/website/docs/android.md": "ded49e2a009b7e012a7d7f0e37f15c00e04c82333a99a7ca2ffbdfd6f87df83e",
"third-party/nsh/website/docs/animation.md": "74859043103a0e12b3e2cf22f5b29ecf3cca7dd9e4009801b38c9d5d5aa2ceed",
"third-party/nsh/website/docs/architecture.md": "1a7f3fa3704a5e1c5f4dd6c2df4986c07cdaa247977f7b1e3a13464bfd82a8e5",
"third-party/nsh/website/docs/canvas.md": "5589e8b7ceae1856e21081b30d3dc424dc977064fcbb711331f25165c962b4e7",
- "third-party/nsh/website/docs/cli.md": "a62a929b93aba13d6074966228300854273c7cec3e00d9c631f6465cda98017d",
+ "third-party/nsh/website/docs/cli.md": "9fca8064b0726580a9a357044ec9a5955b93ffd028eeb2a3ec5fe15b915ee2ee",
"third-party/nsh/website/docs/coding-guides.md": "38953161931200894317fdb0aaa25e5fd65a52b28ac2ac03e2196f771050dca9",
"third-party/nsh/website/docs/comparison.md": "80f11e255cf3463f29d5610213b57808e1ffa92c5de021ebff9e9e153455dca5",
"third-party/nsh/website/docs/compatibility.md": "f2a0af8428dd5232052921e94a6a08914ee4e523c8f3dbac0db51f25c0da6650",
@@ -2386,13 +2407,13 @@
"third-party/nsh/website/docs/images-and-assets.md": "25b3f1455863969f1ce2dcb8961a30783c425c3d8015fe51a4ef5a134cfc2aa6",
"third-party/nsh/website/docs/index.md": "31a039507f14aaf881b9fb351b18a837c5a53650ee8449b14afd91ba1fc22281",
"third-party/nsh/website/docs/installation.md": "a7b1a6804f151719d35b8b62eb5b3861712a24cd90175730266ee5fb0874027d",
- "third-party/nsh/website/docs/ios.md": "2b6907f65fbe688850c11bda89d54caf4cd43f0e350d34160544fb3b8e9f5811",
+ "third-party/nsh/website/docs/ios.md": "a7a5d71586c90f020360c0287071a404b2905acc89353e857ca124d568ea6c81",
"third-party/nsh/website/docs/jsx-and-components.md": "75c24c3cb56503befc7c06d9eaf67c844318890d9fc989427231ecc9e6e9e627",
"third-party/nsh/website/docs/layerfs.md": "827ff94d53cb27ed7a686776b2532de70d0cfaebf0cfc7ba39b433f42ec5342b",
"third-party/nsh/website/docs/layout.md": "80c8f0995e88d01959766d6d95e86ead1034599b848e6e572ddc0c554fe5bf89",
- "third-party/nsh/website/docs/linux.md": "6df424ab04997a1c29fa85ec34ec883c150aaab1f1165d631445f0699a217616",
+ "third-party/nsh/website/docs/linux.md": "c5432744bf1ebf18d8afa74075ccb0581e98f5e98255a07cb9c34ab00153b917",
"third-party/nsh/website/docs/lists.md": "b59033e583a9c147c0a7cf7309bf22b3744946b0a6a6bac3ee1a3f079aa51d54",
- "third-party/nsh/website/docs/macos.md": "83aeb6c4d8214a401cb6413c32cb74b0d62243d59bbbececa32a6dd4ddd18420",
+ "third-party/nsh/website/docs/macos.md": "4b36f58b9662268707a5868a0443eaa55b44ad59778a25b71e421c970143412f",
"third-party/nsh/website/docs/native-components.md": "cd8272bfb271dece1f395009077a762c972c282bbd9246265ee9f6a4ced0e656",
"third-party/nsh/website/docs/navigation.md": "8a4a9ac5d9a44ce8e9a30680b4ab9c0a4d6d4bfd556b8c7347074eefb3ba6357",
"third-party/nsh/website/docs/networking.md": "7447d4ffeefffb33318ada3594160ce30a47adabb4c59bb66bd360a465c5a2b4",
@@ -2400,21 +2421,21 @@
"third-party/nsh/website/docs/performance.md": "f6c4652eef83d81abeee6f2d2dfb370b14a7bb78b2dbb25313afe3a25a20dece",
"third-party/nsh/website/docs/platform-specific.md": "acca9b1bce6444f64370e60eb2911b6e8467a21dbf2078823236ab22c95ad872",
"third-party/nsh/website/docs/platforms.md": "a52bc237b38320867f6f1e7edc65aec2d5006d2b412fc443bc6634a635dda93e",
- "third-party/nsh/website/docs/project-structure.md": "8465844846b69eddb6a2bf532b2cafa8a63e583c5b1ce246952c73d972588230",
+ "third-party/nsh/website/docs/project-structure.md": "5f65867b10167d7efe437147d20e794264deff24a647f9924d956e5d8a0f88a0",
"third-party/nsh/website/docs/reactivity.md": "8c6731f9556ec0c3a4f401c7861c7198cca53a4bd5e95b57f2f773e7acdbf7a8",
"third-party/nsh/website/docs/rust.md": "b3d18c6b202c136d7efbba0f7f87e9d0ae26d2469551f1bb09f47e955333ed34",
- "third-party/nsh/website/docs/sandbox-and-capabilities.md": "4938ef6da08859de906c236ead2a8e70a478c599580dbbfb3ed2201de4bb0dc1",
+ "third-party/nsh/website/docs/sandbox-and-capabilities.md": "1860458dbe33fad7df623efa372bb9568b6e839aad269429d7595b5733cfb0d9",
"third-party/nsh/website/docs/scrolling.md": "54356ccb5d65b3f37263339c91e1fb0ee2bc5db32729a9e9b235f2cce51e6850",
"third-party/nsh/website/docs/signed-packages.md": "203da373bbcf21d84240b2626d91860e4f3d745733cbeebc22a42a042d0185ee",
"third-party/nsh/website/docs/styling.md": "30ecef6fd8f7e881867574898cd05dfb7bb943a996a8da48db2dbfa8af1c4bd3",
- "third-party/nsh/website/docs/tailwind.md": "0bfcc244e1cd81cdb3fdbed95c36af126a1284a2f559712007005927c4bea4af",
- "third-party/nsh/website/docs/testing-and-trace.md": "a0713c55a7c8c1091c85209fd43fd74911a1bba4bf78f871de0676a894b9112a",
+ "third-party/nsh/website/docs/tailwind.md": "e3c48864cf4018b4908bed97b92f69ac66395c4cf74600d84b3d1650de843292",
+ "third-party/nsh/website/docs/testing-and-trace.md": "b4afd8ac17ad3fe7a41a4979495a30575fe19392f5e7b159626479d9737cc5d4",
"third-party/nsh/website/docs/text.md": "afecd14b1f27f340c52dd22666b26f014edd698f1d4ce96dd61188d5203e74c1",
"third-party/nsh/website/docs/touch-and-gestures.md": "4733ec6aad04835324c174900e099737e39bb6cffa84227959193d95c5d2c39c",
"third-party/nsh/website/docs/tui.md": "28f225fcce009393ab1a3a1ac672b3cf3a599e3712fbd344516ac0d3b8a2f7a4",
"third-party/nsh/website/docs/web-view-and-video.md": "249dc38d55b3549052396f782f8087d3abae3e81c57fc64b24589f352acadd5e",
- "third-party/nsh/website/docs/web.md": "1aa3728395fd9a551a0f8fa34d02d384191d8fa82ab52ed85e852078951ba817",
- "third-party/nsh/website/docs/windows.md": "e715393ff39d08901c3a21add178277da9fdf6c38cf1b32d7a2921aab3896ea2",
+ "third-party/nsh/website/docs/web.md": "73e74007aae291bab262fb1372f41c8f61eb723005780b911555e2a45be0723b",
+ "third-party/nsh/website/docs/windows.md": "5c52b9a5e1c03a11a8d13a1686b27a082c21c9663f88050375b44f8f4b55ac03",
"third-party/nsh/website/elements/alert-dialog.md": "ee0f2bcd0d4a622a5e1521dbd9c854cf3e315b5848104ef3f07ac8f6033d9e5c",
"third-party/nsh/website/elements/button.md": "0ee605f47eb3588b4efeb8771bf36b63962101cacc1ed1883d8d22a7a3568da9",
"third-party/nsh/website/elements/canvas.md": "c55d6fc031c3b04f91962ea80714c0593db133fe60b228412097b4c97ca20490",
@@ -2448,7 +2469,7 @@
"third-party/nsh/website/elements/view.md": "a532351633e057acff510fa3dc45c6380724fb166a5784ed15b928b77a13dc37",
"third-party/nsh/website/elements/web-view.md": "7a9ad687ee2afb124f317f4e103572eb07fa8a71d7202d09c8f971130feea591",
"third-party/nsh/website/modules/globals.md": "2c4c6b555c0cd2d0aea5dbebd478d1eb0597a419c379fda4853d55d8f79b8215",
- "third-party/nsh/website/modules/index.md": "ecd4c0b82d978205fa9e75673e854f04b9602550f0056cd30f2fd864a65f6fa8",
+ "third-party/nsh/website/modules/index.md": "38620a429fd176cb882a3f7e958a92e6ffeb57b576540707182cfe4b6d92dd7a",
"third-party/nsh/website/modules/nsh-accessibility.md": "c446bab2d7fe6becb274bf60546d49e13cd4bab1fe17a80ab494c2f7b3ee775e",
"third-party/nsh/website/modules/nsh-android.md": "3bc295d3913732b6f1b75d78a8e879e32fd00f8e65424c7b8468ecb80185a8bb",
"third-party/nsh/website/modules/nsh-animated.md": "f50131304ae97c0537af4927bf4ab62524c91068806ce81f3b9daeae6f09662e",
@@ -2458,9 +2479,10 @@
"third-party/nsh/website/modules/nsh-desktop.md": "74d5b00ac1d1e27da1e439aed901745108146f9495bed44ef114696e1abb5c85",
"third-party/nsh/website/modules/nsh-device.md": "5a49c9c522b883559d6f56ec648dd8502833c69223d52737eebf74bc8f12460e",
"third-party/nsh/website/modules/nsh-gesture.md": "242251b48db3e0c70247cfdb243468a0e2641608e70b66914b696450cfca8bfa",
+ "third-party/nsh/website/modules/nsh-host.md": "5056a6c3c92b50b37db6a20aeded716aa2fcd23b93992eee5a1615179e1dcc1d",
"third-party/nsh/website/modules/nsh-image.md": "9291d338ebcc168cedbfc597188691994ac25d7ac800da3659bcc83b356a197e",
"third-party/nsh/website/modules/nsh-ios.md": "2c6ea1a8c6c25bb06064e6cd6924cd3ab8a890921b151107d206880ab870eea6",
- "third-party/nsh/website/modules/nsh-keymap.md": "b45421fbeb6179a5910a4bf2c2e001ddffbd22037a4e87d7e623a63d32d7c206",
+ "third-party/nsh/website/modules/nsh-keymap.md": "16bb290cbbf6f6f3395f32063dcb9257d4013dd3eb5514b589a6935f29080b9a",
"third-party/nsh/website/modules/nsh-list.md": "91d2b6895835c0327bda430508c483f08d1403d5218e99988555eb2928b38b0d",
"third-party/nsh/website/modules/nsh-native.md": "6bcbcbebb59cafdf01ab56b9c71c6731b941bd8f8431e353cb87bbafd18b18c5",
"third-party/nsh/website/modules/nsh-storage.md": "4d2918fcd5d8a19ff6a2716fcd5717191350f66b39af22c4ca37985387a4c1e2",
@@ -2555,11 +2577,11 @@
"third-party/nsh/website/tests/storage.test.ts": "4711c006141dfea8792fbb1fe2c219746155f57a32650e0fc80aa56b404d1804",
"third-party/nsh/website/tsconfig.json": "7b292fd267352f901284513544cd818de4a8a8fe15880f662b3a0900b2a93c99",
"third-party/nsh/website/zh-CN/docs/accessibility.md": "63977505bc163dfe40b4d0e40168944c28153dde29a73be1db9931d90bb29bde",
- "third-party/nsh/website/zh-CN/docs/android.md": "b64f4fdecf8de8eff2feec7eb185de3982f2d98e78e30cc5aa5eab7399d95aed",
+ "third-party/nsh/website/zh-CN/docs/android.md": "eba50396501d0dde571dedf1d10ce224d93edc781652025d83e366e5a421c4c5",
"third-party/nsh/website/zh-CN/docs/animation.md": "a6d266e73b250cb3274784b48dfcc99ad791897ffdc5d6788e80a11db4fdebd2",
"third-party/nsh/website/zh-CN/docs/architecture.md": "6759f09942a51486476a68a37e12c833be9430012e8b2da137d9123632c00294",
"third-party/nsh/website/zh-CN/docs/canvas.md": "8a237d25ef877ce00715d88a03e50493667a9961ccfe045372485b0ff48c7b77",
- "third-party/nsh/website/zh-CN/docs/cli.md": "d6971d9ce2f9875fcd1073a15c2171ad71d767a4365669ad172844c6d6ef3967",
+ "third-party/nsh/website/zh-CN/docs/cli.md": "d61a977ebbad616432c8fdede8049d7b0ea1d44f54319356fc58ffd05eb79c58",
"third-party/nsh/website/zh-CN/docs/coding-guides.md": "34af3e5c4f13861a7336aca26e636aad3a4a2a71ebbc5d806d25135c7882a716",
"third-party/nsh/website/zh-CN/docs/comparison.md": "05e6ab8a44d3db224895d7029e029dbb48a9e74092e5135099ab0179a6ee974c",
"third-party/nsh/website/zh-CN/docs/compatibility.md": "1990079dc6a9ed2c4c72a0ccd6a10aae154e5ca722f2c6d899d5874345f62d76",
@@ -2573,13 +2595,13 @@
"third-party/nsh/website/zh-CN/docs/images-and-assets.md": "de6564e02b94897bfa6865353acfa8d007b331f0dde1abe572db12281c654d63",
"third-party/nsh/website/zh-CN/docs/index.md": "bdde9f1324a8a2511bc1b740f3a1e42be071ab1390363a7152a4bde1705c2deb",
"third-party/nsh/website/zh-CN/docs/installation.md": "149ece60315c3d00485a5307421a5b2d4898d6259ff59f1c9861295a56a079d9",
- "third-party/nsh/website/zh-CN/docs/ios.md": "615b26e421eb981ece4f360544732b81e9ca4ace2440b6edf05880f142091953",
+ "third-party/nsh/website/zh-CN/docs/ios.md": "09170b3c662d4c158705a0c95bc3bafbb74432ad58e4a556c576b96dcfe7e70c",
"third-party/nsh/website/zh-CN/docs/jsx-and-components.md": "7c80e941fdd795698c237edcfd5f600d748a870f4671b05973d1bc0c2279bf97",
"third-party/nsh/website/zh-CN/docs/layerfs.md": "817b28c2b67d90fa5bc754f7b7dcebf07edcb8328d814e56f394ee91989c8217",
"third-party/nsh/website/zh-CN/docs/layout.md": "34c9288812aa015497363034a38d338a3138808ea936c21948b1db1fca72733b",
- "third-party/nsh/website/zh-CN/docs/linux.md": "b92ff6039e94e50758c13feb3c3fbe38dea346f6e644d35b5863a2bfef8c1298",
+ "third-party/nsh/website/zh-CN/docs/linux.md": "04a113952bfbc8666cfaab4a12ce971d09d5699118b169115b0bb78896e4ffd5",
"third-party/nsh/website/zh-CN/docs/lists.md": "7f70739117e058a511ac1a5b824cd4d109ce407f17b8aacc0f1f3203112ee907",
- "third-party/nsh/website/zh-CN/docs/macos.md": "fb469d4765f3fc5e448f28655841f670cc292878766958b604e913caaf508def",
+ "third-party/nsh/website/zh-CN/docs/macos.md": "7defc3eca134507e1c50b4593f14a121924913df0f22fa2ad645fa6926e28c1b",
"third-party/nsh/website/zh-CN/docs/native-components.md": "53687a8c5bde004f6f8be3f15ab2a82b5c063dc4acadc7a69713c9c150512831",
"third-party/nsh/website/zh-CN/docs/navigation.md": "cf0b0ed2c31d94591a764e54789a8016b248a71d6bbc2e83961c6febd28b7305",
"third-party/nsh/website/zh-CN/docs/networking.md": "3f0d3eaf3d29565c18c92b227cececba879063672f66aeede14fdb05da1c61bf",
@@ -2587,21 +2609,21 @@
"third-party/nsh/website/zh-CN/docs/performance.md": "51e47481cf34e1d5b80cb63214a1f13ad89ad2ad902d0ba476f5b52f93a6a7af",
"third-party/nsh/website/zh-CN/docs/platform-specific.md": "418a89f4880460fd661451290ce6c5d583c6fbee7423978b1d5ac90d098e6412",
"third-party/nsh/website/zh-CN/docs/platforms.md": "a245c546c662981a85865b1a62dc2677301954470933b676d32fd2e5192a13b8",
- "third-party/nsh/website/zh-CN/docs/project-structure.md": "1006d1463cf46514347f888024a9a3a492486952fe6d2fe162c0f16c43d74f9a",
+ "third-party/nsh/website/zh-CN/docs/project-structure.md": "a22b70ff32192cd92edc0bced0a192bad4275ea9f686bbe77a11c0634597fe59",
"third-party/nsh/website/zh-CN/docs/reactivity.md": "2f5ab6ca8af99fe3b6c5aa885e3ea5ee878e612bba4b15cd75de37b2c7f4dbb3",
"third-party/nsh/website/zh-CN/docs/rust.md": "4d8eebbf32c628977ad3d87b1c227a056a6d7be8cd140ababd3ea6294d70d473",
- "third-party/nsh/website/zh-CN/docs/sandbox-and-capabilities.md": "9c021c767938186c7f75cfcb08bfd5200829aa755f013491933e17701dbfb741",
+ "third-party/nsh/website/zh-CN/docs/sandbox-and-capabilities.md": "878c315de9764988f997e6c7c8929e2b4b73eac8303362c737065b31cc96fdb3",
"third-party/nsh/website/zh-CN/docs/scrolling.md": "8e6237c42f3b0d82d87c3c3163661382b00517a6866af57bdd807ddcfe71bf7f",
"third-party/nsh/website/zh-CN/docs/signed-packages.md": "2e48499114cf8cfee2cfbfb866b46afa8b5dc9c615f2640462295e0fbaf0b47d",
"third-party/nsh/website/zh-CN/docs/styling.md": "9bea733d0c0389b8d095b41f57ddaa10eb18a702a964708699c63833c530a798",
- "third-party/nsh/website/zh-CN/docs/tailwind.md": "36506cf5250db89d07f86389cf2bcdf86e15a0107a4a6424e8f0585fd0b59d8b",
- "third-party/nsh/website/zh-CN/docs/testing-and-trace.md": "1f570db23c1e31467dd358d62d7e692f1b279b0205b839828c9fa789c8aa1dc1",
+ "third-party/nsh/website/zh-CN/docs/tailwind.md": "ddb45309d81505a86bea22c582c5ae6ba17561d871c9f3a546d30286ed0fd00c",
+ "third-party/nsh/website/zh-CN/docs/testing-and-trace.md": "e32c9cb30062fc83bf33d8a680d9137709169fbd689cae429a93b5ee76dac05d",
"third-party/nsh/website/zh-CN/docs/text.md": "3e0c854979fe43694024b1c80a9a6dc8d2632009fb2ca1468d978b7084e2ff33",
"third-party/nsh/website/zh-CN/docs/touch-and-gestures.md": "6c52f75dee8de1b1a4db4359e2c155a11131f35bac36f3dfc8b90e758fed2cd1",
"third-party/nsh/website/zh-CN/docs/tui.md": "759679285a6efb60adc31bddc0b29a712a8375b947f1405cd1f923345f4cf59f",
"third-party/nsh/website/zh-CN/docs/web-view-and-video.md": "45ab7af5e283a3021de62b39b7ffe9f64414fd41fe5337d9e51c30656f3e1fb1",
- "third-party/nsh/website/zh-CN/docs/web.md": "f0f47e4b4a0cf26d44807d9e782176f391a7134f1dd5ab192132b0215a9c5cf9",
- "third-party/nsh/website/zh-CN/docs/windows.md": "6694ce31c742015a5f2d26d746039f5d73081ddcdd556b8c8cbd8ef988531747",
+ "third-party/nsh/website/zh-CN/docs/web.md": "d22060420052d788555373fecb5297f242542e62a388bc696da88cfb9abf1331",
+ "third-party/nsh/website/zh-CN/docs/windows.md": "2bc90fb626da5f2c2d65fc39714b21e9c38fefd32154cd16f81693f804ff34a5",
"third-party/nsh/website/zh-CN/elements/alert-dialog.md": "ddee942832b7ddb4afbfcaec5af36245af4189b995a65d55d762634176d4213e",
"third-party/nsh/website/zh-CN/elements/button.md": "322208c0b3b5664f6d5ed83828f77b22c7f7f7a532e84664da7ead9179d144b6",
"third-party/nsh/website/zh-CN/elements/canvas.md": "69200ec9827d47f515d4f2fc2559cff30371d040712ce11341c1de6ab26b11c7",
@@ -2635,7 +2657,7 @@
"third-party/nsh/website/zh-CN/elements/view.md": "07ceb854787d0ccda316a08c2f0ad34523f28f85a0b74c4adef29be612d898a2",
"third-party/nsh/website/zh-CN/elements/web-view.md": "16fc5b2d4e1a2158f7838d96fa8361c8401f14c5ce5f36a474daef0e246d2fd9",
"third-party/nsh/website/zh-CN/modules/globals.md": "8fe3c160dfea2a833e3af37266399a79db319bd3a8279f1bbd0f7173cc33aeea",
- "third-party/nsh/website/zh-CN/modules/index.md": "04185943e6282fdc4cf7073f88cc080399934cc0e1636877b8c3422d0b9ff62d",
+ "third-party/nsh/website/zh-CN/modules/index.md": "7648b4fe4cc43384c7080513b144f5dbf9b23d1ffd9f6096f8fdd63eebb6a35e",
"third-party/nsh/website/zh-CN/modules/nsh-accessibility.md": "53b1949ebe056f6e8d2cde899f6160ce6ac5f78b8e9b404b86c257513391f360",
"third-party/nsh/website/zh-CN/modules/nsh-android.md": "5732783701f5b8eba5134b9d7f4a87be6930e3cd9a567a5d21063c479f8c9e31",
"third-party/nsh/website/zh-CN/modules/nsh-animated.md": "0fc4cd367f3b4d87bf228017fbf8f1d941cd439f9583a8d9641738d5d222b2b8",
@@ -2645,9 +2667,10 @@
"third-party/nsh/website/zh-CN/modules/nsh-desktop.md": "d69cebeb28d6d77643af44d53c01f2d5f135ea096b8381b0b631691fb74e05a9",
"third-party/nsh/website/zh-CN/modules/nsh-device.md": "2aef14241d7a65e204194fe1663499820a8857b98ad0c8450bac2887beea0d25",
"third-party/nsh/website/zh-CN/modules/nsh-gesture.md": "016833be47b4af7906a3b30834f18d2a0daa031f344ef7f655197870be3b0934",
+ "third-party/nsh/website/zh-CN/modules/nsh-host.md": "c550fb10e43d254b20b78590bbac0a27a828deaee6259ce72ecc616df0985f08",
"third-party/nsh/website/zh-CN/modules/nsh-image.md": "ba5af499d466a18bdba503b45d7957c2bcfa64a9454538ab92e271c31cf795be",
"third-party/nsh/website/zh-CN/modules/nsh-ios.md": "e1220439df4359e9ba96b0457169683d68f92559fc77544fc741768c45a34ed1",
- "third-party/nsh/website/zh-CN/modules/nsh-keymap.md": "ce0661d9b5d0c1c359d984d03c721cd1d23f9b695c5e4ca01f581914993a94df",
+ "third-party/nsh/website/zh-CN/modules/nsh-keymap.md": "d040b1daaa7beecb21a3f4523639557b078f9ed89654bc2b4c6fa22c84481614",
"third-party/nsh/website/zh-CN/modules/nsh-list.md": "bca6d00c4c39da107b74accbd135653d0ef18845fa5aa4248262e53bffa7cdb1",
"third-party/nsh/website/zh-CN/modules/nsh-native.md": "f4938e9e7310c840b6bb5517570d7b23d19cf97d7fdd5dd5827cfd148ddb5c0b",
"third-party/nsh/website/zh-CN/modules/nsh-storage.md": "3a4b00515785ced4dab774b340a333487f9b7c4ec5bcf64a7ff00b1eab5b7041",
diff --git a/third-party/nsh/AGENTS.md b/third-party/nsh/AGENTS.md
index 82913a4679..f61f34b127 100644
--- a/third-party/nsh/AGENTS.md
+++ b/third-party/nsh/AGENTS.md
@@ -82,7 +82,8 @@ crates/
nsh-testing/ Headless tests: the driver (`headless/`: an engine without a window, display frames, a model of the views,
the steps, stand-in network and storage, fetch on ureq; no QuickJS, for Rust apps' `cargo test`); feature
`js`: `nsh test`'s side, the `nsh/test` module test files import (`module.rs`, Rust), each test file on an
- engine of its own and each app it opens on a headless one (`run.rs`) or a real host (`driver.rs`, `--target`),
+ engine of its own and each app it opens on a headless one (`run.rs`; the web page's host module,
+ nsh.json `web.host`, answers `nsh/host` there: `page_host.rs`) or a real host (`driver.rs`, `--target`),
apps' headless runners (`runner.rs`); feature `bundle`: building entries in process (TSX entries of
`cargo test`, the CLI's compiler); feature `agent`: a real host's test agent (`agent.rs`, the dev hosts'),
which takes the steps with the platform's input
@@ -158,7 +159,8 @@ examples/ Example apps (gallery: a demo index, one module per demo,
inputs, a long list, stubbed requests, dark mode, a manual clock); gallery-rs: the same in Rust, the app's crate in `native/`,
TypeScript tests of its root (`*.test.tsx`, about a demo each); rust-exports: a TSX app using one of each export of its crate, the reference
for exporting components, modules and classes, with Rust and TypeScript tests of the TSX entry; counter,
- with a test), embedding samples
+ with a test; host-echo: an app calling its web page's host (`nsh/host`, nsh.json `web.host`), the host
+ module and their tests), embedding samples
(embed-ios, embed-android; embed-gpui: a GPUI Kit app, its own workspace; `run/` the root's
`cargo run -p example-embed-gpui`) and the app they run (embedded: uses a native component the samples
register); bench: component benchmarks
diff --git a/third-party/nsh/Cargo.lock b/third-party/nsh/Cargo.lock
index 4677a2e449..a7008f4246 100644
--- a/third-party/nsh/Cargo.lock
+++ b/third-party/nsh/Cargo.lock
@@ -3558,6 +3558,7 @@ dependencies = [
"nsh-dev",
"nsh-engine",
"nsh-js",
+ "quickjs-jit",
"regex",
"serde",
"serde_json",
diff --git a/third-party/nsh/SOURCE.md b/third-party/nsh/SOURCE.md
index f40fcb760f..d40d985abd 100644
--- a/third-party/nsh/SOURCE.md
+++ b/third-party/nsh/SOURCE.md
@@ -26,7 +26,7 @@ at `de176695240d550f2b303643b8019146c7989e7f`. The upstream project is
`upstream.json` keeps that initial `repository` and `revision`. Its
`upstream_revision` records the latest integrated `longbridge/nsh` main revision:
-`44278a653d73b7f8626e964f7895b8a4c4a964dc`. The first upstream synchronization
+`7ed244990097dbbec6b177e26dbc357cb0dba654`. The first upstream synchronization
starts from `6bb0b7908043132067456e4a21dd57c90a0bdce5`, already included in the
initial fork import.
@@ -43,6 +43,10 @@ from the upstream layout tree's reusable slab index. This prevents a delayed
pointer observation from activating a newly allocated hint or anchor; the
existing hint-lifetime regression covers release and reuse. Keep this adaptation
until an upstream owner-identity mechanism provides the same guarantee.
+The list integration also applies published source counts and splices before
+issuing more paced bindings. This retains the fork's source-publication fence
+while using upstream visible-first scheduling; regressions cover source changes
+inside both visible and overscan row constructors.
Maka's workspace uses these local crates, including NSH's offscreen renderer and
image codec; changing them here changes the code built and tested with Maka.
diff --git a/third-party/nsh/crates/nsh-base/src/lib.rs b/third-party/nsh/crates/nsh-base/src/lib.rs
index bf579c9c7a..e2bc2a7b0b 100644
--- a/third-party/nsh/crates/nsh-base/src/lib.rs
+++ b/third-party/nsh/crates/nsh-base/src/lib.rs
@@ -85,7 +85,7 @@ pub use crate::{
TextViewEvent, TextViewLinkPressEvent, TextViewMath, TextViewPart, TextViewParts,
TextViewProps, TextViewStyles, text_view,
},
- theme::{DEFAULT_THEME, Theme, ThemeError, theme, tokens_of},
+ theme::{DEFAULT_THEME, Theme, ThemeError, theme, theme_overrides, tokens_of},
tooltip::{Tooltip, TooltipElement, TooltipEvent, TooltipProps, tooltip},
};
diff --git a/third-party/nsh/crates/nsh-base/src/theme.rs b/third-party/nsh/crates/nsh-base/src/theme.rs
index 3a427200e9..50d7acbb5f 100644
--- a/third-party/nsh/crates/nsh-base/src/theme.rs
+++ b/third-party/nsh/crates/nsh-base/src/theme.rs
@@ -322,12 +322,31 @@ fn font_family(value: &str) -> Option<&str> {
(!generic).then_some(first)
}
+/// The tokens theme file `json` sets, and only those: an embedding app's
+/// overrides of the app's theme (the platforms' `AppView`s, the iOS and
+/// Android SDKs), which win over the app's tokens of the same names and
+/// leave its others as they are (`Core::set_host_tokens`). The file is the
+/// one [`Theme::from_json`] reads, shadcn's `cssVars`; a color it names
+/// (`var(--ring)`) and does not set is the default theme's.
+pub fn theme_overrides(json: &str) -> Result<Tokens, ThemeError> {
+ let mut tokens = Tokens::default();
+ apply_onto(&mut tokens, &DEFAULT_TOKENS, json)?;
+ Ok(tokens)
+}
+
/// Applies theme file `json` (shadcn's `cssVars`) over `tokens`.
fn apply(tokens: &mut Tokens, json: &str) -> Result<(), ThemeError> {
+ let base = tokens.clone();
+ apply_onto(tokens, &base, json)
+}
+
+/// Sets the tokens of theme file `json` in `tokens`; a color it names and
+/// does not set is `base`'s.
+fn apply_onto(tokens: &mut Tokens, base: &Tokens, json: &str) -> Result<(), ThemeError> {
let value: JsonValue =
serde_json::from_str(json).map_err(|e| ThemeError(format!("not JSON: {e}")))?;
if let Some(vars) = value.get("cssVars") {
- return apply_css_vars(tokens, vars);
+ return apply_css_vars(tokens, base, vars);
}
Err(ThemeError(
"expected shadcn's `{ \"cssVars\": { ... } }`".into(),
@@ -341,7 +360,7 @@ fn apply(tokens: &mut Tokens, json: &str) -> Result<(), ThemeError> {
/// file's or the one under it. Values of no kind a token has (shadows,
/// letter spacing) are left out, as they are of no Tailwind utility NSH
/// draws with a token.
-fn apply_css_vars(tokens: &mut Tokens, vars: &JsonValue) -> Result<(), ThemeError> {
+fn apply_css_vars(tokens: &mut Tokens, base: &Tokens, vars: &JsonValue) -> Result<(), ThemeError> {
let section = |name: &str| -> Result<FxHashMap<String, String>, ThemeError> {
let Some(v) = vars.get(name) else {
return Ok(FxHashMap::default());
@@ -374,7 +393,6 @@ fn apply_css_vars(tokens: &mut Tokens, vars: &JsonValue) -> Result<(), ThemeErro
let mut dark_vars = light_vars.clone();
dark_vars.extend(dark.iter().map(|(k, v)| (k.clone(), v.clone())));
- let base = tokens.clone();
let mut names: Vec<&String> = light_vars.keys().chain(dark.keys()).collect();
names.sort();
names.dedup();
@@ -388,7 +406,7 @@ fn apply_css_vars(tokens: &mut Tokens, vars: &JsonValue) -> Result<(), ThemeErro
continue;
}
let resolve = |vars: &FxHashMap<String, String>, dark: bool| {
- resolve_color(name, vars, &base, dark, &mut FxHashSet::default())
+ resolve_color(name, vars, base, dark, &mut FxHashSet::default())
};
match (resolve(&light_vars, false)?, resolve(&dark_vars, true)?) {
(Some(l), Some(d)) => tokens.set_color(name, AdaptiveColor { light: l, dark: d }),
@@ -579,4 +597,31 @@ mod tests {
// The default's, in each appearance.
assert_eq!(theme.color("line").unwrap(), Theme::default().border);
}
+
+ #[test]
+ fn overrides_are_the_tokens_the_file_sets() {
+ let tokens = theme_overrides(
+ r##"{ "cssVars": {
+ "theme": { "radius": "0.75rem" },
+ "light": { "primary": "#e11d48", "ring": "var(--primary)", "focus": "var(--border)" },
+ "dark": { "primary": "#fb7185" }
+ } }"##,
+ )
+ .unwrap();
+ let primary = tokens.color("primary").unwrap();
+ assert_eq!(
+ (hex(primary.light), hex(primary.dark)),
+ (0xe11d48, 0xfb7185)
+ );
+ // The file's own, per appearance.
+ assert_eq!(tokens.color("ring").unwrap(), primary);
+ // One it does not set: the default's.
+ assert_eq!(tokens.color("focus").unwrap(), Theme::default().border);
+ assert_eq!(tokens.number("radius"), Some(12.0));
+ // Nothing else: the app's tokens stay.
+ assert_eq!(tokens.colors().count(), 3);
+ assert_eq!(tokens.color("background"), None);
+ assert!(theme_overrides("{}").is_err());
+ assert!(theme_overrides("not json").is_err());
+ }
}
diff --git a/third-party/nsh/crates/nsh-bundler/src/bundle.rs b/third-party/nsh/crates/nsh-bundler/src/bundle.rs
index 469d5e72f1..8c0f92bd35 100644
--- a/third-party/nsh/crates/nsh-bundler/src/bundle.rs
+++ b/third-party/nsh/crates/nsh-bundler/src/bundle.rs
@@ -439,6 +439,9 @@ pub async fn bundle_async(options: &BundleOptions) -> Result<Bundle, BundleError
assets.sort();
assets.dedup();
let natives = std::mem::take(&mut *natives.lock().expect("natives lock"));
+ if let Some(warning) = ungranted_host(&natives, capabilities.as_ref()) {
+ log::warn!(target: "nsh::build", "{warning}");
+ }
Ok(Bundle {
code,
source_map,
@@ -450,6 +453,22 @@ pub async fn bundle_async(options: &BundleOptions) -> Result<Bundle, BundleError
})
}
+/// The warning for an app that imports `nsh/host` and grants itself none
+/// of the host's functions (`capabilities.host`): each of its calls is
+/// refused, as the host's functions are never granted by default.
+fn ungranted_host<'a>(
+ natives: impl IntoIterator<Item = &'a String>,
+ capabilities: Option<&nsh_core::access::Capabilities>,
+) -> Option<String> {
+ let imports = natives.into_iter().any(|n| n == "nsh/host");
+ let granted = capabilities.is_some_and(|c| !c.host.is_empty());
+ (imports && !granted).then(|| {
+ "the app imports nsh/host and nsh.json declares no `capabilities.host`: every `host.call` is refused \
+ (list the host's functions it calls, prefixes ending in `*`, or `true`)"
+ .to_owned()
+ })
+}
+
/// The chunks of `imports` (the files a chunk imports statically; native
/// modules are not files), the entry chunk left out.
pub(crate) fn chunk_imports<'a>(imports: impl Iterator<Item = &'a str>, own: &str) -> Vec<String> {
@@ -709,6 +728,9 @@ pub async fn bundle_hot_async(
assets.sort();
assets.dedup();
let natives = std::mem::take(&mut *natives.lock().expect("natives lock"));
+ if let Some(warning) = ungranted_host(&natives, capabilities.as_ref()) {
+ log::warn!(target: "nsh::build", "{warning}");
+ }
Ok(HotBundle {
modules,
natives: natives.into_iter().collect(),
@@ -724,6 +746,24 @@ pub async fn bundle_hot_async(
#[cfg(test)]
mod tests {
+ #[test]
+ fn apps_calling_the_host_without_a_grant_are_warned() {
+ use nsh_core::access::Capabilities;
+ let natives = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
+ let caps = |json| Capabilities::from_json(&serde_json::from_str(json).unwrap()).unwrap();
+ let host = natives(&["nsh", "nsh/host"]);
+ assert!(
+ super::ungranted_host(&host, None)
+ .unwrap()
+ .contains("capabilities.host")
+ );
+ assert!(super::ungranted_host(&host, Some(&caps("{}"))).is_some());
+ assert!(super::ungranted_host(&host, Some(&caps(r#"{"host": false}"#))).is_some());
+ assert!(super::ungranted_host(&host, Some(&caps(r#"{"host": ["a"]}"#))).is_none());
+ assert!(super::ungranted_host(&host, Some(&caps(r#"{"host": true}"#))).is_none());
+ assert!(super::ungranted_host(&natives(&["nsh"]), None).is_none());
+ }
+
use super::*;
/// An app whose screens are imported on demand: the first imports what
diff --git a/third-party/nsh/crates/nsh-cli/src/dev.rs b/third-party/nsh/crates/nsh-cli/src/dev.rs
index d9c5da64ee..783ccb67ac 100644
--- a/third-party/nsh/crates/nsh-cli/src/dev.rs
+++ b/third-party/nsh/crates/nsh-cli/src/dev.rs
@@ -66,7 +66,8 @@ impl DevSession {
// --target web` builds it in a checkout).
let rust_root = crate::rust_app::is_root(&entry);
if let Ok(Some(runtime)) = crate::runtime::web(crate::runtime::WebKind::Dev, false) {
- let (page, files) = crate::web::dev_host(&runtime, rust_root)?;
+ let (page, files) =
+ crate::web::with_page_host(crate::web::dev_host(&runtime, rust_root)?, &entry)?;
server.serve_web(page, files);
}
let session = DevSession {
diff --git a/third-party/nsh/crates/nsh-cli/src/main.rs b/third-party/nsh/crates/nsh-cli/src/main.rs
index 5a605beac3..1d6c1393b7 100644
--- a/third-party/nsh/crates/nsh-cli/src/main.rs
+++ b/third-party/nsh/crates/nsh-cli/src/main.rs
@@ -164,6 +164,11 @@ enum Command {
/// apps in (default: the one installed on this machine).
#[arg(long, value_name = "PATH")]
browser: Option<PathBuf>,
+ /// The web page's host module (default: `nsh.json` `web.host`), which
+ /// exports `createHost(player)`: in headless runs it answers the
+ /// `nsh/host` calls of each app the tests open and sends its events.
+ #[arg(long, value_name = "JS")]
+ web_host: Option<PathBuf>,
},
/// Type check an app with TypeScript's native compiler (TypeScript 7,
/// downloaded once from the npm registry; no Node needed).
@@ -589,6 +594,7 @@ fn dispatch(command: Command) -> anyhow::Result<()> {
junit,
target,
browser,
+ web_host,
} => test::test(
&path,
&entry,
@@ -597,6 +603,7 @@ fn dispatch(command: Command) -> anyhow::Result<()> {
junit.as_deref(),
target,
browser.as_deref(),
+ web_host,
),
Command::Dev {
entry,
diff --git a/third-party/nsh/crates/nsh-cli/src/package.rs b/third-party/nsh/crates/nsh-cli/src/package.rs
index d17ad85028..b1dc1e0722 100644
--- a/third-party/nsh/crates/nsh-cli/src/package.rs
+++ b/third-party/nsh/crates/nsh-cli/src/package.rs
@@ -109,6 +109,25 @@ pub(crate) struct AppConfig {
/// A Rust app's root component in its crate (`rust_app::RootConfig`).
#[serde(default, rename = "root")]
_root: Option<serde::de::IgnoredAny>,
+ /// What the web's packages add to the page (`web.rs`).
+ #[serde(default)]
+ pub(crate) web: WebConfig,
+}
+
+/// `nsh.json` `"web"`.
+#[derive(Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub(crate) struct WebConfig {
+ /// The page's host: a JS module, relative to `nsh.json`, whose
+ /// `createHost(player)` returns what `nsh/host` calls reach
+ /// (`createPlayer`'s `host`).
+ pub(crate) host: Option<PathBuf>,
+ /// The hosts the page's host module reaches (patterns, as
+ /// `capabilities.network.hosts`): the page's `connect-src` allows them
+ /// too, while the app's own requests stay checked against its
+ /// `capabilities`.
+ #[serde(default)]
+ pub(crate) connect: Vec<String>,
}
/// Options of `nsh package`.
@@ -139,9 +158,28 @@ pub(crate) fn read_config(entry: &Path) -> anyhow::Result<AppConfig> {
let mut config: AppConfig =
serde_json::from_str(&text).with_context(|| format!("invalid {}", path.display()))?;
config.icon = config.icon.map(|icon| dir.join(icon));
+ config.web.host = config.web.host.map(|host| dir.join(host));
Ok(config)
}
+/// `nsh.json` `web.host` (`read_config` made it absolute), canonical: the
+/// file must be inside the app's directory, symbolic links resolved, as the
+/// page ships it as it is.
+pub(crate) fn web_host_module(entry: &Path, host: &Path) -> anyhow::Result<PathBuf> {
+ let dir = dunce::canonicalize(entry.parent().unwrap_or(Path::new(".")))
+ .with_context(|| format!("the app's directory of {}", entry.display()))?;
+ let module = dunce::canonicalize(host)
+ .with_context(|| format!("nsh.json web.host {}", host.display()))?;
+ if !module.starts_with(&dir) {
+ bail!(
+ "nsh.json web.host {} must be inside the app's directory {}",
+ host.display(),
+ dir.display()
+ );
+ }
+ Ok(module)
+}
+
/// The app's display name and id: from the config, else the entry's
/// directory name and `com.example.<name>`.
pub(crate) fn name_and_id(entry: &Path, config: &AppConfig) -> (String, String) {
@@ -996,6 +1034,44 @@ mod tests {
);
}
+ #[test]
+ fn the_web_host_module_stays_in_the_app_directory() {
+ let root = std::env::temp_dir().join(format!("nsh-web-host-{}", std::process::id()));
+ let app = root.join("app");
+ fs::create_dir_all(app.join("web")).unwrap();
+ fs::write(app.join("web/host.js"), "").unwrap();
+ fs::write(root.join("outside.js"), "").unwrap();
+ let entry = app.join("App.tsx");
+ let config = |host: &str| {
+ fs::write(
+ app.join("nsh.json"),
+ serde_json::json!({ "web": { "host": host } }).to_string(),
+ )
+ .unwrap();
+ read_config(&entry).unwrap().web.host.unwrap()
+ };
+ let inside = web_host_module(&entry, &config("web/host.js")).unwrap();
+ assert_eq!(
+ inside,
+ dunce::canonicalize(app.join("web/host.js")).unwrap()
+ );
+ assert!(web_host_module(&entry, &config("./web/../web/host.js")).is_ok());
+ let outside = root.join("outside.js");
+ let refused = [config("../outside.js"), config(outside.to_str().unwrap())];
+ #[cfg(unix)]
+ let refused = {
+ std::os::unix::fs::symlink(&outside, app.join("link.js")).unwrap();
+ let mut refused = refused.to_vec();
+ refused.push(config("link.js"));
+ refused
+ };
+ for host in refused {
+ let error = web_host_module(&entry, &host).unwrap_err().to_string();
+ assert!(error.contains("inside the app's directory"), "{error}");
+ }
+ fs::remove_dir_all(&root).unwrap();
+ }
+
/// App icons are drawn at 1024 pixels, which `sips` alone cannot
/// convert.
#[cfg(target_os = "macos")]
diff --git a/third-party/nsh/crates/nsh-cli/src/test.rs b/third-party/nsh/crates/nsh-cli/src/test.rs
index 6268b9f08e..29c164023e 100644
--- a/third-party/nsh/crates/nsh-cli/src/test.rs
+++ b/third-party/nsh/crates/nsh-cli/src/test.rs
@@ -39,6 +39,10 @@ const IDLE_MS: u32 = 1200;
/// Runs the tests `path` names, of the app whose entry is `entry`: a test
/// file, the test files in a directory (an app's), or an app's entry with
/// the steps standard input gives.
+#[allow(
+ clippy::too_many_arguments,
+ reason = "the flags of `nsh test`, one each"
+)]
pub(crate) fn test(
path: &Path,
entry: &Path,
@@ -47,6 +51,7 @@ pub(crate) fn test(
junit: Option<&Path>,
target: Option<crate::run::Target>,
browser: Option<&Path>,
+ web_host: Option<PathBuf>,
) -> anyhow::Result<()> {
let app_dir = entry.parent().unwrap_or(Path::new(".")).to_path_buf();
let work_dir = app_dir.join(".nsh/test");
@@ -79,6 +84,25 @@ pub(crate) fn test(
if browser.is_some() && target.is_none() {
bail!("--browser goes with --target web");
}
+ if web_host.is_some() && target.is_some() {
+ bail!(
+ "--web-host goes with headless runs: --target web takes nsh.json's web.host, the other targets have no page host"
+ );
+ }
+ // The page's host of headless runs: `--web-host`, else nsh.json's.
+ // Absolute: an app's headless runner opens it too.
+ let page_host = match web_host {
+ Some(module) => Some(
+ dunce::canonicalize(&module)
+ .with_context(|| format!("the web host module {}", ui::path(&module)))?,
+ ),
+ None if target.is_none() => crate::package::read_config(entry)?
+ .web
+ .host
+ .map(|host| crate::package::web_host_module(entry, &host))
+ .transpose()?,
+ None => None,
+ };
let host = target
.map(|target| crate::test_host::starter(target, browser, &work_dir))
.transpose()?;
@@ -94,6 +118,7 @@ pub(crate) fn test(
filter,
trace,
verbose: ui::verbose(),
+ page_host,
};
in_runner(&app, &request, &shown, &mut tally)?;
}
@@ -105,6 +130,7 @@ pub(crate) fn test(
filter,
trace: trace.then(|| Arc::new(Mutex::new(Box::new(std::io::stdout()) as _))),
host,
+ page_host,
};
for file in &files {
let shown = shown(file);
diff --git a/third-party/nsh/crates/nsh-cli/src/test_web.rs b/third-party/nsh/crates/nsh-cli/src/test_web.rs
index f5502eb8f7..8efd0c6861 100644
--- a/third-party/nsh/crates/nsh-cli/src/test_web.rs
+++ b/third-party/nsh/crates/nsh-cli/src/test_web.rs
@@ -87,7 +87,9 @@ fn start(
options: &HostOptions,
) -> Result<WebHost, String> {
let server = Arc::new(crate::test_host::serve(entry, options)?);
- server.serve_web(host.0.clone(), host.1.clone());
+ let (page, files) =
+ crate::web::with_page_host(host.clone(), entry).map_err(|e| format!("{e:#}"))?;
+ server.serve_web(page, files);
let browser = {
let mut running = running.lock().unwrap_or_else(|e| e.into_inner());
match &*running {
diff --git a/third-party/nsh/crates/nsh-cli/src/web.rs b/third-party/nsh/crates/nsh-cli/src/web.rs
index 3de70ca741..263753207d 100644
--- a/third-party/nsh/crates/nsh-cli/src/web.rs
+++ b/third-party/nsh/crates/nsh-cli/src/web.rs
@@ -13,6 +13,7 @@
//! snippets.<digest>/<crate>-<hash>/ the JS snippets the glue imports, under their own paths
//! app.<digest>.nshc the app's bundle, compiled to QuickJS bytecode
//! harden.<digest>.nshc the sandbox's hardening, compiled likewise
+//! host.<digest>.js the page's host (`nsh.json` `web.host`), when the app has one
//! chunks/<name>.<digest>.nshc what `import()` loads, fetched when first imported
//! assets/<path>.<digest>.<ext> `asset:/<path>`, loaded from its URL when used
//! icons/<size>.<digest>.png favicon, apple-touch-icon, the manifest's
@@ -146,6 +147,16 @@ pub(crate) fn package(
}
assets.insert(asset.path.clone(), url.into());
}
+ // The page imports it as it is: one module, its imports absolute URLs.
+ let host = match &config.web.host {
+ Some(path) => {
+ let path = crate::package::web_host_module(options.entry, path)?;
+ let bytes = fs::read(&path)
+ .with_context(|| format!("read nsh.json web.host {}", path.display()))?;
+ Some(site.add("host.js", &bytes)?)
+ }
+ None => None,
+ };
let icons = match &config.icon {
Some(icon) => Icons::write(&site, icon)?,
None => Icons::default(),
@@ -167,6 +178,7 @@ pub(crate) fn package(
"fonts": fonts,
"chunkFonts": chunk_fonts,
"chunks": chunks,
+ "host": host,
});
if let Some(glue) = &glue {
app["glue"] = glue.as_str().into();
@@ -185,7 +197,11 @@ pub(crate) fn package(
.map(|path| assets[path].as_str().unwrap_or_default().to_owned())
.collect::<Vec<_>>(),
app: &app,
- connect: connect_src(built.capabilities.as_ref()).as_deref(),
+ connect: connect_src(
+ built.capabilities.as_ref(),
+ &page_connect(&config.web.connect)?,
+ )
+ .as_deref(),
});
fs::write(dir.join("index.html"), html)?;
step.done(format!(
@@ -275,7 +291,8 @@ pub(crate) type Files = Vec<(String, Vec<u8>)>;
/// one when its Rust code changes (the session starts again), and a page
/// that reconnects to a server with another one loads again.
/// `rust`: a Rust app's runtime (`nsh.json` `"root"`), whose page runs its
-/// root rather than the bundles the server sends.
+/// root rather than the bundles the server sends. The page still names its
+/// host module as `{{host}}`: [`with_page_host`] fills it in for an app.
pub(crate) fn dev_host(runtime: &WebRuntime, rust: bool) -> anyhow::Result<(String, Files)> {
let read = |path: &Path| fs::read(path).with_context(|| format!("read {}", path.display()));
let ui_sets = serde_json::to_string(&ui_sets())?;
@@ -326,6 +343,34 @@ pub(crate) fn dev_host(runtime: &WebRuntime, rust: bool) -> anyhow::Result<(Stri
Ok((page, files))
}
+/// The dev host's page and files ([`dev_host`]) for the app at `entry`:
+/// with its page's host module (`nsh.json` `web.host`) as `nsh-host.js`,
+/// read now (a change takes another session), or none.
+pub(crate) fn with_page_host(
+ (page, mut files): (String, Files),
+ entry: &Path,
+) -> anyhow::Result<(String, Files)> {
+ let module = crate::package::read_config(entry)?
+ .web
+ .host
+ .map(|host| crate::package::web_host_module(entry, &host))
+ .transpose()?;
+ let host = match module {
+ Some(path) => {
+ let bytes = fs::read(&path)
+ .with_context(|| format!("read nsh.json web.host {}", path.display()))?;
+ files.retain(|(name, _)| name != PAGE_HOST);
+ files.push((PAGE_HOST.to_owned(), bytes));
+ format!("\"./{PAGE_HOST}\"")
+ }
+ None => "null".to_owned(),
+ };
+ Ok((page.replace("{{host}}", &host), files))
+}
+
+/// Where the dev host serves the page's host module ([`with_page_host`]).
+const PAGE_HOST: &str = "nsh-host.js";
+
/// The app's appearance (`nsh.json` `appearance`): the page's background
/// and theme color before the app draws.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -520,14 +565,33 @@ fn manifest(name: &str, id: &str, appearance: Appearance, icons: &Icons) -> Stri
serde_json::to_string_pretty(&manifest).unwrap_or_default()
}
+/// `nsh.json` `web.connect`, checked as `capabilities.network.hosts` are:
+/// patterns of letters, digits, `.`, `-`, `:` and brackets (nothing that
+/// could end a CSP source).
+fn page_connect(hosts: &[String]) -> anyhow::Result<Vec<nsh_core::access::HostPattern>> {
+ hosts
+ .iter()
+ .map(|host| {
+ nsh_core::access::HostPattern::new(host)
+ .map_err(|e| anyhow::anyhow!("nsh.json web.connect: {e}"))
+ })
+ .collect()
+}
+
/// The page's `connect-src` when the app restricts the hosts it reaches
/// (`capabilities.network`): the browser then refuses every request, and
/// every redirect hop, to another host, which nsh cannot see (the browser
/// follows redirects before telling). `'self'` stays: the page loads the
/// runtime, the code and the fonts with `fetch`, and the app's relative
/// URLs reach its own origin (nsh still checks those against the
-/// declaration). `None`: no declaration, or one that allows any host.
-fn connect_src(capabilities: Option<&nsh_core::access::Capabilities>) -> Option<String> {
+/// declaration). `page`: the hosts the page's host module reaches
+/// (`web.connect`), allowed to the page alone: nsh checks the app's
+/// requests against its declaration before the browser sees them. `None`:
+/// no declaration, or one (or `page`) that allows any host.
+fn connect_src(
+ capabilities: Option<&nsh_core::access::Capabilities>,
+ page: &[nsh_core::access::HostPattern],
+) -> Option<String> {
let network = &capabilities?.network;
let mut sources = vec!["'self'".to_owned()];
let mut add = |scheme: &str, host: &str, port: Option<u16>| {
@@ -548,7 +612,7 @@ fn connect_src(capabilities: Option<&nsh_core::access::Capabilities>) -> Option<
sources.push(source);
}
};
- for host in &network.hosts {
+ for host in network.hosts.iter().chain(page) {
let host = host.as_str();
if host == "*" {
return None;
@@ -730,6 +794,37 @@ mod tests {
assert!(hashed_name(".env", b"x").starts_with(".env."));
}
+ #[test]
+ fn the_dev_page_runs_the_apps_page_host() {
+ let dir = std::env::temp_dir().join(format!("nsh-web-dev-host-{}", std::process::id()));
+ fs::create_dir_all(&dir).unwrap();
+ let entry = dir.join("App.tsx");
+ let base = || ("const HOST = {{host}};".to_owned(), Files::new());
+ // No nsh.json, or one without `web.host`: no host.
+ let (page, files) = with_page_host(base(), &entry).unwrap();
+ assert_eq!(page, "const HOST = null;");
+ assert!(files.is_empty());
+ fs::write(dir.join("host.js"), "export function createHost() {}").unwrap();
+ fs::write(dir.join("nsh.json"), r#"{ "web": { "host": "host.js" } }"#).unwrap();
+ let (page, files) = with_page_host(base(), &entry).unwrap();
+ assert_eq!(page, "const HOST = \"./nsh-host.js\";");
+ assert_eq!(
+ files,
+ [(
+ "nsh-host.js".to_owned(),
+ b"export function createHost() {}".to_vec()
+ )]
+ );
+ // Outside the app's directory: refused, as `nsh package` does.
+ fs::write(
+ dir.join("nsh.json"),
+ r#"{ "web": { "host": "../host.js" } }"#,
+ )
+ .unwrap();
+ assert!(with_page_host(base(), &entry).is_err());
+ fs::remove_dir_all(&dir).unwrap();
+ }
+
#[test]
fn the_dev_page_knows_its_runtime() {
let dir = std::env::temp_dir().join(format!("nsh-web-dev-{}", std::process::id()));
@@ -752,7 +847,8 @@ mod tests {
page.contains(&format!("const RUNTIME = \"{first}\";")),
"{page}"
);
- assert!(!page.contains("{{"), "{page}");
+ // The page's host is the app's (`with_page_host`).
+ assert!(!page.replace("{{host}}", "").contains("{{"), "{page}");
assert!(page.contains("const UI_SETS = [\"nsh/base\"];"));
fs::write(&runtime.wasm, "two").unwrap();
assert_ne!(version(&dev_host(&runtime, false).unwrap().1), first);
@@ -974,9 +1070,9 @@ mod tests {
fn requests_are_limited_to_the_declared_hosts() {
let caps =
|json: serde_json::Value| nsh_core::access::Capabilities::from_json(&json).unwrap();
- assert_eq!(connect_src(None), None);
+ assert_eq!(connect_src(None, &[]), None);
assert_eq!(
- connect_src(Some(&caps(serde_json::json!({ "clipboard": true })))).as_deref(),
+ connect_src(Some(&caps(serde_json::json!({ "clipboard": true }))), &[]).as_deref(),
Some("'self'")
);
let declared = caps(serde_json::json!({ "network": {
@@ -984,7 +1080,7 @@ mod tests {
"http": [{ "host": "api.example.com", "methods": ["GET"] }],
} }));
assert_eq!(
- connect_src(Some(&declared)).as_deref(),
+ connect_src(Some(&declared), &[]).as_deref(),
Some(
"'self' http://api.example.com:* https://api.example.com:* ws://api.example.com:* wss://api.example.com:* http://*.cdn.example.com:* https://*.cdn.example.com:* ws://*.cdn.example.com:* wss://*.cdn.example.com:* http://localhost:8080 https://localhost:8080 ws://localhost:8080 wss://localhost:8080"
)
@@ -995,7 +1091,7 @@ mod tests {
{ "host": "legacy.example.com", "methods": ["GET"], "schemes": ["ws"], "port": 9000 }
]} }));
assert_eq!(
- connect_src(Some(&constrained)).as_deref(),
+ connect_src(Some(&constrained), &[]).as_deref(),
Some(
"'self' https://secure.example.com:8443 wss://secure.example.com:8443 https://upload.example.com:9443 ws://legacy.example.com:9000"
)
@@ -1004,7 +1100,7 @@ mod tests {
{ "host": "*", "methods": ["GET"], "schemes": ["https"], "port": 443 }
]} }));
assert_eq!(
- connect_src(Some(&wildcard)).as_deref(),
+ connect_src(Some(&wildcard), &[]).as_deref(),
Some("'self' https://*:443 wss://*:443")
);
let ipv6 = caps(serde_json::json!({ "network": {"http": [
@@ -1012,11 +1108,30 @@ mod tests {
{ "host": "[::2]", "methods": ["GET"], "schemes": ["https"] }
]} }));
assert_eq!(
- connect_src(Some(&ipv6)).as_deref(),
+ connect_src(Some(&ipv6), &[]).as_deref(),
Some("'self' http://[::1]:9877 ws://[::1]:9877 https://[::2]:* wss://[::2]:*")
);
let any = caps(serde_json::json!({ "network": { "hosts": ["*"] } }));
- assert_eq!(connect_src(Some(&any)), None);
+ assert_eq!(connect_src(Some(&any), &[]), None);
+ // The page's host module's hosts (`web.connect`) join the app's; any
+ // host there lifts the restriction too; none without a declaration.
+ let page = page_connect(&["auth.example.com".into()]).unwrap();
+ assert_eq!(
+ connect_src(Some(&caps(serde_json::json!({}))), &page).as_deref(),
+ Some(
+ "'self' http://auth.example.com:* https://auth.example.com:* ws://auth.example.com:* wss://auth.example.com:*"
+ )
+ );
+ assert_eq!(connect_src(None, &page), None);
+ let anywhere = page_connect(&["*".into()]).unwrap();
+ assert_eq!(
+ connect_src(Some(&caps(serde_json::json!({}))), &anywhere),
+ None
+ );
+ // Nothing that could end a CSP source.
+ for bad in ["a.com; script-src *", "'unsafe-inline'", "a b", ""] {
+ assert!(page_connect(&[bad.into()]).is_err(), "{bad}");
+ }
}
#[test]
diff --git a/third-party/nsh/crates/nsh-cli/src/web/dev.html b/third-party/nsh/crates/nsh-cli/src/web/dev.html
index d02fa1c3dc..78a540aabf 100644
--- a/third-party/nsh/crates/nsh-cli/src/web/dev.html
+++ b/third-party/nsh/crates/nsh-cli/src/web/dev.html
@@ -42,6 +42,9 @@
const RUST = {{rust}};
// The runtime's wasm-bindgen glue (an app's crates that call JS through it).
const GLUE = {{glue}};
+ // The page's host (`nsh.json` `web.host`): its `createHost(player)` runs
+ // once the player exists, before the first bundle, as on a packaged page.
+ const HOST = {{host}};
const status = document.getElementById("status");
const problem = document.getElementById("problem");
const decoder = new TextDecoder();
@@ -88,14 +91,25 @@
return data;
}
+ let host;
const player = await createPlayer(document.getElementById("app"), {
wasm: "./nsh.wasm",
glue: GLUE ?? undefined,
+ host: HOST ? { call: (name, args) => host.call(name, args) } : undefined,
assetBase: "/assets/",
readAsset,
onLog: (level, message) => send({ type: "log", level: levels[level] ?? "info", target: "js", message }),
});
window.player = player;
+ if (HOST) {
+ try {
+ host = await (await import(HOST)).createHost(player);
+ if (typeof host?.call !== "function") throw new Error("createHost(player) returned no call(name, args)");
+ } catch (err) {
+ showProblem("nsh.json web.host", String(err?.stack ?? err));
+ host = { call: () => Promise.reject(new Error("nsh.json web.host did not load")) };
+ }
+ }
function device() {
const ua = navigator.userAgent;
diff --git a/third-party/nsh/crates/nsh-cli/src/web/index.html b/third-party/nsh/crates/nsh-cli/src/web/index.html
index 6c865152b2..82e03140fe 100644
--- a/third-party/nsh/crates/nsh-cli/src/web/index.html
+++ b/third-party/nsh/crates/nsh-cli/src/web/index.html
@@ -34,7 +34,9 @@
// code (bytecode, with the hardening it runs first) and its fonts in
// parallel, then runs the app as a release; a chunk's own fonts come
// with it. A Rust app (`app.rust`) has no code: its runtime mounts its
- // root.
+ // root. The page's host (`app.host`, nsh.json `web.host`) answers the
+ // app's `nsh/host` calls: its `createHost(player)` runs once the player
+ // exists, before the app starts, so no call comes earlier.
import { createPlayer } from "./{{runtime}}";
const app = {{app}};
@@ -77,10 +79,12 @@
};
try {
- const [loaded, code, harden] = await Promise.all([
+ let host;
+ const [loaded, code, harden, hostModule] = await Promise.all([
createPlayer(document.getElementById("app"), {
wasm: take(app.wasm),
glue: app.glue,
+ host: app.host ? { call: (name, args) => host.call(name, args) } : undefined,
assets: app.assets,
readAsset: (uri) => fonts.get(path(uri)) ?? null,
prepareChunk: (id) => Promise.all((app.chunkFonts[id] ?? []).map(loadFont)),
@@ -92,9 +96,14 @@
}),
app.rust ? null : get(app.code).then((r) => r.arrayBuffer()),
app.rust ? null : get(app.harden).then((r) => r.arrayBuffer()),
+ app.host ? import(`./${app.host}`) : null,
...app.fonts.map(loadFont),
]);
player = loaded;
+ if (hostModule) {
+ host = await hostModule.createHost(player);
+ if (typeof host?.call !== "function") throw new Error("nsh.json web.host: createHost(player) returned no call(name, args)");
+ }
status.hidden = true;
if (app.rust) player.runRust();
else player.run(code, { release: true, harden, capabilities: app.capabilities ?? "", appId: app.id ?? "", chunks: app.chunks });
diff --git a/third-party/nsh/crates/nsh-core/src/access.rs b/third-party/nsh/crates/nsh-core/src/access.rs
index 26f58e63fd..21cd8ed1e5 100644
--- a/third-party/nsh/crates/nsh-core/src/access.rs
+++ b/third-party/nsh/crates/nsh-core/src/access.rs
@@ -16,7 +16,8 @@
//! "sockets": ["mqtt.example.com:8883"]
//! },
//! "webview": { "hosts": ["*.example.com"] },
-//! "statusBar": true
+//! "statusBar": true,
+//! "host": ["getUserInfo", "bridge.*"]
//! }
//! ```
//!
@@ -28,10 +29,14 @@
//! `network.sockets` grants raw TCP (`net.connect`) to exact `host:port`
//! pairs only: no wildcards, and neither `hosts` nor `http` grants imply
//! one. `webview` lists the hosts a web view may show and navigate to (its
-//! pages' own subresources are the web content's business). An app without
-//! `"capabilities"` (a standalone app, its developer's own) retains
-//! unrestricted HTTP, web views, the status bar and the clipboard; raw
-//! sockets, processes and the filesystem always require an explicit
+//! pages' own subresources are the web content's business). `host` lists
+//! the functions of the embedding app `nsh/host` may call: names (1 to 128
+//! of `[A-Za-z0-9_.:-]`, without `..`; the core refuses calls of any other
+//! name), or prefixes of them ending in `*` (`true`: all of them); the
+//! host's events need no grant, the host sends them. An app without `"capabilities"` (a
+//! standalone app, its developer's own) retains unrestricted HTTP, web
+//! views, the status bar and the clipboard; raw sockets, processes, the
+//! filesystem and the host's functions always require an explicit
//! declaration.
//! A `network.http` rule names a `host` (a pattern, as in `hosts`); every
@@ -349,6 +354,10 @@ pub struct Capabilities {
pub status_bar: bool,
/// The app may read and write the system clipboard (`nsh/clipboard`).
pub clipboard: bool,
+ /// The functions of the embedding app `nsh/host` may call: names, or
+ /// prefixes ending in `*` (`"*"`: all); empty: none, also for apps
+ /// without a declaration ([`Capabilities::all`]).
+ pub host: Vec<String>,
}
/// The parts of a URL the checks look at.
@@ -411,8 +420,8 @@ pub fn parse_url(url: &str) -> Option<UrlParts<'_>> {
impl Capabilities {
/// Legacy unrestricted APIs for apps without a declaration. Socket,
- /// process and filesystem grants stay empty and always require explicit
- /// opt-in.
+ /// process, filesystem and host function grants stay empty and always
+ /// require explicit opt-in.
pub fn all() -> Self {
let any = vec![HostPattern("*".into())];
Self {
@@ -424,6 +433,7 @@ impl Capabilities {
webview: Some(any),
status_bar: true,
clipboard: true,
+ // The host's functions are never granted without a declaration.
..Self::default()
}
}
@@ -449,13 +459,14 @@ impl Capabilities {
/// `dropped`: lenient, recording what unknown keys dropped; `None`:
/// strict.
fn parse(value: &Value, mut dropped: Option<&mut Vec<String>>) -> Result<Self, String> {
- const KNOWN: [&str; 6] = [
+ const KNOWN: [&str; 7] = [
"network",
"webview",
"statusBar",
"clipboard",
"process",
"fs",
+ "host",
];
let obj = object(value, "capabilities")?;
for key in obj.keys().filter(|k| !KNOWN.contains(&k.as_str())) {
@@ -601,6 +612,30 @@ impl Capabilities {
}
}
}
+ if let Some(host) = obj.get("host") {
+ caps.host = match host {
+ Value::Bool(all) => {
+ if *all {
+ vec!["*".into()]
+ } else {
+ Vec::new()
+ }
+ }
+ _ => {
+ let names = strings(host, "capabilities.host")
+ .map_err(|e| format!("{e} (or a boolean)"))?;
+ if let Some(bad) = names.iter().find(|n| match n.strip_suffix('*') {
+ Some(prefix) => !prefix.is_empty() && !valid_host_name(prefix),
+ None => !valid_host_name(n),
+ }) {
+ return Err(format!(
+ "capabilities.host: invalid function `{bad}` (a name of {HOST_NAME_CHARS}, or a prefix of them ending in `*`)"
+ ));
+ }
+ names
+ }
+ };
+ }
Ok(caps)
}
@@ -662,6 +697,10 @@ impl Capabilities {
}
obj.insert("statusBar".into(), self.status_bar.into());
obj.insert("clipboard".into(), self.clipboard.into());
+ // Only when granted: older runtimes read what newer signers write.
+ if !self.host.is_empty() {
+ obj.insert("host".into(), self.host.clone().into());
+ }
Value::Object(obj)
}
@@ -673,6 +712,13 @@ impl Capabilities {
|| self.network.http.iter().any(|g| g.allows(method, url))
}
+ fn allows_host(&self, name: &str) -> bool {
+ self.host.iter().any(|g| match g.strip_suffix('*') {
+ Some(prefix) => name.starts_with(prefix),
+ None => g == name,
+ })
+ }
+
fn allows_webview(&self, url: &UrlParts<'_>) -> bool {
matches!(url.scheme.as_str(), "http" | "https")
&& self
@@ -682,6 +728,20 @@ impl Capabilities {
}
}
+/// What host function names (`nsh/host`) are made of, for messages.
+pub(crate) const HOST_NAME_CHARS: &str = "1 to 128 of A-Z a-z 0-9 _ . : -, without `..`";
+
+/// Whether `name` may name a function of the embedding host: 1 to 128 of
+/// `[A-Za-z0-9_.:-]`, without `..`. Hosts may put a name in a path or a URL,
+/// so no name escapes the prefix a grant gives it.
+pub(crate) fn valid_host_name(name: &str) -> bool {
+ (1..=128).contains(&name.len())
+ && name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
+ && !name.contains("..")
+}
+
fn valid_command(command: &str) -> bool {
!command.is_empty()
&& !command.contains(['\0', '*'])
@@ -1136,6 +1196,11 @@ impl Access {
pub fn allows_clipboard(&self) -> bool {
self.each(|c| c.clipboard)
}
+
+ /// Whether `nsh/host` may call the embedding app's function `name`.
+ pub fn allows_host(&self, name: &str) -> bool {
+ self.each(|c| c.allows_host(name))
+ }
}
/// `location` against `base` (absolute, scheme-relative, absolute-path or
@@ -1633,9 +1698,65 @@ mod tests {
assert!(!access.allows_webview("https://example.com/"));
assert!(!access.allows_status_bar());
assert!(!access.allows_clipboard());
+ assert!(!access.allows_host("getUserInfo"));
assert!(Access::all().is_unrestricted());
}
+ #[test]
+ fn host_functions_need_an_explicit_grant() {
+ // Undeclared, the other capabilities are all granted; the host's
+ // functions are not.
+ let all = Access::all();
+ assert!(all.allows_clipboard());
+ assert!(!all.allows_host("getUserInfo"));
+ let access = |json: &str| Access {
+ declared: caps(json),
+ granted: None,
+ };
+ let every = access(r#"{ "host": true }"#);
+ assert!(every.allows_host("getUserInfo") && every.allows_host("bridge.call"));
+ assert!(!access(r#"{ "host": false }"#).allows_host("getUserInfo"));
+ let listed = access(r#"{ "host": ["getUserInfo", "bridge.*"] }"#);
+ assert!(listed.allows_host("getUserInfo"));
+ assert!(listed.allows_host("bridge.share"));
+ assert!(!listed.allows_host("getUserInfoX"));
+ assert!(!listed.allows_host("bridge"));
+ assert!(Capabilities::from_json(&serde_json::json!({ "host": ["a*b"] })).is_err());
+ assert!(Capabilities::from_json(&serde_json::json!({ "host": [""] })).is_err());
+ // The embedding app may grant less.
+ let narrowed = Access {
+ declared: caps(r#"{ "host": true }"#),
+ granted: Some(caps(r#"{ "host": ["bridge.*"] }"#)),
+ };
+ assert!(narrowed.allows_host("bridge.call") && !narrowed.allows_host("getUserInfo"));
+ }
+
+ #[test]
+ fn host_function_names_and_grants_keep_to_their_charset() {
+ for good in ["getUserInfo", "user.info", "a-b_c:d.E9", &"a".repeat(128)] {
+ assert!(valid_host_name(good), "{good}");
+ }
+ for bad in [
+ "",
+ "user.x/../../admin",
+ "a..b",
+ "user x",
+ "a\\b",
+ "caf\u{e9}",
+ "ok\u{0}",
+ "a*",
+ &"a".repeat(129),
+ ] {
+ assert!(!valid_host_name(bad), "{bad:?}");
+ }
+ let parses =
+ |grant: &str| Capabilities::from_json(&serde_json::json!({ "host": [grant] })).is_ok();
+ assert!(parses("*") && parses("user.*") && parses("user.info"));
+ for bad in ["user/*", "a..*", "../*", "a b", "**"] {
+ assert!(!parses(bad), "{bad}");
+ }
+ }
+
#[test]
fn hosts_and_finer_grants() {
let c = caps(
diff --git a/third-party/nsh/crates/nsh-core/src/core/components.rs b/third-party/nsh/crates/nsh-core/src/core/components.rs
index fa302eeb3b..1256bfbb4f 100644
--- a/third-party/nsh/crates/nsh-core/src/core/components.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/components.rs
@@ -472,6 +472,10 @@ pub(super) struct Components {
/// id: their answers.
clipboard_reads: FxHashMap<u32, ClipboardRead>,
next_clipboard_read: u32,
+ /// Calls of the embedding app's functions (`nsh/host`), by request id:
+ /// their answers.
+ host_calls: FxHashMap<u64, AnswerSlot<Result<crate::JsonValue, String>>>,
+ next_host_call: u64,
/// HTTP requests of tasks (`ui::fetch`), by request id: what arrived
/// so far and the answer.
fetches: FxHashMap<u32, PendingFetch>,
@@ -555,6 +559,8 @@ impl Default for Components {
next_image_info: 1,
clipboard_reads: FxHashMap::default(),
next_clipboard_read: 1,
+ host_calls: FxHashMap::default(),
+ next_host_call: 1,
fetches: FxHashMap::default(),
fetch_requests: Vec::new(),
next_fetch: 1,
@@ -586,6 +592,12 @@ impl Default for Components {
}
impl Components {
+ /// Whether `list` is one of the components' own lists (bound in the
+ /// core).
+ pub(super) fn is_virtual_list(&self, list: NodeId) -> bool {
+ self.virtual_lists.contains_key(&list)
+ }
+
pub(super) fn for_viewport(viewport: crate::Size<crate::Points>) -> Self {
let mut components = Self::default();
components.environment.window = viewport;
@@ -4408,6 +4420,28 @@ impl<M: TextEngine> Core<M> {
command,
});
}
+ Action::HostCall { name, args, answer } => {
+ if !self.components.environment.host {
+ answer
+ .borrow_mut()
+ .deliver(Err(crate::runtime::host::NO_HOST.into()));
+ return;
+ }
+ let id = self.components.next_host_call;
+ self.components.next_host_call += 1;
+ self.components.host_calls.insert(id, answer);
+ let _ = self.apply_op(Op::Command {
+ id: crate::ROOT_ID,
+ command: Command::HostCall { id, name, args },
+ });
+ }
+ // A call timed out (`runtime/host.rs`), its answer dropped: its
+ // slot waits no more.
+ Action::ForgetHostCalls => {
+ self.components
+ .host_calls
+ .retain(|_, slot| std::rc::Rc::strong_count(slot) > 1);
+ }
}
}
@@ -4515,6 +4549,20 @@ impl<M: TextEngine> Core<M> {
}
}
+ /// The answer to host call `id`: the host's value, or its error. A
+ /// call that timed out (`runtime/host.rs`) takes none: its late answer
+ /// is dropped.
+ pub(super) fn answer_host(&mut self, id: u64, result: Result<crate::JsonValue, String>) {
+ match self.components.host_calls.remove(&id) {
+ Some(answer) if std::rc::Rc::strong_count(&answer) > 1 => {
+ answer.borrow_mut().deliver(result);
+ }
+ _ => {
+ log::debug!(target: "js", "nsh/host: dropped the answer to call {id}, which no longer waits")
+ }
+ }
+ }
+
/// Events the components handle before anything else: dialog answers
/// (theirs never reach JS), insets of screens and modals.
fn platform_event(&mut self, id: NodeId, kind: EventKind, payload: &EventPayload) -> bool {
@@ -4614,6 +4662,42 @@ impl<M: TextEngine> Core<M> {
self.answer_clipboard(request as u32, answer);
true
}
+ // The host's replies; its events go on to `nsh/host`'s
+ // listeners (`nsh-js`).
+ EventKind::Host if id == crate::ROOT_ID => {
+ let EventPayload::Json(reply) = payload else {
+ return false;
+ };
+ if reply.get("type").and_then(crate::JsonValue::as_str) != Some("reply") {
+ return false;
+ }
+ // JSON's numbers may come as floats (`1.0`); `1.5` is no
+ // call. Ids stay within JS' safe integers.
+ let call = reply.get("id").and_then(|id| {
+ id.as_u64().or_else(|| {
+ id.as_f64()
+ .filter(|n| {
+ n.fract() == 0.0 && (1.0..=9_007_199_254_740_991.0).contains(n)
+ })
+ .map(|n| n as u64)
+ })
+ });
+ let Some(call) = call else {
+ log::debug!(target: "js", "nsh/host: dropped a reply without a call's id: {reply}");
+ return true;
+ };
+ let result = if reply.get("ok").and_then(crate::JsonValue::as_bool) == Some(true) {
+ Ok(reply.get("value").cloned().unwrap_or_default())
+ } else {
+ Err(reply
+ .get("error")
+ .and_then(crate::JsonValue::as_str)
+ .unwrap_or("nsh/host: the call failed")
+ .to_owned())
+ };
+ self.answer_host(call, result);
+ true
+ }
EventKind::Insets => {
let insets = EdgeInsets {
top: Points(number("top").unwrap_or(0.0) as f32),
diff --git a/third-party/nsh/crates/nsh-core/src/core/list.rs b/third-party/nsh/crates/nsh-core/src/core/list.rs
index e0465ca1c3..5da76b23b9 100644
--- a/third-party/nsh/crates/nsh-core/src/core/list.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/list.rs
@@ -16,7 +16,18 @@
//! `batch` and then sets its `cellIndex` to confirm, and only confirmed cells
//! are measured and positioned. New cells are created with their first
//! index. The engine runs at most 8 "bind → JS → layout" rounds per frame,
-//! merged into one batch. Row-local state travels with recycled cells, so
+//! merged into one batch.
+//!
+//! A slot remembers the item JS was last asked to show (`requested`), so
+//! what is still to bind is always derived from the current assignment:
+//! nothing stale is queued. Cells whose item moved (a splice) learn their
+//! new index in the same commit, and so does every cell in the viewport and
+//! just ahead of it in the scroll direction. A list with `paced` set (the
+//! runtime's) leaves the rest of the overscan pending: the engine binds it
+//! with `take_paced`, nearest the leading edge first, as its per-turn
+//! budget allows, and asks for a display frame while some is left.
+//!
+//! Row-local state travels with recycled cells, so
//! apps keep it outside, keyed by id. Unbound cells keep their last content:
//! the core moves them out of sight (never with a NaN frame; cells never
//! placed are skipped). The header, footer and empty placeholder are cells
@@ -72,6 +83,9 @@ pub(super) struct Slot {
pub(super) index: Option<u32>,
/// Item the cell's content currently shows (confirmed by the runtime).
pub(super) rendered: Option<u32>,
+ /// Item the runtime was last asked to show in this slot (its cell's
+ /// index); `None` before the first request or when that item is gone.
+ pub(super) requested: Option<u32>,
}
impl Slot {
@@ -310,6 +324,13 @@ pub(super) struct ListState {
/// Cells whose item moved to another index (a splice): the runtime
/// learns their new index with the next bindings.
moved: Vec<(u32, u32)>,
+ /// Binds of overscan cells wait for the engine's budget
+ /// ([`Self::take_paced`]); the rest bind with the window.
+ pub(super) paced: bool,
+ /// The last scroll step the platform reported, in natural coordinates
+ /// (its sign is the scroll direction). The core's own moves (anchoring
+ /// a splice, measured rows, following a pin) are not steps.
+ lead: f32,
/// Scroll that keeps the visible content in place after a splice.
splice_anchor: f32,
/// The target of the last scroll command, until it is reached with
@@ -376,6 +397,8 @@ impl ListState {
issued: 0,
pending: Vec::new(),
moved: Vec::new(),
+ paced: false,
+ lead: 0.0,
splice_anchor: 0.0,
pin: None,
source_geometry: SourceGeometry::default(),
@@ -519,24 +542,35 @@ impl ListState {
};
self.by_index.clear();
for (slot, s) in self.slots.iter_mut().enumerate() {
- let Some(old) = s.index else { continue };
- match shift(old) {
- Some(new) => {
- s.index = Some(new);
- if s.rendered == Some(old) {
- s.rendered = Some(new);
- }
- if new != old {
- self.moved.push((slot as u32, new));
+ let index = s.index.and_then(shift);
+ let Some(new) = index else {
+ if s.index.take().is_some() {
+ self.free.push(slot as u32);
+ }
+ // The cell's content follows the new data at its old index:
+ // it is bound again before it shows.
+ s.rendered = None;
+ s.requested = None;
+ continue;
+ };
+ s.index = Some(new);
+ s.rendered = s.rendered.and_then(shift);
+ // The runtime's index for the cell follows its item at once, or
+ // its content shows the item now at the old index.
+ match s.requested.map(|r| (r, shift(r))) {
+ Some((r, Some(n))) => {
+ s.requested = Some(n);
+ if n != r {
+ self.moved.push((slot as u32, n));
}
- self.by_index.insert(new, slot as u32);
}
- None => {
- s.index = None;
+ Some((_, None)) => {
+ s.requested = None;
s.rendered = None;
- self.free.push(slot as u32);
}
+ None => {}
}
+ self.by_index.insert(new, slot as u32);
}
self.free.sort_unstable();
self.free.dedup();
@@ -591,6 +625,8 @@ impl ListState {
fn moved_to(&mut self, at: f32, settled: bool) {
let moved = at != self.offset;
if moved {
+ let step = at - self.offset;
+ self.lead = if self.inverted { -step } else { step };
self.offset = at;
self.dirty = true;
}
@@ -1289,6 +1325,7 @@ impl ListState {
cell: u32::MAX,
index: None,
rendered: None,
+ requested: None,
},
);
}
@@ -1307,6 +1344,7 @@ impl ListState {
if slot.cell == cell {
slot.cell = u32::MAX;
slot.rendered = None;
+ slot.requested = None;
self.dirty = true;
}
}
@@ -1409,23 +1447,25 @@ impl ListState {
StickyRange { cell, start, end }
}
- /// Recomputes the render window. New `(slot, index)` bindings are
- /// pushed to `binds`. Returns true if `onLoadMore` should fire.
+ /// Recomputes the render window. `(slot, index)` bindings due now are
+ /// pushed to `binds`: cells whose item moved, and new ones in the
+ /// viewport or just ahead of it (all of them unless `paced`). Returns
+ /// true if `onLoadMore` should fire.
pub(super) fn update_window(&mut self, binds: &mut Vec<(u32, u32)>) -> bool {
self.dirty = false;
+ // Several splices can share one published source. Intermediate
+ // indices no longer describe that source or the slot's current item.
+ self.moved
+ .retain(|&(slot, index)| self.slots[slot as usize].index == Some(index));
+ // Cells whose item moved learn their final index before anything
+ // else binds, including when their viewport just became empty.
+ binds.append(&mut self.moved);
if self.count == 0 || self.viewport <= 0.0 {
- self.moved.clear();
for slot in 0..self.slots.len() {
self.unbind(slot as u32);
}
return false;
}
- // Several splices can share one published source. Intermediate
- // indices no longer describe that source or the slot's current item.
- self.moved
- .retain(|&(slot, index)| self.slots[slot as usize].index == Some(index));
- // Cells whose item moved: the runtime learns their new index.
- binds.append(&mut self.moved);
let at = self.natural_offset();
let overscan = self.viewport * OVERSCAN;
let range = self.items_in(at - overscan, at + self.viewport + overscan);
@@ -1446,7 +1486,7 @@ impl ListState {
self.unbind(slot);
}
}
- let mut bind = |this: &mut Self, i: u32| {
+ let bind = |this: &mut Self, i: u32| {
if this.by_index.contains_key(&i) {
return;
}
@@ -1457,13 +1497,13 @@ impl ListState {
cell: u32::MAX,
index: None,
rendered: None,
+ requested: None,
});
this.slots.len() as u32 - 1
}
};
this.slots[slot as usize].index = Some(i);
this.by_index.insert(i, slot);
- binds.push((slot, i));
};
if let Some((first, last)) = range {
for i in first..=last {
@@ -1473,6 +1513,24 @@ impl ListState {
for s in [before, sticky].into_iter().flatten() {
bind(self, s);
}
+ let (start, end) = self.urgent_span(at);
+ let new = binds.len();
+ for slot in 0..self.slots.len() {
+ let s = self.slots[slot];
+ let Some(i) = s.index else { continue };
+ if s.requested == Some(i) {
+ continue;
+ }
+ if !self.paced
+ || sticky == Some(i)
+ || before == Some(i)
+ || self.item_overlaps(i, start, end)
+ {
+ self.slots[slot].requested = Some(i);
+ binds.push((slot as u32, i));
+ }
+ }
+ binds[new..].sort_unstable_by_key(|b| b.1);
let total = self.blocks().total;
let near_end = at + self.viewport >= total - self.viewport * self.end_threshold;
@@ -1483,6 +1541,82 @@ impl ListState {
false
}
+ /// The span (natural coordinates) whose cells bind with the window:
+ /// the viewport, and ahead of it in the scroll direction twice the last
+ /// step (what the next frames of a fling show). A step longer than the
+ /// viewport is a jump: only the viewport.
+ fn urgent_span(&self, at: f32) -> (f32, f32) {
+ let step = self.lead.abs();
+ let ahead = if step <= self.viewport {
+ (2.0 * step).min(self.viewport * OVERSCAN)
+ } else {
+ 0.0
+ };
+ if self.lead > 0.0 {
+ (at, at + self.viewport + ahead)
+ } else {
+ (at - ahead, at + self.viewport)
+ }
+ }
+
+ /// Whether item `i`'s row overlaps `start..end` (natural coordinates).
+ fn item_overlaps(&self, i: u32, start: f32, end: f32) -> bool {
+ let top = self.item_at(i);
+ top < end && top + self.row_size(self.row_of(i)) > start
+ }
+
+ /// Whether cells wait for [`Self::take_paced`].
+ pub(super) fn has_paced(&self) -> bool {
+ self.slots
+ .iter()
+ .any(|s| s.index.is_some() && s.index != s.requested)
+ }
+
+ /// Up to `max` of the cells waiting to bind, nearest the viewport first
+ /// on the side the list scrolls towards, then the other side; pushed to
+ /// `binds`. Returns how many. The engine takes one at a time (it checks
+ /// its budget after each), so this picks the nearest by a scan of the
+ /// slots instead of sorting them.
+ pub(super) fn take_paced(&mut self, max: usize, binds: &mut Vec<(u32, u32)>) -> usize {
+ let mut taken = 0;
+ while taken < max {
+ let Some((slot, i)) = self.nearest_waiting() else {
+ break;
+ };
+ self.slots[slot as usize].requested = Some(i);
+ binds.push((slot, i));
+ taken += 1;
+ }
+ taken
+ }
+
+ /// The waiting cell [`Self::take_paced`] binds next: `(slot, index)`.
+ fn nearest_waiting(&self) -> Option<(u32, u32)> {
+ let at = self.natural_offset();
+ let (top, bottom) = (at, at + self.viewport);
+ let forward = self.lead >= 0.0;
+ let mut best: Option<((bool, f32), u32, u32)> = None;
+ for (slot, s) in self.slots.iter().enumerate() {
+ let Some(i) = s.index else { continue };
+ if s.requested == Some(i) {
+ continue;
+ }
+ let start = self.item_at(i);
+ let (distance, below) = if start >= bottom {
+ (start - bottom, true)
+ } else {
+ let end = start + self.row_size(self.row_of(i));
+ ((top - end).max(0.0), false)
+ };
+ // The side behind waits for the side ahead.
+ let key = (below != forward, distance);
+ if best.is_none_or(|(b, ..)| key.0.cmp(&b.0).then(key.1.total_cmp(&b.1)).is_lt()) {
+ best = Some((key, slot as u32, i));
+ }
+ }
+ best.map(|(_, slot, i)| (slot, i))
+ }
+
fn unbind(&mut self, slot: u32) {
if let Some(i) = self.slots[slot as usize].index.take() {
self.by_index.remove(&i);
@@ -1889,6 +2023,117 @@ mod tests {
);
}
+ #[test]
+ fn paced_batched_splices_discard_bindings_for_replaced_sources() {
+ let mut l = list(100, 50.0, 400.0);
+ l.paced = true;
+ let mut binds = Vec::new();
+ l.update_window(&mut binds);
+ assert_eq!(binds.len(), 8);
+ assert!(l.has_paced());
+ binds.clear();
+
+ // Both changes publish together. Previously requested cells and
+ // overscan cells still waiting must read only the final snapshot.
+ l.splice(0, 0, 1);
+ l.splice(0, 101, 2);
+ l.update_window(&mut binds);
+ // The prepend moved the viewport to 50: final row 1 is visible,
+ // while row 0 is overscan above it and must wait for paced work.
+ assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [1]);
+ assert_eq!(l.take_paced(100, &mut binds), 1);
+ assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [1, 0]);
+ assert!(
+ binds
+ .iter()
+ .all(|&(slot, index)| l.slots[slot as usize].requested == Some(index))
+ );
+ assert!(!l.has_paced());
+ binds.clear();
+
+ // An empty final source must not receive even a moved-cell bind.
+ l.splice(0, 0, 2);
+ l.splice(0, 4, 0);
+ l.update_window(&mut binds);
+ l.take_paced(100, &mut binds);
+ assert!(binds.is_empty());
+ assert!(!l.has_paced());
+ }
+
+ #[test]
+ fn paced_lists_bind_the_viewport_now_and_the_overscan_ahead_first() {
+ let mut l = list(1000, 50.0, 400.0);
+ l.paced = true;
+ let mut binds = Vec::new();
+ l.update_window(&mut binds);
+ let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+ assert_eq!(now, (0..8).collect::<Vec<_>>(), "the viewport");
+ assert!(l.has_paced());
+ binds.clear();
+ assert_eq!(l.take_paced(3, &mut binds), 3);
+ assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [8, 9, 10]);
+ while l.take_paced(100, &mut binds) > 0 {}
+ assert!(!l.has_paced());
+
+ // The platform reports the scroll: scrolling up by 100, the
+ // viewport and two steps above it bind now; the rest above comes
+ // before what is below.
+ l.reported(2000.0, 0);
+ l.update_window(&mut binds);
+ while l.take_paced(100, &mut binds) > 0 {}
+ l.reported(1900.0, 0);
+ binds.clear();
+ l.update_window(&mut binds);
+ // 1700..2300 was bound already; 1500..1600 is new and waits.
+ assert_eq!(binds, []);
+ // A jump binds its viewport only.
+ l.reported(5000.0, 0);
+ l.update_window(&mut binds);
+ let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+ assert_eq!(now, (100..108).collect::<Vec<_>>());
+ // A step up before the overscan bound: the viewport (4900..5300)
+ // and two steps ahead (4700..4900) bind now ...
+ l.reported(4900.0, 0);
+ binds.clear();
+ l.update_window(&mut binds);
+ let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+ assert_eq!(now, (94..100).collect::<Vec<_>>());
+ // ... the rest above before what is below.
+ binds.clear();
+ l.take_paced(3, &mut binds);
+ assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [93, 92, 91]);
+ }
+
+ #[test]
+ fn splices_tell_bound_cells_their_index_and_forget_gone_items() {
+ let mut l = list(100, 50.0, 400.0);
+ l.paced = true;
+ let mut binds = Vec::new();
+ l.update_window(&mut binds);
+ for &(slot, index) in &binds {
+ l.add_slot_cell(CellSlot::Item(slot), 100 + slot);
+ l.rendered(100 + slot, index);
+ }
+ // Overscan cells wait; one row arrives at the top.
+ l.splice(0, 0, 1);
+ binds.clear();
+ l.update_window(&mut binds);
+ // Every bound cell learns its new index, at once (the anchoring
+ // scroll keeps them in place). The anchoring scroll is the core's,
+ // not a step of the user's: the cells waiting below it, and the new
+ // row above, still wait.
+ let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+ assert_eq!(now, (1..=8).collect::<Vec<_>>(), "{binds:?}");
+ assert!(l.has_paced());
+ // The data shrinks under the cells still waiting: none is asked
+ // for an item that is gone.
+ l.splice(3, 98, 0);
+ binds.clear();
+ l.update_window(&mut binds);
+ while l.take_paced(100, &mut binds) > 0 {}
+ assert!(binds.iter().all(|&(_, i)| i < 3), "{binds:?}");
+ }
+
#[test]
fn pins_follow_until_landed_measured_or_scrolled_away() {
let mut l = list(100, 50.0, 400.0);
diff --git a/third-party/nsh/crates/nsh-core/src/core/mod.rs b/third-party/nsh/crates/nsh-core/src/core/mod.rs
index 9abd124399..9e200ea80d 100644
--- a/third-party/nsh/crates/nsh-core/src/core/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/mod.rs
@@ -796,6 +796,11 @@ pub struct Core<M: TextEngine> {
/// headers under the status bar).
safe_area: EdgeInsets,
list_binds: Vec<ListBind>,
+ /// The runtime's lists with overscan cells waiting to bind
+ /// ([`Core::take_paced_list_binds`]), in the order they asked.
+ paced_lists: Vec<NodeId>,
+ /// [`Core::take_paced_list_binds`]'s buffer, reused.
+ paced_scratch: Vec<(u32, u32)>,
/// The events for JS, in order (the platform's and the core's events,
/// Rust components' events, calls' results).
events: Vec<crate::ui::JsEvent>,
@@ -927,6 +932,8 @@ impl<M: TextEngine> Core<M> {
header_heights: (Points(44.0), Points(96.0)),
safe_area: EdgeInsets::default(),
list_binds: Vec::new(),
+ paced_lists: Vec::new(),
+ paced_scratch: Vec::new(),
events: Vec::new(),
cells_scratch: Vec::new(),
root,
@@ -1181,11 +1188,58 @@ impl<M: TextEngine> Core<M> {
Some((scroll.event_throttle_ms, content * z, frame, z))
}
- /// Moves list render requests produced by the last commit into `out`.
+ /// Moves list render requests produced by the last commit into `out`:
+ /// what must bind before the frame shows (cells whose item moved, cells
+ /// in the viewport and just ahead of it). The rest of the overscan
+ /// waits ([`Core::take_paced_list_binds`]).
pub fn take_list_binds(&mut self, out: &mut Vec<ListBind>) {
out.append(&mut self.list_binds);
}
+ /// Whether lists have overscan cells waiting to bind.
+ pub fn has_paced_list_binds(&self) -> bool {
+ !self.paced_lists.is_empty()
+ }
+
+ /// Hands out up to `cells` of the overscan cells waiting to bind, one
+ /// per list in turn (a nested list is not starved by its parent), each
+ /// list's nearest its leading edge first. What a list assigns now is
+ /// what waits: cells it left or whose items went are never handed out.
+ pub fn take_paced_list_binds(&mut self, cells: usize, out: &mut Vec<ListBind>) {
+ let mut left = cells;
+ let mut lists = std::mem::take(&mut self.paced_lists);
+ let mut one = std::mem::take(&mut self.paced_scratch);
+ let start = out.len();
+ while left > 0 && !lists.is_empty() {
+ lists.retain(|&id| {
+ if left == 0 {
+ return true;
+ }
+ let Some(list) = self
+ .key(id)
+ .ok()
+ .and_then(|key| self.nodes[key as usize].list.as_mut())
+ else {
+ return false;
+ };
+ one.clear();
+ left -= list.take_paced(1, &mut one);
+ if let Some((slot, index)) = one.pop() {
+ match out[start..].iter_mut().find(|b| b.list == id) {
+ Some(bind) => bind.binds.push((slot, index)),
+ None => out.push(ListBind {
+ list: id,
+ binds: vec![(slot, index)],
+ }),
+ }
+ }
+ list.has_paced()
+ });
+ }
+ self.paced_lists = lists;
+ self.paced_scratch = one;
+ }
+
/// Moves what the core hands JS (events it raised, such as
/// `onLoadMore` and `onLayout`, Rust components' events, calls'
/// results) into `out`, in order.
@@ -1629,6 +1683,27 @@ impl<M: TextEngine> Core<M> {
}
continue;
}
+ // Checked before the grants: a host may put the name in a
+ // path or a URL, where `user.x/../admin` leaves `user.*`.
+ Command::HostCall { id: call, name, .. }
+ if !crate::access::valid_host_name(name) =>
+ {
+ let message = format!(
+ "nsh/host: invalid function name {name:?} ({})",
+ crate::access::HOST_NAME_CHARS
+ );
+ self.answer_host(*call, Err(message.clone()));
+ self.deny(message);
+ continue;
+ }
+ Command::HostCall { id: call, name, .. } if !self.access.allows_host(name) => {
+ let message = format!(
+ "nsh/host: `{name}` is not granted: add it to capabilities.host in nsh.json"
+ );
+ self.answer_host(*call, Err(message.clone()));
+ self.deny(message);
+ continue;
+ }
Command::StatusBar(_) if !self.access.allows_status_bar() => {
self.deny("the status bar is not granted: add `\"statusBar\": true` to capabilities in nsh.json".into());
continue;
@@ -5757,7 +5832,11 @@ impl<M: TextEngine> Core<M> {
self.commands.push((id, command));
}
+ // The components' own lists bind in the core, at once; the
+ // runtime's pace their overscan.
+ let paced = !self.components.is_virtual_list(id);
let list = self.nodes[key as usize].list.as_mut().expect("list");
+ list.paced = paced;
let mut binds = Vec::new();
if list.update_window(&mut binds) {
self.events.push(crate::ui::JsEvent::Event(
@@ -5769,6 +5848,9 @@ impl<M: TextEngine> Core<M> {
if !binds.is_empty() {
self.list_binds.push(ListBind { list: id, binds });
}
+ if list.paced && list.has_paced() && !self.paced_lists.contains(&id) {
+ self.paced_lists.push(id);
+ }
if events.contains(EventKind::ViewableItemsChange) {
let viewable = list.viewable_items();
if viewable != list.viewable {
@@ -5810,10 +5892,11 @@ impl<M: TextEngine> Core<M> {
if extras[1].cell != u32::MAX {
cells.push(self.nodes[extras[1].cell as usize].id);
}
- // Cells left without an item still show their last one. Those
- // in the render window (the data shrank, the list jumped) would
- // show through: move them out of sight. Cells the window left
- // behind while scrolling are out of sight already.
+ // Cells left without an item, or not showing theirs yet (a paced
+ // bind, or one the frame's rounds did not reach), still show their
+ // last one. Those in the render window (the data changed, the list
+ // jumped) would show through: move them out of sight. Cells the
+ // window left behind while scrolling are out of sight already.
let (window_start, window_end) = list.render_window();
// In its parent's viewport, the cells start inside the list's
// border and padding.
@@ -5821,7 +5904,7 @@ impl<M: TextEngine> Core<M> {
let hidden: Vec<Key> = list
.slots
.iter()
- .filter(|s| s.index.is_none() && s.cell != u32::MAX)
+ .filter(|s| s.ready().is_none() && s.cell != u32::MAX)
.map(|s| s.cell)
.collect();
for cell in hidden {
diff --git a/third-party/nsh/crates/nsh-core/src/core/tests.rs b/third-party/nsh/crates/nsh-core/src/core/tests.rs
index f53a58d785..be6d161392 100644
--- a/third-party/nsh/crates/nsh-core/src/core/tests.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/tests.rs
@@ -1534,7 +1534,8 @@ fn scroll_content_is_sent_once_per_batch() {
}
/// Plays JS: creates a cell per new slot, renders a fixed-height
-/// row for each bind and confirms the index.
+/// row for each bind and confirms the index; the overscan too, as an
+/// engine with time to spare does.
fn render_binds(
c: &mut Core<FakeEngine>,
next_id: &mut u32,
@@ -1543,6 +1544,7 @@ fn render_binds(
) -> usize {
let mut binds = Vec::new();
c.take_list_binds(&mut binds);
+ c.take_paced_list_binds(usize::MAX, &mut binds);
let mut count = 0;
for bind in binds {
for (slot, index) in bind.binds {
@@ -16699,6 +16701,8 @@ fn render_lists_binds(
) {
let mut binds = Vec::new();
c.take_list_binds(&mut binds);
+ // The overscan too, as an engine with time to spare binds it.
+ c.take_paced_list_binds(usize::MAX, &mut binds);
for bind in binds {
let list_cells = cells.entry(bind.list).or_default();
for (slot, index) in bind.binds {
diff --git a/third-party/nsh/crates/nsh-core/src/js/host.rs b/third-party/nsh/crates/nsh-core/src/js/host.rs
index ef30531976..7058e57444 100644
--- a/third-party/nsh/crates/nsh-core/src/js/host.rs
+++ b/third-party/nsh/crates/nsh-core/src/js/host.rs
@@ -812,7 +812,7 @@ pub const EVENTS: &[(EventKind, u32, &str, &str)] = &[
];
/// The events no element's props declare: the root's (`BackHandler`,
-/// `nsh/keymap`, dialogs, `Image.getSize`, clipboard reads, menus), the runtime's own (touches, insets, native
+/// `nsh/keymap`, dialogs, `Image.getSize`, clipboard reads, menus, `nsh/host`'s replies and events), the runtime's own (touches, insets, native
/// components' events) and those whose payload a module makes
/// (`List`'s viewable items, with the app's items).
pub const INTERNAL_EVENTS: &[EventKind] = &[
@@ -823,6 +823,7 @@ pub const INTERNAL_EVENTS: &[EventKind] = &[
EventKind::BackPress,
EventKind::ImageInfo,
EventKind::Clipboard,
+ EventKind::Host,
EventKind::KeyCommand,
EventKind::Menu,
EventKind::ViewableItemsChange,
diff --git a/third-party/nsh/crates/nsh-core/src/js/mod.rs b/third-party/nsh/crates/nsh-core/src/js/mod.rs
index 34d0eb4f7f..5ed8086d33 100644
--- a/third-party/nsh/crates/nsh-core/src/js/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/js/mod.rs
@@ -31,6 +31,7 @@ pub const NATIVE_MODULES: &[&str] = &[
"nsh/canvas",
"nsh/list",
"nsh/image",
+ "nsh/host",
"nsh/keymap",
];
diff --git a/third-party/nsh/crates/nsh-core/src/keymap.rs b/third-party/nsh/crates/nsh-core/src/keymap.rs
index 7794af9a83..305f8f3fbf 100644
--- a/third-party/nsh/crates/nsh-core/src/keymap.rs
+++ b/third-party/nsh/crates/nsh-core/src/keymap.rs
@@ -151,8 +151,162 @@ impl Key {
Key::F(_) => return None,
})
}
+
+ /// The key of USB HID keyboard usage `usage` (usage page 7, which
+ /// UIKit's `UIKey.keyCode` is): the key's place on the US layout,
+ /// whatever layout types on it; the keypad's digits and Enter too.
+ pub fn from_hid_usage(usage: u16) -> Option<Key> {
+ Some(match usage {
+ 4..=29 => Key::Char(char::from(b'a' + (usage - 4) as u8)),
+ 30..=38 => Key::Char(char::from(b'1' + (usage - 30) as u8)),
+ 39 => Key::Char('0'),
+ 40 | 88 => Key::Enter,
+ 41 => Key::Escape,
+ 42 => Key::Backspace,
+ 43 => Key::Tab,
+ 44 => Key::Char(' '),
+ 58..=69 => Key::F((usage - 57) as u8),
+ 73 => Key::Insert,
+ 74 => Key::Home,
+ 75 => Key::PageUp,
+ 76 => Key::Delete,
+ 77 => Key::End,
+ 78 => Key::PageDown,
+ 79 => Key::Right,
+ 80 => Key::Left,
+ 81 => Key::Down,
+ 82 => Key::Up,
+ 89..=97 => Key::Char(char::from(b'1' + (usage - 89) as u8)),
+ 98 => Key::Char('0'),
+ 104..=115 => Key::F((usage - 91) as u8),
+ _ => {
+ return HID_KEYS
+ .iter()
+ .find(|(u, _)| *u == usage)
+ .map(|&(_, c)| Key::Char(c));
+ }
+ })
+ }
+
+ /// The USB HID keyboard usage of this key (the main keyboard's, not
+ /// the keypad's): what a hardware keyboard sends for it.
+ pub fn hid_usage(self) -> Option<u16> {
+ Some(match self {
+ Key::Char(c @ 'a'..='z') => 4 + u16::from(c as u8 - b'a'),
+ Key::Char(c @ '1'..='9') => 30 + u16::from(c as u8 - b'1'),
+ Key::Char('0') => 39,
+ Key::Enter => 40,
+ Key::Escape => 41,
+ Key::Backspace => 42,
+ Key::Tab => 43,
+ Key::Char(' ') => 44,
+ Key::F(n @ 1..=12) => 57 + u16::from(n),
+ Key::F(n @ 13..=24) => 91 + u16::from(n),
+ Key::Insert => 73,
+ Key::Home => 74,
+ Key::PageUp => 75,
+ Key::Delete => 76,
+ Key::End => 77,
+ Key::PageDown => 78,
+ Key::Right => 79,
+ Key::Left => 80,
+ Key::Down => 81,
+ Key::Up => 82,
+ Key::Char(c) => return HID_KEYS.iter().find(|(_, k)| *k == c).map(|&(u, _)| u),
+ Key::F(_) => return None,
+ })
+ }
+
+ /// The key of Android key code `code` (`KeyEvent.KEYCODE_*`); the
+ /// keypad's digits and Enter too.
+ pub fn from_android_keycode(code: i32) -> Option<Key> {
+ Some(match code {
+ 7..=16 => Key::Char(char::from(b'0' + (code - 7) as u8)),
+ 29..=54 => Key::Char(char::from(b'a' + (code - 29) as u8)),
+ 19 => Key::Up,
+ 20 => Key::Down,
+ 21 => Key::Left,
+ 22 => Key::Right,
+ 61 => Key::Tab,
+ 62 => Key::Char(' '),
+ 66 | 160 => Key::Enter,
+ 67 => Key::Backspace,
+ 92 => Key::PageUp,
+ 93 => Key::PageDown,
+ 111 => Key::Escape,
+ 112 => Key::Delete,
+ 122 => Key::Home,
+ 123 => Key::End,
+ 124 => Key::Insert,
+ 131..=142 => Key::F((code - 130) as u8),
+ 144..=153 => Key::Char(char::from(b'0' + (code - 144) as u8)),
+ _ => {
+ return ANDROID_KEYS
+ .iter()
+ .find(|(k, _)| *k == code)
+ .map(|&(_, c)| Key::Char(c));
+ }
+ })
+ }
+
+ /// The Android key code of this key (`KeyEvent.KEYCODE_*`, the main
+ /// keyboard's): what a key event for it carries.
+ pub fn android_keycode(self) -> Option<i32> {
+ Some(match self {
+ Key::Char(c @ '0'..='9') => 7 + i32::from(c as u8 - b'0'),
+ Key::Char(c @ 'a'..='z') => 29 + i32::from(c as u8 - b'a'),
+ Key::Up => 19,
+ Key::Down => 20,
+ Key::Left => 21,
+ Key::Right => 22,
+ Key::Tab => 61,
+ Key::Char(' ') => 62,
+ Key::Enter => 66,
+ Key::Backspace => 67,
+ Key::PageUp => 92,
+ Key::PageDown => 93,
+ Key::Escape => 111,
+ Key::Delete => 112,
+ Key::Home => 122,
+ Key::End => 123,
+ Key::Insert => 124,
+ Key::F(n @ 1..=12) => 130 + i32::from(n),
+ Key::Char(c) => return ANDROID_KEYS.iter().find(|(_, k)| *k == c).map(|&(k, _)| k),
+ Key::F(_) => return None,
+ })
+ }
}
+/// The US layout's punctuation keys' USB HID usages.
+const HID_KEYS: &[(u16, char)] = &[
+ (45, '-'),
+ (46, '='),
+ (47, '['),
+ (48, ']'),
+ (49, '\\'),
+ (51, ';'),
+ (52, '\''),
+ (53, '`'),
+ (54, ','),
+ (55, '.'),
+ (56, '/'),
+];
+
+/// The US layout's punctuation keys' Android key codes.
+const ANDROID_KEYS: &[(i32, char)] = &[
+ (55, ','),
+ (56, '.'),
+ (68, '`'),
+ (69, '-'),
+ (70, '='),
+ (71, '['),
+ (72, ']'),
+ (73, '\\'),
+ (74, ';'),
+ (75, '\''),
+ (76, '/'),
+];
+
/// One key with its modifiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Keystroke {
@@ -657,6 +811,34 @@ mod tests {
assert_eq!(Key::F(25).virtual_key(), None);
}
+ #[test]
+ fn keys_have_hid_usages_and_android_key_codes_both_ways() {
+ assert_eq!(Key::from_hid_usage(4), Some(Key::Char('a')));
+ assert_eq!(Key::from_hid_usage(39), Some(Key::Char('0')));
+ assert_eq!(Key::from_hid_usage(81), Some(Key::Down));
+ assert_eq!(Key::from_hid_usage(56), Some(Key::Char('/')));
+ assert_eq!(Key::from_hid_usage(88), Some(Key::Enter));
+ assert_eq!(Key::from_hid_usage(104), Some(Key::F(13)));
+ assert_eq!(Key::from_hid_usage(225), None);
+ // The keypad's keys have the main keyboard's usages back.
+ for usage in (0..=0xFF).filter(|u| !(88..=98).contains(u)) {
+ if let Some(key) = Key::from_hid_usage(usage) {
+ assert_eq!(key.hid_usage(), Some(usage), "{key:?}");
+ }
+ }
+ assert_eq!(Key::from_android_keycode(29), Some(Key::Char('a')));
+ assert_eq!(Key::from_android_keycode(7), Some(Key::Char('0')));
+ assert_eq!(Key::from_android_keycode(20), Some(Key::Down));
+ assert_eq!(Key::from_android_keycode(76), Some(Key::Char('/')));
+ assert_eq!(Key::from_android_keycode(160), Some(Key::Enter));
+ assert_eq!(Key::from_android_keycode(59), None);
+ for code in (0..=300).filter(|c| *c != 160 && !(144..=153).contains(c)) {
+ if let Some(key) = Key::from_android_keycode(code) {
+ assert_eq!(key.android_keycode(), Some(code), "{key:?}");
+ }
+ }
+ }
+
fn parse(text: &str) -> Result<Keystroke, KeystrokeError> {
Keystroke::parse(text, Os::MacOs)
}
diff --git a/third-party/nsh/crates/nsh-core/src/ops.rs b/third-party/nsh/crates/nsh-core/src/ops.rs
index 2a5fc49fcd..eb14f175c0 100644
--- a/third-party/nsh/crates/nsh-core/src/ops.rs
+++ b/third-party/nsh/crates/nsh-core/src/ops.rs
@@ -220,6 +220,8 @@ events! {
Position = "onPosition": "The core placed an overlay (payload: `placement`, the side it went on, `x`, `y`, `width`, `height` in the root view, `availableWidth`, `availableHeight`: the room on that side).",
MouseMove = "onMouseMove": "The mouse moved inside the view, including with a button down (desktops and web; local coordinates `x`, `y`; at most once per frame).",
ScrollIntent = "onScrollIntent": "The user requested scrolling, before the offset changes, including at a boundary (payload: `intent`: `\"start\"`, `\"end\"` or `\"scroll\"`; once when a drag starts, before `onScrollDragStart`; programmatic scrolling does not emit it).",
+ Host = "onHost": "The embedding host answered a [`Command::HostCall`] or sent an event to `nsh/host`'s listeners (root only; payload: [`EventPayload::Json`], `{ type: \"reply\", id, ok, value?, error? }` or `{ type: \"event\", event, payload }`).",
+
}
/// What an input holds, for the system's autofill and suggestions
@@ -593,6 +595,8 @@ pub enum EventPayload {
/// Its data.
data: crate::JsonValue,
},
+ /// A JSON value handed to JS as it is ([`EventKind::Host`]).
+ Json(crate::JsonValue),
}
impl TouchEvent {
@@ -1428,6 +1432,19 @@ pub enum Command {
/// A desktop command (on the root; `nsh/desktop`): the window, its
/// title bar, menus. Mobile platforms ignore it.
Desktop(Box<crate::desktop::DesktopCommand>),
+ /// Call function `name` of the app embedding NSH (on the root;
+ /// `nsh/host`'s `host.call`): the platform answers with
+ /// [`EventKind::Host`] `{ type: "reply", id, .. }`. Sent only on
+ /// platforms whose [`crate::platform::Environment::host`] is set, for
+ /// functions the app's `capabilities.host` grants.
+ HostCall {
+ /// The call, echoed by the reply.
+ id: u64,
+ /// The host's function.
+ name: String,
+ /// Its argument (any JSON value; `null` without one).
+ args: crate::JsonValue,
+ },
/// A call on a registered native component (`ref.command(name, args)`).
Native {
/// The command.
diff --git a/third-party/nsh/crates/nsh-core/src/platform.rs b/third-party/nsh/crates/nsh-core/src/platform.rs
index 20f831f9bc..a0738c7d01 100644
--- a/third-party/nsh/crates/nsh-core/src/platform.rs
+++ b/third-party/nsh/crates/nsh-core/src/platform.rs
@@ -192,6 +192,9 @@ pub struct Environment {
pub terminal_keyboard: bool,
/// The user's locale (number and date formats: `Intl`'s default).
pub locale: Locale,
+ /// The app embedding NSH answers `nsh/host`'s calls
+ /// ([`crate::Command::HostCall`]); without it, they reject at once.
+ pub host: bool,
}
impl Environment {
@@ -235,6 +238,7 @@ impl Default for Environment {
apple_keyboard: false,
terminal_keyboard: false,
locale: Locale::EN_US,
+ host: false,
}
}
}
diff --git a/third-party/nsh/crates/nsh-core/src/runtime/host.rs b/third-party/nsh/crates/nsh-core/src/runtime/host.rs
new file mode 100644
index 0000000000..8db4521631
--- /dev/null
+++ b/third-party/nsh/crates/nsh-core/src/runtime/host.rs
@@ -0,0 +1,89 @@
+//! `nsh/host`: functions of the app embedding NSH, called by name with a
+//! JSON argument and answered by the platform ([`crate::Command::HostCall`],
+//! [`crate::EventKind::Host`] `{ type: "reply" }`). A call needs a valid
+//! name (`crate::access::valid_host_name`), granted by the `host`
+//! capability, also when the app declares no capabilities; platforms whose
+//! environment has no host ([`crate::platform::Environment::host`]) reject
+//! every call. A call the host does not answer in time rejects
+//! ([`DEFAULT_TIMEOUT`]); its answer, when it comes, is dropped. The host's
+//! events and `available()` are `nsh-js`'s (`platform_modules.rs`): its
+//! listeners are JS functions.
+
+use std::{future::Future, task::Poll, time::Duration};
+
+use crate::{
+ JsonValue,
+ ui::{JsObject, module},
+};
+
+/// What a call rejects with on a platform without a host.
+pub(crate) const NO_HOST: &str = "nsh/host: this platform has no host";
+
+/// How long a call waits for the host's answer unless its options say.
+const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// `host.call`'s options.
+#[derive(Debug, Clone, Default, JsObject)]
+pub(crate) struct HostCallOptions {
+ /// How long the call waits for the host's answer, in milliseconds,
+ /// before it rejects: 30000 unless set; 0, or one so large the clock
+ /// never gets there, waits as long as the host takes; a negative or
+ /// non-finite one rejects the call.
+ timeout: Option<f64>,
+}
+
+/// The app embedding NSH: `host.call(name, args)` calls one of its
+/// functions (granted by `capabilities.host` in nsh.json).
+#[module]
+pub(crate) struct Host;
+
+#[module(name = "host")]
+impl Host {
+ /// Calls the host's function `name` with `args`: what it returns, or a
+ /// rejection with its error, or once `options.timeout` passed.
+ pub(crate) async fn call(
+ name: String,
+ args: Option<JsonValue>,
+ options: Option<HostCallOptions>,
+ ) -> Result<JsonValue, String> {
+ // A timeout past what a `Duration` or the clock holds waits as 0.
+ let deadline = match options.and_then(|o| o.timeout) {
+ Some(ms) if !ms.is_finite() || ms < 0.0 => {
+ return Err(format!("nsh/host: invalid timeout {ms}"));
+ }
+ Some(0.0) => None,
+ Some(ms) => Duration::try_from_secs_f64(ms / 1000.0)
+ .ok()
+ .and_then(|t| Some((t, crate::time::now().checked_add(t)?))),
+ None => Some((DEFAULT_TIMEOUT, crate::time::now() + DEFAULT_TIMEOUT)),
+ };
+ let answer = crate::ui::ambient::host_call(name.clone(), args.unwrap_or_default());
+ let Some((timeout, at)) = deadline else {
+ return answer.await;
+ };
+ // On the app's clock, as its timers: a manual clock moves it.
+ let answered = {
+ let mut answer = std::pin::pin!(answer);
+ let mut delay = std::pin::pin!(crate::ui::ambient::timer_until(at));
+ std::future::poll_fn(|cx| {
+ if let Poll::Ready(result) = answer.as_mut().poll(cx) {
+ return Poll::Ready(Some(result));
+ }
+ if delay.as_mut().poll(cx).is_ready() {
+ return Poll::Ready(None);
+ }
+ Poll::Pending
+ })
+ .await
+ };
+ answered.unwrap_or_else(|| {
+ // The answer went with the block: the core drops its slot now,
+ // and a late reply finds none (`Core::answer_host`).
+ crate::ui::ambient::forget_host_calls();
+ Err(format!(
+ "nsh/host: {name} timed out after {} ms",
+ timeout.as_millis()
+ ))
+ })
+ }
+}
diff --git a/third-party/nsh/crates/nsh-core/src/runtime/mod.rs b/third-party/nsh/crates/nsh-core/src/runtime/mod.rs
index ad0708af02..6565297768 100644
--- a/third-party/nsh/crates/nsh-core/src/runtime/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/runtime/mod.rs
@@ -2,7 +2,8 @@
//! `nsh/accessibility`'s commands, all of `nsh/image`, `nsh/clipboard` and
//! `nsh/storage`,
//! `nsh/desktop`'s `Window`, the surface-independent `os` metadata,
-//! `net`'s sockets, `websocket`'s connections and `fs/promises`' files. Their JS face is made
+//! `net`'s sockets, `websocket`'s connections, `fs/promises`' files and
+//! `nsh/host`'s calls. Their JS face is made
//! from their specs like a UI set's (`nsh-js` `components.rs`), and so are
//! their declarations ([`crate::ui::declarations`]); `nsh-js` adds what has
//! to run in JS (the app's listeners, reactive accessors).
@@ -16,6 +17,7 @@ mod android;
mod clipboard;
mod desktop;
mod fs;
+pub(crate) mod host;
mod image;
mod net;
mod os;
@@ -64,6 +66,8 @@ pub fn registries() -> &'static [Registry] {
websocket.module::<websocket::WebSocket>();
websocket
};
+ let mut host = Registry::runtime("nsh/host");
+ host.module::<host::Host>();
vec![
android,
accessibility,
@@ -76,6 +80,7 @@ pub fn registries() -> &'static [Registry] {
process,
fs,
websocket,
+ host,
]
})
}
diff --git a/third-party/nsh/crates/nsh-core/src/ui/ambient.rs b/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
index d6749a0415..6b299a44ef 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
@@ -439,6 +439,22 @@ pub fn clipboard_files() -> Answer<Vec<String>> {
answer
}
+/// The value function `name` of the embedding app returns for `args`
+/// (TS: `host.call`), to await in a task.
+pub(crate) fn host_call(
+ name: String,
+ args: crate::JsonValue,
+) -> Answer<Result<crate::JsonValue, String>> {
+ let (answer, action) = super::services::host_call(name, args);
+ act(action);
+ answer
+}
+
+/// Forgets the host calls whose answers were dropped (they timed out).
+pub(crate) fn forget_host_calls() {
+ act(Action::ForgetHostCalls);
+}
+
/// Shows `message` at the bottom of the screen (Android's toasts, TS:
/// `Toast.show`); nothing happens elsewhere.
pub fn toast(message: impl Into<String>, duration: super::ToastDuration) {
diff --git a/third-party/nsh/crates/nsh-core/src/ui/env.rs b/third-party/nsh/crates/nsh-core/src/ui/env.rs
index 76bdab103e..808021fca9 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/env.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/env.rs
@@ -47,6 +47,9 @@ pub struct Environment {
pub apple_keyboard: bool,
/// The host receives terminal key sequences, even with graphical rendering.
pub terminal_keyboard: bool,
+ /// The embedding app answers `nsh/host`'s calls
+ /// ([`crate::platform::Environment::host`]).
+ pub host: bool,
}
impl Default for Environment {
@@ -70,6 +73,7 @@ impl Default for Environment {
locale: Locale::EN_US,
apple_keyboard: false,
terminal_keyboard: false,
+ host: false,
}
}
}
diff --git a/third-party/nsh/crates/nsh-core/src/ui/services.rs b/third-party/nsh/crates/nsh-core/src/ui/services.rs
index e9366e2ec1..eaf01cb19a 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/services.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/services.rs
@@ -157,6 +157,22 @@ pub(crate) fn clipboard_files() -> (Answer<Vec<String>>, Action) {
(answer, Action::ReadClipboard(ClipboardRead::Files(slot)))
}
+/// Calls function `name` of the embedding app with `args` (`nsh/host`).
+pub(crate) fn host_call(
+ name: String,
+ args: crate::JsonValue,
+) -> (Answer<Result<crate::JsonValue, String>>, Action) {
+ let (answer, slot) = Answer::new();
+ (
+ answer,
+ Action::HostCall {
+ name,
+ args,
+ answer: slot,
+ },
+ )
+}
+
/// The size of the image at `uri`, its bytes loaded.
pub(crate) fn image_size(uri: String) -> (Answer<Result<ImageSize, String>>, Action) {
let (answer, slot) = Answer::new();
diff --git a/third-party/nsh/crates/nsh-core/src/ui/task.rs b/third-party/nsh/crates/nsh-core/src/ui/task.rs
index 0890dfd0f5..3c64d04177 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/task.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/task.rs
@@ -79,6 +79,16 @@ pub(crate) enum Action {
),
/// Reads the system clipboard; the answer resolves the slot.
ReadClipboard(ClipboardRead),
+ /// Calls function `name` of the embedding app (`nsh/host`); its
+ /// value, or why it failed, resolves the slot.
+ HostCall {
+ name: String,
+ args: crate::JsonValue,
+ answer: AnswerSlot<Result<crate::JsonValue, String>>,
+ },
+ /// Drops the slots of the host calls whose answers are gone (they
+ /// timed out).
+ ForgetHostCalls,
/// An operation on the native animation graph.
Animation(crate::animation::AnimationOp),
/// Starts an animation; its end resolves the slot.
diff --git a/third-party/nsh/crates/nsh-engine/src/lib.rs b/third-party/nsh/crates/nsh-engine/src/lib.rs
index 12d1849536..8ee344adff 100644
--- a/third-party/nsh/crates/nsh-engine/src/lib.rs
+++ b/third-party/nsh/crates/nsh-engine/src/lib.rs
@@ -564,9 +564,9 @@ pub trait MountSink: Send + 'static {
}
/// JS or a component's task asked for a display frame
- /// (`requestAnimationFrame`, `ui::ambient::next_frame`): call
- /// [`EngineControl::frame`] at the next vsync. Requests are not repeated
- /// until that call.
+ /// (`requestAnimationFrame`, `ui::ambient::next_frame`), or lists have
+ /// overscan cells left to bind: call [`EngineControl::frame`] at the
+ /// next vsync. Requests are not repeated until that call.
fn request_frame(&self) {}
/// A development update ([`EngineControl::hot_update`]) could not be
@@ -885,6 +885,16 @@ pub struct HotUpdate {
/// leftovers continue next frame.
const MAX_LIST_ROUNDS: usize = 8;
+/// The overscan a turn binds: cells until their ops reach this many (about
+/// as many mount items, which the main thread applies in one go) or their
+/// JS takes [`PACED_BIND_TIME`], at least one. A fast scroll asks for a
+/// screen of cells at once and each bind rebuilds a cell's subtree; what
+/// shows (the viewport, the leading edge, cells whose item moved) binds
+/// regardless, the rest on the next display frames.
+const PACED_BIND_OPS: usize = 150;
+/// See [`PACED_BIND_OPS`].
+const PACED_BIND_TIME: std::time::Duration = std::time::Duration::from_millis(2);
+
/// A cheap, cloneable handle for sending input to the engine from any thread.
#[derive(Clone)]
pub struct EngineControl {
@@ -1419,6 +1429,7 @@ impl<M: TextEngine + 'static> Driver<M> {
touch_move: None,
hover_moves: Vec::new(),
hover_moved: None,
+ sets_seen: FxHashMap::default(),
resized: None,
pinned: config.pinned,
net: Net {
@@ -1783,6 +1794,8 @@ struct State<M: TextEngine> {
hover_moves: Vec<(NodeId, EventKind, EventPayload)>,
/// When `hover_moves` were last delivered.
hover_moved: Option<Instant>,
+ /// [`keep_last_sets`]'s buffer, reused.
+ sets_seen: FxHashMap<(u8, NodeId), usize>,
/// The viewport size a resize set this turn ([`MountSink::resized`]).
resized: Option<Size<Points>>,
}
@@ -2075,7 +2088,9 @@ impl<M: TextEngine> State<M> {
/// for one.
fn request_frame(&mut self, script: Option<&Script>, sink: &dyn MountSink) {
if !self.frame_requested
- && (script.is_some_and(Script::wants_frame) || self.core.wants_frame())
+ && (script.is_some_and(Script::wants_frame)
+ || self.core.wants_frame()
+ || (script.is_some() && self.core.has_paced_list_binds()))
{
self.frame_requested = true;
sink.request_frame();
@@ -2115,8 +2130,11 @@ impl<M: TextEngine> State<M> {
fn commit(&mut self, script: Option<&Script>) -> Option<MountBatch> {
let start = Instant::now();
let mut merged: Option<MountBatch> = None;
+ let mut commits = 0;
let mut op_count = 0;
let mut js = std::time::Duration::ZERO;
+ // The overscan bound this turn ([`PACED_BIND_OPS`]).
+ let mut paced = PacedBinds::default();
for _ in 0..MAX_LIST_ROUNDS {
// Requests from the previous round (or frame) first.
let js_start = Instant::now();
@@ -2127,6 +2145,35 @@ impl<M: TextEngine> State<M> {
for bind in binds {
script.bind_list(bind.list, &bind.binds);
}
+ script.take_ops(&mut self.ops);
+ // Taking ops publishes JS list sources. Their count/splice
+ // must reach the core before its queued indices read them.
+ let source_changed = |ops: &[Op]| {
+ ops.iter().any(|op| {
+ matches!(
+ op,
+ Op::SetProp {
+ prop: nsh_core::Prop::Count(_) | nsh_core::Prop::Splice(..),
+ ..
+ } | Op::Release { .. }
+ )
+ })
+ };
+ let mut source_pending = source_changed(&self.ops);
+ let mut binds = Vec::new();
+ while !source_pending && paced.left() && self.core.has_paced_list_binds() {
+ let start = Instant::now();
+ let ops = self.ops.len();
+ self.core.take_paced_list_binds(1, &mut binds);
+ for bind in binds.drain(..) {
+ paced.cells += bind.binds.len();
+ script.bind_list(bind.list, &bind.binds);
+ }
+ script.take_ops(&mut self.ops);
+ source_pending = source_changed(&self.ops[ops..]);
+ paced.ops += self.ops.len() - ops;
+ paced.time += start.elapsed();
+ }
} else {
drop(binds);
}
@@ -2183,6 +2230,7 @@ impl<M: TextEngine> State<M> {
}
}
if let Some(batch) = self.core.commit() {
+ commits += 1;
match &mut merged {
Some(m) => extend_frame(&mut m.items, batch.items),
None => merged = Some(batch),
@@ -2202,10 +2250,17 @@ impl<M: TextEngine> State<M> {
}
_ => false,
};
- if self.binds.is_empty() && self.events.is_empty() && !posted && !retheme {
+ let more_paced = script.is_some() && paced.left() && self.core.has_paced_list_binds();
+ if self.binds.is_empty() && self.events.is_empty() && !posted && !retheme && !more_paced
+ {
break;
}
}
+ if commits > 1
+ && let Some(batch) = &mut merged
+ {
+ keep_last_sets(&mut batch.items, &mut self.sets_seen);
+ }
self.committed_since_gc |= op_count > 0;
if self.perf.is_some() && (op_count > 0 || merged.is_some()) {
let c = &mut self.perf_commit;
@@ -2252,6 +2307,7 @@ fn ui_environment(env: &Environment, window: Size<Points>) -> nsh_core::ui::Envi
locale: env.locale,
apple_keyboard: env.apple_keyboard,
terminal_keyboard: env.terminal_keyboard,
+ host: env.host,
}
}
@@ -2484,6 +2540,22 @@ fn handle<M: TextEngine>(script: Option<&Script>, state: &mut State<M>, message:
true
}
+/// What a turn spent binding overscan cells.
+#[derive(Default)]
+struct PacedBinds {
+ cells: usize,
+ ops: usize,
+ time: std::time::Duration,
+}
+
+impl PacedBinds {
+ /// Whether the turn may bind another: at least one, then within the
+ /// budget.
+ fn left(&self) -> bool {
+ self.cells == 0 || (self.ops < PACED_BIND_OPS && self.time < PACED_BIND_TIME)
+ }
+}
+
/// Appends a commit's items to the frame's. A scroll view's content size
/// that the frame already sets is updated to the later size (unless the
/// view scrolls in between): a row relaid over two commits must not send
@@ -2508,6 +2580,44 @@ fn extend_frame(frame: &mut Vec<nsh_core::MountItem>, items: Vec<nsh_core::Mount
}
}
+/// Of the items of a frame merged from several commits that set one part
+/// of a view whole (its frame, style, text, ...), keeps the last: the
+/// platform would apply each in one pass, and a list's rounds (binds, then
+/// the paced ones) relay and restyle the same cells. Once per turn, with
+/// `last` the reused buffer.
+fn keep_last_sets(frame: &mut Vec<nsh_core::MountItem>, last: &mut FxHashMap<(u8, NodeId), usize>) {
+ last.clear();
+ let mut repeated = false;
+ for (k, item) in frame.iter().enumerate() {
+ if let Some(key) = sets_whole(item) {
+ repeated |= last.insert(key, k).is_some();
+ }
+ }
+ if !repeated {
+ return;
+ }
+ let mut k = 0;
+ frame.retain(|item| {
+ k += 1;
+ sets_whole(item).is_none_or(|key| last[&key] == k - 1)
+ });
+}
+
+/// The part of a view an item sets whole, and the view: of two such items
+/// the later is all the view ends up with.
+fn sets_whole(item: &nsh_core::MountItem) -> Option<(u8, nsh_core::NodeId)> {
+ use nsh_core::MountItem;
+ Some(match *item {
+ MountItem::Frame { id, .. } => (0, id),
+ MountItem::Overflow { id, .. } => (1, id),
+ MountItem::ViewStyle { id, .. } => (2, id),
+ MountItem::Text { id, .. } => (3, id),
+ MountItem::Events { id, .. } => (4, id),
+ MountItem::Image { id, .. } => (5, id),
+ _ => return None,
+ })
+}
+
#[cfg(test)]
mod tests {
#[test]
@@ -3072,4 +3182,35 @@ mount(() => <View>
super::extend_frame(&mut frame, vec![content(Points(544.0))]);
assert_eq!(heights(&frame), [313.0, 544.0]);
}
+
+ #[test]
+ fn a_frame_sends_a_views_last_frame_once() {
+ use nsh_core::{Bounds, Point};
+ let at = |id, y| MountItem::Frame {
+ id,
+ frame: Bounds::new(
+ Point::new(Points(0.0), Points(y)),
+ Size::new(Points(10.0), Points(10.0)),
+ ),
+ };
+ let mut frame = vec![
+ MountItem::Create {
+ id: 4,
+ kind: nsh_core::ViewKind::View,
+ },
+ at(4, 0.0),
+ at(6, 0.0),
+ ];
+ super::extend_frame(&mut frame, vec![at(4, 50.0)]);
+ super::keep_last_sets(&mut frame, &mut Default::default());
+ assert!(matches!(frame[0], MountItem::Create { id: 4, .. }));
+ let frames: Vec<_> = frame
+ .iter()
+ .filter_map(|m| match m {
+ MountItem::Frame { id, frame } => Some((*id, frame.origin.y.0)),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(frames, [(6, 0.0), (4, 50.0)]);
+ }
}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs b/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs
index 37389e27c7..0cfa5ffde9 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs
@@ -219,6 +219,7 @@ impl App {
items.extend(batch.items);
}
self.core.take_list_binds(&mut self.binds);
+ self.core.take_paced_list_binds(usize::MAX, &mut self.binds);
self.native += t.elapsed();
if self.binds.is_empty() {
break;
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
index f0e36ccd6c..1c8b5bc379 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
@@ -26,7 +26,7 @@ fn start_in(app: &str, environment: Environment) -> (Engine, Receiver<MountBatch
/// [`start_in`] with the capabilities the app declares (JSON, `nsh.json`'s
/// `capabilities` form) and an `nsh.json`.
-fn start_with(
+pub(crate) fn start_with(
app: &str,
environment: Environment,
capabilities: Option<&str>,
@@ -209,7 +209,10 @@ fn a_stateful_component_steps_while_held_on((engine, rx): (Engine, Receiver<Moun
}
/// The batches until `done` holds for one (or nothing came for 500ms).
-fn until(rx: &Receiver<MountBatch>, mut done: impl FnMut(&MountBatch) -> bool) -> Vec<MountBatch> {
+pub(crate) fn until(
+ rx: &Receiver<MountBatch>,
+ mut done: impl FnMut(&MountBatch) -> bool,
+) -> Vec<MountBatch> {
let mut batches = Vec::new();
while let Ok(batch) = rx.recv_timeout(Duration::from_millis(500)) {
let stop = done(&batch);
@@ -221,7 +224,7 @@ fn until(rx: &Receiver<MountBatch>, mut done: impl FnMut(&MountBatch) -> bool) -
batches
}
-fn shows(batch: &MountBatch, wanted: &str) -> bool {
+pub(crate) fn shows(batch: &MountBatch, wanted: &str) -> bool {
text_ids(batch, wanted).is_some()
}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/host.rs b/third-party/nsh/crates/nsh-engine/tests/engine/host.rs
new file mode 100644
index 0000000000..d00146218f
--- /dev/null
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/host.rs
@@ -0,0 +1,476 @@
+//! `nsh/host`: calls into the app embedding NSH and its events, answered
+//! by a stand-in for the platform (as `components.rs`' clipboard is).
+
+use crossbeam_channel::Receiver;
+use nsh_core::{Command, EventKind, EventPayload, MountBatch, MountItem, ROOT_ID};
+use nsh_engine::{Engine, Environment};
+use serde_json::json;
+
+use crate::components::{shows, start_with, until};
+
+/// What the tests' apps declare: every host function granted.
+const ALL: Option<&str> = Some(r#"{ "host": true }"#);
+
+/// A platform that embeds a host.
+fn with_host() -> Environment {
+ Environment {
+ host: true,
+ ..Environment::default()
+ }
+}
+
+/// Calls each of `names` with `{ a: 1 }` and shows `available()` and what
+/// each call settled with.
+fn calls(names: &[&str]) -> String {
+ let names = serde_json::to_string(names).unwrap();
+ format!(
+ r#"import {{ mount, signal, Text }} from "nsh";
+import {{ host }} from "nsh/host";
+
+const shown = signal("waiting");
+const settled = async (p) => {{
+ try {{ return JSON.stringify(await p); }} catch (e) {{ return `rejected ${{e.message}}`; }}
+}};
+setTimeout(async () => {{
+ const results = [];
+ for (const name of {names}) results.push(await settled(host.call(name, {{ a: 1 }})));
+ shown.set(`${{host.available()}} ${{results.join(" | ")}}`);
+}}, 0);
+
+mount(() => <Text>{{shown()}}</Text>);
+"#
+ )
+}
+
+/// A host on the platform: answers each call with its arguments, `fail`
+/// with an error. Returns the calls that reached it.
+fn serve_host(
+ engine: &Engine,
+ rx: &Receiver<MountBatch>,
+ mut done: impl FnMut(&str) -> bool,
+) -> Vec<Command> {
+ let control = engine.control();
+ let mut commands = Vec::new();
+ until(rx, |batch| {
+ for item in &batch.items {
+ let MountItem::Command {
+ id: ROOT_ID,
+ command: command @ Command::HostCall { id, name, args },
+ } = item
+ else {
+ continue;
+ };
+ let reply = if name == "fail" {
+ json!({ "type": "reply", "id": id, "ok": false, "error": "boom" })
+ } else {
+ json!({ "type": "reply", "id": id, "ok": true, "value": args })
+ };
+ control.dispatch_event(ROOT_ID, EventKind::Host, EventPayload::Json(reply));
+ commands.push(command.clone());
+ }
+ texts(batch).any(|t| done(&t))
+ });
+ commands
+}
+
+fn texts(batch: &MountBatch) -> impl Iterator<Item = String> + '_ {
+ batch.items.iter().filter_map(|i| match i {
+ MountItem::Text { text, .. } => Some(text.text.to_string()),
+ _ => None,
+ })
+}
+
+/// The names of the calls that reached the platform.
+fn names(commands: &[Command]) -> Vec<&str> {
+ commands
+ .iter()
+ .filter_map(|c| match c {
+ Command::HostCall { name, .. } => Some(name.as_str()),
+ _ => None,
+ })
+ .collect()
+}
+
+#[test]
+fn host_call_round_trips_through_the_platform() {
+ let echo = Some(r#"{ "host": ["echo"] }"#);
+ let (engine, rx) = start_with(&calls(&["echo"]), with_host(), echo, None);
+ let commands = serve_host(&engine, &rx, |t| t == r#"true {"a":1}"#);
+ assert_eq!(
+ commands,
+ [Command::HostCall {
+ id: 1,
+ name: "echo".into(),
+ args: json!({ "a": 1 }),
+ }]
+ );
+ // Without a host, nothing reaches the platform and calls reject.
+ let (engine, rx) = start_with(&calls(&["echo"]), Environment::default(), echo, None);
+ let commands = serve_host(&engine, &rx, |t| {
+ t == "false rejected nsh/host: this platform has no host"
+ });
+ assert!(commands.is_empty(), "{commands:?}");
+}
+
+#[test]
+fn a_failing_host_call_rejects_with_its_message() {
+ let (engine, rx) = start_with(&calls(&["fail"]), with_host(), ALL, None);
+ let mut shown = String::new();
+ serve_host(&engine, &rx, |t| {
+ let done = t.starts_with("true ");
+ if done {
+ shown = t.to_owned();
+ }
+ done
+ });
+ assert_eq!(shown, "true rejected boom");
+}
+
+#[test]
+fn host_calls_need_their_capability() {
+ // Declared without `host`, and not declared at all: denied alike (the
+ // other capabilities keep their grant-all default for apps without a
+ // declaration; the host's functions never do).
+ for capabilities in [Some("{}"), None] {
+ host_call_is_denied(capabilities);
+ }
+}
+
+fn host_call_is_denied(capabilities: Option<&str>) {
+ crate::logs::capture();
+ let (engine, rx) = start_with(&calls(&["echo"]), with_host(), capabilities, None);
+ let mut shown = String::new();
+ let commands = serve_host(&engine, &rx, |t| {
+ let done = t.starts_with("true ");
+ if done {
+ shown = t.to_owned();
+ }
+ done
+ });
+ assert!(commands.is_empty(), "{commands:?}");
+ assert!(
+ shown.starts_with("true rejected ") && shown.contains("capabilities.host"),
+ "{shown}"
+ );
+ // The refusal is logged, saying which key to add.
+ let logged = crate::logs::lines_containing("nsh/host: `echo` is not granted");
+ assert!(
+ logged.iter().any(|w| w.contains("capabilities.host")),
+ "{logged:?}"
+ );
+}
+
+#[test]
+fn host_allowlist_matches_prefixes() {
+ let (engine, rx) = start_with(
+ &calls(&["bridge.call", "other"]),
+ with_host(),
+ Some(r#"{ "host": ["bridge.*"] }"#),
+ None,
+ );
+ let mut shown = String::new();
+ let commands = serve_host(&engine, &rx, |t| {
+ let done = t.starts_with("true ");
+ if done {
+ shown = t.to_owned();
+ }
+ done
+ });
+ assert_eq!(names(&commands), ["bridge.call"]);
+ assert!(shown.starts_with(r#"true {"a":1} | rejected "#), "{shown}");
+}
+
+const EVENTS: &str = r#"import { mount, signal, Text } from "nsh";
+import { host } from "nsh/host";
+
+const shown = signal("waiting");
+let once = 0;
+let all = 0;
+const first = host.on("tick", () => {
+ once++;
+ first.remove();
+});
+host.on("tick", (payload) => {
+ all++;
+ shown.set(`${once} ${all} ${JSON.stringify(payload)}`);
+});
+host.on("other", () => shown.set("wrong event"));
+setTimeout(() => shown.set("ready"), 0);
+
+mount(() => <Text>{shown()}</Text>);
+"#;
+
+#[test]
+fn host_events_reach_listeners_until_removed() {
+ let (engine, rx) = start_with(EVENTS, with_host(), Some("{}"), None);
+ until(&rx, |b| shows(b, "ready"));
+ for n in [1, 2] {
+ engine.control().dispatch_event(
+ ROOT_ID,
+ EventKind::Host,
+ EventPayload::Json(json!({ "type": "event", "event": "tick", "payload": { "n": n } })),
+ );
+ }
+ let batches = until(&rx, |b| shows(b, r#"1 2 {"n":2}"#));
+ assert!(
+ batches.iter().any(|b| shows(b, r#"1 2 {"n":2}"#)),
+ "{:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+}
+
+/// Calls `slow` with `options` (JS) once the app runs, and shows how it
+/// settled.
+fn timed(options: &str) -> String {
+ format!(
+ r#"import {{ mount, signal, Text }} from "nsh";
+import {{ host }} from "nsh/host";
+
+const shown = signal("waiting");
+setTimeout(() => {{
+ host.call("slow", null, {options}).then(
+ (value) => shown.set(`ok ${{JSON.stringify(value)}}`),
+ (e) => shown.set(`rejected ${{e.message}}`),
+ );
+}}, 0);
+
+mount(() => <Text>{{shown()}}</Text>);
+"#
+ )
+}
+
+/// The ids of the host calls in `batches`.
+fn call_ids(batches: &[MountBatch]) -> Vec<u64> {
+ batches
+ .iter()
+ .flat_map(|b| &b.items)
+ .filter_map(|item| match item {
+ MountItem::Command {
+ command: Command::HostCall { id, .. },
+ ..
+ } => Some(*id),
+ _ => None,
+ })
+ .collect()
+}
+
+fn reply(engine: &Engine, id: u64, value: serde_json::Value) {
+ reply_with_id(engine, json!(id), value);
+}
+
+fn reply_with_id(engine: &Engine, id: serde_json::Value, value: serde_json::Value) {
+ engine.control().dispatch_event(
+ ROOT_ID,
+ EventKind::Host,
+ EventPayload::Json(json!({ "type": "reply", "id": id, "ok": true, "value": value })),
+ );
+}
+
+#[test]
+fn a_host_call_times_out_and_its_late_reply_is_ignored() {
+ let (engine, rx) = start_with(&timed("{ timeout: 200 }"), with_host(), ALL, None);
+ let batches = until(&rx, |b| {
+ shows(b, "rejected nsh/host: slow timed out after 200 ms")
+ });
+ assert!(
+ batches
+ .iter()
+ .any(|b| shows(b, "rejected nsh/host: slow timed out after 200 ms")),
+ "{:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+ let ids = call_ids(&batches);
+ assert_eq!(ids.len(), 1, "{ids:?}");
+ // The host answers late: nothing changes, nothing fails.
+ reply(&engine, ids[0], json!(1));
+ let after = until(&rx, |_| false);
+ assert!(
+ !after.iter().flat_map(texts).any(|t| t.starts_with("ok")),
+ "{:?}",
+ after.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+}
+
+#[test]
+fn a_reply_before_the_timeout_wins() {
+ let (engine, rx) = start_with(&timed("{ timeout: 300 }"), with_host(), ALL, None);
+ let mut answered = false;
+ let batches = until(&rx, |b| {
+ if !answered && let Some(&id) = call_ids(std::slice::from_ref(b)).first() {
+ reply(&engine, id, json!(7));
+ answered = true;
+ }
+ shows(b, "ok 7")
+ });
+ assert!(batches.iter().any(|b| shows(b, "ok 7")));
+ // Past the timeout, the settled call stays settled.
+ std::thread::sleep(std::time::Duration::from_millis(400));
+ let after = until(&rx, |_| false);
+ assert!(
+ !after
+ .iter()
+ .flat_map(texts)
+ .any(|t| t.starts_with("rejected")),
+ "{:?}",
+ after.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+}
+
+#[test]
+fn a_timeout_of_zero_waits_for_the_host() {
+ let (engine, rx) = start_with(&timed("{ timeout: 0 }"), with_host(), ALL, None);
+ let mut ids = Vec::new();
+ // No answer for a while: the call keeps waiting.
+ let start = std::time::Instant::now();
+ while start.elapsed() < std::time::Duration::from_millis(600) {
+ let batches = until(&rx, |_| false);
+ ids.extend(call_ids(&batches));
+ assert!(
+ !batches
+ .iter()
+ .flat_map(texts)
+ .any(|t| t.starts_with("rejected")),
+ "{:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+ }
+ assert_eq!(ids.len(), 1, "{ids:?}");
+ reply(&engine, ids[0], json!("late"));
+ let batches = until(&rx, |b| shows(b, r#"ok "late""#));
+ assert!(batches.iter().any(|b| shows(b, r#"ok "late""#)));
+}
+
+#[test]
+fn host_function_names_outside_their_charset_are_refused() {
+ // Granted everything, yet none of these reach the host: a host that
+ // puts the name in a path or a URL never sees `/` or `..`.
+ let long = "a".repeat(129);
+ let bad = [
+ "user.x/../../admin",
+ "",
+ "a..b",
+ "user x",
+ "ok\u{0}",
+ long.as_str(),
+ ];
+ let (engine, rx) = start_with(&calls(&bad), with_host(), ALL, None);
+ let mut shown = String::new();
+ let commands = serve_host(&engine, &rx, |t| {
+ let done = t.starts_with("true ");
+ if done {
+ shown = t.to_owned();
+ }
+ done
+ });
+ assert!(commands.is_empty(), "{commands:?}");
+ let results: Vec<&str> = shown.trim_start_matches("true ").split(" | ").collect();
+ assert_eq!(results.len(), bad.len(), "{shown}");
+ assert!(
+ results
+ .iter()
+ .all(|r| r.starts_with("rejected nsh/host: invalid function name")),
+ "{shown}"
+ );
+ // The longest name and every character of the set pass.
+ let max = "a".repeat(128);
+ let good = ["user.info", "a-b_c:d.E9", max.as_str()];
+ let (engine, rx) = start_with(&calls(&good), with_host(), ALL, None);
+ let commands = serve_host(&engine, &rx, |t| t.starts_with("true "));
+ assert_eq!(names(&commands), good);
+}
+
+#[test]
+fn timeouts_past_the_clocks_reach_wait_without_limit() {
+ // Too long for a `Duration` (1e30 ms) or for the clock's `Instant`
+ // (1.5e22 ms): the call waits for the host as with 0, and the engine
+ // goes on.
+ for timeout in ["1e30", "1.5e22"] {
+ let options = format!("{{ timeout: {timeout} }}");
+ let (engine, rx) = start_with(&timed(&options), with_host(), ALL, None);
+ let mut answered = false;
+ let batches = until(&rx, |b| {
+ if !answered && let Some(&id) = call_ids(std::slice::from_ref(b)).first() {
+ reply(&engine, id, json!(timeout));
+ answered = true;
+ }
+ shows(b, &format!(r#"ok "{timeout}""#))
+ });
+ assert!(
+ batches
+ .iter()
+ .any(|b| shows(b, &format!(r#"ok "{timeout}""#))),
+ "{timeout}: {:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+ }
+}
+
+#[test]
+fn negative_and_non_finite_timeouts_reject() {
+ for (timeout, shown) in [("-1", "-1"), ("NaN", "NaN"), ("Infinity", "inf")] {
+ let options = format!("{{ timeout: {timeout} }}");
+ let (_engine, rx) = start_with(&timed(&options), with_host(), ALL, None);
+ let wanted = format!("rejected nsh/host: invalid timeout {shown}");
+ let batches = until(&rx, |b| shows(b, &wanted));
+ assert!(
+ batches.iter().any(|b| shows(b, &wanted)),
+ "{timeout}: {:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+ assert!(call_ids(&batches).is_empty());
+ }
+}
+
+#[test]
+fn a_reply_whose_id_is_not_a_whole_number_answers_no_call() {
+ let (engine, rx) = start_with(&timed("{ timeout: 0 }"), with_host(), ALL, None);
+ let mut answered = false;
+ let batches = until(&rx, |b| {
+ if !answered && let Some(&id) = call_ids(std::slice::from_ref(b)).first() {
+ assert_eq!(id, 1);
+ // 1.5 is not call 1; 1.0 is (JSON numbers may come as floats).
+ reply_with_id(&engine, json!(1.5), json!("half"));
+ reply_with_id(&engine, json!(1.0), json!("whole"));
+ answered = true;
+ }
+ shows(b, r#"ok "whole""#) || shows(b, r#"ok "half""#)
+ });
+ assert!(
+ batches.iter().any(|b| shows(b, r#"ok "whole""#)),
+ "{:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+}
+
+const THROWING_LISTENER: &str = r#"import { mount, signal, Text } from "nsh";
+import { host } from "nsh/host";
+
+const shown = signal("waiting");
+host.on("tick", () => {
+ throw new Error("first listener");
+});
+host.on("tick", (payload) => shown.set(`second ${JSON.stringify(payload)}`));
+setTimeout(() => shown.set("ready"), 0);
+
+mount(() => <Text>{shown()}</Text>);
+"#;
+
+#[test]
+fn a_throwing_host_listener_leaves_the_others_called() {
+ crate::logs::capture();
+ let (engine, rx) = start_with(THROWING_LISTENER, with_host(), Some("{}"), None);
+ until(&rx, |b| shows(b, "ready"));
+ engine.control().dispatch_event(
+ ROOT_ID,
+ EventKind::Host,
+ EventPayload::Json(json!({ "type": "event", "event": "tick", "payload": 1 })),
+ );
+ let batches = until(&rx, |b| shows(b, "second 1"));
+ assert!(
+ batches.iter().any(|b| shows(b, "second 1")),
+ "{:?}",
+ batches.iter().flat_map(texts).collect::<Vec<_>>()
+ );
+ // The first listener's error is still reported.
+ assert!(!crate::logs::wait_for("first listener").is_empty());
+}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
index 861cf3aac6..bf260087b6 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
@@ -6,12 +6,23 @@ use std::{sync::mpsc, time::Duration};
use nsh_core::{ApproxTextEngine, MountBatch, MountItem, Size, ViewKind, geometry::px};
use nsh_engine::{Bundle, Engine, EngineConfig, MountSink};
-struct Sink(mpsc::Sender<MountBatch>);
+/// Sends batches to the test, and answers a request for a display frame
+/// at once, as a display would at its next refresh.
+struct Sink(
+ mpsc::Sender<MountBatch>,
+ std::sync::Arc<std::sync::OnceLock<nsh_engine::EngineControl>>,
+);
impl MountSink for Sink {
fn submit(&self, batch: MountBatch) {
let _ = self.0.send(batch);
}
+
+ fn request_frame(&self) {
+ if let Some(control) = self.1.get() {
+ control.frame();
+ }
+ }
}
/// `List`, made in place by the compiler.
@@ -77,9 +88,9 @@ fn list_as_a_value_scrolls_with_recycled_cells() {
scrolls_with_recycled_cells(&app);
}
-/// Bundles `app` and starts it on a 320 x 500 viewport: the engine, its
-/// batches and the app's directory (the caller removes it).
-fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf) {
+/// Bundles `app` into a config for a 320 x 500 viewport, and the app's
+/// directory (the caller removes it).
+fn config(app: &str) -> (EngineConfig, std::path::PathBuf) {
static APPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"nsh-list-{}-{}",
@@ -97,8 +108,6 @@ fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf)
})
.unwrap();
let bytecode = nsh_js::compile_bytecode(&bundle.code, false).unwrap();
-
- let (tx, rx) = mpsc::channel();
let config = EngineConfig {
// SAFETY: compiled just now by this runtime.
app: nsh_engine::App::Js(unsafe { Bundle::bytecode(bytecode) }),
@@ -120,17 +129,31 @@ fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf)
pinned: Default::default(),
clipboard: None,
};
- let engine = Engine::start(config, || ApproxTextEngine, Sink(tx)).unwrap();
+ (config, dir)
+}
+
+/// Bundles `app` and starts it on a 320 x 500 viewport: the engine, its
+/// batches and the app's directory (the caller removes it).
+fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf) {
+ let (config, dir) = config(app);
+ let (tx, rx) = mpsc::channel();
+ let control = std::sync::Arc::new(std::sync::OnceLock::new());
+ let engine = Engine::start(config, || ApproxTextEngine, Sink(tx, control.clone())).unwrap();
+ let _ = control.set(engine.control());
+ // A frame asked for before the control was there.
+ engine.control().frame();
(engine, rx, dir)
}
fn scrolls_with_recycled_cells(app: &str) {
let (engine, rx, dir) = start(app);
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
- // 500 pt viewport + 500 pt overscan at 50 pt per row = 21 rows.
+ // The first frame shows the viewport's rows; the overscan may follow.
let first_texts = texts(&first);
- assert_eq!(first_texts.len(), 21, "{first_texts:?}");
assert_eq!(first_texts[0], "row 0");
+ for i in 0..10 {
+ assert!(first_texts.contains(&format!("row {i}")), "{first_texts:?}");
+ }
let list = first
.items
.iter()
@@ -142,6 +165,12 @@ fn scrolls_with_recycled_cells(app: &str) {
_ => None,
})
.unwrap();
+ // 500 pt viewport + 500 pt overscan at 50 pt per row = 21 rows.
+ let mut window = first_texts;
+ while let Ok(batch) = rx.recv_timeout(Duration::from_millis(300)) {
+ window.extend(texts(&batch));
+ }
+ assert_eq!(window.len(), 21, "{window:?}");
// Scroll to row 40: rows 30..=60 are needed; cells are recycled, so the
// update re-renders text in existing views instead of creating them.
@@ -325,3 +354,313 @@ mount(App);
assert!(shown.iter().any(|t| t == "size 320x483"), "{shown:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// What a [`Paced`] engine's sink saw: its errors and whether a display
+/// frame was asked for.
+#[derive(Default)]
+struct Seen {
+ errors: std::sync::Mutex<Vec<String>>,
+ frame: std::sync::atomic::AtomicBool,
+}
+
+struct PacedSink(mpsc::Sender<MountBatch>, std::sync::Arc<Seen>);
+
+impl MountSink for PacedSink {
+ fn submit(&self, batch: MountBatch) {
+ let _ = self.0.send(batch);
+ }
+
+ fn report_error(&self, message: &str) {
+ self.1.errors.lock().unwrap().push(message.to_owned());
+ }
+
+ fn request_frame(&self) {
+ self.1
+ .frame
+ .store(true, std::sync::atomic::Ordering::SeqCst);
+ }
+}
+
+/// A list app on a [`nsh_engine::LocalEngine`], so a test sees every
+/// turn's batch: [`Paced::turn`] is one pump, [`Paced::frame`] the display
+/// frame the engine asked for. Keeps what each text view shows.
+struct Paced {
+ engine: nsh_engine::LocalEngine<ApproxTextEngine>,
+ rx: mpsc::Receiver<MountBatch>,
+ seen: std::sync::Arc<Seen>,
+ texts: std::collections::HashMap<nsh_core::NodeId, String>,
+ list: Option<nsh_core::NodeId>,
+ pressable: Option<nsh_core::NodeId>,
+ dir: std::path::PathBuf,
+}
+
+impl Paced {
+ fn start(app: &str) -> Self {
+ let (config, dir) = config(app);
+ let (tx, rx) = mpsc::channel();
+ let seen = std::sync::Arc::new(Seen::default());
+ let engine =
+ nsh_engine::LocalEngine::start(config, ApproxTextEngine, PacedSink(tx, seen.clone()))
+ .expect("the app starts");
+ let mut paced = Paced {
+ engine,
+ rx,
+ seen,
+ texts: Default::default(),
+ list: None,
+ pressable: None,
+ dir,
+ };
+ paced.receive();
+ paced
+ }
+
+ /// Applies the batches sent so far to the model; one `Vec` of the
+ /// texts each batch set, per batch.
+ fn receive(&mut self) -> Vec<Vec<String>> {
+ let mut batches = Vec::new();
+ while let Ok(batch) = self.rx.try_recv() {
+ let mut set = Vec::new();
+ for item in &batch.items {
+ match item {
+ MountItem::Text { id, text, .. } => {
+ self.texts.insert(*id, text.text.clone());
+ set.push(text.text.clone());
+ }
+ MountItem::Create {
+ id,
+ kind: ViewKind::Scroll,
+ } => self.list = self.list.or(Some(*id)),
+ MountItem::Events { id, events }
+ if events.contains(nsh_core::EventKind::Press) =>
+ {
+ self.pressable = Some(*id);
+ }
+ _ => {}
+ }
+ }
+ batches.push(set);
+ }
+ batches
+ }
+
+ /// One wake-up of the engine: what was sent is handled and committed.
+ fn turn(&mut self) -> Vec<Vec<String>> {
+ self.engine.pump();
+ self.receive()
+ }
+
+ /// The display frame the engine asked for, if it did.
+ fn frame(&mut self) -> Option<Vec<Vec<String>>> {
+ if !self
+ .seen
+ .frame
+ .swap(false, std::sync::atomic::Ordering::SeqCst)
+ {
+ return None;
+ }
+ self.engine.control().frame();
+ Some(self.turn())
+ }
+
+ /// Turns and frames until nothing changes, with `check` after each
+ /// batch.
+ fn settle(&mut self, mut check: impl FnMut(&Self, &[String])) {
+ for _ in 0..500 {
+ let mut batches = self.turn();
+ if let Some(more) = self.frame() {
+ batches.extend(more);
+ }
+ if batches.is_empty() && !self.seen.frame.load(std::sync::atomic::Ordering::SeqCst) {
+ return;
+ }
+ for batch in &batches {
+ check(self, batch);
+ }
+ }
+ panic!("the list never settled");
+ }
+
+ fn scroll_to(&self, y: f32) {
+ self.engine.control().scroll(
+ self.list.expect("a list"),
+ nsh_core::geometry::point(px(0.0), px(y)),
+ u32::MAX,
+ );
+ }
+
+ fn errors(&self) -> Vec<String> {
+ self.seen.errors.lock().unwrap().clone()
+ }
+
+ /// Rows `first..=last` are what text views show.
+ fn shows(&self, first: u32, last: u32) -> bool {
+ (first..=last).all(|i| self.texts.values().any(|t| *t == format!("row {i}")))
+ }
+}
+
+impl Drop for Paced {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.dir);
+ }
+}
+
+/// Rows of objects (`row().label`), `count` of them, and a pressable view
+/// whose press runs `press` on the rows signal.
+fn rows_app(count: usize, mount_effect: &str, press: &str) -> String {
+ format!(
+ r#"
+import {{ List, mount, signal, Text, View }} from "nsh";
+const rows = signal(Array.from({{ length: {count} }}, (_, i) => ({{ label: `row ${{i}}` }})));
+{mount_effect}
+function App() {{
+ return (
+ <View style={{{{ flex: 1 }}}}>
+ <View onPress={{() => {{ {press} }}}} style={{{{ height: 0 }}}} />
+ <List each={{rows()}} estimatedItemSize={{50}} style={{{{ flex: 1 }}}}>
+ {{(row) => <View style={{{{ height: 50 }}}}><Text>{{row().label}}</Text></View>}}
+ </List>
+ </View>
+ );
+}}
+mount(App);
+"#
+ )
+}
+
+/// The data shrinking while cells of the first window wait to be bound:
+/// cells whose items are gone are never asked for (a cell made for an
+/// index past the end would read `undefined`).
+#[test]
+fn shrinking_rows_before_the_window_is_bound_binds_no_missing_item() {
+ let app = rows_app(
+ 100,
+ "setTimeout(() => rows.set(rows().slice(0, 3)), 0);",
+ "",
+ );
+ let mut paced = Paced::start(&app);
+ paced.settle(|_, _| {});
+ assert_eq!(paced.errors(), Vec::<String>::new());
+ assert!(paced.shows(0, 2), "{:?}", paced.texts);
+}
+
+/// Publishing the source after a row binds must not let remaining paced
+/// requests read it before the corresponding splice reaches the core.
+#[test]
+fn source_changes_during_binding_are_applied_before_paced_reads() {
+ // The last visible row tests the fence before any paced work (the
+ // budget always allows a first cell). The first overscan row tests a
+ // source published between two paced binds while budget remains.
+ for change_at in [9, 10] {
+ let app = format!(
+ r#"
+import {{ List, mount, signal, Text, View }} from "nsh";
+const rows = signal(Array.from({{ length: 100 }}, (_, i) => ({{ label: `row ${{i}}` }})));
+function App() {{
+ return (
+ <View style={{{{ flex: 1 }}}}>
+ <View style={{{{ height: 0 }}}}><Text>{{`count ${{rows().length}}`}}</Text></View>
+ <List each={{rows()}} estimatedItemSize={{50}} style={{{{ flex: 1 }}}}>
+ {{(row, index) => {{
+ if (index() === {change_at} && rows().length > 3) rows.set(rows().slice(0, 3));
+ return <View style={{{{ height: 50 }}}}><Text>{{row().label}}</Text></View>;
+ }}}}
+ </List>
+ </View>
+ );
+}}
+mount(App);
+"#
+ );
+ let mut paced = Paced::start(&app);
+ paced.settle(|_, _| {});
+ assert!(
+ paced.texts.values().any(|text| text == "count 3"),
+ "row {change_at} did not shrink the source: {:?}",
+ paced.texts
+ );
+ assert_eq!(paced.errors(), Vec::<String>::new(), "row {change_at}");
+ assert!(paced.shows(0, 2), "row {change_at}: {:?}", paced.texts);
+ }
+}
+
+/// A row added above keeps every cell on its row in every batch: cells
+/// whose item moved learn their new index in the commit of the change.
+#[test]
+fn prepending_a_row_never_shows_a_cell_with_its_neighbours_row() {
+ let app = rows_app(100, "", "rows.update((r) => [{ label: \"new\" }, ...r]);");
+ let mut paced = Paced::start(&app);
+ paced.settle(|_, _| {});
+ assert!(paced.shows(0, 20), "{:?}", paced.texts);
+ let before = paced.texts.clone();
+ paced.engine.control().dispatch_event(
+ paced.pressable.expect("the pressable view"),
+ nsh_core::EventKind::Press,
+ nsh_core::EventPayload::None,
+ );
+ let mut wrong = Vec::new();
+ paced.settle(|paced, _| {
+ for (id, text) in &before {
+ if paced.texts.get(id) != Some(text) {
+ wrong.push((text.clone(), paced.texts.get(id).cloned()));
+ }
+ }
+ });
+ assert!(wrong.is_empty(), "cells showed another row: {wrong:?}");
+ assert!(paced.texts.values().any(|t| t == "new"));
+ assert_eq!(paced.errors(), Vec::<String>::new());
+}
+
+/// A fling that moves the window faster than cells bind: once it stops,
+/// only rows of the window it stopped at are bound (requests for windows
+/// already left are dropped, not bound late).
+#[test]
+fn a_fling_binds_no_row_of_a_window_left_behind() {
+ let app = rows_app(2000, "", "");
+ let mut paced = Paced::start(&app);
+ paced.settle(|_, _| {});
+ let mut binds = 0;
+ for step in 1..=20 {
+ paced.scroll_to(step as f32 * 1500.0);
+ binds += paced.turn().iter().map(Vec::len).sum::<usize>();
+ if let Some(batches) = paced.frame() {
+ binds += batches.iter().map(Vec::len).sum::<usize>();
+ }
+ }
+ // The last window: a viewport of overscan on either side of 30000.
+ let window = (30_000 - 500) / 50..=(30_000 + 500 + 500) / 50;
+ let mut stale = Vec::new();
+ paced.settle(|_, batch| {
+ binds += batch.len();
+ for text in batch {
+ let row: u32 = text.trim_start_matches("row ").parse().unwrap();
+ if !window.contains(&row) {
+ stale.push(row);
+ }
+ }
+ });
+ assert!(stale.is_empty(), "bound after the fling: rows {stale:?}");
+ assert!(paced.shows(600, 609), "{:?}", paced.texts);
+ eprintln!("fling: {binds} binds");
+ assert_eq!(paced.errors(), Vec::<String>::new());
+}
+
+/// A jump binds what it shows first: the batch of the jump holds every
+/// visible row; the overscan around it may follow.
+#[test]
+fn a_jump_binds_the_visible_rows_first() {
+ let app = rows_app(2000, "", "");
+ let mut paced = Paced::start(&app);
+ paced.settle(|_, _| {});
+ paced.scroll_to(5000.0);
+ let batches = paced.turn();
+ let first = batches.first().expect("a batch for the jump");
+ for i in 100..=109 {
+ assert!(
+ first.contains(&format!("row {i}")),
+ "row {i} missing from the jump's batch: {first:?}"
+ );
+ }
+ paced.settle(|_, _| {});
+ assert!(paced.shows(90, 120), "{:?}", paced.texts);
+}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs b/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs
index c8bb2291dc..a743ac1f6a 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs
@@ -50,6 +50,7 @@ fn bench_gallery_scroll() {
});
host.eval_bytecode(&bytecode).unwrap();
let mut core = Core::new(ApproxTextEngine, Size::new(px(390.0), px(844.0)));
+ core.set_components(nsh_base::registry());
let mut ops = Vec::new();
let mut binds: Vec<ListBind> = Vec::new();
let list = std::cell::Cell::new(None);
@@ -83,6 +84,10 @@ fn bench_gallery_scroll() {
}
}
core.take_list_binds(binds);
+ // The overscan the engine paces over frames, all in this one.
+ if binds.is_empty() {
+ core.take_paced_list_binds(usize::MAX, binds);
+ }
if binds.is_empty() {
break;
}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs b/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs
index 6cfa57637d..4ede3817e9 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs
@@ -53,6 +53,17 @@ pub(crate) fn containing(word: &str) -> Vec<String> {
.collect()
}
+/// The lines of every level logged so far that contain `word`.
+pub(crate) fn lines_containing(word: &str) -> Vec<String> {
+ LINES
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|(_, l)| l.contains(word))
+ .map(|(_, l)| l.clone())
+ .collect()
+}
+
/// The errors that contain `word`, once there is one (or after 5 s).
pub(crate) fn wait_for(word: &str) -> Vec<String> {
let deadline = Instant::now() + Duration::from_secs(5);
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/main.rs b/third-party/nsh/crates/nsh-engine/tests/engine/main.rs
index 049d4177f1..6d5436b3bb 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/main.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/main.rs
@@ -24,6 +24,7 @@ mod fetch;
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod fs;
mod handle_bench;
+mod host;
mod host_modules;
mod hot;
mod hover;
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs b/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
index 8ec9518f8a..9b5ea2da1b 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
@@ -2,7 +2,7 @@
//! frame, sent later it updates what read the theme, and it wins over the
//! app's own theme until cleared.
-use std::time::Duration;
+use std::{collections::BTreeMap, time::Duration};
use crossbeam_channel::Receiver;
use nsh_base::{Theme, theme};
@@ -10,7 +10,7 @@ use nsh_core::{
ApproxTextEngine, Color, MountBatch, MountItem, Size,
ui::{Root, prelude::*},
};
-use nsh_engine::{App, Engine, EngineConfig, Environment};
+use nsh_engine::{App, ColorScheme, Engine, EngineConfig, Environment};
use crate::ChannelSink;
@@ -165,3 +165,107 @@ fn a_tsx_apps_token_classes_follow_the_host_theme() {
assert_eq!(backgrounds(&rx), vec![default_primary()]);
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// The backgrounds the views draw within a moment, by node id.
+fn backgrounds_by_id(rx: &Receiver<MountBatch>) -> BTreeMap<u32, Color> {
+ let mut seen = BTreeMap::new();
+ while let Ok(batch) = rx.recv_timeout(Duration::from_millis(300)) {
+ for item in &batch.items {
+ if let MountItem::ViewStyle { id, style } = item
+ && style.background_color.0 & 0xff != 0
+ {
+ seen.insert(*id, style.background_color);
+ }
+ }
+ }
+ seen
+}
+
+/// The SDKs' theme overrides (`nsh_base::theme_overrides`): what the host
+/// sets wins over the app's own theme (`nsh.json`), light and dark, and
+/// what it leaves out stays the app's.
+#[test]
+fn a_hosts_theme_overrides_win_over_the_apps_tokens_they_set_and_keep_the_rest() {
+ let dir = std::env::temp_dir().join(format!("nsh-theme-overrides-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(
+ dir.join("nsh.json"),
+ r##"{ "theme": { "cssVars": { "light": { "primary": "#0000ff", "background": "#ffff00" } } } }"##,
+ )
+ .unwrap();
+ let entry = dir.join("App.tsx");
+ std::fs::write(
+ &entry,
+ "import { mount, View } from \"nsh\";\n\
+ mount(() => (\n\
+ <View>\n\
+ <View class=\"h-10 w-10 bg-primary\" />\n\
+ <View class=\"h-10 w-10 bg-background\" />\n\
+ </View>\n\
+ ));\n",
+ )
+ .unwrap();
+ let bundle = nsh_bundler::bundle(&nsh_bundler::BundleOptions {
+ entry,
+ minify: false,
+ split: false,
+ ui_sets: Vec::new(),
+ })
+ .unwrap();
+ let bytecode = nsh_js::compile_bytecode(&bundle.code, false).unwrap();
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let config = EngineConfig {
+ // SAFETY: compiled just now by this runtime.
+ app: App::Js(unsafe { nsh_engine::Bundle::bytecode(bytecode) }),
+ viewport: Size::new(px(390.0), px(844.0)),
+ trace: false,
+ network: None,
+ websocket: None,
+ filesystem: None,
+ assets: None,
+ environment: Environment::default(),
+ header_heights: (nsh_core::Points(44.0), nsh_core::Points(96.0)),
+ limits: nsh_engine::Limits::default(),
+ ui_sets: vec![nsh_base::registry()],
+ runtime: None,
+ data_dir: None,
+ storage: None,
+ clock: Default::default(),
+ context: Default::default(),
+ pinned: Default::default(),
+ clipboard: None,
+ };
+ // A brand's primary, light and dark, set before the app runs.
+ let overrides = nsh_base::theme_overrides(
+ r##"{ "cssVars": { "light": { "primary": "#e11d48" }, "dark": { "primary": "#fb7185" } } }"##,
+ )
+ .unwrap();
+ let engine = Engine::start_with(
+ config,
+ || ApproxTextEngine,
+ ChannelSink(tx),
+ |control| control.set_tokens(Some(overrides)),
+ )
+ .unwrap();
+ let colors = |seen: BTreeMap<u32, Color>| seen.into_values().collect::<Vec<_>>();
+ // The host's primary; the app's own background stays.
+ assert_eq!(
+ colors(backgrounds_by_id(&rx)),
+ vec![Color(0xe11d48ff), Color(0xffff00ff)]
+ );
+ // Dark: the override's dark value.
+ engine.control().set_environment(Environment {
+ color_scheme: ColorScheme::Dark,
+ ..Environment::default()
+ });
+ assert!(colors(backgrounds_by_id(&rx)).contains(&Color(0xfb7185ff)));
+ // Changed live, then cleared: the app's own again.
+ engine.control().set_tokens(Some(
+ nsh_base::theme_overrides(r##"{ "cssVars": { "theme": { "primary": "#00ff00" } } }"##)
+ .unwrap(),
+ ));
+ assert_eq!(colors(backgrounds_by_id(&rx)), vec![Color(0x00ff00ff)]);
+ engine.control().set_tokens(None);
+ assert!(colors(backgrounds_by_id(&rx)).contains(&Color(0x0000ffff)));
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/third-party/nsh/crates/nsh-js/src/host.rs b/third-party/nsh/crates/nsh-js/src/host.rs
index 996a39423c..cf8776c789 100644
--- a/third-party/nsh/crates/nsh-js/src/host.rs
+++ b/third-party/nsh/crates/nsh-js/src/host.rs
@@ -1188,6 +1188,7 @@ fn payload_object<'js>(cx: &Ctx<'js>, payload: &EventPayload) -> rquickjs::Resul
obj.set("name", &**name)?;
obj.set("data", json_value(cx, data)?)?;
}
+ EventPayload::Json(value) => return json_value(cx, value),
EventPayload::TextLayout(lines) => {
let list = rquickjs::Array::new(cx.clone())?;
for (i, l) in lines.iter().enumerate() {
diff --git a/third-party/nsh/crates/nsh-js/src/keymap.rs b/third-party/nsh/crates/nsh-js/src/keymap.rs
index 1b9d75d8b2..9b1437d5b8 100644
--- a/third-party/nsh/crates/nsh-js/src/keymap.rs
+++ b/third-party/nsh/crates/nsh-js/src/keymap.rs
@@ -341,8 +341,8 @@ pub(crate) const TYPES: &str = r#"export interface KeyBindingOptions {
within?: YNode;
}
-/** The app's keyboard shortcuts (desktop and terminal backends).
- * A keystroke is modifiers and a key joined by
+/** The app's keyboard shortcuts: on desktop and terminal backends, the web,
+ * and iOS and Android with a hardware keyboard. A keystroke is modifiers and a key joined by
* `+`, in any case (lowercase is the recommended style; a capital letter is
* the same key, not Shift): `"mod+k"`, `"ctrl+shift+p"`, `"alt+enter"`, `"escape"`,
* `"f5"`, `"mod+plus"`. Modifiers: `mod` (`cmdorctrl`): Command on Apple
diff --git a/third-party/nsh/crates/nsh-js/src/modules.rs b/third-party/nsh/crates/nsh-js/src/modules.rs
index 716d681081..628b893869 100644
--- a/third-party/nsh/crates/nsh-js/src/modules.rs
+++ b/third-party/nsh/crates/nsh-js/src/modules.rs
@@ -453,6 +453,13 @@ pub(crate) const MODULES: &[NativeModule] = &[
moved_to: Some("nsh"),
types: "",
},
+ NativeModule {
+ name: "nsh/host",
+ exports: platform_modules::HOST_EXPORTS,
+ also: &[],
+ moved_to: None,
+ types: decl!(platform_modules::HOST_TYPES),
+ },
NativeModule {
name: "nsh/keymap",
exports: keymap::EXPORTS,
diff --git a/third-party/nsh/crates/nsh-js/src/platform_modules.rs b/third-party/nsh/crates/nsh-js/src/platform_modules.rs
index bf28f7ea9f..3511c2d8e2 100644
--- a/third-party/nsh/crates/nsh-js/src/platform_modules.rs
+++ b/third-party/nsh/crates/nsh-js/src/platform_modules.rs
@@ -2,10 +2,11 @@
//! (`isAvailable`, `BackHandler.onBackPress` over the registry's module),
//! `nsh/ios` (`isAvailable`, `InputAccessoryView`), `nsh/webview` and
//! `nsh/video` (their host elements as components, which the compiler
-//! makes in place for their tags), and `nsh/native` (`nativeComponent`: the
+//! makes in place for their tags), `nsh/native` (`nativeComponent`: the
//! JS face of components the embedding app registers natively;
//! `nativeModule`: the exports of a UI set it registers, reached by name at
-//! run time).
+//! run time) and `nsh/host` (`available()` and the host's events over the
+//! registry's `call`).
use std::{
cell::RefCell,
@@ -33,6 +34,8 @@ pub(crate) struct PlatformModules {
/// `BackHandler.onBackPress` listeners, in order (id, owned
/// function).
back: RefCell<Vec<(u32, JsValue)>>,
+ /// `host.on` listeners, in order (id, event, owned function).
+ host_listeners: RefCell<Vec<(u32, String, JsValue)>>,
next: std::cell::Cell<u32>,
}
@@ -43,6 +46,10 @@ impl PlatformModules {
// SAFETY: owned values; no JS runs from here on.
unsafe { qjs::JS_FreeValue(raw, f) };
}
+ for (_, _, f) in self.host_listeners.borrow_mut().drain(..) {
+ // SAFETY: as above.
+ unsafe { qjs::JS_FreeValue(raw, f) };
+ }
}
}
@@ -259,6 +266,208 @@ export interface BackListeners {
}
"#;
+// --- nsh/host ---------------------------------------------------------------------------
+
+/// `host`: the registry's module (`call`) with `available()` and the
+/// listeners of the host's events.
+fn host_module(ctx: *mut JsContext) -> JsValue {
+ let host = crate::components::runtime_module(ctx, "nsh/host", "host")
+ // SAFETY: a new object when the runtime has none.
+ .unwrap_or_else(|| unsafe { qjs::JS_NewObject(ctx) });
+ let natives: [(&CStr, reactive::Native, c_int); 2] = [
+ (c"available", host_available, 0),
+ (c"on", add_host_listener, 2),
+ ];
+ for (name, f, length) in natives {
+ let func = reactive::new_function(ctx, name, f, length);
+ // SAFETY: the object takes the function.
+ unsafe { qjs::JS_SetPropertyStr(ctx, host, name.as_ptr(), func) };
+ }
+ host
+}
+
+/// `host.available()`: whether the platform embeds a host that answers
+/// calls (read when called: the environment may change).
+unsafe extern "C" fn host_available(
+ ctx: *mut JsContext,
+ _this: JsValue,
+ _argc: c_int,
+ _argv: *mut JsValue,
+) -> JsValue {
+ boolean(crate::env::current(ctx).host)
+}
+
+/// `host.on(event, listener)`: `{ remove() }`.
+unsafe extern "C" fn add_host_listener(
+ ctx: *mut JsContext,
+ _this: JsValue,
+ argc: c_int,
+ argv: *mut JsValue,
+) -> JsValue {
+ let Some(event) = dom::string_arg(ctx, arg(argc, argv, 0)) else {
+ // SAFETY: throws on a live context.
+ return unsafe { qjs::JS_ThrowTypeError(ctx, c"host.on: an event name".as_ptr()) };
+ };
+ let f = arg(argc, argv, 1);
+ // SAFETY: an inline check.
+ if !unsafe { qjs::JS_IsFunction(ctx, f) } {
+ return unsafe { qjs::JS_ThrowTypeError(ctx, c"host.on: a function".as_ptr()) };
+ }
+ let m = modules(ctx);
+ let id = m.next.get() + 1;
+ m.next.set(id);
+ // SAFETY: a dup the listeners own.
+ let first = {
+ let mut host = m.host_listeners.borrow_mut();
+ host.push((id, event, unsafe { qjs::JS_DupValue(ctx, f) }));
+ host.len() == 1
+ };
+ if first {
+ let on = reactive::new_function(ctx, c"onHost", on_host_event, 1);
+ let r = dom::set_handler(ctx, 1, EventKind::Host, on);
+ // SAFETY: ours.
+ unsafe { qjs::JS_FreeValue(ctx, on) };
+ if let Err(e) = r {
+ return e;
+ }
+ }
+ let remove = native(
+ ctx,
+ remove_host_listener,
+ 0,
+ &mut [qjs::JS_NewFloat64(id as f64)],
+ );
+ // SAFETY: a new object taking the function.
+ unsafe {
+ let o = qjs::JS_NewObject(ctx);
+ qjs::JS_SetPropertyStr(ctx, o, c"remove".as_ptr(), remove);
+ o
+ }
+}
+
+unsafe extern "C" fn remove_host_listener(
+ ctx: *mut JsContext,
+ _this: JsValue,
+ _argc: c_int,
+ _argv: *mut JsValue,
+ _magic: c_int,
+ data: *mut JsValue,
+) -> JsValue {
+ let mut id = 0f64;
+ // SAFETY: one data value, the listener's id.
+ unsafe { qjs::JS_ToFloat64(ctx, &mut id, *data) };
+ let (gone, empty) = {
+ let mut host = modules(ctx).host_listeners.borrow_mut();
+ let i = host.iter().position(|(i, ..)| *i == id as u32);
+ (i.map(|i| host.remove(i).2), host.is_empty())
+ };
+ if let Some(f) = gone {
+ // SAFETY: owned.
+ unsafe { qjs::JS_FreeValue(ctx, f) };
+ if empty {
+ let _ = dom::set_handler(ctx, 1, EventKind::Host, UNDEFINED);
+ }
+ }
+ UNDEFINED
+}
+
+/// The root's `Host` events of `type: "event"` (`{ event, payload }`; the
+/// core takes the replies): the listeners of `event`, in the order they
+/// were added, each with `payload`. A listener that throws leaves the next
+/// ones called; the first error is thrown once they all ran.
+unsafe extern "C" fn on_host_event(
+ ctx: *mut JsContext,
+ _this: JsValue,
+ argc: c_int,
+ argv: *mut JsValue,
+) -> JsValue {
+ let e = arg(argc, argv, 0);
+ let string = |key: &std::ffi::CStr| {
+ // SAFETY: a read of the handler's argument; the result is freed.
+ unsafe {
+ let v = qjs::JS_GetPropertyStr(ctx, e, key.as_ptr());
+ let s = qjs::JS_IsString(v)
+ .then(|| dom::string_arg(ctx, v))
+ .flatten();
+ qjs::JS_FreeValue(ctx, v);
+ s
+ }
+ };
+ if string(c"type").as_deref() != Some("event") {
+ return UNDEFINED;
+ }
+ let event = string(c"event");
+ // SAFETY: an owned result, freed below.
+ let payload = unsafe { qjs::JS_GetPropertyStr(ctx, e, c"payload".as_ptr()) };
+ let listeners: Vec<JsValue> = match &event {
+ Some(event) => modules(ctx)
+ .host_listeners
+ .borrow()
+ .iter()
+ .filter(|(_, e, _)| e == event)
+ // SAFETY: dups for the calls.
+ .map(|&(_, _, f)| unsafe { qjs::JS_DupValue(ctx, f) })
+ .collect(),
+ None => Vec::new(),
+ };
+ let mut first = None;
+ let mut fatal = false;
+ for f in listeners {
+ if !fatal {
+ let mut args = [payload];
+ // SAFETY: a call of an owned function with a borrowed argument,
+ // then its result.
+ unsafe {
+ let r = qjs::JS_Call(ctx, f, UNDEFINED, 1, args.as_mut_ptr());
+ if qjs::JS_IsException(r) {
+ let error = crate::web::js::take_exception(ctx);
+ // An interrupt stops them all.
+ if qjs::JS_IsUncatchableError(error.raw()) {
+ first = Some(error);
+ fatal = true;
+ } else {
+ first.get_or_insert(error);
+ }
+ } else {
+ qjs::JS_FreeValue(ctx, r);
+ }
+ }
+ }
+ // SAFETY: ours.
+ unsafe { qjs::JS_FreeValue(ctx, f) };
+ }
+ // SAFETY: ours.
+ unsafe { qjs::JS_FreeValue(ctx, payload) };
+ match first {
+ Some(error) => {
+ let _ = crate::web::js::throw(ctx, error);
+ qjs::JS_EXCEPTION
+ }
+ None => UNDEFINED,
+ }
+}
+
+/// The hand-written exports of `nsh/host` (with the registry's).
+pub(crate) const HOST_EXPORTS: &[Item] = &[(
+ "host",
+ Export::Value(host_module),
+ Ts::value(
+ "The app embedding NSH: `host.call(name, args)` calls one of its functions\n(granted by `capabilities.host` in nsh.json), `host.on(event, f)` hears its\nevents, `host.available()` says whether the platform embeds one.",
+ "hostModule & HostEvents",
+ ),
+)];
+
+/// `nsh/host`'s types (with the runtime registry's module).
+pub(crate) const HOST_TYPES: &str = r#"/** The embedding host's events and whether there is one. */
+export interface HostEvents {
+ /** Whether the platform embeds a host that answers calls; without one,
+ * `call` rejects at once. */
+ available(): boolean;
+ /** Calls `listener` with each `event` the host sends, until `remove()`. */
+ on(event: string, listener: (payload: unknown) => void): { remove(): void };
+}
+"#;
+
/// `nsh/ios`.
pub(crate) const IOS_EXPORTS: &[Item] = &[
(
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java
index 9f71fddc75..a76bf86e55 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java
@@ -72,6 +72,7 @@ final class Native {
static final int POSITION = 61;
static final int MOUSE_MOVE = 62;
static final int SCROLL_INTENT = 63;
+ static final int HOST = 64;
/**
* Draws the SVG {@code bytes} for a view {@code maxPx} pixels on its longer side, with its shorter side that long
@@ -99,6 +100,12 @@ final class Native {
/** The directory the storage of the apps loaded next is kept in (null: each app's own in the data directory). */
static native void setStorage(long handle, String dir);
+ /**
+ * The embedding app's theme overrides (a shadcn/ui theme file, null: the
+ * app's own theme). Returns why the file does not parse, or null.
+ */
+ static native String setThemeOverrides(long handle, String json);
+
/** Whether the running app's web views may navigate to {@code url}. */
static native boolean allowsWebview(long handle, String url);
@@ -161,6 +168,16 @@ final class Native {
static native void event(long handle, int id, int kind, String text, int count, float x, float y);
+ /**
+ * A hardware key went down: whether the app's shortcuts ({@code nsh/keymap}) take it.
+ * {@code unicode}: what it types without modifiers ({@code KeyEvent.getUnicodeChar(0)});
+ * {@code repeat}: an auto-repeat; {@code editing}: a text input or hosted view has the focus.
+ */
+ static native boolean keyDown(long handle, int keyCode, int unicode, int meta, boolean repeat, boolean editing);
+
+ /** A hardware key went up: whether its press was taken (then so is its release). */
+ static native boolean keyUp(long handle, int keyCode);
+
/**
* {@code onHoverMove} of view {@code id}: the pointer at ({@code x}, {@code y}) in it and at
* ({@code pageX}, {@code pageY}) in its root view (dp); {@code modifiers}: 1 Ctrl, 2 Alt,
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java
index ef58cb4c64..ca19f1a64c 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java
@@ -62,6 +62,45 @@ public class NshView extends FrameLayout {
// no view takes it.
return super.dispatchTouchEvent(e) || tracking;
}
+
+ /**
+ * A hardware keyboard's keys go to the app's shortcuts ({@code nsh/keymap}) before the focused view
+ * gets them: a key a binding takes is consumed, with its release (the input never sees it).
+ */
+ @Override
+ public boolean dispatchKeyEvent(android.view.KeyEvent e) {
+ if (handle != 0) {
+ int action = e.getAction();
+ if (action == android.view.KeyEvent.ACTION_DOWN && keymapKey(e)) {
+ return true;
+ } else if (action == android.view.KeyEvent.ACTION_UP && Native.keyUp(handle, e.getKeyCode())) {
+ return true;
+ }
+ }
+ return super.dispatchKeyEvent(e);
+ }
+
+ /**
+ * Keys with Control come as shortcuts first (the window's input stage offers them before the key
+ * event, and a text field selects all, copies or pastes there): the app's shortcuts take them first.
+ */
+ @Override
+ public boolean dispatchKeyShortcutEvent(android.view.KeyEvent e) {
+ if (handle != 0 && e.getAction() == android.view.KeyEvent.ACTION_DOWN && keymapKey(e)) {
+ return true;
+ }
+ return super.dispatchKeyShortcutEvent(e);
+ }
+
+ /** Whether the app's shortcuts take key press {@code e}. */
+ private boolean keymapKey(android.view.KeyEvent e) {
+ // An input or hosted view (a web view, a native component) has the focus: in text.
+ View focus = findFocus();
+ boolean editing = focus != null && focus != this;
+ return Native.keyDown(handle, e.getKeyCode(), e.getUnicodeChar(0), e.getMetaState(),
+ e.getRepeatCount() > 0, editing);
+ }
+
private final Handler main = new Handler(Looper.getMainLooper());
private ErrorListener errorListener;
/** Where `asset:/…` files come from: an http base, a directory, or APK assets. */
@@ -174,6 +213,28 @@ public class NshView extends FrameLayout {
Native.setStorage(handle, dir == null ? null : dir.getPath());
}
+ /**
+ * Overrides the app's theme with the host's: a theme file of shadcn/ui's
+ * ({@code { "cssVars": { "theme": {…}, "light": {…}, "dark": {…} } }},
+ * values as CSS writes them, the format of {@code nsh.json}'s
+ * {@code "theme"}). Its tokens win over the app's of the same names, in
+ * light and dark mode; the app's others stay, so a branded host sets only
+ * what its brand changes ({@code primary}, {@code radius}, ...). Call it
+ * before or after loading: it applies at once (classes such as
+ * {@code bg-primary} are resolved again) and to every app loaded later,
+ * from its first frame. Main thread.
+ *
+ * @param json the theme file, or null to give the app its own theme back
+ * @throws IllegalArgumentException when the file does not parse; the
+ * overrides then stay as they were
+ */
+ public void setThemeOverrides(String json) {
+ String error = Native.setThemeOverrides(handle, json);
+ if (error != null) {
+ throw new IllegalArgumentException(error);
+ }
+ }
+
/** The contents of {@code file}, or null when it does not exist. */
private static String readText(File file) throws java.io.IOException {
return file.exists() ? new String(Net.readAll(new FileInputStream(file)), StandardCharsets.UTF_8) : null;
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java
index 84e24372d2..b0f7973d19 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java
@@ -187,6 +187,25 @@ final class TestAgent {
});
}
+ /**
+ * Presses the key `code` (`KeyEvent.KEYCODE_*`) with `meta` (`KeyEvent.META_*`) on a hardware
+ * keyboard: the window's view hierarchy gets it from its root, as the window's input stage
+ * (ViewRootImpl) hands it over after the input method: with Control as a shortcut first.
+ */
+ static void pressKeys(NshView host, int code, int meta) {
+ onMain(() -> {
+ View window = target(host);
+ long now = SystemClock.uptimeMillis();
+ KeyEvent down = new KeyEvent(now, now, KeyEvent.ACTION_DOWN, code, 0, meta,
+ KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, android.view.InputDevice.SOURCE_KEYBOARD);
+ if ((meta & KeyEvent.META_CTRL_ON) == 0 || !window.dispatchKeyShortcutEvent(down)) {
+ window.dispatchKeyEvent(down);
+ }
+ window.dispatchKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP));
+ return null;
+ });
+ }
+
/** The text input `id` holds; null when it is no input. */
static String inputText(NshView host, int id) {
return onMain(() -> {
diff --git a/third-party/nsh/crates/nsh-platform-android/src/agent.rs b/third-party/nsh/crates/nsh-platform-android/src/agent.rs
index 425e7dc768..e9619093fe 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/agent.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/agent.rs
@@ -279,6 +279,40 @@ impl Native for AndroidNative {
});
}
+ fn press_keys(&self, keys: &str) -> Result<(), String> {
+ use nsh_core::keymap::Keystroke;
+ let stroke =
+ Keystroke::parse(keys, nsh_core::platform::Os::Android).map_err(|e| e.to_string())?;
+ let code = stroke
+ .key
+ .android_keycode()
+ .ok_or_else(|| format!("an Android keyboard has no key {keys:?}"))?;
+ // `KeyEvent.META_*_ON`.
+ let m = stroke.modifiers;
+ let meta = [
+ (m.shift, 0x1),
+ (m.alt, 0x2),
+ (m.ctrl, META_CTRL_ON),
+ (m.cmd, 0x10000),
+ ]
+ .into_iter()
+ .filter(|(on, _)| *on)
+ .fold(0, |meta, (_, bit)| meta | bit);
+ if self.host.upgrade().is_none() {
+ return Err("the app is gone".into());
+ }
+ self.call(|env, class, view| {
+ env.call_static_method(
+ class,
+ "pressKeys",
+ "(Lcom/longbridge/nsh/NshView;II)V",
+ &[JValue::Object(view), JValue::Int(code), JValue::Int(meta)],
+ )
+ .map(drop)
+ });
+ Ok(())
+ }
+
fn input_text(&self, id: NodeId) -> Option<String> {
self.call(|env, class, view| {
let s = env
diff --git a/third-party/nsh/crates/nsh-platform-android/src/encode.rs b/third-party/nsh/crates/nsh-platform-android/src/encode.rs
index e13c3f11cc..04c71796d1 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/encode.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/encode.rs
@@ -80,6 +80,9 @@ pub(crate) struct Encoded {
pub(crate) objects: Vec<Obj>,
/// Items for the UI-thread animator, in order.
pub(crate) animation: Vec<MountItem>,
+ /// The app's keyboard shortcuts, when the batch changed them: matched
+ /// natively (`crate::keymap`), never sent to Java.
+ pub(crate) keymap: Option<std::sync::Arc<nsh_core::keymap::Keymap>>,
}
impl Encoded {
@@ -306,8 +309,7 @@ pub(crate) fn encode(batch: MountBatch) -> Encoded {
e.int(LAYOUT_DIRECTION);
e.bool(rtl);
}
- // Keyboard shortcuts are not matched on Android yet.
- MountItem::Keymap(_) => {}
+ MountItem::Keymap(keymap) => e.keymap = Some(keymap),
MountItem::Appearance { appearance } => {
e.int(APPEARANCE);
e.int(appearance as i32);
@@ -604,7 +606,10 @@ pub(crate) fn encode(batch: MountBatch) -> Encoded {
| Command::Measure { .. }
| Command::ScrollToEnd { .. }
// Desktops only.
- | Command::Desktop(_) => e.int(-1),
+ | Command::Desktop(_)
+ // No host answers `nsh/host` here (`Environment::host`):
+ // the core rejects the calls first.
+ | Command::HostCall { .. } => e.int(-1),
}
}
}
diff --git a/third-party/nsh/crates/nsh-platform-android/src/host.rs b/third-party/nsh/crates/nsh-platform-android/src/host.rs
index 0d8821f422..38287e38b1 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/host.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/host.rs
@@ -65,9 +65,13 @@ pub(crate) struct Host {
storage: Mutex<Option<std::path::PathBuf>>,
/// What the running app may reach (web view navigations check it).
access: Mutex<Arc<nsh_engine::Access>>,
+ /// The embedding app's theme overrides (`NshView.setThemeOverrides`).
+ host_tokens: Mutex<Option<nsh_engine::Tokens>>,
/// The bytes of the in-memory images (`data:` URIs, blobs) the app
/// shows, by URI, for `Images.java` (`Native.imageBytes`).
inline_images: Mutex<nsh_core::image::InlineImages>,
+ /// The app's keyboard shortcuts, matched on the UI thread.
+ pub(crate) keymap: Mutex<crate::keymap::KeymapState>,
#[cfg(feature = "dev")]
pub(crate) dev: Mutex<Option<crate::dev::DevSession>>,
/// A test session's model of the app (`nsh test --target android`):
@@ -93,11 +97,28 @@ impl Host {
self.engine.lock().ok()?.as_ref().map(Engine::control)
}
+ /// Hands event `kind` of node `id` to the running app.
+ pub(crate) fn dispatch(&self, id: NodeId, kind: EventKind, payload: EventPayload) {
+ if let Some(control) = self.control() {
+ control.dispatch_event(id, kind, payload);
+ }
+ }
+
/// The VM limits of the apps loaded from now on.
pub(crate) fn set_limits(&self, limits: nsh_engine::Limits) {
*self.limits.lock().expect("limits") = limits;
}
+ /// The embedding app's theme overrides (`None`: the app's own theme):
+ /// sent to the running app at once, and to every app loaded later
+ /// before its first frame.
+ fn set_host_tokens(&self, tokens: Option<nsh_engine::Tokens>) {
+ *self.host_tokens.lock().expect("tokens") = tokens.clone();
+ if let Some(control) = self.control() {
+ control.set_tokens(tokens);
+ }
+ }
+
/// Runs `app`, replacing the running app.
pub(crate) fn load(self: &Arc<Self>, app: impl Into<App>) {
let mut app = app.into();
@@ -127,6 +148,10 @@ impl Host {
self.app_stopped();
}
*self.animation_queue.lock().expect("queue") = VecDeque::new();
+ self.keymap
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .clear();
*self.ui.lock().expect("ui") = UiAnimation::default();
#[allow(unused_mut)]
let mut config = EngineConfig {
@@ -159,7 +184,14 @@ impl Host {
if let Some(model) = &*self.test_model.lock().unwrap_or_else(|e| e.into_inner()) {
model.set_up_engine(&mut config);
}
- match Engine::start(config, move || AndroidTextEngine { density }, sink) {
+ // The embedding app's theme overrides are in the first frame.
+ let tokens = self.host_tokens.lock().expect("tokens").clone();
+ let setup = |control: &EngineControl| {
+ if tokens.is_some() {
+ control.set_tokens(tokens);
+ }
+ };
+ match Engine::start_with(config, move || AndroidTextEngine { density }, sink, setup) {
Ok(engine) => {
// The test moves its clock through the engine.
#[cfg(feature = "dev")]
@@ -335,7 +367,13 @@ impl MountSink for Sink {
}
answer_clipboard_reads(host, &batch);
register_inline_images(host, &batch);
- let encoded = encode::encode(batch);
+ let mut encoded = encode::encode(batch);
+ if let Some(keymap) = encoded.keymap.take() {
+ host.keymap
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .set(keymap);
+ }
let seq = host.seq.fetch_add(1, Ordering::Relaxed) + 1;
if !encoded.animation.is_empty() {
host.animation_queue
@@ -485,7 +523,9 @@ extern "system" fn Java_com_longbridge_nsh_Native_create(
data_dir,
storage: Mutex::new(None),
access: Mutex::new(Arc::default()),
+ host_tokens: Mutex::new(None),
inline_images: Mutex::default(),
+ keymap: Mutex::default(),
#[cfg(feature = "dev")]
dev: Mutex::new(None),
#[cfg(feature = "dev")]
@@ -587,6 +627,41 @@ extern "system" fn Java_com_longbridge_nsh_Native_setStorage(
*host.storage.lock().expect("storage") = dir;
}
+/// The embedding app's theme overrides: a theme file of shadcn/ui's
+/// (`cssVars`, as `nsh.json`'s `"theme"`), whose tokens win over the app's
+/// of the same names (null: the app's own theme again). Applies at once and
+/// to every app loaded later. Returns why the file does not parse (nothing
+/// changes), or null.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_setThemeOverrides<'local>(
+ mut env: JNIEnv<'local>,
+ _: JClass<'local>,
+ handle: jlong,
+ json: JString<'local>,
+) -> JString<'local> {
+ // SAFETY: Java passes its live handle.
+ let host = unsafe { host(handle) };
+ let tokens = if json.is_null() {
+ Ok(None)
+ } else {
+ match env.get_string(&json) {
+ Ok(json) => nsh_base::theme_overrides(&String::from(json))
+ .map(Some)
+ .map_err(|err| format!("invalid theme: {err}")),
+ Err(err) => Err(format!("invalid theme: {err}")),
+ }
+ };
+ match tokens {
+ Ok(tokens) => {
+ host.set_host_tokens(tokens);
+ JString::from(JObject::null())
+ }
+ Err(err) => env
+ .new_string(err)
+ .unwrap_or_else(|_| JString::from(JObject::null())),
+ }
+}
+
/// Whether the running app's web views may navigate to `url`.
#[unsafe(no_mangle)]
extern "system" fn Java_com_longbridge_nsh_Native_allowsWebview(
diff --git a/third-party/nsh/crates/nsh-platform-android/src/keymap.rs b/third-party/nsh/crates/nsh-platform-android/src/keymap.rs
new file mode 100644
index 0000000000..1b06dd0ffc
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-android/src/keymap.rs
@@ -0,0 +1,161 @@
+//! The app's keyboard shortcuts (`nsh/keymap`) on a hardware keyboard (a
+//! tablet's, a Chromebook's, an emulator's): the table the engine mounts
+//! ([`MountItem::Keymap`](nsh_core::MountItem::Keymap)), kept here rather
+//! than sent to Java, and matched against every key on the spot.
+//!
+//! `NshView.dispatchKeyEvent` asks before its focused descendant gets a
+//! key (`Native.keyDown` / `Native.keyUp`, on the UI thread): a key that
+//! fires a binding is consumed (the focused input never sees it: no caret
+//! move, no line break, no typed character) and reported to JS with the
+//! root's `KeyCommand` event; JS runs the handler afterwards. Its release
+//! is consumed too. The input method sees keys before the view hierarchy
+//! does, so a composition keeps its keys. `NshView` holds the focus when
+//! no input does, so keys reach it wherever the focus is in it; a text
+//! input or hosted view (a web view, a native component) having the focus
+//! is "in text". Android repeats a held key's events itself (their repeat
+//! count): a binding fires on them only when it asks to (`repeat`).
+//!
+//! Keys are named by their key code for the keys that type nothing, and
+//! by the character they type without modifiers on the device's layout
+//! otherwise, the US layout's of the same key when that is no ASCII, as on
+//! the desktops. `mod` is Control.
+
+use std::sync::Arc;
+
+use jni::{
+ JNIEnv,
+ objects::JClass,
+ sys::{JNI_FALSE, JNI_TRUE, jboolean, jint, jlong},
+};
+use nsh_core::{
+ EventKind, EventPayload, PayloadValue, ROOT_ID,
+ keymap::{Key, KeyMatch, Keymap, Keystroke, Modifiers},
+};
+
+/// `KeyEvent.META_*_ON`.
+const META_SHIFT_ON: i32 = 0x1;
+const META_ALT_ON: i32 = 0x2;
+const META_CTRL_ON: i32 = 0x1000;
+const META_META_ON: i32 = 0x10000;
+
+/// A view's table, and the keys it consumed that are still down.
+#[derive(Default)]
+pub(crate) struct KeymapState {
+ table: Option<Arc<Keymap>>,
+ /// Key codes whose press the table consumed.
+ held: Vec<i32>,
+}
+
+impl KeymapState {
+ /// `MountItem::Keymap`: replaces the table.
+ pub(crate) fn set(&mut self, keymap: Arc<Keymap>) {
+ self.table = (!keymap.is_empty()).then_some(keymap);
+ }
+
+ /// A new app: no bindings yet.
+ pub(crate) fn clear(&mut self) {
+ *self = KeymapState::default();
+ }
+}
+
+/// The keystroke of a key event: key code `code`, `unicode` the character
+/// it types without modifiers (`KeyEvent.getUnicodeChar(0)`, 0 for none),
+/// `meta` its meta state.
+pub(crate) fn keystroke(code: i32, unicode: i32, meta: i32) -> Option<Keystroke> {
+ let modifiers = Modifiers {
+ ctrl: meta & META_CTRL_ON != 0,
+ alt: meta & META_ALT_ON != 0,
+ shift: meta & META_SHIFT_ON != 0,
+ cmd: meta & META_META_ON != 0,
+ };
+ let key = match Key::from_android_keycode(code)? {
+ us @ Key::Char(c) if c != ' ' => match char::from_u32(unicode as u32) {
+ Some(c) if c.is_ascii_graphic() => Key::Char(c.to_ascii_lowercase()),
+ _ => us,
+ },
+ named => named,
+ };
+ Some(Keystroke { modifiers, key })
+}
+
+/// A key went down (`repeat`: an auto-repeat of a held key): whether a
+/// binding takes it, firing it.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_keyDown(
+ _: JNIEnv<'_>,
+ _: JClass<'_>,
+ handle: jlong,
+ code: jint,
+ unicode: jint,
+ meta: jint,
+ repeat: jboolean,
+ editing: jboolean,
+) -> jboolean {
+ // SAFETY: Java passes its live handle.
+ let host = unsafe { crate::host::host(handle) };
+ let Some(stroke) = keystroke(code, unicode, meta) else {
+ return JNI_FALSE;
+ };
+ let found = {
+ let mut state = host.keymap.lock().unwrap_or_else(|e| e.into_inner());
+ let Some(table) = state.table.clone() else {
+ return JNI_FALSE;
+ };
+ let found = table.find(stroke, editing != 0, repeat != 0);
+ if found.consumed() && !state.held.contains(&code) {
+ state.held.push(code);
+ }
+ found
+ };
+ if let KeyMatch::Fire(id) = found {
+ host.dispatch(
+ ROOT_ID,
+ EventKind::KeyCommand,
+ EventPayload::Value(PayloadValue::Number(f64::from(id))),
+ );
+ }
+ if found.consumed() {
+ JNI_TRUE
+ } else {
+ JNI_FALSE
+ }
+}
+
+/// A key went up: whether its press was consumed (then so is this).
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_keyUp(
+ _: JNIEnv<'_>,
+ _: JClass<'_>,
+ handle: jlong,
+ code: jint,
+) -> jboolean {
+ // SAFETY: Java passes its live handle.
+ let host = unsafe { crate::host::host(handle) };
+ let mut state = host.keymap.lock().unwrap_or_else(|e| e.into_inner());
+ match state.held.iter().position(|held| *held == code) {
+ Some(i) => {
+ state.held.remove(i);
+ JNI_TRUE
+ }
+ None => JNI_FALSE,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn names_keys_by_what_they_type() {
+ let parse = |t| Keystroke::parse(t, nsh_core::platform::Os::Android).ok();
+ assert_eq!(keystroke(20, 0, 0), parse("down"));
+ assert_eq!(keystroke(66, 0, 0), parse("enter"));
+ assert_eq!(keystroke(39, 'k' as i32, META_CTRL_ON), parse("mod+k"));
+ assert_eq!(keystroke(76, '/' as i32, META_SHIFT_ON), parse("shift+/"));
+ // A layout typing no ASCII there: the US layout's key.
+ assert_eq!(keystroke(39, 'л' as i32, 0), parse("k"));
+ // A layout typing another letter there (AZERTY's A on Q's key).
+ assert_eq!(keystroke(45, 'a' as i32, 0), parse("a"));
+ assert_eq!(keystroke(59, 0, META_SHIFT_ON), None);
+ }
+}
diff --git a/third-party/nsh/crates/nsh-platform-android/src/lib.rs b/third-party/nsh/crates/nsh-platform-android/src/lib.rs
index 3af9c8332f..ac91352232 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/lib.rs
@@ -25,6 +25,7 @@ mod dev;
mod encode;
mod host;
mod java;
+mod keymap;
mod logger;
mod net;
#[cfg(feature = "js")]
diff --git a/third-party/nsh/crates/nsh-platform-ios/include/nsh.h b/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
index c50f288419..05481a96dc 100644
--- a/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
+++ b/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
@@ -68,6 +68,14 @@ char *_Nullable nsh_app_set_locale(NshApp *app, const char *_Nullable tag);
/// for Dynamic Type's; 0 follows the system's again.
void nsh_app_set_font_scale(NshApp *app, float scale);
+/// The embedding app's theme overrides: a theme file of shadcn/ui's
+/// (`{ "cssVars": { "theme": {...}, "light": {...}, "dark": {...} } }`, as
+/// `nsh.json`'s `"theme"`), whose tokens win over the app's of the same
+/// names, light and dark; NULL gives the app its own theme back. Applies at
+/// once and to every app loaded later. Returns why the file does not parse,
+/// or NULL.
+char *_Nullable nsh_app_set_theme_overrides(NshApp *app, const char *_Nullable json);
+
/// Development: loads from a `nsh dev` server (`ws://host:port/ws`) and
/// reloads on every change. Returns an error without development support,
/// or NULL.
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/agent.rs b/third-party/nsh/crates/nsh-platform-ios/src/agent.rs
index 3a9e3fbaf2..8960baa4e9 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/agent.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/agent.rs
@@ -200,6 +200,45 @@ impl Native for IosNative {
});
}
+ fn press_keys(&self, keys: &str) -> Result<(), String> {
+ use nsh_core::keymap::Keystroke;
+ let stroke =
+ Keystroke::parse(keys, nsh_core::platform::Os::Ios).map_err(|e| e.to_string())?;
+ let usage = stroke
+ .key
+ .hid_usage()
+ .ok_or_else(|| format!("a hardware keyboard has no key {keys:?}"))?;
+ let m = stroke.modifiers;
+ let modifiers: Vec<u16> = [
+ (m.ctrl, synth::CONTROL),
+ (m.alt, synth::OPTION),
+ (m.shift, synth::SHIFT),
+ (m.cmd, synth::COMMAND),
+ ]
+ .into_iter()
+ .filter_map(|(on, usage)| on.then_some(usage))
+ .collect();
+ // As a hand presses a shortcut: the modifiers down first, the key
+ // pressed and released, then the modifiers released.
+ let held = modifiers.clone();
+ self.on_main(move |_, window| {
+ for usage in held {
+ synth::key(window, usage, true);
+ }
+ synth::key(window, usage, true)
+ })
+ .filter(|sent| *sent)
+ .ok_or_else(|| "the simulator takes no keyboard events".to_owned())?;
+ std::thread::sleep(TAP);
+ self.on_main(move |_, window| {
+ synth::key(window, usage, false);
+ for usage in modifiers.into_iter().rev() {
+ synth::key(window, usage, false);
+ }
+ });
+ Ok(())
+ }
+
fn input_text(&self, id: NodeId) -> Option<String> {
self.on_main(move |view, _| view.with_mounter(|m| m.input_text(id)).flatten())
.flatten()
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/embed.rs b/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
index 0b5e333b2c..968aad53e9 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
@@ -295,6 +295,38 @@ pub unsafe extern "C" fn nsh_app_set_font_scale(app: *mut NshApp, scale: f32) {
.set_font_scale((scale.is_finite() && scale > 0.0).then_some(scale));
}
+/// The embedding app's theme overrides: a theme file of shadcn/ui's
+/// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`, as
+/// `nsh.json`'s `"theme"`), whose tokens win over the app's of the same
+/// names, light and dark; `NULL` gives the app its own theme back. Applies
+/// at once (classes naming the tokens are resolved again) and to every app
+/// loaded later, from its first frame. Returns why the file does not parse
+/// (nothing changes), or `NULL`.
+///
+/// # Safety
+///
+/// `app` must come from [`nsh_app_create`]; `json` must be `NULL` or a
+/// NUL-terminated string.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn nsh_app_set_theme_overrides(
+ app: *mut NshApp,
+ json: *const c_char,
+) -> *mut c_char {
+ // SAFETY: guaranteed by the caller.
+ let app = unsafe { &*app };
+ let json = if json.is_null() {
+ Ok(None)
+ } else {
+ // SAFETY: guaranteed by the caller.
+ unsafe { utf8(json, "the theme") }.map(Some)
+ };
+ status(json.and_then(|json| {
+ app.view
+ .set_theme_overrides(json)
+ .map_err(|err| format!("invalid theme: {err}"))
+ }))
+}
+
/// Development: loads from a `nsh dev` server (`ws://host:port/ws`) and
/// reloads on every change, showing errors over the app. Returns an error
/// when the library was built without development support, or `NULL`.
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/input.rs b/third-party/nsh/crates/nsh-platform-ios/src/input.rs
index bf3b803c2a..b06e70d112 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/input.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/input.rs
@@ -102,6 +102,32 @@ define_class!(
backspace_on_empty(self.as_ref());
}
}
+
+ /// A hardware keyboard's keys go to the app's shortcuts first
+ /// (`nsh/keymap`): a key a binding takes never reaches the input.
+ #[unsafe(method(pressesBegan:withEvent:))]
+ fn presses_began(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ if !crate::keymap::input_began(self.as_ref(), presses) {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] };
+ }
+ }
+
+ #[unsafe(method(pressesEnded:withEvent:))]
+ fn presses_ended(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ if !crate::keymap::input_ended(self.as_ref(), presses) {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] };
+ }
+ }
+
+ #[unsafe(method(pressesCancelled:withEvent:))]
+ fn presses_cancelled(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ if !crate::keymap::input_ended(self.as_ref(), presses) {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] };
+ }
+ }
}
);
@@ -201,6 +227,32 @@ define_class!(
let _: () = unsafe { msg_send![self, insertText: &*NSString::from_str("\n")] };
self.ivars().line_break.set(false);
}
+
+ /// A hardware keyboard's keys go to the app's shortcuts first
+ /// (`nsh/keymap`): a key a binding takes never reaches the input.
+ #[unsafe(method(pressesBegan:withEvent:))]
+ fn presses_began(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ if !crate::keymap::input_began(self.as_ref(), presses) {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] };
+ }
+ }
+
+ #[unsafe(method(pressesEnded:withEvent:))]
+ fn presses_ended(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ if !crate::keymap::input_ended(self.as_ref(), presses) {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] };
+ }
+ }
+
+ #[unsafe(method(pressesCancelled:withEvent:))]
+ fn presses_cancelled(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ if !crate::keymap::input_ended(self.as_ref(), presses) {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] };
+ }
+ }
}
);
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/keymap.rs b/third-party/nsh/crates/nsh-platform-ios/src/keymap.rs
new file mode 100644
index 0000000000..b4a02272bb
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-ios/src/keymap.rs
@@ -0,0 +1,288 @@
+//! The app's keyboard shortcuts (`nsh/keymap`) on a hardware keyboard
+//! (an iPad's, a Mac's under Catalyst, the simulator's): the table the
+//! engine mounts ([`MountItem::Keymap`](nsh_core::MountItem::Keymap)),
+//! matched against every key press on the spot.
+//!
+//! UIKit hands a hardware key to the first responder as a press
+//! (`pressesBegan:withEvent:`), which passes what it does not handle up the
+//! responder chain. The text inputs (`input::Field`, `input::Area`) look at
+//! the table before UIKit's text system does, so a binding that fires in
+//! text (`when: "always"`, or a keystroke that edits no text) takes the key
+//! there and the input never sees it: no caret move, no line break, no
+//! typed character. Presses nothing handled reach the surface's root view
+//! (`surface::RootView`), which matches them as the desktops do when the
+//! focus is on none of the app's inputs: a press from a hosted view (a web
+//! view, a native component) counts as in text.
+//!
+//! A key that fires a binding is consumed until it is released (its
+//! release is consumed too) and reported to JS with the root's
+//! `KeyCommand` event; JS runs the handler afterwards. Keys are named by
+//! their USB HID usage (their place on the US layout) for the keys that
+//! type nothing, and by the character they type without modifiers
+//! otherwise, the US layout's of the same key when that is no ASCII, as on
+//! the desktops. UIKit sends a held key's press once: a binding that
+//! repeats (`repeat: true`) fires again on a timer of its own while the key
+//! is held, at the system's usual pace.
+
+use std::{
+ cell::{Cell, RefCell},
+ sync::Arc,
+ time::Duration,
+};
+
+use nsh_core::{
+ EventKind, EventPayload, PayloadValue, ROOT_ID,
+ keymap::{Key, KeyMatch, Keymap, Keystroke, Modifiers},
+};
+use objc2::{MainThreadMarker, msg_send, rc::Retained, runtime::AnyObject};
+use objc2_foundation::{NSArray, NSString};
+use objc2_ui_kit::UIView;
+
+use crate::target::EventTarget;
+
+/// How long a key is held before it repeats, and the time between repeats:
+/// iOS's defaults for a hardware keyboard.
+const REPEAT_DELAY: Duration = Duration::from_millis(400);
+const REPEAT_INTERVAL: Duration = Duration::from_millis(100);
+
+/// `UIKeyModifierFlags`.
+const SHIFT: isize = 1 << 17;
+const CONTROL: isize = 1 << 18;
+const ALTERNATE: isize = 1 << 19;
+const COMMAND: isize = 1 << 20;
+
+/// A surface's table, and the keys it consumed that are still down.
+#[derive(Default)]
+pub(crate) struct KeymapState {
+ table: RefCell<Option<Arc<Keymap>>>,
+ /// Keys whose press the table consumed, by HID usage, with the binding
+ /// that repeats while they are held, if any.
+ held: RefCell<Vec<(isize, Option<u32>)>>,
+ /// Bumped by every press and release: a repeat timer of an older
+ /// generation stops.
+ generation: Cell<u64>,
+}
+
+impl KeymapState {
+ /// `MountItem::Keymap`: replaces the table.
+ pub(crate) fn set(&self, keymap: Arc<Keymap>) {
+ *self.table.borrow_mut() = (!keymap.is_empty()).then_some(keymap);
+ }
+
+ /// Whether the table has bindings: presses need no look otherwise.
+ fn active(&self) -> bool {
+ self.table.borrow().is_some() || !self.held.borrow().is_empty()
+ }
+}
+
+/// The presses of a `UIPress` set, with their keys (`UIKey`); presses of
+/// no key (a remote's, a game controller's) are left out.
+fn keys(presses: &AnyObject) -> Vec<Retained<AnyObject>> {
+ // SAFETY: `presses` is the `NSSet<UIPress *>` UIKit passes; `key` is
+ // UIPress's (iOS 13.4, below the deployment target).
+ unsafe {
+ let all: Retained<NSArray<AnyObject>> = msg_send![presses, allObjects];
+ all.iter()
+ .filter_map(|press| msg_send![&*press, key])
+ .collect()
+ }
+}
+
+/// `UIKey.keyCode`: the key's USB HID usage.
+fn code(key: &AnyObject) -> isize {
+ // SAFETY: UIKey's property.
+ unsafe { msg_send![key, keyCode] }
+}
+
+/// The keystroke of `key` (a `UIKey`), by its key on the US layout.
+fn keystroke(key: &AnyObject) -> Option<Keystroke> {
+ // SAFETY: UIKey's properties.
+ let (flags, typed): (isize, Option<Retained<NSString>>) = unsafe {
+ (
+ msg_send![key, modifierFlags],
+ msg_send![key, charactersIgnoringModifiers],
+ )
+ };
+ let modifiers = Modifiers {
+ ctrl: flags & CONTROL != 0,
+ alt: flags & ALTERNATE != 0,
+ shift: flags & SHIFT != 0,
+ cmd: flags & COMMAND != 0,
+ };
+ let usage = u16::try_from(code(key)).ok()?;
+ let key = match Key::from_hid_usage(usage)? {
+ // A key that types a character: the one it types without
+ // modifiers. UIKit may leave Shift applied (`?` for Shift+/): then
+ // the key's own, which the US layout names.
+ us @ Key::Char(c) if c != ' ' => match typed.and_then(|s| s.to_string().chars().next()) {
+ Some(c) if c.is_ascii_alphabetic() => Key::Char(c.to_ascii_lowercase()),
+ Some(c) if c.is_ascii_graphic() && !modifiers.shift => Key::Char(c),
+ _ => us,
+ },
+ named => named,
+ };
+ Some(Keystroke { modifiers, key })
+}
+
+/// `pressesBegan:withEvent:` of a responder of the surface: whether the
+/// table consumed `presses` (then the responder neither handles them nor
+/// passes them on). `editing`: a text input or hosted view has the focus.
+pub(crate) fn began(target: &EventTarget, presses: &AnyObject, editing: bool) -> bool {
+ let state = target.keymap();
+ if !state.active() {
+ return false;
+ }
+ let mut consumed = false;
+ for key in keys(presses) {
+ let code = code(&key);
+ // UIKit sends a key's press once: one already held lost its
+ // release to a responder elsewhere (the focus moved), so it is
+ // pressed anew.
+ state.held.borrow_mut().retain(|(held, _)| *held != code);
+ let Some(stroke) = keystroke(&key) else {
+ continue;
+ };
+ let Some(table) = state.table.borrow().clone() else {
+ continue;
+ };
+ // The dev host's Cmd+R reloads, whatever the app binds.
+ if cfg!(feature = "dev") && stroke.reloads(nsh_core::platform::Os::Ios) {
+ continue;
+ }
+ // Presses come once: no auto-repeat to match (`KeyMatch::Held`).
+ let KeyMatch::Fire(id) = table.find(stroke, editing, false) else {
+ continue;
+ };
+ fire(target, id);
+ let repeats = table.bindings().iter().any(|b| b.id == id && b.repeat);
+ state.held.borrow_mut().push((code, repeats.then_some(id)));
+ if repeats {
+ let generation = state.generation.get() + 1;
+ state.generation.set(generation);
+ schedule(target, code, generation, REPEAT_DELAY);
+ }
+ consumed = true;
+ }
+ consumed
+}
+
+/// `pressesEnded:` / `pressesCancelled:`: whether `presses` are releases
+/// of keys the table consumed (then they are consumed too).
+pub(crate) fn ended(target: &EventTarget, presses: &AnyObject) -> bool {
+ let state = target.keymap();
+ if state.held.borrow().is_empty() {
+ return false;
+ }
+ let mut consumed = false;
+ for key in keys(presses) {
+ let code = code(&key);
+ let mut held = state.held.borrow_mut();
+ if let Some(i) = held.iter().position(|(h, _)| *h == code) {
+ held.remove(i);
+ state.generation.set(state.generation.get() + 1);
+ consumed = true;
+ }
+ }
+ consumed
+}
+
+/// Reports binding `id` to JS.
+fn fire(target: &EventTarget, id: u32) {
+ target.dispatch(
+ ROOT_ID,
+ EventKind::KeyCommand,
+ EventPayload::Value(PayloadValue::Number(f64::from(id))),
+ );
+}
+
+/// Fires the repeating binding of held key `code` again after `delay`,
+/// and on, while it is held and nothing else was pressed.
+fn schedule(target: &EventTarget, code: isize, generation: u64, delay: Duration) {
+ let mtm = MainThreadMarker::from(target);
+ let weak = dispatch2::MainThreadBound::new(objc2::rc::Weak::from(target), mtm);
+ let when = dispatch2::DispatchTime::NOW.time(delay.as_nanos() as i64);
+ dispatch2::DispatchQueue::main()
+ .after(when, move || {
+ let mtm = MainThreadMarker::new().expect("main queue");
+ let Some(target) = weak.into_inner(mtm).load() else {
+ return;
+ };
+ let state = target.keymap();
+ if state.generation.get() != generation {
+ return;
+ }
+ let binding = state
+ .held
+ .borrow()
+ .iter()
+ .find(|(held, _)| *held == code)
+ .and_then(|(_, binding)| *binding);
+ // The binding may have gone with its component.
+ let bound = state
+ .table
+ .borrow()
+ .as_ref()
+ .is_some_and(|t| t.bindings().iter().any(|b| Some(b.id) == binding));
+ if let (Some(id), true) = (binding, bound) {
+ fire(&target, id);
+ schedule(&target, code, generation, REPEAT_INTERVAL);
+ }
+ })
+ .ok();
+}
+
+/// The event target of text input `view` (its delegate).
+fn input_target(view: &UIView) -> Option<Retained<EventTarget>> {
+ // SAFETY: both input classes have a `delegate`, set to the target.
+ let delegate: Option<Retained<AnyObject>> = unsafe { msg_send![view, delegate] };
+ delegate?.downcast::<EventTarget>().ok()
+}
+
+/// `pressesBegan:withEvent:` of text input `view`: whether the table
+/// consumed `presses`. While an input method composes, its keys are its
+/// own (choosing a candidate, committing).
+pub(crate) fn input_began(view: &UIView, presses: &AnyObject) -> bool {
+ if crate::input::marked_text(view).is_some() {
+ return false;
+ }
+ input_target(view).is_some_and(|target| began(&target, presses, true))
+}
+
+/// `pressesEnded:` / `pressesCancelled:` of text input `view`.
+pub(crate) fn input_ended(view: &UIView, presses: &AnyObject) -> bool {
+ input_target(view).is_some_and(|target| ended(&target, presses))
+}
+
+/// `pressesBegan:withEvent:` of the surface's root view, which gets the
+/// presses no responder inside it handled: whether the table consumed
+/// them. The app's text inputs matched theirs already; a press from
+/// another view taking text (a web view's, a native component's) is in
+/// text.
+pub(crate) fn root_began(target: &EventTarget, presses: &AnyObject) -> bool {
+ use objc2::ProtocolType;
+ // SAFETY: as in `keys`; `responder` is UIPress's.
+ let responder: Option<Retained<AnyObject>> = unsafe {
+ let all: Retained<NSArray<AnyObject>> = msg_send![presses, allObjects];
+ all.iter()
+ .next()
+ .and_then(|press| msg_send![&*press, responder])
+ };
+ let editing = match responder.as_deref() {
+ Some(r)
+ if r.downcast_ref::<crate::input::Field>().is_some()
+ || r.downcast_ref::<crate::input::Area>().is_some() =>
+ {
+ return false;
+ }
+ Some(r) => {
+ let text = <dyn objc2_ui_kit::UIKeyInput>::protocol()
+ .is_some_and(|p| r.class().conforms_to(p));
+ // The app's own texts take keys only to select (`TextView`).
+ text && r.downcast_ref::<crate::native_view::NshView>().is_none()
+ && r.downcast_ref::<crate::selection_area::AreaResponder>()
+ .is_none()
+ }
+ None => false,
+ };
+ began(target, presses, editing)
+}
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/lib.rs b/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
index 89312300e4..704756079c 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
@@ -30,6 +30,7 @@ pub mod embed;
mod image;
mod input;
mod interaction;
+mod keymap;
mod mount;
mod native_view;
mod navigation;
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/mount.rs b/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
index b0668b251e..281ca3d727 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
@@ -475,6 +475,11 @@ impl Mounter {
}
}
+ /// The surface's event target.
+ pub(crate) fn target(&self) -> &Retained<EventTarget> {
+ &self.target
+ }
+
/// Node `id`'s view (`nsh test --target ios`).
#[cfg(feature = "dev")]
pub(crate) fn ui_view(&self, id: NodeId) -> Option<Retained<UIView>> {
@@ -1084,7 +1089,7 @@ impl Mounter {
}
MountItem::Command { id, command } => self.command(id, command),
// Hardware keyboards' shortcuts are not matched yet.
- MountItem::Keymap(_) => {}
+ MountItem::Keymap(keymap) => self.target.keymap().set(keymap),
}
false
}
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/surface.rs b/third-party/nsh/crates/nsh-platform-ios/src/surface.rs
index 8a44f0267a..9e083f1c51 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/surface.rs
@@ -14,7 +14,7 @@ use nsh_engine::{
use objc2::{
DefinedClass, MainThreadMarker, MainThreadOnly, Message, define_class, msg_send,
rc::{Retained, Weak},
- runtime::ProtocolObject,
+ runtime::{AnyObject, ProtocolObject},
};
use objc2_core_foundation::{CGRect, CGSize};
use objc2_foundation::{
@@ -125,6 +125,8 @@ pub(crate) struct RootViewIvars {
/// The embedding app's locale and text size over the system's
/// ([`Surface::set_host_environment`]).
host: Cell<nsh_engine::HostEnvironment>,
+ /// The embedding app's theme overrides ([`Surface::set_host_tokens`]).
+ host_tokens: RefCell<Option<nsh_engine::Tokens>>,
/// Keyboard notification registrations.
observers: RefCell<Vec<Retained<ProtocolObject<dyn NSObjectProtocol>>>>,
#[cfg(feature = "dev")]
@@ -213,6 +215,42 @@ define_class!(
}
});
}
+
+ /// Hardware keys no responder inside handled: the app's shortcuts
+ /// (`nsh/keymap`) take those they bind.
+ #[unsafe(method(pressesBegan:withEvent:))]
+ fn presses_began(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ let consumed = self
+ .target()
+ .is_some_and(|target| crate::keymap::root_began(&target, presses));
+ if !consumed {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] };
+ }
+ }
+
+ #[unsafe(method(pressesEnded:withEvent:))]
+ fn presses_ended(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ let consumed = self
+ .target()
+ .is_some_and(|target| crate::keymap::ended(&target, presses));
+ if !consumed {
+ // SAFETY: calling the superclass implementation.
+ let _: () = unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] };
+ }
+ }
+
+ #[unsafe(method(pressesCancelled:withEvent:))]
+ fn presses_cancelled(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+ let consumed = self
+ .target()
+ .is_some_and(|target| crate::keymap::ended(&target, presses));
+ if !consumed {
+ // SAFETY: calling the superclass implementation.
+ let _: () =
+ unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] };
+ }
+ }
}
);
@@ -233,6 +271,7 @@ impl RootView {
keyboard: Cell::new((0.0, 0.25)),
environment: Cell::new(None),
host: Cell::default(),
+ host_tokens: RefCell::new(None),
observers: RefCell::new(Vec::new()),
#[cfg(feature = "dev")]
test_model: TestModel::default(),
@@ -241,6 +280,18 @@ impl RootView {
unsafe { msg_send![super(this), initWithFrame: frame] }
}
+ /// The running app's event target.
+ fn target(&self) -> Option<Retained<crate::target::EventTarget>> {
+ let running = self.ivars().running.borrow();
+ let mounter = running
+ .as_ref()?
+ .mounter
+ .get(self.mtm())
+ .try_borrow()
+ .ok()?;
+ mounter.as_ref().map(|m| m.target().clone())
+ }
+
/// What JS sees through `windowSize()`, `safeArea()`, `keyboard()`...
fn environment(&self) -> Environment {
let size = self.bounds().size;
@@ -292,6 +343,9 @@ impl RootView {
// Only read for the web: iOS keys are always Apple's.
apple_keyboard: true,
terminal_keyboard: false,
+ // No host answers `nsh/host` here: an embedding app talks to its
+ // apps through its own UI sets (`nsh/native`).
+ host: false,
};
// The embedding app's settings over the system's.
self.ivars().host.get().apply(&mut env);
@@ -492,7 +546,13 @@ impl RootView {
#[cfg(feature = "dev")]
test_model: self.ivars().test_model.clone(),
};
- let engine = match Engine::start(config, CoreTextEngine::default, sink) {
+ // The embedding app's theme overrides are in the first frame.
+ let tokens = self.ivars().host_tokens.borrow().clone();
+ let engine = match Engine::start_with(config, CoreTextEngine::default, sink, |control| {
+ if tokens.is_some() {
+ control.set_tokens(tokens);
+ }
+ }) {
Ok(engine) => engine,
Err(err) => return log::error!("failed to start the engine: {err}"),
};
@@ -689,6 +749,17 @@ impl Surface {
self.view.publish_environment();
}
+ /// The embedding app's theme overrides (`nsh_base::theme_overrides`),
+ /// which win over the app's tokens of the same names (`None`: the
+ /// app's own again): sent to the running app at once, and to every app
+ /// started later before its first frame.
+ pub fn set_host_tokens(&self, tokens: Option<nsh_engine::Tokens>) {
+ *self.view.ivars().host_tokens.borrow_mut() = tokens.clone();
+ if let Some(running) = &*self.view.ivars().running.borrow() {
+ running.engine.control().set_tokens(tokens);
+ }
+ }
+
/// Replaces the running app with `app` (state is reset).
pub fn reload(&self, app: impl Into<App>) {
let app = app.into();
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/synth.rs b/third-party/nsh/crates/nsh-platform-ios/src/synth.rs
index 6e63dae3f0..4093d3d9a4 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/synth.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/synth.rs
@@ -68,6 +68,7 @@ struct Hid {
u8,
u32,
) -> Event,
+ keyboard: unsafe extern "C" fn(Allocator, u64, u32, u32, u8, u32) -> Event,
append: unsafe extern "C" fn(Event, Event, u32),
set_integer: unsafe extern "C" fn(Event, u32, i64),
set_float: unsafe extern "C" fn(Event, u32, f64),
@@ -113,6 +114,7 @@ fn load() -> Option<Hid> {
Some(Hid {
digitizer: symbol(iokit, "IOHIDEventCreateDigitizerEvent")?,
finger: symbol(iokit, "IOHIDEventCreateDigitizerFingerEvent")?,
+ keyboard: symbol(iokit, "IOHIDEventCreateKeyboardEvent")?,
append: symbol(iokit, "IOHIDEventAppendEvent")?,
set_integer: symbol(iokit, "IOHIDEventSetIntegerValue")?,
set_float: symbol(iokit, "IOHIDEventSetFloatValue")?,
@@ -236,6 +238,39 @@ pub(crate) fn touch(window: &UIWindow, x: f64, y: f64, phase: Phase) -> bool {
true
}
+/// `kHIDPage_KeyboardOrKeypad`.
+const KEYBOARD_PAGE: u32 = 7;
+
+/// The USB HID usages of the left Control, Shift, Option and Command keys.
+pub(crate) const CONTROL: u16 = 0xE0;
+pub(crate) const SHIFT: u16 = 0xE1;
+pub(crate) const OPTION: u16 = 0xE2;
+pub(crate) const COMMAND: u16 = 0xE3;
+
+/// A hardware keyboard's key `usage` (a USB HID keyboard usage) going down
+/// or up, to `window`, as UIKit takes a real keyboard's: it becomes the
+/// presses, key commands and typing of the key.
+pub(crate) fn key(window: &UIWindow, usage: u16, down: bool) -> bool {
+ let Some(hid) = hid() else { return false };
+ // SAFETY: IOKit's constructor with its documented arguments; the event
+ // is handed over (and released) by `enqueue`.
+ let event = unsafe {
+ (hid.keyboard)(
+ std::ptr::null(),
+ now(),
+ KEYBOARD_PAGE,
+ u32::from(usage),
+ u8::from(down),
+ 0,
+ )
+ };
+ if event.is_null() {
+ return false;
+ }
+ enqueue(hid, event, window, window.mtm());
+ true
+}
+
/// The window's first responder (the focused input).
fn first_responder(window: &UIWindow) -> Option<Retained<AnyObject>> {
// SAFETY: UIWindow's `firstResponder` (private: the responder chain's).
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/target.rs b/third-party/nsh/crates/nsh-platform-ios/src/target.rs
index aeeecc0f28..d4f0807e00 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/target.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/target.rs
@@ -31,6 +31,8 @@ pub(crate) struct TargetIvars {
/// Scrolls the core asked for that each scroll view applied, reported
/// with its offsets (`EngineControl::scroll`).
scrolls_applied: std::cell::RefCell<rustc_hash::FxHashMap<NodeId, u32>>,
+ /// The app's keyboard shortcuts, matched as keys are pressed.
+ keymap: crate::keymap::KeymapState,
}
define_class!(
@@ -455,6 +457,7 @@ impl EventTarget {
control,
mounter,
scrolls_applied: Default::default(),
+ keymap: Default::default(),
});
// SAFETY: NSObject's designated initializer.
unsafe { msg_send![super(this), init] }
@@ -476,7 +479,12 @@ impl EventTarget {
self.ivars().scrolls_applied.borrow_mut().remove(&id);
}
- fn dispatch(&self, id: NodeId, event: EventKind, payload: EventPayload) {
+ /// The app's keyboard shortcuts.
+ pub(crate) fn keymap(&self) -> &crate::keymap::KeymapState {
+ &self.ivars().keymap
+ }
+
+ pub(crate) fn dispatch(&self, id: NodeId, event: EventKind, payload: EventPayload) {
self.ivars().control.dispatch_event(id, event, payload);
}
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/view.rs b/third-party/nsh/crates/nsh-platform-ios/src/view.rs
index a2a2e9932f..3963d9a25d 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/view.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/view.rs
@@ -37,6 +37,8 @@ struct State {
options: SurfaceOptions,
/// The embedding app's locale and text size.
host: nsh_engine::HostEnvironment,
+ /// The embedding app's theme overrides.
+ host_tokens: Option<nsh_engine::Tokens>,
on_error: Option<ErrorHandler>,
#[cfg(feature = "dev")]
dev: Option<crate::dev::DevSession>,
@@ -59,6 +61,7 @@ impl AppView {
surface: None,
options,
host: nsh_engine::HostEnvironment::default(),
+ host_tokens: None,
on_error: None,
#[cfg(feature = "dev")]
dev: None,
@@ -156,6 +159,24 @@ impl AppView {
self.set_host(|host| host.font_scale = scale);
}
+ /// The embedding app's theme overrides: a theme file of shadcn/ui's
+ /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+ /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose
+ /// tokens win over the app's of the same names, light and dark, while
+ /// the app's others stay (a brand's `primary` and `radius`). Classes
+ /// naming them (`bg-primary`) are resolved again at once, and every
+ /// app loaded later starts with them. `None` gives the app its own
+ /// theme back. Fails, changing nothing, on a file that does not parse.
+ pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+ let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+ let mut state = self.inner.state.borrow_mut();
+ state.host_tokens = tokens;
+ if let Some(surface) = &state.surface {
+ surface.set_host_tokens(state.host_tokens.clone());
+ }
+ Ok(())
+ }
+
fn set_host(&self, change: impl FnOnce(&mut nsh_engine::HostEnvironment)) {
let mut state = self.inner.state.borrow_mut();
change(&mut state.host);
@@ -224,6 +245,7 @@ impl Inner {
UIViewAutoresizing::FlexibleWidth | UIViewAutoresizing::FlexibleHeight,
);
surface.set_host_environment(state.host);
+ surface.set_host_tokens(state.host_tokens.clone());
#[cfg(feature = "dev")]
surface.set_test_model(state.test_model.clone());
let weak: Weak<Inner> = Rc::downgrade(this);
diff --git a/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift b/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift
index ada2d584bc..3c09573d8b 100644
--- a/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift
+++ b/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift
@@ -172,6 +172,20 @@ public final class NshAppView: UIView {
}
}
+ /// Overrides the app's theme with the host's: a theme file of shadcn/ui's
+ /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+ /// values as CSS writes them, the format of `nsh.json`'s `"theme"`). Its
+ /// tokens win over the app's of the same names, in light and dark mode;
+ /// the app's others stay, so a branded host sets only what its brand
+ /// changes (`primary`, `radius`, ...). Call it before or after loading:
+ /// it applies at once (classes such as `bg-primary` are resolved again)
+ /// and to every app loaded later, from its first frame. `nil` gives the
+ /// app its own theme back. Throws why the file does not parse; the
+ /// overrides then stay as they were.
+ public func setThemeOverrides(_ json: String?) throws {
+ try check(nsh_app_set_theme_overrides(app, json))
+ }
+
/// Grants the apps loaded from now on at most these capabilities: JSON in
/// the form of `nsh.json`'s `capabilities` (`nil`: what each app
/// declares). A request must be allowed by both.
diff --git a/third-party/nsh/crates/nsh-platform-linux/src/app.rs b/third-party/nsh/crates/nsh-platform-linux/src/app.rs
index 642e96025c..b9692c2ec2 100644
--- a/third-party/nsh/crates/nsh-platform-linux/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-linux/src/app.rs
@@ -289,6 +289,21 @@ impl AppView {
crate::system::host_environment_changed(&self.surface);
}
+ /// The embedding app's theme overrides: a theme file of shadcn/ui's
+ /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+ /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose tokens
+ /// win over the app's of the same names, light and dark, while the
+ /// app's others stay (a brand's `primary` and `radius`). Classes naming
+ /// them (`bg-primary`) are resolved again at once, and every app loaded
+ /// later starts with them. `None` gives the app its own theme back.
+ /// Fails, changing nothing, on a file that does not parse.
+ pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+ let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+ *self.surface.host_tokens.borrow_mut() = tokens.clone();
+ self.surface.control(|c| c.set_tokens(tokens));
+ Ok(())
+ }
+
/// Stops the app.
pub fn stop(&self) {
self.surface.stop();
diff --git a/third-party/nsh/crates/nsh-platform-linux/src/surface.rs b/third-party/nsh/crates/nsh-platform-linux/src/surface.rs
index 1b2c5313bf..db25ba28b9 100644
--- a/third-party/nsh/crates/nsh-platform-linux/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-linux/src/surface.rs
@@ -110,6 +110,10 @@ pub(crate) struct Surface {
/// The embedding app's locale and text size, standing in for the
/// system's ([`crate::AppView::set_locale`], `set_font_scale`).
pub(crate) host_environment: Cell<nsh_core::platform::HostEnvironment>,
+ /// The embedding app's theme overrides
+ /// ([`crate::AppView::set_theme_overrides`]): sent to every engine the
+ /// surface starts, before its first frame.
+ pub(crate) host_tokens: RefCell<Option<nsh_engine::Tokens>>,
/// JS asked for a display frame.
js_frame: Cell<bool>,
/// The tick callback runs.
@@ -156,6 +160,7 @@ impl Surface {
environment: Cell::new(environment),
appearance: Cell::default(),
host_environment: Cell::default(),
+ host_tokens: RefCell::new(None),
js_frame: Cell::new(false),
ticking: Cell::new(false),
on_error: RefCell::new(None),
@@ -237,7 +242,14 @@ impl Surface {
*self.rx.borrow_mut() = Some(rx);
*self.pending.borrow_mut() = pending;
let family = crate::text::ui_family();
- match Engine::start(config, move || PangoTextEngine::new(family), sink) {
+ // The embedding app's theme overrides are in the first frame.
+ let tokens = self.host_tokens.borrow().clone();
+ let setup = |control: &EngineControl| {
+ if tokens.is_some() {
+ control.set_tokens(tokens);
+ }
+ };
+ match Engine::start_with(config, move || PangoTextEngine::new(family), sink, setup) {
Ok(engine) => {
// The test moves its clock through the engine.
#[cfg(feature = "dev")]
diff --git a/third-party/nsh/crates/nsh-platform-macos/src/app.rs b/third-party/nsh/crates/nsh-platform-macos/src/app.rs
index 6a6041505a..c049c0471f 100644
--- a/third-party/nsh/crates/nsh-platform-macos/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-macos/src/app.rs
@@ -337,6 +337,21 @@ impl AppView {
crate::system::host_environment_changed(&self.surface);
}
+ /// The embedding app's theme overrides: a theme file of shadcn/ui's
+ /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+ /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose tokens
+ /// win over the app's of the same names, light and dark, while the
+ /// app's others stay (a brand's `primary` and `radius`). Classes naming
+ /// them (`bg-primary`) are resolved again at once, and every app loaded
+ /// later starts with them. `None` gives the app its own theme back.
+ /// Fails, changing nothing, on a file that does not parse.
+ pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+ let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+ *self.surface.host_tokens.borrow_mut() = tokens.clone();
+ self.surface.control(|c| c.set_tokens(tokens));
+ Ok(())
+ }
+
/// Stops the app.
pub fn stop(&self) {
self.surface.stop();
diff --git a/third-party/nsh/crates/nsh-platform-macos/src/surface.rs b/third-party/nsh/crates/nsh-platform-macos/src/surface.rs
index 0b3428e078..8844ef5036 100644
--- a/third-party/nsh/crates/nsh-platform-macos/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-macos/src/surface.rs
@@ -5,7 +5,13 @@
//! wakes the main thread once (`DispatchQueue::main`); the main thread
//! drains the queue and applies every batch in order ([`Surface::drain`]).
//! Native animations and JS frame requests run on the root view's display
-//! link while there is something to do.
+//! link while there is something to do. While native animations or the
+//! platform's own (scroll glides) run, batches wait for the start of the
+//! next frame and each frame applies them for at most
+//! [`FRAME_BATCHES`] ([`Surface::apply_queued`]): a scroll glide's frames
+//! are not delayed by batches landing between them, and several batches
+//! (list binds while scrolling) spread over frames instead of one frame
+//! taking them all. An idle surface applies a batch as soon as it lands.
//!
//! The one exception to "the main thread never waits for JS": when the
//! window resizes the root ([`Surface::resized`]), the main thread waits
@@ -21,6 +27,7 @@
use std::{
cell::{Cell, RefCell},
+ collections::VecDeque,
path::PathBuf,
rc::{Rc, Weak},
sync::{
@@ -64,6 +71,11 @@ enum Message {
/// (a frame at 60 Hz): see the module doc.
const RESIZE_WAIT: std::time::Duration = std::time::Duration::from_millis(16);
+/// The main-thread time one frame spends applying queued batches
+/// ([`Surface::apply_queued`]): about half a refresh at 120 Hz, which
+/// leaves the rest to input, animations and Core Animation's commit.
+const FRAME_BATCHES: std::time::Duration = std::time::Duration::from_millis(4);
+
thread_local! {
/// The surfaces of this (main) thread, by key: the sink wakes one by
/// its key, as dispatched closures must be `Send`.
@@ -188,8 +200,16 @@ pub(crate) struct Surface {
/// The embedding app's locale and text size, standing in for the
/// system's ([`crate::AppView::set_locale`], `set_font_scale`).
pub(crate) host_environment: Cell<nsh_core::platform::HostEnvironment>,
+ /// The embedding app's theme overrides
+ /// ([`crate::AppView::set_theme_overrides`]): sent to every engine the
+ /// surface starts, before its first frame.
+ pub(crate) host_tokens: RefCell<Option<nsh_engine::Tokens>>,
/// JS asked for a display frame.
js_frame: Cell<bool>,
+ /// Batches that arrived while animations run, and what came after
+ /// them, applied at the start of the next frames
+ /// ([`Surface::apply_queued`]).
+ queued: RefCell<VecDeque<Message>>,
/// Frame callbacks of the platform's own animations (scroll glides,
/// transitions): called on every frame while they return true.
frame_callbacks: RefCell<Vec<FrameCallback>>,
@@ -250,7 +270,9 @@ impl Surface {
appearance: Cell::default(),
chunks: RefCell::new(None),
host_environment: Cell::default(),
+ host_tokens: RefCell::new(None),
js_frame: Cell::new(false),
+ queued: RefCell::default(),
frame_callbacks: RefCell::default(),
#[cfg(feature = "dev")]
test_model: RefCell::default(),
@@ -312,7 +334,14 @@ impl Surface {
};
*self.rx.borrow_mut() = Some(rx);
*self.pending.borrow_mut() = pending;
- match Engine::start(config, CoreTextEngine::default, sink) {
+ // The embedding app's theme overrides are in the first frame.
+ let tokens = self.host_tokens.borrow().clone();
+ let setup = |control: &EngineControl| {
+ if tokens.is_some() {
+ control.set_tokens(tokens);
+ }
+ };
+ match Engine::start_with(config, CoreTextEngine::default, sink, setup) {
Ok(engine) => {
// The test moves its clock through the engine.
#[cfg(feature = "dev")]
@@ -343,6 +372,7 @@ impl Surface {
crate::input::reset(self);
crate::keymap::clear(self);
self.js_frame.set(false);
+ self.queued.borrow_mut().clear();
self.resize_late.set(false);
self.frame_callbacks.borrow_mut().clear();
}
@@ -425,6 +455,11 @@ impl Surface {
// Measured as main-thread work: the wait is part of the frame.
self.measure(true, || {
quiet(|| {
+ // What waited for a frame comes before the new layout.
+ let queued = std::mem::take(&mut *self.queued.borrow_mut());
+ for message in queued {
+ applied |= self.handle(message, monitor.as_deref());
+ }
loop {
let Ok(message) = rx.recv_deadline(deadline) else {
self.resize_late.set(true);
@@ -470,7 +505,8 @@ impl Surface {
r
}
- /// Applies what the engine sent.
+ /// Handles what the engine sent. While animations run (see the module
+ /// doc), batches wait for the next frame ([`Surface::apply_queued`]).
fn drain(self: &Rc<Self>) {
let monitor = self.perf.borrow().clone();
let start = monitor.as_ref().map(|_| Instant::now());
@@ -482,6 +518,18 @@ impl Surface {
let mut applied = false;
quiet(|| {
for message in messages {
+ // A batch waits while the platform's own animations run;
+ // whatever follows a waiting batch waits behind it, which
+ // keeps the order (a frame request only sets a flag).
+ let wait = match message {
+ Message::Batch(..) => self.defers_batches(),
+ Message::Frame => false,
+ _ => !self.queued.borrow().is_empty(),
+ };
+ if wait {
+ self.queued.borrow_mut().push_back(message);
+ continue;
+ }
applied |= self.handle(message, monitor.as_deref());
}
if applied {
@@ -602,10 +650,12 @@ impl Surface {
}
/// Whether a native animation runs: the graph's, or the platform's own
- /// (scroll glides, zooms).
+ /// (scroll glides, zooms); or batches wait for the next frame.
#[cfg(feature = "dev")]
pub(crate) fn animating(&self) -> bool {
- self.anim.borrow().animating() || !self.frame_callbacks.borrow().is_empty()
+ self.anim.borrow().animating()
+ || !self.frame_callbacks.borrow().is_empty()
+ || !self.queued.borrow().is_empty()
}
/// Keeps the model `nsh test --target` steps see of the app
@@ -616,11 +666,23 @@ impl Surface {
*self.test_model.borrow_mut() = model;
}
+ /// Whether batches wait for the start of the next frame: while native
+ /// animations or the platform's own (scroll glides, zooms) run, so
+ /// their frames are not delayed by batches landing between them, and
+ /// behind batches already waiting. A frame JS asked for alone does not
+ /// make them wait: what it draws shows in the frame it was asked for.
+ fn defers_batches(&self) -> bool {
+ !self.queued.borrow().is_empty()
+ || self.anim.borrow().animating()
+ || !self.frame_callbacks.borrow().is_empty()
+ }
+
/// Whether the display link should run (animations, frame callbacks,
/// a JS frame request): while it does, a bounds commit mid-scroll
/// lands on a live frame.
pub(crate) fn wants_frames(&self) -> bool {
self.js_frame.get()
+ || !self.queued.borrow().is_empty()
|| self.anim.borrow().animating()
|| !self.frame_callbacks.borrow().is_empty()
}
@@ -738,6 +800,7 @@ impl Surface {
}
fn tick(self: &Rc<Self>, now: f64) {
+ self.apply_queued(FRAME_BATCHES);
if self.anim.borrow().animating() {
self.anim.borrow_mut().tick(now);
self.write_animations();
@@ -758,6 +821,28 @@ impl Surface {
self.revisit();
}
+ /// Applies the batches that waited for this frame, in order, until
+ /// `budget` is spent (at least one): the rest wait for the next frame,
+ /// so one frame's main-thread work stays bounded when the engine sent
+ /// several. A batch is the state of one commit and is applied whole.
+ fn apply_queued(self: &Rc<Self>, budget: std::time::Duration) {
+ if self.queued.borrow().is_empty() {
+ return;
+ }
+ let monitor = self.perf.borrow().clone();
+ let start = Instant::now();
+ let mut applied = false;
+ while !applied || start.elapsed() < budget {
+ let Some(message) = self.queued.borrow_mut().pop_front() else {
+ break;
+ };
+ applied |= self.handle(message, monitor.as_deref());
+ }
+ if applied {
+ self.write_animations();
+ }
+ }
+
/// Draws views whose drawing changed out of sight and that show now
/// ([`Mounter::draw_when_shown`]); after anything that moves views.
pub(crate) fn revisit(&self) {
diff --git a/third-party/nsh/crates/nsh-platform-tui/src/surface.rs b/third-party/nsh/crates/nsh-platform-tui/src/surface.rs
index 48672af468..75cecd8c89 100644
--- a/third-party/nsh/crates/nsh-platform-tui/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-tui/src/surface.rs
@@ -2117,6 +2117,7 @@ mod tests {
}
let mut binds = Vec::new();
core.take_list_binds(&mut binds);
+ core.take_paced_list_binds(usize::MAX, &mut binds);
if binds.is_empty() {
return;
}
diff --git a/third-party/nsh/crates/nsh-platform-web/src/encode.rs b/third-party/nsh/crates/nsh-platform-web/src/encode.rs
index 7880e95617..7c7ecf7af9 100644
--- a/third-party/nsh/crates/nsh-platform-web/src/encode.rs
+++ b/third-party/nsh/crates/nsh-platform-web/src/encode.rs
@@ -1407,6 +1407,21 @@ fn encode_command(out: &mut Out, id: u32, command: &Command) {
.num(*request)
.end();
}
+ // The id is written whole (`num` rounds); the page answers with
+ // `onHost` `{ type: "reply" }` (`nsh.js` `hostCall`).
+ Command::HostCall {
+ id: request,
+ name,
+ args,
+ } => {
+ out.item("cmd")
+ .num(id)
+ .str("hostCall")
+ .raw(&request.to_string())
+ .str(name)
+ .raw(&args.to_string())
+ .end();
+ }
Command::ImageInfo { id: request, uri } => {
out.item("cmd")
.num(id)
diff --git a/third-party/nsh/crates/nsh-platform-web/src/lib.rs b/third-party/nsh/crates/nsh-platform-web/src/lib.rs
index b1deae3044..114627dfc2 100644
--- a/third-party/nsh/crates/nsh-platform-web/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-web/src/lib.rs
@@ -335,9 +335,10 @@ fn page() -> Environment {
/// (`"phone"`, `"pad"`, `"desktop"`), `locale` (a BCP 47 tag), `rtl`,
/// `reduceMotion`, `fontScale`, `pixelRatio`, `safeArea` (`[top, right,
/// bottom, left]`), `keyboard` (the height the on-screen keyboard
-/// covers) and `appleKeyboard` (the browser runs on an Apple platform: its
-/// shortcuts are Apple's); missing keys keep their value. Called before [`nsh_start`]
-/// and on every change; the idiom is the one at start, as on every
+/// covers), `appleKeyboard` (the browser runs on an Apple platform: its
+/// shortcuts are Apple's) and `host` (the page answers `nsh/host` calls:
+/// `createPlayer`'s `host`); missing keys keep their value. Called before [`nsh_start`]
+/// and on every change; the idiom and `host` are the ones at start, as on every
/// platform. Returns the pump delay.
///
/// # Safety
@@ -391,6 +392,7 @@ pub unsafe extern "C" fn nsh_environment(ptr: *const u8, len: usize) -> f64 {
.map(|&v| nsh_core::Points(v));
}
env.keyboard_height = Points(num("keyboard").unwrap_or(env.keyboard_height.0));
+ env.host = flag("host").unwrap_or(env.host);
PAGE.with(|p| p.set(Some(env)));
send(|app| {
let e = &mut app.environment;
@@ -1159,6 +1161,7 @@ pub unsafe extern "C" fn nsh_event(
text: text("text"),
count: num("count") as u32,
},
+ EventKind::Host => EventPayload::Json(value),
_ => match &value {
serde_json::Value::Object(map) => EventPayload::Map(
map.iter()
diff --git a/third-party/nsh/crates/nsh-platform-web/web/nsh.js b/third-party/nsh/crates/nsh-platform-web/web/nsh.js
index c4741b6f7d..e216230f73 100644
--- a/third-party/nsh/crates/nsh-platform-web/web/nsh.js
+++ b/third-party/nsh/crates/nsh-platform-web/web/nsh.js
@@ -20,7 +20,13 @@
// runs, such as fonts for `readAsset`; awaited with the chunk's download),
// `dark`, `idiom` ("phone", "pad" or "desktop" instead of the one the
// pointer and the viewport suggest), `overlay` (false: no error box),
-// `onError`, `onLog`.
+// `onError`, `onLog`, `host` (the functions `nsh/host`'s
+// `host.call(name, args)` reaches: an object whose `call(name, args)`
+// returns the result or a promise of it; a throw or a rejection rejects
+// the app's call; without it `host.available()` is false). The page sends
+// `host.on` listeners events with `player.hostEvent(event, payload)`
+// (JSON; it throws when the payload is not); events sent before the app
+// runs are delivered, in order, once it starts.
//
// A split bundle (`nsh build --split`) loads its chunks on demand: from
// `chunks[id]` when given (`app.<digest>.js` on a CDN), else `id` relative
@@ -535,6 +541,8 @@ class Player {
// default only when the app is the page (in an iframe each push would
// be an entry of the embedding page's history too).
this.history = options.history ?? window.top === window;
+ // Host events sent before the app runs (`begin`).
+ this.hostEvents = [];
if (window.top === window) document.documentElement.classList.add("nsh-page");
}
@@ -1000,13 +1008,25 @@ class Player {
this.idiom = this.options.idiom ?? this.measureIdiom(width);
const environment = (this.reported = JSON.stringify(this.environment()));
this.updateRootClasses();
+ // Host events wait for this app's top-level `host.on` listeners, not
+ // the replaced app's; those queued after a failed start are dropped.
+ this.started = false;
+ this.hostEvents ??= [];
// Fonts registered while it starts hold its first frame back (`fontLoading`).
this.starting = true;
try {
this.call(() => start(environment));
+ } catch (err) {
+ // Only a trap (a panic) throws: no app runs to take events.
+ this.hostEvents = null;
+ throw err;
} finally {
this.starting = false;
}
+ this.started = true;
+ const events = this.hostEvents;
+ this.hostEvents = [];
+ for (const json of events) this.eventJson(ROOT_ID, "onHost", json);
}
/** Fetches chunk `id` of the running bundle and hands it to the engine. */
@@ -1068,6 +1088,9 @@ class Player {
}
stop() {
+ // Host events wait for the next app (`begin`).
+ this.started = false;
+ this.hostEvents ??= [];
this.fileOperations.clear();
for (const id of [...this.requests.keys()]) this.fetchCancel(id);
for (const id of [...this.websockets.keys()]) this.websocketCancel(id);
@@ -1219,6 +1242,7 @@ class Player {
safeArea: this.safeArea(),
keyboard: this.keyboardHeight(),
appleKeyboard: APPLE,
+ host: Boolean(this.options.host),
};
}
@@ -1239,7 +1263,12 @@ class Player {
}
event(id, name, payload = null) {
- this.call(() => this.withStrings([name, JSON.stringify(payload)], (np, nl, pp, pl) =>
+ this.eventJson(id, name, JSON.stringify(payload));
+ }
+
+ /** {@link event} with its payload already serialized. */
+ eventJson(id, name, json) {
+ this.call(() => this.withStrings([name, json], (np, nl, pp, pl) =>
this.wasm.nsh_event(id, np, nl, pp, pl)));
}
@@ -3479,6 +3508,7 @@ class Player {
case "readClipboardImage": this.readClipboardImage(args[0], args[1]); break;
// Pages see no local paths: never any files.
case "readClipboardFiles": this.event(ROOT_ID, "onClipboard", { id: args[0], has: false, files: "" }); break;
+ case "hostCall": this.hostCall(args[0], args[1], args[2]); break;
case "imageInfo": this.imageInfo(args[0], args[1]); break;
case "back": this.defaultBack(); break;
case "exitApp": {
@@ -3586,6 +3616,39 @@ class Player {
img.src = uri;
}
+ /**
+ * `nsh/host`'s `host.call`: `options.host.call(name, args)`, answered
+ * with `onHost` `{ type: "reply" }` whatever happens (the app's call waits for the
+ * reply): a missing host, a throw, a rejection or a result that is not
+ * JSON answer `ok: false` with the error's message.
+ */
+ async hostCall(request, name, args) {
+ // Answers for an app that was replaced meanwhile are dropped.
+ const app = this.app;
+ let reply;
+ try {
+ const host = this.options.host;
+ if (typeof host?.call !== "function") throw new Error("this page has no host (createPlayer's `host`)");
+ const value = (await host.call(name, args)) ?? null;
+ reply = JSON.stringify({ type: "reply", id: request, ok: true, value });
+ } catch (err) {
+ reply = JSON.stringify({ type: "reply", id: request, ok: false, error: String(err?.message ?? err) });
+ }
+ if (app === this.app) this.eventJson(ROOT_ID, "onHost", reply);
+ }
+
+ /**
+ * Sends `event` with `payload` to the app's `host.on(event)` listeners.
+ * The payload is serialized here, so one that is not JSON throws to the
+ * caller rather than inside a deferred engine call. Events sent before
+ * the app runs wait for it (`begin`).
+ */
+ hostEvent(event, payload = null) {
+ const json = JSON.stringify({ type: "event", event: String(event), payload: payload ?? null });
+ if (this.started) this.eventJson(ROOT_ID, "onHost", json);
+ else this.hostEvents?.push(json);
+ }
+
/** `Image.getSize` / `prefetch`: loads the image (the browser keeps it cached for the views that show it). */
imageInfo(request, uri) {
const img = new Image();
diff --git a/third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs b/third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs
new file mode 100644
index 0000000000..62270330d0
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs
@@ -0,0 +1,167 @@
+// nsh.js's host channel without a browser or nsh.wasm:
+// `node --test crates/nsh-platform-web/web/nsh.test.mjs`. A fake engine
+// records what reaches it through the real `call` / `withStrings` path.
+
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+const source = await readFile(new URL("nsh.js", import.meta.url), "utf8");
+// `Player` is not exported; the page only gets it from `createPlayer`.
+const { Player } = await import(
+ `data:text/javascript,${encodeURIComponent(`${source}\nexport { Player };`)}`
+);
+
+/** A player on a fake engine; `sent` holds `[name, payload]` of the events it got. */
+function fakePlayer(options = {}) {
+ const sent = [];
+ const memory = { buffer: new ArrayBuffer(1 << 16) };
+ let next = 8;
+ const text = (ptr, len) => new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len));
+ const player = Object.create(Player.prototype);
+ Object.assign(player, {
+ // What the constructor sets that these paths read.
+ options: { idiom: "phone", ...options },
+ hostEvents: [],
+ fileOperations: new Map(),
+ requests: new Map(),
+ websockets: new Map(),
+ environment: () => ({}),
+ updateRootClasses() {},
+ wasm: {
+ memory,
+ nsh_alloc(len) {
+ const ptr = next;
+ next += len;
+ return ptr;
+ },
+ nsh_dealloc() {},
+ nsh_stop() {},
+ nsh_event(_id, np, nl, pp, pl) {
+ sent.push([text(np, nl), JSON.parse(text(pp, pl))]);
+ return -1;
+ },
+ },
+ });
+ return { player, sent };
+}
+
+const started = () => -1;
+const failing = () => {
+ throw new Error("unreachable");
+};
+const events = (sent) =>
+ sent.filter(([name, p]) => name === "onHost" && p.type === "event").map(([, p]) => p.event);
+
+test("events sent before the app runs reach it in order once it starts, later ones at once", () => {
+ const { player, sent } = fakePlayer();
+ player.hostEvent("a", 1);
+ player.hostEvent("b", 2);
+ assert.deepEqual(sent, []);
+ player.begin(390, started);
+ assert.deepEqual(sent, [
+ ["onHost", { type: "event", event: "a", payload: 1 }],
+ ["onHost", { type: "event", event: "b", payload: 2 }],
+ ]);
+ player.hostEvent("c");
+ assert.deepEqual(events(sent), ["a", "b", "c"]);
+});
+
+test("events for an app that failed to start reach neither it nor the next app", () => {
+ const { player, sent } = fakePlayer();
+ player.hostEvent("before");
+ assert.throws(() => player.begin(390, failing));
+ player.hostEvent("after");
+ player.begin(390, started);
+ player.hostEvent("next");
+ assert.deepEqual(events(sent), ["next"]);
+});
+
+test("a replacing app that fails to start gets no events", () => {
+ const { player, sent } = fakePlayer();
+ player.begin(390, started);
+ assert.throws(() => player.begin(390, failing));
+ player.hostEvent("lost");
+ assert.deepEqual(events(sent), []);
+});
+
+test("events sent while a replacing app starts reach it once it has started", () => {
+ const { player, sent } = fakePlayer();
+ player.begin(390, started);
+ let during;
+ player.begin(390, () => {
+ player.hostEvent("early");
+ during = events(sent).length;
+ return -1;
+ });
+ assert.equal(during, 0);
+ assert.deepEqual(events(sent), ["early"]);
+});
+
+test("events sent after the app stopped wait for the next app", () => {
+ // `stop` cancels the frame it asked for; node has no frames.
+ globalThis.cancelAnimationFrame ??= () => {};
+ const { player, sent } = fakePlayer();
+ player.begin(390, started);
+ player.hostEvent("running");
+ player.stop();
+ player.hostEvent("stopped");
+ assert.deepEqual(events(sent), ["running"]);
+ player.begin(390, started);
+ assert.deepEqual(events(sent), ["running", "stopped"]);
+});
+
+async function reply(host) {
+ const { player, sent } = fakePlayer(host === undefined ? {} : { host });
+ player.app = 1;
+ await player.hostCall(7, "echo", ["x"]);
+ return sent;
+}
+
+test("a host call that returns answers ok with its value", async () => {
+ assert.deepEqual(await reply({ call: async (name, args) => ({ name, args }) }), [
+ ["onHost", { type: "reply", id: 7, ok: true, value: { name: "echo", args: ["x"] } }],
+ ]);
+});
+
+test("a host call that throws answers not ok with its message", async () => {
+ const host = {
+ call() {
+ throw new Error("boom");
+ },
+ };
+ assert.deepEqual(await reply(host), [["onHost", { type: "reply", id: 7, ok: false, error: "boom" }]]);
+});
+
+test("a host call that rejects answers not ok with its message", async () => {
+ const host = { call: () => Promise.reject(new Error("denied")) };
+ assert.deepEqual(await reply(host), [["onHost", { type: "reply", id: 7, ok: false, error: "denied" }]]);
+});
+
+test("a host call that rejects with a non-Error answers its string", async () => {
+ const host = { call: () => Promise.reject("nope") };
+ assert.deepEqual(await reply(host), [["onHost", { type: "reply", id: 7, ok: false, error: "nope" }]]);
+});
+
+test("a host call whose result is not JSON answers not ok", async () => {
+ const [[name, payload]] = await reply({ call: () => 1n });
+ assert.equal(name, "onHost");
+ assert.equal(payload.type, "reply");
+ assert.equal(payload.ok, false);
+ assert.match(payload.error, /BigInt/);
+});
+
+test("a page without a host answers not ok", async () => {
+ assert.deepEqual(await reply(undefined), [
+ ["onHost", { type: "reply", id: 7, ok: false, error: "this page has no host (createPlayer's `host`)" }],
+ ]);
+});
+
+test("a host reply for an app that was replaced is dropped", async () => {
+ const { player, sent } = fakePlayer({ host: { call: () => Promise.reject(new Error("late")) } });
+ player.app = 1;
+ const pending = player.hostCall(7, "echo", []);
+ player.app = 2;
+ await pending;
+ assert.deepEqual(sent, []);
+});
diff --git a/third-party/nsh/crates/nsh-platform-windows/src/app.rs b/third-party/nsh/crates/nsh-platform-windows/src/app.rs
index be22d98750..19abade7ec 100644
--- a/third-party/nsh/crates/nsh-platform-windows/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-windows/src/app.rs
@@ -287,6 +287,21 @@ impl AppView {
crate::system::host_environment_changed(&self.surface);
}
+ /// The embedding app's theme overrides: a theme file of shadcn/ui's
+ /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+ /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose tokens
+ /// win over the app's of the same names, light and dark, while the
+ /// app's others stay (a brand's `primary` and `radius`). Classes naming
+ /// them (`bg-primary`) are resolved again at once, and every app loaded
+ /// later starts with them. `None` gives the app its own theme back.
+ /// Fails, changing nothing, on a file that does not parse.
+ pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+ let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+ *self.surface.host_tokens.borrow_mut() = tokens.clone();
+ self.surface.control(|c| c.set_tokens(tokens));
+ Ok(())
+ }
+
/// Stops the app.
pub fn stop(&self) {
self.surface.stop();
diff --git a/third-party/nsh/crates/nsh-platform-windows/src/surface.rs b/third-party/nsh/crates/nsh-platform-windows/src/surface.rs
index eba953bbe1..d2e20d7a24 100644
--- a/third-party/nsh/crates/nsh-platform-windows/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-windows/src/surface.rs
@@ -139,6 +139,10 @@ pub(crate) struct Surface {
/// The embedding app's locale and text size, standing in for the
/// system's ([`crate::AppView::set_locale`], `set_font_scale`).
pub(crate) host_environment: Cell<nsh_core::platform::HostEnvironment>,
+ /// The embedding app's theme overrides
+ /// ([`crate::AppView::set_theme_overrides`]): sent to every engine the
+ /// surface starts, before its first frame.
+ pub(crate) host_tokens: RefCell<Option<nsh_engine::Tokens>>,
/// JS asked for a display frame.
js_frame: Cell<bool>,
/// Paces animation frames while something animates.
@@ -203,6 +207,7 @@ impl Surface {
environment: Cell::new(environment),
appearance: Cell::default(),
host_environment: Cell::default(),
+ host_tokens: RefCell::new(None),
js_frame: Cell::new(false),
ticker: RefCell::new(None),
frame_clients: RefCell::default(),
@@ -300,7 +305,14 @@ impl Surface {
*self.rx.borrow_mut() = Some(rx);
*self.pending.borrow_mut() = pending;
let family = crate::text::ui_family();
- match Engine::start(config, move || DWriteTextEngine::new(family), sink) {
+ // The embedding app's theme overrides are in the first frame.
+ let tokens = self.host_tokens.borrow().clone();
+ let setup = |control: &EngineControl| {
+ if tokens.is_some() {
+ control.set_tokens(tokens);
+ }
+ };
+ match Engine::start_with(config, move || DWriteTextEngine::new(family), sink, setup) {
Ok(engine) => {
// The test moves its clock through the engine.
#[cfg(feature = "dev")]
diff --git a/third-party/nsh/crates/nsh-testing/Cargo.toml b/third-party/nsh/crates/nsh-testing/Cargo.toml
index a833c5bb39..4cdd7ee9cd 100644
--- a/third-party/nsh/crates/nsh-testing/Cargo.toml
+++ b/third-party/nsh/crates/nsh-testing/Cargo.toml
@@ -22,8 +22,9 @@ live = ["dep:ureq"]
# hosts link it.
agent = ["dep:nsh-dev", "dep:serde", "dep:serde_json", "dep:base64"]
# `nsh test`'s side: the `nsh/test` module, test files on an engine of their
-# own (`run_file`) and apps' headless runners (`runner`): QuickJS.
-js = ["live", "nsh-engine/js", "dep:nsh-dev", "dep:base64", "dep:dunce", "dep:serde", "dep:serde_json", "dep:tokio"]
+# own (`run_file`) and apps' headless runners (`runner`): QuickJS, which also
+# runs the web page's host module (`page_host`).
+js = ["live", "nsh-engine/js", "dep:nsh-dev", "dep:base64", "dep:dunce", "dep:rquickjs", "dep:serde", "dep:serde_json", "dep:tokio"]
# Building entries in this process (`compile_tsx`, `tsx`, `compiler`): nsh's
# bundler. Off in apps' headless runners, which `nsh` builds the entries for.
bundle = ["js", "dep:nsh-bundler", "dep:nsh-js"]
@@ -39,6 +40,8 @@ crossbeam-channel.workspace = true
dunce = { workspace = true, optional = true }
log.workspace = true
regex.workspace = true
+# The web page's host module of headless runs (`page_host`).
+rquickjs = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
# The requests routes' JS functions answer, waiting for the test file's VM.
diff --git a/third-party/nsh/crates/nsh-testing/src/agent.rs b/third-party/nsh/crates/nsh-testing/src/agent.rs
index 5cf050d728..576ee8cb7a 100644
--- a/third-party/nsh/crates/nsh-testing/src/agent.rs
+++ b/third-party/nsh/crates/nsh-testing/src/agent.rs
@@ -851,15 +851,11 @@ pub trait Native: Send + Sync {
}
/// Presses `keys`, a keystroke as `nsh/keymap` names it (`"mod+k"`;
/// `mod` the platform's command key), on a hardware keyboard: the app's
- /// shortcuts hear it, else what has the keyboard focus. Platforms
- /// without app shortcuts (the phones) refuse it.
+ /// shortcuts hear it, else what has the keyboard focus. Every
+ /// platform's agent makes them; one that cannot refuses it.
fn press_keys(&self, keys: &str) -> Result<(), String> {
let _ = keys;
- Err(
- "this platform has no keyboard shortcuts (`nsh/keymap`) to press: \
- run the test headless, or on a desktop or the web"
- .into(),
- )
+ Err("this platform's test agent presses no hardware keys: run the test headless".into())
}
/// Types `text` into the focused input, key by key.
fn type_text(&self, text: &str);
diff --git a/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs b/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs
index 611d89735f..65d46072a2 100644
--- a/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs
+++ b/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs
@@ -1,9 +1,10 @@
//! The step `keyboard.press(<keys>)`: a key with its modifiers, as a
-//! hardware keyboard gives it. On a desktop platform (`open`'s
-//! `platform`) the app's shortcuts (`nsh/keymap`) take it first, as the
-//! platforms' keymaps do: the newest binding of the
-//! keystroke that fires where the focus is reports `onKeyCommand`. Else
-//! the focused input takes the keys that edit it, and Escape goes back.
+//! hardware keyboard gives it, on every platform (`open`'s `platform`: the
+//! phones and tablets have one too, a tablet's keyboard, a Chromebook's).
+//! The app's shortcuts (`nsh/keymap`) take it first, as the platforms'
+//! keymaps do: the newest binding of the keystroke that fires where the
+//! focus is reports `onKeyCommand`. Else the focused input takes the keys
+//! that edit it, and Escape goes back.
use nsh_core::{
EventKind, EventPayload, PayloadValue, ROOT_ID,
@@ -24,10 +25,11 @@ impl Headless {
self.step(format!("keyboard.press({keys})"));
let stroke = Keystroke::parse(keys, self.environment.os).map_err(|e| e.to_string())?;
let editing = self.screen.focused().is_some();
- // Desktop platforms fire the app's shortcuts (`on_key`, `keymap.bind`).
+ // A hardware keyboard fires the app's shortcuts (`on_key`,
+ // `keymap.bind`), on every platform.
let found = match self.screen.keymap() {
- Some(keymap) if self.environment.desktop() => keymap.find(stroke, editing, false),
- _ => KeyMatch::None,
+ Some(keymap) => keymap.find(stroke, editing, false),
+ None => KeyMatch::None,
};
if let KeyMatch::Fire(id) = found {
self.engine.control().dispatch_event(
diff --git a/third-party/nsh/crates/nsh-testing/src/headless/mod.rs b/third-party/nsh/crates/nsh-testing/src/headless/mod.rs
index 2b1c8cc23e..d597999896 100644
--- a/third-party/nsh/crates/nsh-testing/src/headless/mod.rs
+++ b/third-party/nsh/crates/nsh-testing/src/headless/mod.rs
@@ -158,6 +158,11 @@ type Observer = Box<dyn FnMut(&str, &MountBatch) + Send>;
/// Answers a dialog ([`Headless::on_dialog`]).
type DialogHandler = Box<dyn FnMut(&Dialog) -> DialogAnswer + Send>;
+/// Takes the app's `nsh/host` calls ([`Headless::on_host_call`]): their
+/// id, the host's function and its argument.
+#[cfg(feature = "js")]
+type HostCalls = Box<dyn FnMut(u64, &str, &nsh_core::JsonValue) + Send>;
+
/// The answer to a dialog (`AlertDialog.show`, an action sheet).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialogAnswer {
@@ -213,7 +218,8 @@ enum Pumped {
/// It plays the platform's part the NSH protocol defines and nothing
/// else: the native animation graph runs frame by frame (nsh-core's `animation`),
/// a back pops screens and closes modals, dialogs are answered (by the
-/// test, else [`default_answer`]), the clipboard holds text. Images are
+/// test, else [`default_answer`]), the clipboard holds text, and `nsh
+/// test`'s web page host answers `nsh/host` calls. Images are
/// not loaded: no `onLoad` comes, and `Image.getSize` / `Image.prefetch`
/// fail.
pub struct Headless {
@@ -238,6 +244,9 @@ pub struct Headless {
clock: Clock,
/// On a manual clock: the engine asked for a frame.
wanted: Option<Arc<AtomicBool>>,
+ /// Where the app's `nsh/host` calls go (the web page's host).
+ #[cfg(feature = "js")]
+ host_calls: Option<HostCalls>,
}
impl Headless {
@@ -270,6 +279,8 @@ impl Headless {
observer: None,
clock,
wanted,
+ #[cfg(feature = "js")]
+ host_calls: None,
})
}
@@ -448,6 +459,18 @@ impl Headless {
self.done()
}
+ /// Sends the app's `nsh/host` calls (the root's `Command::HostCall`) to
+ /// `calls` as their batches are applied; the environment must say the
+ /// platform has a host (`Environment::host`), else the core rejects
+ /// them before they reach a batch.
+ #[cfg(feature = "js")]
+ pub(crate) fn on_host_call(
+ &mut self,
+ calls: impl FnMut(u64, &str, &nsh_core::JsonValue) + Send + 'static,
+ ) {
+ self.host_calls = Some(Box::new(calls));
+ }
+
/// Puts `text` on the clipboard (`None`: nothing), as the user copying.
pub fn set_clipboard(&mut self, text: Option<String>) {
self.screen.set_clipboard(text);
@@ -479,6 +502,18 @@ impl Headless {
for item in &batch.items {
self.animator.apply(item);
}
+ #[cfg(feature = "js")]
+ if let Some(calls) = &mut self.host_calls {
+ for item in &batch.items {
+ if let MountItem::Command {
+ id: ROOT_ID,
+ command: Command::HostCall { id, name, args },
+ } = item
+ {
+ calls(*id, name, args);
+ }
+ }
+ }
self.applied += 1;
if let Some(observer) = &mut self.observer {
observer(&self.step, batch);
@@ -1738,10 +1773,14 @@ mod tests {
.unwrap_err()
.contains("nothing")
);
- // Phones fire no app shortcuts.
+ // Phones and tablets fire them from a hardware keyboard too; on
+ // Android `mod` is Control.
let mut phone = open_shortcuts(nsh_core::platform::Os::Ios);
- phone.press_key("mod+k").unwrap();
- assert!(phone.shows("Palette 0"));
+ phone.press_key("cmd+k").unwrap();
+ phone.wait_for("Palette 1").unwrap();
+ let mut android = open_shortcuts(nsh_core::platform::Os::Android);
+ android.press_key("ctrl+k").unwrap();
+ android.wait_for("Palette 1").unwrap();
}
#[cfg(feature = "js")]
diff --git a/third-party/nsh/crates/nsh-testing/src/lib.rs b/third-party/nsh/crates/nsh-testing/src/lib.rs
index d2da6a7750..33baf2fd5f 100644
--- a/third-party/nsh/crates/nsh-testing/src/lib.rs
+++ b/third-party/nsh/crates/nsh-testing/src/lib.rs
@@ -39,6 +39,8 @@ pub mod menu;
#[cfg(feature = "js")]
mod module;
#[cfg(feature = "js")]
+mod page_host;
+#[cfg(feature = "js")]
mod run;
#[cfg(feature = "js")]
mod runner;
diff --git a/third-party/nsh/crates/nsh-testing/src/page_host.rs b/third-party/nsh/crates/nsh-testing/src/page_host.rs
new file mode 100644
index 0000000000..ddd7861c98
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/src/page_host.rs
@@ -0,0 +1,332 @@
+//! The web page's host in headless runs ([`TestRun::page_host`]): the
+//! module `nsh.json` `web.host` names, answering the `nsh/host` calls of
+//! the apps tests open.
+//!
+//! The module is the page's as it is: its `createHost(player)` runs in a
+//! QuickJS context of its own, on a thread of its own, once per app opened
+//! (a page loads it once per app), before the app starts. What it returns
+//! answers every `Command::HostCall` with a `Host` `{ type: "reply" }`, as
+//! `nsh.js`'s `hostCall` does on the page: always, `ok: false` with the
+//! message when `call` throws, rejects or returns what is not JSON.
+//! `player.hostEvent(event, payload)` sends `Host` `{ type: "event" }`;
+//! what is sent before the app's engine runs waits and reaches it in order
+//! once it does. The context has `console` (the log's target `js`, as the
+//! app's), `setTimeout` / `setInterval` and their `clear*` on the app's
+//! clock (a manual one moves them as it moves the app's timers), and
+//! nothing of the browser's;
+//! imports do not resolve (the page loads the module as it is, so its
+//! imports are absolute URLs a headless run cannot load either).
+//!
+//! [`TestRun::page_host`]: crate::TestRun::page_host
+
+use std::{
+ path::Path,
+ sync::{Arc, Mutex},
+ time::{Duration, Instant},
+};
+
+use crossbeam_channel::{Receiver, Sender};
+use nsh_core::{EventKind, EventPayload, JsonValue, ROOT_ID};
+use nsh_engine::{Clock, EngineControl};
+use rquickjs::{CatchResultExt as _, Ctx, Function, Module, Object, Value};
+
+/// The page's host of one app: where its calls go, and where the host's
+/// replies and events go.
+pub(crate) struct PageHost {
+ calls: Sender<Call>,
+ target: Arc<Target>,
+}
+
+impl PageHost {
+ /// Loads the module at `path` and runs its `createHost(player)`. Fails
+ /// when the module does not load, exports no `createHost`, or what
+ /// `createHost` returns (or resolves with, as far as its promise
+ /// settles at once) has no `call`.
+ pub(crate) fn start(path: &Path, clock: Clock) -> Result<PageHost, String> {
+ let source = std::fs::read_to_string(path)
+ .map_err(|e| format!("nsh.json web.host {}: {e}", path.display()))?;
+ let name = path.display().to_string();
+ let target = Arc::new(Target::default());
+ let (calls, rx) = crossbeam_channel::unbounded();
+ let (loaded, load) = crossbeam_channel::bounded(1);
+ let thread_target = target.clone();
+ std::thread::Builder::new()
+ .name("nsh-test-page-host".into())
+ .spawn(move || run(&name, source, clock, thread_target, rx, loaded))
+ .map_err(|e| e.to_string())?;
+ load.recv()
+ .unwrap_or_else(|_| Err("the host module stopped".into()))
+ .map_err(|e| format!("nsh.json web.host {}: {e}", path.display()))?;
+ Ok(PageHost { calls, target })
+ }
+
+ /// The app runs on `control`: delivers what waited, then everything as
+ /// it comes.
+ pub(crate) fn attach(&self, control: EngineControl) {
+ self.target.attach(control);
+ }
+
+ /// The app's call `id` of the host's function `name` with `args`.
+ pub(crate) fn call(&self, id: u64, name: &str, args: &JsonValue) {
+ let _ = self.calls.send(Call {
+ id,
+ name: name.to_owned(),
+ args: args.clone(),
+ });
+ }
+}
+
+/// Where the module's replies and events go: the engine once it runs; what
+/// is sent before waits, in order, as the page holds events sent before
+/// the app runs.
+#[derive(Default)]
+struct Target(Mutex<TargetState>);
+
+#[derive(Default)]
+struct TargetState {
+ control: Option<EngineControl>,
+ pending: Vec<JsonValue>,
+}
+
+impl Target {
+ fn attach(&self, control: EngineControl) {
+ let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
+ for value in state.pending.drain(..) {
+ control.dispatch_event(ROOT_ID, EventKind::Host, EventPayload::Json(value));
+ }
+ state.control = Some(control);
+ }
+
+ fn send(&self, value: JsonValue) {
+ let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
+ match &state.control {
+ Some(control) => {
+ control.dispatch_event(ROOT_ID, EventKind::Host, EventPayload::Json(value))
+ }
+ None => state.pending.push(value),
+ }
+ }
+}
+
+/// A call the app made: `Command::HostCall`'s fields.
+struct Call {
+ id: u64,
+ name: String,
+ args: JsonValue,
+}
+
+/// What the module's context runs before the module: the timers and the
+/// player, and how calls are answered (as `nsh.js` `hostCall`).
+const GLUE: &str = r#"
+(() => {
+ const timers = new Map();
+ let nextTimer = 1;
+ const add = (f, ms, args, repeat) => {
+ const id = nextTimer++;
+ const delay = Math.max(0, Number(ms) || 0);
+ timers.set(id, { f, args, delay, repeat, due: __nshNow() + delay });
+ return id;
+ };
+ globalThis.setTimeout = (f, ms, ...args) => add(f, ms, args, false);
+ globalThis.setInterval = (f, ms, ...args) => add(f, ms, args, true);
+ globalThis.clearTimeout = globalThis.clearInterval = (id) => { timers.delete(id); };
+ const print = (level) => (...args) => __nshLog(level, args.map(String).join(" "));
+ globalThis.console = { log: print("info"), info: print("info"), warn: print("warn"), error: print("error") };
+
+ // Runs the timers that are due; when the next one is (ms since the start), or -1.
+ globalThis.__nshTimers = (now) => {
+ for (const [id, t] of [...timers]) {
+ if (t.due > now || !timers.has(id)) continue;
+ if (t.repeat) t.due = now + Math.max(1, t.delay);
+ else timers.delete(id);
+ try {
+ t.f(...t.args);
+ } catch (e) {
+ console.error(`web.host timer: ${e?.message ?? e}`);
+ }
+ }
+ let next = -1;
+ for (const t of timers.values()) if (next < 0 || t.due < next) next = t.due;
+ return next;
+ };
+
+ const player = {
+ hostEvent(event, payload = null) {
+ __nshSend(JSON.stringify({ type: "event", event: String(event), payload: payload ?? null }));
+ },
+ };
+ let ready;
+ let failed = "";
+ globalThis.__nshInstall = (evaluated, module) => {
+ ready = evaluated.then(() => {
+ if (typeof module.createHost !== "function") throw new Error("the module exports no createHost(player)");
+ return module.createHost(player);
+ }).then((host) => {
+ if (typeof host?.call !== "function") throw new Error("createHost(player) returned no call(name, args)");
+ return host;
+ });
+ ready.catch((e) => { failed = String(e?.message ?? e); });
+ };
+ globalThis.__nshFailed = () => failed;
+ globalThis.__nshCall = async (id, name, args) => {
+ let reply;
+ try {
+ const host = await ready;
+ const value = (await host.call(name, JSON.parse(args))) ?? null;
+ reply = JSON.stringify({ type: "reply", id, ok: true, value });
+ } catch (e) {
+ reply = JSON.stringify({ type: "reply", id, ok: false, error: String(e?.message ?? e) });
+ }
+ __nshSend(reply);
+ };
+})();
+"#;
+
+fn run(
+ name: &str,
+ source: String,
+ clock: Clock,
+ target: Arc<Target>,
+ calls: Receiver<Call>,
+ loaded: Sender<Result<(), String>>,
+) {
+ // The module's time: milliseconds of the app's clock since it loaded.
+ let origin = clock.now();
+ let setup = || -> Result<(rquickjs::Runtime, rquickjs::Context), String> {
+ let runtime = rquickjs::Runtime::new().map_err(|e| e.to_string())?;
+ let context = rquickjs::Context::full(&runtime).map_err(|e| e.to_string())?;
+ context.with(|cx| install(&cx, name, source, &target, &clock, origin))?;
+ drain(&runtime);
+ let failed: String = context.with(|cx| eval(&cx, "__nshFailed()"))?;
+ if !failed.is_empty() {
+ return Err(failed);
+ }
+ Ok((runtime, context))
+ };
+ let (runtime, context) = match setup() {
+ Ok(js) => {
+ let _ = loaded.send(Ok(()));
+ js
+ }
+ Err(err) => {
+ let _ = loaded.send(Err(err));
+ return;
+ }
+ };
+ // The clock's alarms wake the thread when the next timer is due.
+ let (wake, woken) = crossbeam_channel::unbounded::<()>();
+ let mut alarm: Option<f64> = None;
+ loop {
+ let next = context
+ .with(|cx| {
+ let f: Function = cx.globals().get("__nshTimers")?;
+ f.call::<_, f64>((ms(&clock, origin),))
+ })
+ .unwrap_or(-1.0);
+ drain(&runtime);
+ if next >= 0.0 && alarm.is_none_or(|at| next < at) {
+ alarm = Some(next);
+ let wake = wake.clone();
+ clock.at(origin + Duration::from_secs_f64(next / 1000.0), move || {
+ let _ = wake.send(());
+ });
+ }
+ crossbeam_channel::select! {
+ recv(calls) -> call => {
+ // The app closed.
+ let Ok(call) = call else { return };
+ let answered = context.with(|cx| -> Result<(), String> {
+ let f: Function = cx.globals().get("__nshCall").map_err(|e| e.to_string())?;
+ let args = call.args.to_string();
+ f.call::<_, Value>((call.id as f64, call.name, args))
+ .catch(&cx)
+ .map_err(|e| e.to_string())?;
+ Ok(())
+ });
+ if let Err(err) = answered {
+ log::error!(target: "js", "web.host: {err}");
+ }
+ }
+ recv(woken) -> _ => alarm = None,
+ }
+ drain(&runtime);
+ }
+}
+
+/// Defines the glue and its native functions, then loads the module and
+/// starts its `createHost`.
+fn install(
+ cx: &Ctx<'_>,
+ name: &str,
+ source: String,
+ target: &Arc<Target>,
+ clock: &Clock,
+ origin: Instant,
+) -> Result<(), String> {
+ let error = |e: rquickjs::Error| e.to_string();
+ let globals = cx.globals();
+ let send = {
+ let target = target.clone();
+ move |json: String| match serde_json::from_str(&json) {
+ Ok(value) => target.send(value),
+ Err(err) => log::error!(target: "js", "web.host: {err}"),
+ }
+ };
+ globals
+ .set("__nshSend", Function::new(cx.clone(), send).map_err(error)?)
+ .map_err(error)?;
+ globals
+ .set(
+ "__nshNow",
+ Function::new(cx.clone(), {
+ let clock = clock.clone();
+ move || ms(&clock, origin)
+ })
+ .map_err(error)?,
+ )
+ .map_err(error)?;
+ globals
+ .set(
+ "__nshLog",
+ Function::new(cx.clone(), |level: String, message: String| {
+ let level = match level.as_str() {
+ "error" => log::Level::Error,
+ "warn" => log::Level::Warn,
+ _ => log::Level::Info,
+ };
+ log::log!(target: "js", level, "{message}");
+ })
+ .map_err(error)?,
+ )
+ .map_err(error)?;
+ eval::<Value>(cx, GLUE)?;
+ let (module, evaluated) = Module::declare(cx.clone(), name, source)
+ .and_then(Module::eval)
+ .catch(cx)
+ .map_err(|e| e.to_string())?;
+ let namespace: Object = module.namespace().map_err(error)?;
+ let install: Function = globals.get("__nshInstall").map_err(error)?;
+ install
+ .call::<_, Value>((evaluated, namespace))
+ .catch(cx)
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+fn eval<'js, V: rquickjs::FromJs<'js>>(cx: &Ctx<'js>, source: &str) -> Result<V, String> {
+ cx.eval::<V, _>(source).catch(cx).map_err(|e| e.to_string())
+}
+
+/// Runs the promise jobs that are queued.
+fn drain(runtime: &rquickjs::Runtime) {
+ while runtime.is_job_pending() {
+ if let Err(err) = runtime.execute_pending_job() {
+ log::error!(target: "js", "web.host: {err}");
+ }
+ }
+}
+
+/// Milliseconds of `clock` since `origin`.
+fn ms(clock: &Clock, origin: Instant) -> f64 {
+ clock.now().duration_since(origin).as_secs_f64() * 1000.0
+}
diff --git a/third-party/nsh/crates/nsh-testing/src/run.rs b/third-party/nsh/crates/nsh-testing/src/run.rs
index 3cd72566d3..14b5076303 100644
--- a/third-party/nsh/crates/nsh-testing/src/run.rs
+++ b/third-party/nsh/crates/nsh-testing/src/run.rs
@@ -36,6 +36,7 @@ use crate::{
DirectoryAssets, Headless, RecordingNetwork, StubNetwork, UreqNetwork, WAIT, print_batch,
},
module::{Case, Hook, Item, Launch, Mode, NetworkSetup, Platform, SafeArea, Suite},
+ page_host::PageHost,
};
/// Builds an entry ([`Entry`]; an app's may be its directory).
@@ -84,6 +85,11 @@ pub struct TestRun {
/// Opens the apps on a real platform's host instead of a headless
/// engine (`nsh test --target`).
pub host: Option<StartHost>,
+ /// The web page's host module (`nsh.json` `web.host`, exporting
+ /// `createHost(player)`): each app opened headless gets one of its own,
+ /// which answers its `nsh/host` calls and sends its events, and
+ /// `host.available()` is true. `None`: the apps have no host.
+ pub page_host: Option<PathBuf>,
}
/// Starts the app whose entry is the path on a real platform's host, as
@@ -453,6 +459,15 @@ impl Session {
let files = set_files(&self.run.ui_sets);
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let safe_area = launch.safe_area;
+ // Loaded before the app starts, as the page's host is: what it
+ // sends meanwhile waits for the app.
+ let page_host = match &self.run.page_host {
+ Some(module) => Some(
+ PageHost::start(module, clock.clone())
+ .map_err(|e| format!("open({entry:?}): {e}"))?,
+ ),
+ None => None,
+ };
let default = EngineConfig::new(app);
let config = EngineConfig {
viewport: nsh_core::Size::new(
@@ -485,6 +500,7 @@ impl Session {
Platform::Ios | Platform::Android => Idiom::default(),
_ => Idiom::Desktop,
},
+ host: page_host.is_some(),
..Default::default()
},
ui_sets: self.run.ui_sets.clone(),
@@ -493,6 +509,10 @@ impl Session {
};
let mut app = Headless::start(config)
.map_err(|e| format!("open({entry:?}): the engine did not start: {e}"))?;
+ if let Some(host) = page_host {
+ host.attach(app.engine().control());
+ app.on_host_call(move |id, name, args| host.call(id, name, args));
+ }
if let Some(out) = &self.run.trace {
let out = out.clone();
app.observe(move |step, batch| {
diff --git a/third-party/nsh/crates/nsh-testing/src/runner.rs b/third-party/nsh/crates/nsh-testing/src/runner.rs
index f15d563b2a..1ea3615c2b 100644
--- a/third-party/nsh/crates/nsh-testing/src/runner.rs
+++ b/third-party/nsh/crates/nsh-testing/src/runner.rs
@@ -26,7 +26,7 @@ use crate::run::{Built, Entry, TestResult, TestRun, run_file};
/// The version of the [`TestRequest`], [`RunnerMessage`] and [`Reply`]
/// formats: `nsh` and the runner it built speak the same.
-pub const PROTOCOL: u32 = 3;
+pub const PROTOCOL: u32 = 4;
/// What `nsh` asks of a runner: the test files to run and how.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -46,6 +46,8 @@ pub struct TestRequest {
pub trace: bool,
/// Forwards debug lines (`nsh -v`).
pub verbose: bool,
+ /// The web page's host module ([`TestRun::page_host`]).
+ pub page_host: Option<PathBuf>,
}
/// A line a runner writes on its standard error for `nsh`.
@@ -236,6 +238,7 @@ pub fn runner(ui_sets: Vec<Registry>, root: Option<fn() -> Root>) -> ExitCode {
// On a real platform, `nsh` runs the test files itself: the apps run
// in hosts built with the app's crates.
host: None,
+ page_host: request.page_host,
};
for file in &request.files {
let mut report = |result: &TestResult| {
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx
new file mode 100644
index 0000000000..b2b7653c37
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx
@@ -0,0 +1,40 @@
+import { mount, signal, Text, View } from "nsh";
+import { host } from "nsh/host";
+
+function App() {
+ const result = signal("(none)");
+ const ticks = signal<unknown[]>([]);
+ host.on("tick", (payload) => ticks.update((t) => [...t, payload]));
+ const call = (name: string, options?: { timeout?: number }) => () =>
+ host.call(name, { n: 1 }, options).then(
+ (value) => result.set(JSON.stringify(value)),
+ (e: Error) => result.set(`rejected: ${e.message}`),
+ );
+ return (
+ <View>
+ <Text>{`available: ${host.available()}`}</Text>
+ <View onPress={call("echo")} accessibilityLabel="echo">
+ <Text>echo</Text>
+ </View>
+ <View onPress={call("fail")} accessibilityLabel="fail">
+ <Text>fail</Text>
+ </View>
+ <View onPress={call("big")} accessibilityLabel="big">
+ <Text>big</Text>
+ </View>
+ <View onPress={call("other")} accessibilityLabel="other">
+ <Text>other</Text>
+ </View>
+ <View onPress={call("slow", { timeout: 1000 })} accessibilityLabel="slow">
+ <Text>slow</Text>
+ </View>
+ <View onPress={call("later")} accessibilityLabel="later">
+ <Text>later</Text>
+ </View>
+ <Text>{`result: ${result()}`}</Text>
+ <Text>{`ticks: ${JSON.stringify(ticks())}`}</Text>
+ </View>
+ );
+}
+
+mount(App);
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx
new file mode 100644
index 0000000000..1e06ea5177
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx
@@ -0,0 +1,5 @@
+import { expect, open, test } from "nsh/test";
+
+test("a module without createHost fails open", async () => {
+ await expect(() => open("./App.tsx")).rejects.toThrow("createHost");
+});
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx
new file mode 100644
index 0000000000..5e3f3f3a54
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx
@@ -0,0 +1,51 @@
+import { expect, open, test } from "nsh/test";
+
+test("the page's host answers calls", async () => {
+ const app = await open("./App.tsx");
+ expect(app.shows("available: true")).toBe(true);
+ await app.press("label:echo");
+ await app.waitFor('text:result: {"n":1}');
+});
+
+test("a call the host fails rejects with its message", async () => {
+ const app = await open("./App.tsx");
+ await app.press("label:fail");
+ await app.waitFor("text:result: rejected: boom");
+ await app.press("label:big");
+ await app.waitFor("text:/result: rejected: .*BigInt/");
+});
+
+test("events sent before the app started reach it in order", async () => {
+ const app = await open("./App.tsx");
+ await app.waitFor("text:ticks: [1,2]");
+});
+
+test("each app gets a host of its own", async () => {
+ await open("./App.tsx");
+ const second = await open("./App.tsx");
+ await second.waitFor("text:ticks: [1,2]");
+});
+
+test("a function the app does not declare is refused", async () => {
+ const app = await open("./App.tsx");
+ await app.press("label:other");
+ await app.waitFor("text:/result: rejected: .*capabilities\\.host/");
+});
+
+test("a call the host does not answer times out on the app's clock", async () => {
+ const app = await open("./App.tsx", { clock: {} });
+ await app.press("label:slow");
+ await app.clock.runFor(900);
+ expect(app.shows("result: (none)")).toBe(true);
+ await app.clock.runFor(200);
+ await app.waitFor("text:result: rejected: nsh/host: slow timed out after 1000 ms");
+});
+
+test("the host's timers run on the app's clock", async () => {
+ const app = await open("./App.tsx", { clock: {} });
+ await app.press("label:later");
+ await app.clock.runFor(400);
+ expect(app.shows("result: (none)")).toBe(true);
+ await app.clock.runFor(200);
+ await app.waitFor('text:result: "late"');
+});
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx
new file mode 100644
index 0000000000..f935c3426e
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx
@@ -0,0 +1,8 @@
+import { expect, open, test } from "nsh/test";
+
+test("without a page host, calls reject", async () => {
+ const app = await open("./App.tsx");
+ expect(app.shows("available: false")).toBe(true);
+ await app.press("label:echo");
+ await app.waitFor("text:result: rejected: nsh/host: this platform has no host");
+});
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/host.js b/third-party/nsh/crates/nsh-testing/tests/testing/host/host.js
new file mode 100644
index 0000000000..78235efe09
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/host.js
@@ -0,0 +1,17 @@
+// The page's host of the tests: answers `echo` with its argument, throws
+// for `fail`, returns what is not JSON for `big`, never answers `slow`,
+// answers `later` after 500 ms of its timers, and sends two events before
+// the app starts.
+export function createHost(player) {
+ player.hostEvent("tick", 1);
+ player.hostEvent("tick", 2);
+ return {
+ async call(name, args) {
+ if (name === "fail") throw new Error("boom");
+ if (name === "big") return 1n;
+ if (name === "slow") return new Promise(() => {});
+ if (name === "later") return new Promise((resolve) => setTimeout(() => resolve("late"), 500));
+ return args;
+ },
+ };
+}
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/none.js b/third-party/nsh/crates/nsh-testing/tests/testing/host/none.js
new file mode 100644
index 0000000000..e056e20962
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/none.js
@@ -0,0 +1 @@
+export const notAHost = 1;
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json b/third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json
new file mode 100644
index 0000000000..a6a50cba77
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json
@@ -0,0 +1,5 @@
+{
+ "capabilities": {
+ "host": ["echo", "fail", "big", "slow", "later"]
+ }
+}
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/main.rs b/third-party/nsh/crates/nsh-testing/tests/testing/main.rs
index 9a43f1fad3..2a2a06c95b 100644
--- a/third-party/nsh/crates/nsh-testing/tests/testing/main.rs
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/main.rs
@@ -17,6 +17,7 @@ fn test_run(filter: Option<&str>) -> TestRun {
filter: filter.map(str::to_owned),
trace: None,
host: None,
+ page_host: None,
}
}
@@ -231,6 +232,24 @@ fn a_manual_clock_moves_as_the_test_says() {
assert_eq!(results.len(), 7);
}
+#[test]
+fn the_page_s_host_answers_the_apps_tests_open() {
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/testing/host");
+ let run = |module: Option<&str>, file: &str| {
+ let run = TestRun {
+ page_host: module.map(|m| dir.join(m)),
+ ..test_run(None)
+ };
+ let results = run_file(&run, &dir.join(file), &mut |_| {}).unwrap();
+ let failed: Vec<_> = results.iter().filter(|r| r.failure.is_some()).collect();
+ assert!(failed.is_empty(), "{file}: {failed:#?}");
+ results.len()
+ };
+ assert_eq!(run(Some("host.js"), "Host.test.tsx"), 7);
+ assert_eq!(run(None, "NoHost.test.tsx"), 1);
+ assert_eq!(run(Some("none.js"), "Broken.test.tsx"), 1);
+}
+
#[test]
fn apps_run_under_the_capabilities_their_nsh_json_declares() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/testing/capabilities");
diff --git a/third-party/nsh/docs/dev/elements.md b/third-party/nsh/docs/dev/elements.md
index 3ff090e203..999023acc6 100644
--- a/third-party/nsh/docs/dev/elements.md
+++ b/third-party/nsh/docs/dev/elements.md
@@ -208,7 +208,12 @@ misses the deadline is not waited for again until it catches up (`docs/dev/macos
- **Keyboard shortcuts** (`nsh/keymap`): the root's `Prop::Keymap` mounts the surface's whole table
(`MountItem::Keymap`); the platform matches a key against it on the spot, consumes it and reports the root's
`KeyCommand` (the binding's id), so the main thread never waits for JS (`docs/dev/js-modules.md`, the macOS and
- Linux pages' "Keyboard"). Rust views' bindings (`on_key`) join JS's in that one table, and the core runs
+ Linux pages' "Keyboard"). iOS and Android match a hardware keyboard's keys: iOS in `pressesBegan:` of its text
+ inputs (before UIKit's text system) and of the root view (presses nothing inside handled), keys named by their HID
+ usage (`Key::from_hid_usage`) and the character they type, a `repeat` binding firing on a timer of its own while
+ the key is held (UIKit sends a held key's press once); Android in `NshView.dispatchKeyEvent` (before the focused
+ view, after the input method) through `Native.keyDown` / `keyUp`, the table kept in Rust (`keymap.rs`), never
+ encoded for Java. Both leave a composing input method its keys and consume a taken key's release too. Rust views' bindings (`on_key`) join JS's in that one table, and the core runs
their handlers (`docs/dev/components.md`).
Terminals resolve `mod` to Control on every host OS, and reserve Control+Q for quitting; graphical Apple
backends keep Command. Rust components read the same renderer through `renderer()` / `is_terminal()`.
diff --git a/third-party/nsh/docs/dev/embedding.md b/third-party/nsh/docs/dev/embedding.md
index 088252f421..fd99592b4e 100644
--- a/third-party/nsh/docs/dev/embedding.md
+++ b/third-party/nsh/docs/dev/embedding.md
@@ -108,6 +108,22 @@
toasts) follows too. `None` / `nil` / `null` / 0 follows the system's again (its settings are read again). The text
size scales what allows font scaling (`Core::set_font_scale`): texts, inputs, the components' labels; lengths, `rem`
in classes included, stay as they are. The layout direction stays the system's language's.
+- **The host's theme overrides**: a host that embeds one app in several branded host apps overrides the app's theme
+ tokens (brand colors, `radius`, ...) at run time: Rust `AppView::set_theme_overrides(Option<&str>)` (AppKit, GTK,
+ Win32; the iOS `AppView`'s too), Swift `NshAppView.setThemeOverrides(_:)` (C `nsh_app_set_theme_overrides`), Java
+ `NshView.setThemeOverrides`. The value is a theme file in the format of `nsh.json`'s `"theme"` (shadcn's `cssVars`:
+ `theme`, `light` and `dark` sections, values as CSS writes them; `components.md` "The theme"); it is parsed on the
+ calling thread by `nsh_base::theme_overrides`, which keeps only the tokens the file sets (a `var()` it names and
+ does not set is the default theme's), and a file that does not parse is an error (Swift throws `NshError`, Java an
+ `IllegalArgumentException`, C returns it) that changes nothing. The tokens go to the engine as host tokens
+ (`EngineControl::set_tokens`, `Core::set_host_tokens`): they win over the app's tokens of the same names in both
+ appearances, the app's others stay (`Tokens::over`), and classes naming the changed tokens (`bg-primary`,
+ `rounded-(--radius)`) are resolved again at once, as are Rust components reading `theme()`. Each platform keeps
+ them on its surface and starts every engine with them (`Engine::start_with`), so they are in the first frame of
+ every app loaded later, a development reload included; `None` / `nil` / `null` gives the app its own theme back. A
+ token takes both appearances: one the file gives only in `light` (or `theme`) is that color in dark mode too, as
+ in a theme file, so an override that differs in dark mode names it in `dark`. GPUI's `AppView::set_theme` /
+ `set_theme_json` (below) give a whole theme instead, the default's where the file is silent.
- **The iOS SDK** (`crates/xtask/src/ios.rs`): per platform slice (device, simulator, Catalyst) a static
`NativeShell.framework` whose binary is `nsh-platform-ios` with feature `embed` (built by `cargo rustc --crate-type
staticlib`, so the hosts never build or export it) merged by `libtool` with the Swift module, compiled with library
diff --git a/third-party/nsh/docs/dev/js-modules.md b/third-party/nsh/docs/dev/js-modules.md
index 1488700103..2a65975928 100644
--- a/third-party/nsh/docs/dev/js-modules.md
+++ b/third-party/nsh/docs/dev/js-modules.md
@@ -49,6 +49,7 @@ re-exported by `nsh`). Apps import a primitive's tag from `nsh` (`native-pipelin
| `nsh/gesture` | `PanResponder` | `touch.rs` |
| `nsh/clipboard` | `Clipboard.writeText(text)`, `Clipboard.readText()` / `hasText()`, `writeImage(src)`, `readImage()` / `hasImage()`, `readFiles()` (promises the core resolves from the root's `Clipboard` event; the `has*` reads do not read the content, so iOS shows no paste notice). Images cross as base64 `data:` URIs (an image source and a `fetch` URL, so bytes stay native until JS asks for them), files as `file:` URLs one per line; the backends build both with `nsh_core::platform::cl [...]
| `nsh/storage` | `Storage.getItem` / `setItem` / `removeItem` / `keys` / `clear` (async functions of the core; the entries live in memory in `nsh_core::storage`, loaded once from the platform's `StorageBackend` (`EngineConfig::storage`: `nsh_engine::FileStorage::open` in `<EngineConfig::data_dir>/<app id>` (the id travels with the app: `app.json`, the dev server, packages, `Root::app_id`), one per file in the process so a reload reads unflushed writes; a damaged file is set aside as `st [...]
+| `nsh/host` | `host.call(name, args, { timeout })` (a task of the core (`runtime/host.rs`), racing the answer against the app's clock: 30 s unless `timeout` says, 0 none; a call that timed out drops its slot at once (`Action::ForgetHostCalls`), so `Core::answer_host` drops a late reply with a debug line; the root's `Command::HostCall { id, name, args }`, answered by the root's `Host` event, `EventPayload::Json` `{ type: "reply", id, ok, value?, error? }` (an id that is not a whole numbe [...]
| `nsh/keymap` | `keymap.bind(keystroke, handler, { when, repeat, within })` / `keymap.bind({ keystroke: handler }, options)`: keyboard shortcuts. Keystrokes are parsed and checked in Rust (`nsh_core::keymap`: syntax, `mod`, reserved keystrokes, which ones text inputs keep); the whole table goes to the platform as the root's `Prop::Keymap` → `MountItem::Keymap`, which matches keys on the spot and reports the root's `KeyCommand` event (the binding's id). Host-supported `within: YNode` res [...]
| `nsh/webview`, `nsh/video` (deprecated: `nsh`) | `WebView`, `Video` | `platform_modules.rs` |
| `nsh/native` | `nativeComponent(name, { props, events })`: a component for a native component the embedding app registered; `nativeModule(specifier)`: the exports of a UI set the embedding app gave its engines (`EngineConfig::ui_sets`), as one object made by the loader's code (`modules::host_module`, the same values an import of the set gets) and kept per specifier; only the host's sets, never nsh's own modules or the runtime's registries | `platform_modules.rs`, `modules.rs` |
diff --git a/third-party/nsh/docs/dev/macos.md b/third-party/nsh/docs/dev/macos.md
index e2cfb8b50c..9ab412736e 100644
--- a/third-party/nsh/docs/dev/macos.md
+++ b/third-party/nsh/docs/dev/macos.md
@@ -84,6 +84,12 @@ server client, log forwarding, the error overlay: `dev.rs`). The runtime needs m
the events got to (`settle`).
- **One hop per commit.** The engine runs on its own thread; its sink queues each commit and wakes the main queue once
(`DispatchQueue::main`), and the main thread applies every queued batch in order (`surface.rs`, `mount.rs`).
+ While native animations or the platform's own (a scroll glide, a zoom) run, batches wait for the start of the next
+ frame, and each frame applies them for at most 4 ms (`FRAME_BATCHES`, at least one whole batch), carrying the rest:
+ a scroll's frames are not delayed by batches landing between them, and a burst of batches spreads over frames. What
+ the engine sends after a waiting batch waits behind it, in order. A frame JS asked for alone does not hold batches
+ back (what it draws shows in that frame); otherwise a batch is applied as it lands, and a resize applies the waiting
+ ones first.
Dispatched closures must be `Send`, so they find their surface by key in a thread-local map; no globals are shared
between surfaces.
- **Resizes are shown at once** (`Surface::resized`, the one place the main thread waits for the engine): when the
diff --git a/third-party/nsh/docs/dev/performance.md b/third-party/nsh/docs/dev/performance.md
index c8bf517d86..32462f8128 100644
--- a/third-party/nsh/docs/dev/performance.md
+++ b/third-party/nsh/docs/dev/performance.md
@@ -63,6 +63,10 @@ coalescing interval, with the latest coordinates kept independently per view and
bundling included); applying a hot update takes 0.2–0.5ms on the engine thread.
- gallery Photos list (`examples/gallery/Bench.tsx`, debug, headless): first frame 653 ops / 224 nodes, ~2.7ms (list bind
rounds included).
+- Paced list binds (`Bench.tsx` scrolled 3000 pt, release, headless, three runs): the first commit binds the viewport,
+ 786 ops / 641 mount items in ~1.1ms (1226 / 1029 in ~1.5ms when the whole overscan bound with it); the overscan
+ follows in commits of ~165 ops / 138 items. 43 commits and 1923 items in all (40 and 1943 before), commit median
+ ~0.26–0.31ms either way.
- Photos scrolling (release): a frame that rebinds one row is 4 ops / 4 mount items; frames that reveal no new row call no JS and produce
no batch. `list_bench` runs at ~9µs per frame back to back (JS ~4µs; ~30µs / JS ~20µs before reactivity went native) and
~50µs with caches flushed between frames, but ~60–150µs after the thread sleeps 10ms. The difference is the CPU clocking down while the thread sleeps (Apple CLPC), not the code path; neither QoS (the engine
@@ -278,6 +282,25 @@ draw a panel.
p50 ~1.9 → ~1.7 ms, CPU ~38–40% both; a test canvas (524×340: a 400-point area and line, 400 bars, a pulsing ring,
a moving square) presented 86–117 fps before and 92–119 after, `main` p50 ~1.2 → ~1.0 ms, CPU ~17% both.
+ List binds while scrolling (release host, 120 Hz MacBook Pro; the list scrolled by chained native
+ `scrollToOffset({ animated: true })` every 300 ms, no input; each display link refresh logged with its link
+ timestamp and main-thread time; three runs each, refreshes missed = link gaps of two periods or more). Before =
+ four cells per turn on `Wake`; after = the viewport bound at once, the overscan paced per frame, one item per view
+ part per merged frame, batches applied at frame start (4 ms), the cursor set only when it changes:
+
+ | Scenario | Before: fps, missed, main mean / p99 / max | After: fps, missed, main mean / p99 / max |
+ |---|---|---|
+ | 1100 rows of 76 pt, 390×844, 6000 pt/s | 119.2–119.9, 1–12, 0.31–0.32 / 1.2 / 1.9–2.0 ms | 118.5–119.2, 12–22, 0.25–0.26 / 1.0 / 1.9–4.0 ms |
+ | The same at 16700 pt/s | 119.0–120.0, 0–6, 0.51–0.52 / 1.9–2.0 / 3.2–6.3 ms | 119.7–120.0, 0–2, 0.39–0.40 / 1.7 / 3.1–4.7 ms |
+ | Photos grid, 1200×800, 6000 pt/s | 118.1–120.0, 0–53, 0.30–0.34 / 0.9–1.0 / 8.5–11.3 ms | 119.8–120.0, 0–6, 0.44–0.48 / 2.2–2.5 / 4.2–5.4 ms |
+
+ The rows' "after" runs were taken before the cursor change. Where the mouse was is not controlled: hover and cursor
+ work happen only with it over the window, which may be why the grid's mean rose. The missed refreshes do not follow the main thread's time (a run missing 18% had a 0.7 ms mean): the machine was loaded
+ (load average 8–29 from other processes), and one excluded run had ~100 ms stalls every 5 s with the main thread
+ idle. Frames over a refresh were the Photos grid's: setting the cursor as content moved under the mouse, which
+ renders its image again when the pointer is customized in Accessibility settings (45% of the main thread's
+ samples). Without that, a single batch of the first tiles a scroll shows costs 4–5 ms.
+
- GPUI backend (`nsh-platform-gpui`; release with thin LTO, rootful Xwayland at scale 2, a focused 285×315 pt window at
60 Hz, against the GTK backend (`GSK_RENDERER=ngl`) on the same display and bundle). `draw` is the surface element's
prepaint and paint (the whole view tree, every frame), `frame` GPUI's whole frame with presenting (`ZED_MEASUREMENTS=1`),
diff --git a/third-party/nsh/docs/dev/pitfalls.md b/third-party/nsh/docs/dev/pitfalls.md
index a452ac897a..abed678baa 100644
--- a/third-party/nsh/docs/dev/pitfalls.md
+++ b/third-party/nsh/docs/dev/pitfalls.md
@@ -182,6 +182,12 @@ Things that were found the hard way. Read the section of the area you change bef
prevented every press there, so a navigation bar's back button did nothing (seen on an iPhone foldable simulator; a
real tap through XCUITest reproduced it). `PressRecognizer` is not preventable by tap recognizers on a `UIWindow`
(`press.rs`, `is_window_tap`); edge-gesture gates are not taps and still cancel presses.
+- **iOS, a hardware keyboard's keys** reach the first responder as presses (`pressesBegan:withEvent:`) before
+ UIKit's text system acts on them: a text input that does not call `super` for a press neither moves its caret,
+ inserts a line break nor types it, and must not call `super` for that key's release either. A held key's press
+ comes once (the text system repeats on its own), so a binding that repeats needs a timer of its own
+ (`keymap.rs`). A press's `responder` is the first responder it went to: presses bubbling up to the root view from
+ an input were matched there already.
- **iOS, the screen scale**: a `UIView` that draws nothing itself keeps a `contentScaleFactor` of 1 on a 3x screen, and a
drawn one raises it while zoomed, so neither is the screen's scale. Images decoded at it came out at a third of their
@@ -246,6 +252,11 @@ Things that were found the hard way. Read the section of the area you change bef
so a line is wider than its sum of advances; `TextLayouts.Prepared.desired` adds the largest negative spacing back, or text laid
out at its natural width wraps (`tracking-tight`).
+- **Android, a hardware keyboard's keys** go to the input method first, then down the window's views
+ (`dispatchKeyEvent`, the parents before the focused view): `NshView`, which holds the focus when no input does,
+ sees every key meant for the views inside it. Keys with Control come as `dispatchKeyShortcutEvent` first, where a
+ text field selects all, copies and pastes, so the app's shortcuts are matched there too.
+
## Linux, GTK
- **inotify reports reads** (`Access` events), FSEvents does not: file watchers (`nsh dev`, `cargo run`'s xtask) ignore
@@ -296,6 +307,9 @@ Things that were found the hard way. Read the section of the area you change bef
- **Made-up edge swipes do not start `interactivePopGestureRecognizer`**; the back button is tapped instead. A
scroll view's glide or bounce is not a Core Animation animation: `isDecelerating` says it moves, and a tap during
it only stops it.
+- **A hardware keyboard's keys are IOHID keyboard events** (`IOHIDEventCreateKeyboardEvent`, usage page 7) handed to
+ UIKit the way touches are: they become presses, key commands and typing as a real keyboard's do. Modifiers are
+ keys of their own (left Control, Shift, Option, Command), down before the key and up after it.
- **A menu's backdrop covers the header**: on a touch screen a menu is a sheet over a backdrop on the whole window,
so a tap at the header's back button lands on the backdrop and closes the menu (`backdropPress`) instead of going
back from a submenu. iOS has no back key, so while the app listens to going back the agent's `back` is VoiceOver's
diff --git a/third-party/nsh/docs/dev/sandbox.md b/third-party/nsh/docs/dev/sandbox.md
index dcb5fa104c..7b2f8b6959 100644
--- a/third-party/nsh/docs/dev/sandbox.md
+++ b/third-party/nsh/docs/dev/sandbox.md
@@ -37,13 +37,19 @@ trusted: every limit and check is enforced in Rust, never in JS.
(`hosts`: `*`, `*.example.com` or a host; `http`: a host narrowed by methods, schemes, a port, paths and path prefixes),
`webview` (`hosts` a web view may show and navigate to), `statusBar`, `process` (`execute`: exact executable names or absolute paths; `exit`: host exit requests; `env`: additional
visible environment names), `fs` (`read` / `write`: trusted path roots), `clipboard` (`nsh/clipboard`: reading and
- writing the system clipboard; the copy menu of selectable text is the user's own act and needs none). `nsh/storage`
+ writing the system clipboard; the copy menu of selectable text is the user's own act and needs none), `host`
+ (`nsh/host`: the embedding app's functions `host.call` may call, names or prefixes ending in `*`, `true` for all;
+ names and grants are 1 to 128 of `[A-Za-z0-9_.:-]` without `..` (`access::valid_host_name`), and the core refuses
+ the call of any other name before it reads the grants, since hosts may put names in paths or URLs;
+ never granted without it, also in apps without a declaration (`Capabilities::all` leaves it empty), and the bundler
+ warns of an app importing `nsh/host` without it (`bundle.rs` `ungranted_host`); the host's events need none, the
+ host sends them). `nsh/storage`
is no capability: an app's storage holds only its own data, and the host decides where it is kept. Everything not listed is denied and unknown keys
are build errors (`Capabilities::from_json`); a signed package's declaration is read with `from_json_lenient`, as a
newer signer may have written it: an unknown capability is not granted, an unknown key inside one drops that
capability or `http` grant (it may be a restriction), so unknown keys never widen access; an app without the key
- retains unrestricted network, webview, status bar and clipboard access. Process execution and filesystem access
- always require explicit declarations, including standalone apps. The embedding app
+ retains unrestricted network, webview, status bar and clipboard access. Process execution, filesystem access and
+ the host's functions always require explicit declarations, including standalone apps. The embedding app
may grant less (`NshAppView.grantCapabilities` / `NshView.setGrantedCapabilities`, the same JSON): a request must be
allowed by both (`Access`). The declaration travels with the bundle (`capabilities.json` next to it, written by
`nsh build` / `nsh package`; the dev protocol's `Bundle` message, so development enforces the same capabilities; a change
@@ -51,7 +57,7 @@ trusted: every limit and check is enforced in Rust, never in JS.
- Enforced in Rust: `fetch` in the engine (`access.rs`; refused requests never reach the network, and redirects are
followed by the engine with `follow_redirects` off for the platform, so every hop is checked — hops are issued on the
engine thread when a hop's head arrives; a streamed request body is not sent again, so a 307 / 308 then fails); remote images, placeholders, canvas images, `Image.getSize` / `prefetch`, video sources, web view
- sources, the status bar and the clipboard in the core (`Core::check_prop`; a refused image fails with `onError`, clipboard reads resolve empty, the rest show nothing;
+ sources, the status bar, the clipboard and host calls in the core (`Core::check_prop`, the commit's commands; a refused image fails with `onError`, clipboard reads resolve empty, host calls reject, the rest show nothing;
each refusal is logged once, target `js`, saying which key to add); top-level web view navigations on the platforms
(`WKNavigationDelegate decidePolicyForNavigationAction` / `WebViewClient.shouldOverrideUrlLoading` → `Native.allowsWebview`).
`asset:` URIs are always allowed. Host checks take the host after the last `@` (`https://ok.com@evil.io` is evil.io).
diff --git a/third-party/nsh/docs/dev/scrolling-and-lists.md b/third-party/nsh/docs/dev/scrolling-and-lists.md
index d2dca9848b..1124b70061 100644
--- a/third-party/nsh/docs/dev/scrolling-and-lists.md
+++ b/third-party/nsh/docs/dev/scrolling-and-lists.md
@@ -95,5 +95,13 @@
- **Indexed data**: TSX `each` also accepts `ReadonlyListSource<T>`, a readonly snapshot with `length` and `at(index)`.
Only bound and viewable items are read. Replacing a snapshot's identity replaces the full range without reading its
items; arrays retain their identity-based common-prefix / suffix splice behavior. Counts use the core's existing u32 range.
+- **Paced binding** (the runtime's lists): the window keeps a viewport of overscan on either side, and a fast scroll or
+ a jump assigns a screen of cells at once, updating each cell's item and index signals together. What shows binds in
+ the commit that assigns it: the viewport, two scroll steps ahead of it, sticky items and cells whose item moved (a splice tells them
+ their new index). The rest of the overscan waits in the core (`Slot::requested`: what JS was last asked to show, so
+ nothing stale is queued and cells whose items went are never asked for) and the engine binds it as its per-turn
+ budget allows (`PACED_BIND_OPS` ops or `PACED_BIND_TIME` of JS, at least one cell; nearest the leading edge first,
+ one cell per list in turn), then on the next display frames (`MountSink::request_frame`). Cells assigned but not
+ bound yet are moved out of sight like unassigned ones. Rust components' lists bind in the core, unpaced.
- **Rust components**: the same props are `Scrollable`'s methods (`scroll()`, `virtual_list(..)`) and
`VirtualList`'s (header, footer, empty view, separators, sticky rows, viewability); `docs/dev/components.md`.
diff --git a/third-party/nsh/docs/dev/tooling.md b/third-party/nsh/docs/dev/tooling.md
index 0a2a219669..663a95ba7e 100644
--- a/third-party/nsh/docs/dev/tooling.md
+++ b/third-party/nsh/docs/dev/tooling.md
@@ -282,7 +282,7 @@ and more than one fails the step; a press whose match, or a view between it and
submenu's as `"Share > Mail"`) until it is chosen from (the root hears `Menu` with the action's id, then
`Menu { closed }`) or closed;
- `press_key` (`headless/keyboard.rs`, `app.keyboard.press`): the keystroke as `nsh/keymap` parses it for the
- environment's OS; on a desktop environment (`open`'s `platform`: `Environment::desktop`) the root's keymap
+ environment's OS; on every environment (a hardware keyboard: phones and tablets have one too) the root's keymap
(`MountItem::Keymap`, `Screen::keymap`) finds the binding that fires where the focus is (`Keymap::find`) and the
root hears `KeyCommand`; else the focused input takes a character, Backspace or the return key, else Escape goes
back when there is something to go back from;
@@ -321,6 +321,18 @@ Headless runs play the platform's part NSH's protocol defines, and refuse what o
them after each step itself (`answer_dialogs_myself`), so its JS handler runs on the test file's VM;
- the clipboard holds text (`SetClipboard`, `ReadClipboard` answered with `onClipboard`), modals get `onShow` when
inserted;
+- with `TestRun::page_host` (`nsh test`'s `--web-host`, else `nsh.json` `web.host`; `TestRequest::page_host` for an
+ app's runner) the web page's host answers `nsh/host` (`page_host.rs`, feature `js`): each app `open` starts
+ headless gets a host of its own, started before the engine (`createHost(player)` in a QuickJS context of its own on
+ its own thread: timers on the app's clock, woken by its alarms (`Clock::at`), `console` to the `js` log, no imports), `Environment::host` set, and the
+ driver hands the root's `Command::HostCall`s to it as their batches are applied (`Headless::on_host_call`); it
+ answers each with a `Host` `{ type: "reply" }` event as `nsh.js` `hostCall` does (`ok: false` with the message on a throw, a rejection or
+ a value that is not JSON), and `player.hostEvent` sends `Host` `{ type: "event" }`; what it sends before the engine runs waits in
+ `page_host::Target` until `attach`, so the app's top-level `host.on` hears it, and the host goes with the app
+ (its thread ends when the driver drops its calls). A module that does not load, exports no `createHost` or whose
+ `createHost` returns no `call` fails the `open`. A call's timeout (`runtime/host.rs`) is the core's timer, on the
+ engine's clock: virtual on a manual clock, real otherwise, as the host module's own timers are. `--target web`'s
+ page runs `nsh.json`'s `web.host` (`web::with_page_host`); the other real hosts (`--target`) have none;
- the app runs under what its `nsh.json` declares (`Bundle::with_capabilities_json`: `nsh test`'s compiler and an
app's runner, `Reply::Js::capabilities`, as `nsh build` writes `capabilities.json`), so headless refusals are a
device's;
@@ -429,7 +441,8 @@ menus of its own; the web, whose `Menu.popup` is the app's own UI, has none), th
`Native::mouse`: macOS hands the move to the surface's input, as AppKit gives posted
moves to the first responder only), keystrokes (`press_keys`: macOS's posted key events, Windows' and GPUI's key entry points with
`keymap::Key::virtual_key`, the web's CDP key events, Linux's bindings fired as its shortcut controller would, as GTK
-takes no made-up keys; the phones, without app shortcuts, refuse them),
+takes no made-up keys; iOS's IOHID keyboard events (`synth::key`, modifiers down first) that UIKit takes as a
+real keyboard's, Android's `KeyEvent`s handed to the window's root view as its input stage hands them over),
`back`, dialogs, screenshots, the window's size and appearance),
then waits until neither a batch came nor a native animation ran for 100 ms (2 s at most; the web's page counts its own
finite animations too: a screen sliding in; Android counts a scroll view drawing, as its overscroll stretch relaxes
diff --git a/third-party/nsh/docs/dev/web.md b/third-party/nsh/docs/dev/web.md
index 72cc7225dc..e80eb77e15 100644
--- a/third-party/nsh/docs/dev/web.md
+++ b/third-party/nsh/docs/dev/web.md
@@ -61,7 +61,8 @@ build to the same file) and takes `nsh.js` from the crate.
bytes: the engine reads fonts synchronously, `Assets::read`, to measure text with them). Fonts the entry or the UI
sets register are fetched before the app starts; one only chunks register (`font("asset:/…")` in their code) comes
with the first of them, through `createPlayer`'s `prepareChunk(id)`, which `nsh.js` awaits with the chunk's download
- (`web::font_loads`). It runs the bundle with `run(code, { release: true, harden, capabilities, appId, chunks })`
+ (`web::font_loads`). With `nsh.json` `web.host` the page also imports `host.<digest>.js` and passes `createPlayer` a
+ `host` (Platform services). It runs the bundle with `run(code, { release: true, harden, capabilities, appId, chunks })`
(`appId`: the `nsh.json` id, the page's `app.id`; a Rust app's root carries its own), without `limits`: a published
app runs on its own, with none (`sandbox.md`); an app whose capabilities restrict `network`
gets a CSP `connect-src` meta with `'self'` and explicit HTTP / WebSocket scheme, host and port sources
@@ -81,7 +82,10 @@ build to the same file) and takes `nsh.js` from the crate.
`bytecode_version` (the server then sends JS source, without the warning a mismatched version gets), it runs
each bundle with its message's `app_id` (the app's storage), applies patches with `player.hotUpdate` (`nsh_hot_update`; when it reports a failure, `nsh_hot_failed`, the bundle that
follows loads from scratch), shows build errors and `CannotRun` over the app, sends the app's log lines back, and
- reads fonts with a synchronous `XMLHttpRequest` from `/assets/`.
+ reads fonts with a synchronous `XMLHttpRequest` from `/assets/`. With `nsh.json` `web.host` it imports the module
+ (`web::with_page_host` serves it as `nsh-host.js`, read when the session starts, checked as `nsh package` checks it)
+ and runs its `createHost(player)` before the first bundle, as a published page does; `nsh test --target web` serves
+ each app's the same way (headless `nsh test` runs the module itself: `docs/dev/tooling.md` "Headless runs").
- **Tests** (`nsh test --target web`, `docs/dev/tooling.md` "On a real platform"): the CLI opens the development host
in a headless Chromium browser it drives over the DevTools protocol (`nsh-cli/src/cdp.rs`, `test_web.rs`). When the
server's hello says `test`, the page calls `player.testStart()` (`nsh_test_start`): from the next bundle on, every
@@ -609,9 +613,33 @@ pointer, touch or key takes it over.
deleted video is stopped (a detached media element plays on). Browsers only start a video with sound after a user
gesture (a warning says so).
- **Registered native components** are the native apps' own: their view stays empty, with a warning once per name.
+- **The host** (`nsh/host`): the page answers the app's calls with `createPlayer`'s `host` option, an object whose
+ `call(name, args)` returns the result or a promise of it. `Command::HostCall` is the command `hostCall` (`encode.rs`;
+ the id written whole, not rounded as `num` would), and `nsh.js` `hostCall` answers every one with `onHost`
+ `{ type: "reply", id, ok, value?, error? }` (`EventPayload::Json`, `nsh_event`): `ok: false` with the error's message when the page has no host, `call` throws or
+ its promise rejects, or the result is not JSON, since the app's call waits until it is answered. The page sends
+ events with `player.hostEvent(event, payload)` (`onHost` `{ type: "event", event, payload }`; one `EventKind` for
+ both keeps the kinds within `EventMask`'s 64 bits): the payload is serialized at once (a payload that is
+ not JSON throws to the page, not inside a deferred engine call where it would drop the calls queued after it), and
+ events sent before the app runs are queued and delivered in order right after `begin` starts it, so its top-level
+ `host.on` listeners get them (no replay to listeners registered later); every `begin` queues again until the new app
+ has started, and a start that throws (a trap) drops the queue and the events sent after it. `web/nsh.test.mjs`
+ tests `hostCall` and this queue on a fake engine (`node --test crates/nsh-platform-web/web/nsh.test.mjs`).
+ `Environment::host` is set when the player has a `host` (`nsh_environment`'s `host`, read at start only).
+ `nsh package --target web` copies `nsh.json` `web.host` (a JS module relative to `nsh.json`, `package::WebConfig`; `package::web_host_module` requires it inside the app's
+ directory once symbolic links are resolved, as `nsh test` does) as `host.<digest>.js`; the page imports it alongside the
+ runtime and runs its `createHost(player)` once the player exists, before `run`, and its `host` option forwards to
+ what that returns (no callable `call`: the page's "could not be loaded" error). The host module shares the page's
+ CSP: an app restricting `network` gets a `connect-src` of `'self'`, its `capabilities.network` and `web.connect`
+ (`package::WebConfig::connect`, host patterns checked as `HostPattern`s, which keeps any CSP syntax out:
+ `web::page_connect`). `web.connect` is the page's alone: the core checks the app's requests against its
+ capabilities before `fetch` sees them, so only the redirect hops CSP guards widen with it. The module is copied as it
+ is: its own imports are absolute URLs.
+ Headless `nsh test` runs the same module for each app a test opens (`nsh_testing` `page_host.rs`,
+ `docs/dev/tooling.md` "Headless runs").
## Not implemented
-Platform services other than the theme color, toasts, dialogs and the clipboard; image download progress; a web
+Platform services other than the theme color, toasts, dialogs, the clipboard and the host; image download progress; a web
view's events and commands for pages of other sites (beyond `postMessage` and loads), user agents. A published app
ships no source map.
diff --git a/third-party/nsh/examples/embed-android/README.md b/third-party/nsh/examples/embed-android/README.md
index 2af8d2580c..723050eb8a 100644
--- a/third-party/nsh/examples/embed-android/README.md
+++ b/third-party/nsh/examples/embed-android/README.md
@@ -20,6 +20,14 @@ nsh.registerComponent("embed.badge", context -> new Badge(this, context)); // B
The component gets its props (`setProp`, JSON as maps and lists) and commands, emits events with
`context.emit("tap", data)` and may report its size (dp) with `context.setSize(w, h)`.
+Theme overrides: "Brand" puts the host's brand over the app's theme. The tokens of a theme file
+(`nsh.json`'s `"theme"` format) win over the app's of the same names, light and dark, and
+classes such as `bg-primary` restyle at once; `null` gives the app its own theme back:
+
+```java
+nsh.setThemeOverrides("{ \"cssVars\": { \"light\": { \"primary\": \"#e11d48\" } } }");
+```
+
Signed packages (`nsh publish`; see `docs/dev/signed-packages.md`), e.g. updates the app
downloads: verify off the UI thread, then run on it.
diff --git a/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java b/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java
index 0647c6b867..681698b440 100644
--- a/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java
+++ b/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java
@@ -18,11 +18,19 @@ import com.longbridge.nsh.NshView;
* An existing Android app that embeds a nsh app below its own native UI.
* The nsh bundle (`nsh build ../embedded`) is packaged as APK
* assets under `nsh/`; it uses a native component the app registers
- * (`embed.badge`).
+ * (`embed.badge`). "Brand" puts the host's brand over the app's theme
+ * (`setThemeOverrides`).
*/
public final class MainActivity extends Activity {
private NshView nsh;
private int loads;
+ private boolean branded;
+
+ /** The host's brand: the tokens it sets win over the app's, light and dark. */
+ private static final String BRAND = "{ \"cssVars\": {"
+ + " \"theme\": { \"radius\": \"1rem\" },"
+ + " \"light\": { \"primary\": \"#e11d48\", \"primary-foreground\": \"#ffffff\" },"
+ + " \"dark\": { \"primary\": \"#fb7185\", \"primary-foreground\": \"#1c1917\" } } }";
@Override
protected void onCreate(Bundle state) {
@@ -48,6 +56,11 @@ public final class MainActivity extends Activity {
count.setText("loaded " + (++loads) + "×");
});
column.addView(reload);
+ Button brand = new Button(this);
+ brand.setText("Brand");
+ // null gives the app its own theme back.
+ brand.setOnClickListener(v -> nsh.setThemeOverrides((branded = !branded) ? BRAND : null));
+ column.addView(brand);
column.addView(count);
// The embedded nsh app, in a bordered frame.
diff --git a/third-party/nsh/examples/embed-ios/README.md b/third-party/nsh/examples/embed-ios/README.md
index 63f5ac38d3..928980e206 100644
--- a/third-party/nsh/examples/embed-ios/README.md
+++ b/third-party/nsh/examples/embed-ios/README.md
@@ -33,6 +33,14 @@ app.register(component: "embed.badge") { context in Badge(context) } // Badge:
The component gets its props (`setProp`, JSON as Foundation objects) and commands, emits events with
`context.emit("tap", ["count": n])` and may report its size with `context.setSize(_:)`.
+Theme overrides: "Brand" puts the host's brand over the app's theme. The tokens of a theme file
+(`nsh.json`'s `"theme"` format) win over the app's of the same names, light and dark, and
+classes such as `bg-primary` restyle at once; `nil` gives the app its own theme back:
+
+```swift
+try app.setThemeOverrides(##"{ "cssVars": { "light": { "primary": "#e11d48" } } }"##)
+```
+
With `NativeShellDev.xcframework`, `try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)`
loads from `nsh dev` instead and reloads on every change.
diff --git a/third-party/nsh/examples/embed-ios/main.swift b/third-party/nsh/examples/embed-ios/main.swift
index 4d26b6836e..44a280bf5b 100644
--- a/third-party/nsh/examples/embed-ios/main.swift
+++ b/third-party/nsh/examples/embed-ios/main.swift
@@ -5,7 +5,8 @@
// a native component the bundle uses (`embed.badge`). "Load update" runs a
// signed package (`nsh publish`) verified against a root key; a real app
// downloads the package, here it ships as `nsh/update.nshpkg` with
-// `nsh/root.pub`.
+// `nsh/root.pub`. "Brand" puts the host's brand over the app's theme
+// (`setThemeOverrides`).
import NativeShell
import UIKit
@@ -14,6 +15,7 @@ final class EmbedController: UIViewController {
private let app = NshAppView()
private let status = UILabel()
private var count = 0
+ private var branded = false
private lazy var trust: NshTrust? = {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return try? NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
@@ -36,7 +38,10 @@ final class EmbedController: UIViewController {
status.font = .systemFont(ofSize: 13)
status.textColor = .secondaryLabel
status.numberOfLines = 0
- let buttons = UIStackView(arrangedSubviews: [reload, update])
+ let brand = UIButton(type: .system)
+ brand.setTitle("Brand", for: .normal)
+ brand.addTarget(self, action: #selector(toggleBrand), for: .touchUpInside)
+ let buttons = UIStackView(arrangedSubviews: [reload, update, brand])
buttons.spacing = 16
let header = UIStackView(arrangedSubviews: [title, buttons, status])
header.axis = .vertical
@@ -67,7 +72,7 @@ final class EmbedController: UIViewController {
}
@objc private func load() {
- guard let url = Bundle.main.url(forResource: "bundle", withExtension: "qbc", subdirectory: "nsh") else {
+ guard let url = Bundle.main.url(forResource: "bundle", withExtension: "nshc", subdirectory: "nsh") else {
return
}
do {
@@ -79,6 +84,24 @@ final class EmbedController: UIViewController {
}
}
+ /// The host's brand over the app's theme: the tokens it sets win, light and
+ /// dark, the app's others stay; `nil` gives the app its own theme back.
+ @objc private func toggleBrand() {
+ branded.toggle()
+ let brand = """
+ { "cssVars": {
+ "theme": { "radius": "1rem" },
+ "light": { "primary": "#e11d48", "primary-foreground": "#ffffff" },
+ "dark": { "primary": "#fb7185", "primary-foreground": "#1c1917" }
+ } }
+ """
+ do {
+ try app.setThemeOverrides(branded ? brand : nil)
+ } catch {
+ status.text = "\(error)"
+ }
+ }
+
@objc private func loadUpdate() {
guard let trust,
let package = Bundle.main.url(forResource: "update", withExtension: "nshpkg", subdirectory: "nsh"),
diff --git a/third-party/nsh/examples/embedded/App.tsx b/third-party/nsh/examples/embedded/App.tsx
index 5fbb47b794..d64012d0ee 100644
--- a/third-party/nsh/examples/embedded/App.tsx
+++ b/third-party/nsh/examples/embedded/App.tsx
@@ -1,8 +1,10 @@
// The nsh app the embedding samples (examples/embed-ios, examples/embed-android)
// run. The badge is a native view of the host app, registered as
// `embed.badge` (`NshAppView.register(component:)`, `NshView.registerComponent`).
+// The pill is drawn with the theme's tokens, which the host apps override
+// with their brand (`NshAppView.setThemeOverrides`, `NshView.setThemeOverrides`).
-import { mount, signal, Text, type YNode } from "nsh";
+import { mount, signal, Text, View, type YNode } from "nsh";
import { nativeComponent } from "nsh/native";
import { SafeAreaView } from "nsh/base";
@@ -23,6 +25,9 @@ function App() {
<Text style={{ color: "#57606a" }}>
The badge below is a native view of the host app: nsh lays it out, sends it props and gets its events.
</Text>
+ <View class="self-start rounded-(--radius) bg-primary px-3 py-2">
+ <Text class="text-primary-foreground">Styled by the host app's theme</Text>
+ </View>
<Badge
ref={(node) => (badge = node)}
text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
diff --git a/third-party/nsh/examples/gallery/tests/fixtures/mention.tsx b/third-party/nsh/examples/gallery/tests/fixtures/mention.tsx
new file mode 100644
index 0000000000..209498be15
--- /dev/null
+++ b/third-party/nsh/examples/gallery/tests/fixtures/mention.tsx
@@ -0,0 +1,39 @@
+// An @-mention candidate list driven by the keyboard while the message
+// input keeps the focus: `up`, `down` and `enter` bound with
+// `{ when: "always" }` take the keys before the input does.
+import { Input, mount, signal, Text, View } from "nsh";
+import { keymap } from "nsh/keymap";
+
+const people = ["Alice", "Bob", "Carol"];
+
+function Candidates(props: { onPick: (name: string) => void }) {
+ const at = signal(0);
+ keymap.bind("up", () => at.set((at() + people.length - 1) % people.length), {
+ when: "always",
+ repeat: true,
+ });
+ keymap.bind("down", () => at.set((at() + 1) % people.length), { when: "always", repeat: true });
+ keymap.bind("enter", () => props.onPick(people[at()]), { when: "always" });
+ return <Text>{`candidate: ${people[at()]}`}</Text>;
+}
+
+function App() {
+ const text = signal("");
+ return (
+ <View style={{ flex: 1, paddingTop: 80 }}>
+ <Input
+ testID="msg"
+ autoCapitalize="none"
+ value={text()}
+ onChange={(t) => text.set(t)}
+ style={{ height: 40 }}
+ />
+ <Text>{`text: ${text()}`}</Text>
+ <If when={text().endsWith("@")}>
+ <Candidates onPick={(name) => text.set(text() + name + " ")} />
+ </If>
+ </View>
+ );
+}
+
+mount(App);
diff --git a/third-party/nsh/examples/gallery/tests/mention.test.tsx b/third-party/nsh/examples/gallery/tests/mention.test.tsx
new file mode 100644
index 0000000000..8e91834203
--- /dev/null
+++ b/third-party/nsh/examples/gallery/tests/mention.test.tsx
@@ -0,0 +1,15 @@
+// A hardware keyboard's arrows and return move through @-mention
+// candidates while the message input keeps the focus (`nsh/keymap` with
+// `when: "always"`): on phones and tablets as on the desktops.
+import { open, test } from "nsh/test";
+
+test("the arrows pick a candidate and return inserts it", async () => {
+ const app = await open("./fixtures/mention.tsx");
+ await app.type("id:msg", "hi @");
+ await app.waitFor("candidate: Alice");
+ await app.keyboard.press("down");
+ await app.keyboard.press("down");
+ await app.waitFor("candidate: Carol");
+ await app.keyboard.press("enter");
+ await app.waitFor("text: hi @Carol ");
+});
diff --git a/third-party/nsh/examples/host-echo/App.test.tsx b/third-party/nsh/examples/host-echo/App.test.tsx
new file mode 100644
index 0000000000..f3951f94b6
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/App.test.tsx
@@ -0,0 +1,17 @@
+// The app with its page's host (nsh.json `web.host`, `host.js`), which
+// headless `nsh test` runs as the page would: `nsh test examples/host-echo`.
+import { expect, open, test } from "nsh/test";
+
+test("calls reach the page's host and come back", async () => {
+ const app = await open("./App.tsx");
+ expect(app.shows("available: true")).toBe(true);
+ await app.press("label:call");
+ await app.waitFor('text:{"n":1}');
+});
+
+test("events reach host.on, those sent before the app started too", async () => {
+ const app = await open("./App.tsx");
+ // `createHost` sent one before the app ran, then one a second.
+ await app.waitFor("ticks: 1");
+ await app.waitFor("ticks: 2");
+});
diff --git a/third-party/nsh/examples/host-echo/App.tsx b/third-party/nsh/examples/host-echo/App.tsx
new file mode 100644
index 0000000000..d93edca4f2
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/App.tsx
@@ -0,0 +1,34 @@
+import { mount, signal, Text, View } from "nsh";
+import { host } from "nsh/host";
+
+function App() {
+ const result = signal("(not called)");
+ const ticks = signal(0);
+ host.on("tick", () => ticks.update((n) => n + 1));
+
+ const call = () => {
+ host.call("echo", { n: 1 }).then(
+ (value) => result.set(JSON.stringify(value)),
+ (err: Error) => result.set(`rejected: ${err.message}`),
+ );
+ };
+
+ return (
+ <View style={{ flex: 1, backgroundColor: "#f4f5f7", padding: 24, paddingTop: 64, gap: 16 }}>
+ <Text style={{ fontSize: 28, fontWeight: "bold", color: "#1f2328" }}>nsh/host</Text>
+ <Text style={{ fontSize: 16, color: "#1f2328" }}>available: {String(host.available())}</Text>
+ <View
+ onPress={call}
+ accessibilityLabel="call"
+ role="button"
+ style={{ backgroundColor: "#1f883d", borderRadius: 12, paddingVertical: 12, alignItems: "center" }}
+ >
+ <Text style={{ color: "#ffffff", fontSize: 18, fontWeight: "bold" }}>call</Text>
+ </View>
+ <Text style={{ fontSize: 16, color: "#1f2328" }}>{result()}</Text>
+ <Text style={{ fontSize: 16, color: "#1f2328" }}>ticks: {ticks()}</Text>
+ </View>
+ );
+}
+
+mount(App);
diff --git a/third-party/nsh/examples/host-echo/host.js b/third-party/nsh/examples/host-echo/host.js
new file mode 100644
index 0000000000..0e8e2795fe
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/host.js
@@ -0,0 +1,13 @@
+// The web host of examples/host-echo (nsh.json `web.host`): answers
+// `host.call("echo", args)` with its argument and sends `tick` at once (it
+// reaches the app when it starts) and then every second.
+export async function createHost(player) {
+ player.hostEvent("tick", {});
+ setInterval(() => player.hostEvent("tick", {}), 1000);
+ return {
+ call(name, args) {
+ if (name === "echo") return args;
+ throw new Error(`no host function ${name}`);
+ },
+ };
+}
diff --git a/third-party/nsh/examples/host-echo/nsh.json b/third-party/nsh/examples/host-echo/nsh.json
new file mode 100644
index 0000000000..6fb3f6ef56
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/nsh.json
@@ -0,0 +1,10 @@
+{
+ "name": "Host Echo",
+ "id": "com.example.hostecho",
+ "capabilities": {
+ "host": ["echo"]
+ },
+ "web": {
+ "host": "./host.js"
+ }
+}
diff --git a/third-party/nsh/examples/host-echo/tsconfig.json b/third-party/nsh/examples/host-echo/tsconfig.json
new file mode 100644
index 0000000000..b78fc0764d
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./.nsh/tsconfig.json",
+ "include": ["**/*.ts", "**/*.tsx"]
+}
diff --git a/third-party/nsh/tests/compat/cases/core/core-list.tsx b/third-party/nsh/tests/compat/cases/core/core-list.tsx
index d1b5a3b959..f21b7029a2 100644
--- a/third-party/nsh/tests/compat/cases/core/core-list.tsx
+++ b/third-party/nsh/tests/compat/cases/core/core-list.tsx
@@ -3,7 +3,9 @@
// `each`, with a header, a footer and separators, rows added when `each`
// changes, the padding of its content (`contentStyle`,
// `contentClass`), and what an empty list shows; `Scroll`'s view around its
-// content (`contentStyle`, `contentClass`). Its script checks each.
+// content (`contentStyle`, `contentClass`); the rows of the overscan around
+// the viewport, rendered once the frames after the first bind them. Its
+// script checks each.
import { List as NativeList, Scroll, Text, View, signal, type ListItemsProps, type ListProps } from "nsh";
import { List, type ReadonlyListSource } from "nsh/list";
@@ -98,6 +100,13 @@ function App() {
<Scroll testID="scroll2" style={{ height: 80 }} contentClass="p-2">
<View testID="inner2" style={{ height: 20 }} />
</Scroll>
+ <List testID="long" each={Array.from({ length: 30 }, (_, i) => i)} style={{ height: 100 }}>
+ {(item) => (
+ <View style={{ height: 40 }}>
+ <Text>Long {item()}</Text>
+ </View>
+ )}
+ </List>
</View>
);
}
diff --git a/third-party/nsh/tests/compat/cases/core/script.txt b/third-party/nsh/tests/compat/cases/core/script.txt
index 89d143b702..2ffc556682 100644
--- a/third-party/nsh/tests/compat/cases/core/script.txt
+++ b/third-party/nsh/tests/compat/cases/core/script.txt
@@ -112,6 +112,11 @@ frame id:inner2 8 8 374 20
expect Plum
absent No plums
expect Nothing here
+# A viewport of overscan below the visible rows (100 pt): bound after the
+# first frame, never further.
+expect Long 0
+expect Long 4
+absent Long 6
tap id:add
expect 4. Item 4
expect 3. Cherry
diff --git a/third-party/nsh/tests/compat/cases/modules/App.tsx b/third-party/nsh/tests/compat/cases/modules/App.tsx
index a333f8eb04..bed62a5756 100644
--- a/third-party/nsh/tests/compat/cases/modules/App.tsx
+++ b/third-party/nsh/tests/compat/cases/modules/App.tsx
@@ -9,6 +9,7 @@ import ModAndroid from "./mod-android";
import ModClipboard from "./mod-clipboard";
import ModDesktop from "./mod-desktop";
import ModDevice from "./mod-device";
+import ModHost from "./mod-host";
import ModIos from "./mod-ios";
import ModKeymap from "./mod-keymap";
import ModStorage from "./mod-storage";
@@ -20,6 +21,7 @@ const cases: Record<string, () => JSX.Element> = {
"mod-clipboard": ModClipboard,
"mod-desktop": ModDesktop,
"mod-device": ModDevice,
+ "mod-host": ModHost,
"mod-ios": ModIos,
"mod-keymap": ModKeymap,
"mod-storage": ModStorage,
diff --git a/third-party/nsh/tests/compat/cases/modules/mod-host.tsx b/third-party/nsh/tests/compat/cases/modules/mod-host.tsx
new file mode 100644
index 0000000000..0317fec720
--- /dev/null
+++ b/third-party/nsh/tests/compat/cases/modules/mod-host.tsx
@@ -0,0 +1,35 @@
+// A case of the API's test suite (docs/dev/compatibility.md): `nsh/host`
+// on a platform without a host (the headless runtime's): `available()` is
+// false, a call rejects at once, and the root's host events reach
+// `host.on`'s listeners until they are removed. Its script checks each.
+import { Text, View, signal } from "nsh";
+import { host } from "nsh/host";
+
+function App() {
+ const called = signal("not called");
+ const heard = signal<unknown[]>([]);
+ const sub = host.on("tick", (payload) => heard.update((h) => [...h, payload]));
+ return (
+ <View style={{ flex: 1, paddingTop: 64 }}>
+ <Text>{`Host available: ${host.available()}`}</Text>
+ <Text>{`Host call: ${called()}`}</Text>
+ <Text testID="heard">{`Host heard: ${JSON.stringify(heard())}`}</Text>
+ <View
+ testID="call"
+ onPress={() =>
+ host.call("echo", { n: 1 }, { timeout: 1000 }).then(
+ (value) => called.set(`ok ${JSON.stringify(value)}`),
+ (e: Error) => called.set(`rejected ${e.message}`),
+ )
+ }
+ >
+ <Text>Call</Text>
+ </View>
+ <View testID="remove" onPress={() => sub.remove()}>
+ <Text>Remove</Text>
+ </View>
+ </View>
+ );
+}
+
+export default App;
diff --git a/third-party/nsh/tests/compat/cases/modules/script.txt b/third-party/nsh/tests/compat/cases/modules/script.txt
index 8a398e2f29..1fc7b0a9ee 100644
--- a/third-party/nsh/tests/compat/cases/modules/script.txt
+++ b/third-party/nsh/tests/compat/cases/modules/script.txt
@@ -133,3 +133,14 @@ expect Storage removed: a=1 missing=null
tap id:clear
expect Storage cleared: none missing=null
covers [nsh/storage] Storage
+# mod-host
+tap id:case-mod-host
+# no host on this platform: the call rejects at once; the root's events
+# reach the listener.
+expect Host available: false
+expect Host call: not called
+tap id:call
+expect Host call: rejected nsh/host: this platform has no host
+event id:heard onHost type=event event=tick payload=1
+expect Host heard: [1]
+covers [nsh/host] host
diff --git a/third-party/nsh/upstream.json b/third-party/nsh/upstream.json
index a21d2d7bb8..fb0c2d16bf 100644
--- a/third-party/nsh/upstream.json
+++ b/third-party/nsh/upstream.json
@@ -34,10 +34,10 @@
".github/workflows/windows.yml": "f3ce2cef5ba8f05156459dcbeba3857c816a2871744729d7a7dd8e58079ecf6e",
".gitignore": "09f8ab58f988ed6ac0047bb8d497cdb6a3cc8af3af02a676f6e39a31addec242",
".rustfmt.toml": "3e2351e140d029ca8104ed001ae088b8041aaf2e5d9606d5ede1807a986740da",
- "AGENTS.md": "8f2c3d834c60111abb7389c6716caa7b2a54aaaeb177ddc4c0d1d156da196913",
+ "AGENTS.md": "2da6fc36e3f2065ec906afff28fb55649b186bb5e387fbc80e2f7b4a8b6ae11a",
"CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49",
"CONTRIBUTING.md": "fb27100fec01ba22dde6da1546f9cd761b8e23bec7d47eca87146c7991e85de2",
- "Cargo.lock": "f1765287f4b60baad0fe758919f878f9da61b6ac0f3e8a17ed9488b3608955c6",
+ "Cargo.lock": "3102413ebdd1dfd794d127aea1344c48d16ea159c65177133eb51bcaa2752e6c",
"Cargo.toml": "95f2a961a6236d03988ab3d4124c9d7586e9da91f775a2bf9f8e669a95e428a5",
"LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38",
"README.md": "bfbb302f6b0689fcfdc8d30cd1133fc11bca801677d17e517b2adf2d67b77fca",
@@ -126,7 +126,7 @@
"crates/nsh-base/src/dock/value.rs": "a8f75f77b4b770755247eac7c07d4340e49541bc3dfd164fbc189fa27d09771e",
"crates/nsh-base/src/dock/value/tests.rs": "3c730d09df8502d04211f7f3f00ede95be06ff1efac11deebc3d802ef8993449",
"crates/nsh-base/src/keyboard.rs": "797de960194d5a7c29c0f88c1e14429895067d68ccd00987777615f73673e856",
- "crates/nsh-base/src/lib.rs": "de0c6516d558654521318e20b97ad0d1609de40d0801a13347903f6aef629f8d",
+ "crates/nsh-base/src/lib.rs": "290ef42b9145ceffe02cf046d72ed4d007068ff7690f9891e7816460a2106dde",
"crates/nsh-base/src/menu.rs": "ba6339940ab1f77de454cd32a3d01fcaa370f2234bf44352a8ade0d043f1cd8a",
"crates/nsh-base/src/navigation.rs": "0fa4be89464b3feffacb521991192ca5d71db53a0021f90461c4e37df77c1278",
"crates/nsh-base/src/popover.rs": "d42902bb2396513bc9eeb98b7e584cee2b99647523779e2e888bf4b0fbbf1063",
@@ -156,14 +156,14 @@
"crates/nsh-base/src/text_view/mod.rs": "f6d200e37c21aece2cf3355781577539a9830118d3e55d34356a55776d3b5a7b",
"crates/nsh-base/src/text_view/parts.rs": "1cc12fdfdb815ea5eb44ceaa37467849b9005d99353809150379708f4b7c1add",
"crates/nsh-base/src/text_view/view.rs": "e1162a8a0e79876951ffc30fd3f930346f2cfb68969d03c284ca350c9e5e4105",
- "crates/nsh-base/src/theme.rs": "cf2c7a0d55987301434e8c61a96d4190c0945fed9c52632076c50812aef8b432",
+ "crates/nsh-base/src/theme.rs": "2eedb3df1738c45b6c295f18cd5a8a2f4959195c87d9c304fa2d8e5ecd8bb9c5",
"crates/nsh-base/src/tooltip.rs": "5ee4deb7358e62d9e88b0287d06624e538c21619229b4cace4ecda09c214c5a0",
"crates/nsh-base/tests/base/dock.rs": "d6657e7a078fec7ee3f3640540b8f2935255d68a176af79444713574d8177c27",
"crates/nsh-base/tests/base/main.rs": "d4bd54e22c71fee7a6ea0f4c242b77aaafd24c7af9773b86db6d26b6e1f9b699",
"crates/nsh-base/tests/base/resizable.rs": "77d40746a511c641b8332fa3187ecc890f278cac1582a4c5a613476fbcf200b7",
"crates/nsh-base/tests/base/sheet.rs": "2a7cfda4b7087d85df7f4e65e23c2386fe3b91a868339aa7dba159e42e27f56a",
"crates/nsh-bundler/Cargo.toml": "8284a211f8f7fbf7fc14de5ac6739a5b837ad2db30729bdbffd70001df95000f",
- "crates/nsh-bundler/src/bundle.rs": "812e38f4d8522267f832f4381d388c2ee8226729beb9605ceeea610c35600721",
+ "crates/nsh-bundler/src/bundle.rs": "19e06f32851a6f70a1ae37b709bf53c26d2165d08d9692654bac63c15e321668",
"crates/nsh-bundler/src/classes.rs": "b6fc4affe5ca9ee0dc38b0b00579375dd2c6d6684424e4b6aa08371ab965fb40",
"crates/nsh-bundler/src/compile.rs": "a8c3881697eedf15074495c7bee24c5bf79af9667ecf5a99fb6089dabdeb6445",
"crates/nsh-bundler/src/hot.rs": "c3655c9dc2c1c561831bf33d0a7915bfa596063ac130cca4b79e37a021d76c10",
@@ -183,12 +183,12 @@
"crates/nsh-cli/src/build.rs": "930e9e61189b299f9b4121db7810c48a463f50d2e45d3dae2a88d820ab08d309",
"crates/nsh-cli/src/cdp.rs": "a7df2735afb1013b4946b9d21393c9d72e1afa2e28e23667eaa242eb5878681d",
"crates/nsh-cli/src/check.rs": "4caecd505d9a8464a99eb6ebc3a34e047aafa205c86e794524f865b4761677ff",
- "crates/nsh-cli/src/dev.rs": "e25c40cf05389b1c2eb8d0de22a5d4fd33492eecd1ef0c0a5ec96d36421dc4f3",
+ "crates/nsh-cli/src/dev.rs": "0c512351de593209eac4c22c1e388b77c5c5a086fb1bfa38e7da88c7ff6e3161",
"crates/nsh-cli/src/entry.rs": "8880023842ca765687e3ce6f356a9f8498881fa8d90f459c025702964098ea41",
"crates/nsh-cli/src/init.rs": "953623491dc2fc64dc1f72c113b8d18edb564271b54a3e62ca7ff8648bb41f08",
"crates/nsh-cli/src/logger.rs": "09574fa1381d8dc650d820f6953541601180698657cf3eda2f1429165122840d",
- "crates/nsh-cli/src/main.rs": "feb8ecbb8f3e4b33cd29e3df840198f28cead2c3341bce22407d234b734ed46e",
- "crates/nsh-cli/src/package.rs": "40c91f4ec6e23cacd7fc256c3bed159a35a52e833de67f21797fb9917fbd542d",
+ "crates/nsh-cli/src/main.rs": "f01c93d5be6e074924c45875082b44dfc1dee6c088d039d5ae56c2c10592d683",
+ "crates/nsh-cli/src/package.rs": "f75470945d7db517f8d493f6ca613d6f14a9e06b2df0edb9dda7c6aa8fdcdc06",
"crates/nsh-cli/src/run.rs": "077ebada325b5ac5470a21a4be510a2507c28f47711be42844e53914cf8f9f51",
"crates/nsh-cli/src/runtime.rs": "5c0c87e5f3793083bc66dcd67c0faff2362de0ee788145fa00198cd29c5b9628",
"crates/nsh-cli/src/rust_app.rs": "ada2a98a27628f508eea1fe818a2dc4252a7c60b1d73179dd3eeceba076ddd8e",
@@ -206,19 +206,19 @@
"crates/nsh-cli/src/templates/tsconfig/3.json": "832b63d1c87c6381f7d49d7f55394ce4ed2503d3cf0d0124b81a88ec9621a330",
"crates/nsh-cli/src/templates/tsconfig/4.json": "d7972c656ce45ce90df97fd5370c26c660c5fe8fca5355cf24822498039685ee",
"crates/nsh-cli/src/templates/tsconfig/5.json": "5d6ee953f0366848a5cbe9fe9d0a6b31637d14e8dfb20761e7730c9228753b1a",
- "crates/nsh-cli/src/test.rs": "ebf44d8fa58c05c0375a0cd7586a3f0ee38968f9c2897682c7411bae8599bce4",
+ "crates/nsh-cli/src/test.rs": "a938625c17784be9648ab3525bb76b3e549c73c8f3173a4028db531f0fba2867",
"crates/nsh-cli/src/test_android.rs": "6f3ca5c6caf8e46d48fa7d3046e82a9d1d5f50efd1a6a68a08f3c0472295c48c",
"crates/nsh-cli/src/test_host.rs": "1d180f234bed65f2e53fbae83e3b9547644e17d3d5bfeb165a12e15b874cf892",
"crates/nsh-cli/src/test_ios.rs": "aa5e20afee9e655471d7971c31f94ed9d3973784dc13edc20019f3ef45de3ef5",
- "crates/nsh-cli/src/test_web.rs": "0f4d0ce39f86ffd4a4de2e6d2d6b9d6f69427aaf10e7c1a3dfa397fc440bdba9",
+ "crates/nsh-cli/src/test_web.rs": "cedb081afca9ece1830142ce344f80cfcc08f217ae6d92dd05ebb7a260fc1748",
"crates/nsh-cli/src/tui.rs": "edadbdd4996dce76cf87b4e8eb4e4101fa6ed338f2ebb7f0725a6c61d2bde6c0",
"crates/nsh-cli/src/ui.rs": "b76573d4d63e976026013526e661b0f32d74f9da995fc1facc7f3f48c040735e",
- "crates/nsh-cli/src/web.rs": "736b5930852cca6f86b5c7ceb376308607a1804dc42ca092869c8c67cfcffcfb",
- "crates/nsh-cli/src/web/dev.html": "6ee194e1e1eeae680741635ff7c4f1f2e8a24613bf190d2ffa81a718dba27bf0",
- "crates/nsh-cli/src/web/index.html": "e31fd8c3e4d72b3b82cf47e79c3dc5fc8405321b9c26558ec86904a90703e49a",
+ "crates/nsh-cli/src/web.rs": "9990b4ddd89ef561c10da236f9613ce731b2aca9bd91c4b867452012c34332e0",
+ "crates/nsh-cli/src/web/dev.html": "e4ff740d6776686ce3fea4f9520f4e811b4adfdd04fe76dbbf3ed167fbf31f06",
+ "crates/nsh-cli/src/web/index.html": "706946ee021a1b0e62c11200242b526d351fb1cde61711fdc449512ad9610dbc",
"crates/nsh-cli/src/windows.rs": "81b1e8af3c91b3d45f7c0d05ac2abef4ca426403e30f7c71c8029d74bfde6b24",
"crates/nsh-core/Cargo.toml": "9e5e298894de41f52334ba29bec2c3cbc37ce8676f754701c9f4680f99f7a7de",
- "crates/nsh-core/src/access.rs": "5e880fb21cc43c8c28fe42bd06a08f56520d4eca33c8e404b199cdc83ac3d764",
+ "crates/nsh-core/src/access.rs": "9b45558ea450cab93f570d28de3d9ec521b2f6f5f82700bdcc63188202a4e3ec",
"crates/nsh-core/src/animation/animator.rs": "a5ccd0ab2bf2f353bf7e31c0e8ef11151f443d3a551ee63ee7487f77b87a7506",
"crates/nsh-core/src/animation/driver.rs": "68f951a4572f81335911c19b65329b3f920a85d9ad402d566716f84c1372311e",
"crates/nsh-core/src/animation/graph.rs": "a4fe8ca39d20fffce8f2114ee8d834c82044053ad5ef34c17e3bfa037c7e1fe2",
@@ -230,18 +230,18 @@
"crates/nsh-core/src/clipboard.rs": "36b1c87f8762288ba9f8282daa03a5deb5541769afe75d58a7ba0cf8fe7defb1",
"crates/nsh-core/src/color.rs": "93afe9bf38e8f7b12108fedc4863f8e1bee3a55ae7d0c75e788b79b02fb72a93",
"crates/nsh-core/src/core/classes.rs": "242ff14438c0785b6d29af6315273d4f0256cc048fad221b4cabd2f3a0f2027f",
- "crates/nsh-core/src/core/components.rs": "93f3cb23f3a570c620d8204bf46a125885f35cebe71e56e637eaafcb673dd057",
+ "crates/nsh-core/src/core/components.rs": "8f7530a4c7734f039b6eede7a50201b2baa4e6617d4c5ea74e583dfe72220421",
"crates/nsh-core/src/core/layout_tree.rs": "767715a7dbbb6d28718cfea4b2893dddc7a0237d48057618738ea80616080c33",
- "crates/nsh-core/src/core/list.rs": "e709dcf029ac3d8d0c4bc87b4e1984ce07f50e18266d0759eaed773de1902684",
+ "crates/nsh-core/src/core/list.rs": "cac76d6037c248f885412ae5a08a7a93162cdb327e7d58bb12f28940c695b3f3",
"crates/nsh-core/src/core/measure.rs": "c6692abd3002576afef5b6d777d7694e28e4754bf7ada0adf8ed1c92419289c1",
- "crates/nsh-core/src/core/mod.rs": "50805dbbb2f7f3b00a100d6b7a30cb4c5702c22063ddb4fa38f8d4e3a8d57906",
+ "crates/nsh-core/src/core/mod.rs": "218e5d4f39a5333c2bbe621e52a54fa88e9d156344671160df9cef63a0fed046",
"crates/nsh-core/src/core/overlay.rs": "7839b8e7da75b4599321d486a8891dfbd3a4514bc1992926b60fdb5b64640f29",
"crates/nsh-core/src/core/parent_viewport.rs": "1326e1b74de2d14cb9b0a99be4ff00b6f2509f79c6a98a3597e8508ac08d3fa6",
"crates/nsh-core/src/core/reading.rs": "3bb3d25dc8b97f35eb73dcbaf2d6f1b5dff8cdbdd012a8edcef7fab6661f3d9f",
"crates/nsh-core/src/core/renders.rs": "eba08379b95272762630258bba8c9d1908f8e8ec94d9518bfd59470edf49b5cd",
"crates/nsh-core/src/core/services.rs": "8812eefd00661841ff2b1de5d4f8cf68dd667da4151b4c79ed966cc940934752",
"crates/nsh-core/src/core/sticky.rs": "2c508038c1677fc10f11b5e6c1bf245cb78a75a02beaac24d0b09b2033a4beaa",
- "crates/nsh-core/src/core/tests.rs": "d6f30766f59f6a3ad46c8e9d46b7416dd14142666f7743e6be2f82797316585a",
+ "crates/nsh-core/src/core/tests.rs": "7564074f04082156eb00acf4efb5f0e47da753a2f3158d3e1d6238c76cbafa61",
"crates/nsh-core/src/core/touch.rs": "a8559c023a443a6defad135af2f319e60130e7df23af2a8353239b0862e494a8",
"crates/nsh-core/src/core/views.rs": "c32c59533d5ee33bc16677b0459816f7670c224f3f8de3507c72029989660834",
"crates/nsh-core/src/css.rs": "56760520c706f64505849d5a32a1eada2d9fd243a5c70e07f21bc8ed0105217f",
@@ -264,20 +264,20 @@
"crates/nsh-core/src/input.rs": "9ef67d528e7a00bb4b325a425aa50e20ee3ebbf7f62325201520b6a3f4ffab07",
"crates/nsh-core/src/js/animation.rs": "e2bb991a9e414e81f1d4e9449b4c017d42a5a54f529f4713651e1c5badca32d8",
"crates/nsh-core/src/js/canvas.rs": "348900df6c3d12e2061e09d7d7c1d50fc409cb1f42fe4da2e1400e89fab93679",
- "crates/nsh-core/src/js/host.rs": "3ff652f77711cb48a1255805fdd51d34d5c514e14ba9d1d4ad3cb8c192c97613",
+ "crates/nsh-core/src/js/host.rs": "8dea87bc668dadf7b6427a0fd749fd336d63dbd16ecc771f75119fc7ce60e269",
"crates/nsh-core/src/js/host_values.rs": "f8da8a7af5cf78c4e514357c7d15b5cec5d32b60dfdfd103a28a32bbc89dac09",
"crates/nsh-core/src/js/limits.rs": "44bafb43b6fc14a6899f7f7219fb8d7505485179bc0ec470f76d9266d9b35fb3",
- "crates/nsh-core/src/js/mod.rs": "0377b9d8f67c77acc47a467dc709090d3c5951a3f4ae6118abe680e97bfef9ca",
+ "crates/nsh-core/src/js/mod.rs": "3dc93ddfd5bb86533bb7289df1ee9b5af071cc493617b06b05fce67f667690c3",
"crates/nsh-core/src/js/ops.rs": "c43398b95c3d9a6d58027b19bd3fb4d073287986939d9b894e9d4d1c905c4cd4",
"crates/nsh-core/src/js/protocol.rs": "19c4c34368d2a05a196e580d5813159f44e3d8183b9c6e9168f9b3ab3db4f91a",
"crates/nsh-core/src/js/style.rs": "8b80f7b45468d22da5403a25092087d42340ca119e6940309e38f22ef1651926",
- "crates/nsh-core/src/keymap.rs": "c55d58813d5d84d2c33dc9a9cc9d9ef95e3e5eaef4e03a1af42c863674281685",
+ "crates/nsh-core/src/keymap.rs": "0ee7d4c0d5fa74ca6ee4bc32ac7889caf51a1cf0727651e4648e4cd109aadeea",
"crates/nsh-core/src/lib.rs": "5106d92ca05d9f46573efa426d1abeef8d7b5cddf71d4b1f7e3f7beda339c82f",
"crates/nsh-core/src/mount.rs": "d8b4f34e10db29189b29538041345b7c3cc90f2d743e48f6d20ad453d51471d3",
"crates/nsh-core/src/navigation.rs": "d79dd1d10a04157cbc5a383dc7b01aee820e6a33a16c682d3e43dc5d4909f5ea",
- "crates/nsh-core/src/ops.rs": "cc2c363638171332617f74ea3534c6da6180c4cfb6ab1ef2cf065b79ccc43d2e",
+ "crates/nsh-core/src/ops.rs": "7bc0cbe50b8669cd22f2d84e315437cabfd0fa687d322e6d8840da61aff389f9",
"crates/nsh-core/src/overlay.rs": "e3029e8e8827a4365a4ac91162c77baa2a8e76fcbf22ded7c46cafac8cc33b13",
- "crates/nsh-core/src/platform.rs": "d0acf7489905e456a648aac2cd2e892a0a7b0a34ead24de921a7cb33bd0029e3",
+ "crates/nsh-core/src/platform.rs": "2170c0865a2dab9f20a99ec99d2fc92a4242042964f6a89852c06dd2ac6c50cd",
"crates/nsh-core/src/platform/clipboard.rs": "42bad81067d3339c38fbd45de16611c92bb6dcbf0c400c5486962ca19e70f282",
"crates/nsh-core/src/props.rs": "88d70945adcdbcf384e6b13ed1105d6f683b877e7a429a9b0804b9b356415963",
"crates/nsh-core/src/reactive.rs": "0d2d4c952be9b7f1c6c9c12898aa5d63b30e9401e8621194fda80904470e41df",
@@ -288,8 +288,9 @@
"crates/nsh-core/src/runtime/clipboard.rs": "916e9bfcf05cc6b8702f0fccaa20e7e1062a71dcacc820d625fc34ecfccecab8",
"crates/nsh-core/src/runtime/desktop.rs": "9f178654e9ddb48d258ccf198b5faac58f68cee3c3ef9fbeb079ed9523b87b46",
"crates/nsh-core/src/runtime/fs.rs": "b7f6770ccaefe5a04b034d649ef639601876651bece06c03b5ed4e5265d05d97",
+ "crates/nsh-core/src/runtime/host.rs": "e66077432dc0ec52f61f3b3b1b8e3a0a80779e22323dc16f539ee8d7498060d8",
"crates/nsh-core/src/runtime/image.rs": "df044f7d2f061309859de8b1c0f293747c85721eaae7ee265805da8c0517b811",
- "crates/nsh-core/src/runtime/mod.rs": "6bb4ba9ac5272e343f2b3453c56151388bc2a49a108a53d11f8b7b12449de0bc",
+ "crates/nsh-core/src/runtime/mod.rs": "e16f88133118e0c7152d757929636d3775da374f9546a085e9574583d13b5642",
"crates/nsh-core/src/runtime/net.rs": "698fadd3e298e8da089e4d4c88ed5f9a714028b021e5155c89fc6f5e86bf143c",
"crates/nsh-core/src/runtime/os.rs": "741a77c8a93dc6343873aea7b90a951f9340bd9a61ddaf185e6e6a3108bb81ea",
"crates/nsh-core/src/runtime/process.rs": "53eaea00026fce5689ee9d500aba7508f1e79d9ea3e4867232f9fdc281978efb",
@@ -319,7 +320,7 @@
"crates/nsh-core/src/time.rs": "c342c2e1d620cd2e8d44691e10064e56505253b696f543c1223c6ca4e34a137d",
"crates/nsh-core/src/transform.rs": "7a5fef9eb9b544d114ccf0f3c11e272aaed77eea1124da06bf247ef354e3b07c",
"crates/nsh-core/src/transition.rs": "f468b6d0a6e07d12ff76d7abbfef4c00233f69484fdfa4bb6eb7f288116375d9",
- "crates/nsh-core/src/ui/ambient.rs": "864367f4f2d4999056e9d804ea0584807c37b377dfada1f5864756817d6bea7a",
+ "crates/nsh-core/src/ui/ambient.rs": "eb874449051ba9c5898ecd9c47b7deaab2d81d7ecd99f2dcf41f4cb8fe36a88b",
"crates/nsh-core/src/ui/animated.rs": "b5e802d1f29181f663e375cbf6d6f09cb1706e4141ebf0b9289f25cc48579344",
"crates/nsh-core/src/ui/asset.rs": "ecc4b40dbe52c239f69de438eab9444768bb5d7e4069aa15a51c3adc7477702e",
"crates/nsh-core/src/ui/back.rs": "ef98d58af2314de48ec9fef54677a69d663a6fbd6fcbe90ba5f80f8c253bbce7",
@@ -332,7 +333,7 @@
"crates/nsh-core/src/ui/element.rs": "0caf17597339f476132700eb31fba3a992ab4e43818b83e51b10113801daa535",
"crates/nsh-core/src/ui/element_ref.rs": "18505f479859e82aeaabe2522318adf0004f2301ba9d7119bfa032f1fc5bc233",
"crates/nsh-core/src/ui/engine_context.rs": "fc9f4487a2ec47939119d2c6f5c75dc081ede1feb0422be8127b19fcb129fa1a",
- "crates/nsh-core/src/ui/env.rs": "6560f20704070b7bb08c1e8e7c58f63c86afc53c150aa41fc571460a0c8e6b37",
+ "crates/nsh-core/src/ui/env.rs": "46aeba0e33d4335410b4e173e91f49dc343985007a20e5f8ec91d83d7bc19cfe",
"crates/nsh-core/src/ui/events.rs": "40d4d1b562781ac13732685d5decdffb81860010790469c9b4219cb3df9736fa",
"crates/nsh-core/src/ui/flow.rs": "3984b86ad1aaba5a1dd8fc32f93c139dcf3260d86500f6791ac8dd15d6de756d",
"crates/nsh-core/src/ui/host.rs": "03366afe31db0cef32adf10f5bd9eccb2406365bf1f5df711ad0e063ace1f408",
@@ -351,11 +352,11 @@
"crates/nsh-core/src/ui/responder.rs": "b5e419ced70b1457743c4c9fd61e3cc51c93db348581cc9bd14b61fe695e0e43",
"crates/nsh-core/src/ui/rows.rs": "a5fd4db97517c39b1e4d898c19cb4a8e60429ffc6de1021527df0f2cd53bb365",
"crates/nsh-core/src/ui/scrollable.rs": "704ae082a1f10e2b344e20299e52177ee77990bacf83eb71c6523e0419f8ec6a",
- "crates/nsh-core/src/ui/services.rs": "df1e10972fbdea7b4af78016948c214eb393c49ef8d4e023e9f55d022e91a8d6",
+ "crates/nsh-core/src/ui/services.rs": "3009cbdcb6dd76d922d1295759c019bd200e084b3112c944d904b2b1a9e8b498",
"crates/nsh-core/src/ui/status_bar.rs": "2fff306388c47bfee440e1042f9143251fe0b210cd57a370e5e8a8247c537acc",
"crates/nsh-core/src/ui/style.rs": "3a39cfbd53c8a754d59685e73ccb7cf1da431063c8b7a4ec0379bb9fd039498c",
"crates/nsh-core/src/ui/system.rs": "077e9dada158047e159eae15a86eadbdff62875e473e11255afacf32a20f9607",
- "crates/nsh-core/src/ui/task.rs": "e19a244fd39fb716a71ad33f653f3f24cc3e390c40159224ad341aa51c37e174",
+ "crates/nsh-core/src/ui/task.rs": "9416e77ea03b568807b95a3c84bd85b3353b7d5788adf6d4bc605be3d17404d7",
"crates/nsh-core/src/ui/tokens.rs": "c95b983afc27c65290b22f5db7e9ece7b48d05e7f4c769c2e882e81695ab4756",
"crates/nsh-core/src/ui/value.rs": "81804c80fb2a357b40f55fa4586c55a818370cb641e8c074e6f3f8f6046ab0b8",
"crates/nsh-core/src/ui/virtual_list.rs": "e3d0ae1417072a65323d978a8fdf7911d936d82f4304993e5ed74b2589335abe",
@@ -374,7 +375,7 @@
"crates/nsh-engine/src/access.rs": "37cb4b18aa2f1d47325969b035bd08f5d6cb9db6c5b437a6a9aa7bd2677f6707",
"crates/nsh-engine/src/app_directory.rs": "bf99d20949b392e5b919f8185a6e234308d53ae7db02c29b0d18ce678b74986f",
"crates/nsh-engine/src/error.rs": "54bce4d455c05a1c37b3e0ce8e46271492369855482283ffae3851605b44bc51",
- "crates/nsh-engine/src/lib.rs": "e443d068bc5197bbc89df2e6bb648a3ba1c2cc679baeaef15eb110f43bc04252",
+ "crates/nsh-engine/src/lib.rs": "96617c9770085ca93389e32017d14a375e8b1fe75ba332a6d1e1f37d322dafbb",
"crates/nsh-engine/src/local_storage.rs": "bf75d06d4d917a14b6d4f08abb10d385c68ed4ab50b6c70c4851cbc72ad3f631",
"crates/nsh-engine/src/net.rs": "3a1fe7de92639eb17aac9e97156b7a9c6cf9384bbd9a973548c12434b48910f9",
"crates/nsh-engine/src/perf.rs": "29aeede364da40ee8cbdf2acb750abaab7cee523b2df34870b7bbeaf291f1a33",
@@ -386,23 +387,24 @@
"crates/nsh-engine/tests/engine/clock.rs": "b11f9c4240f2787b39dc819bdbe90b2d3666b4ff3f89d267ec90d5356780495f",
"crates/nsh-engine/tests/engine/compiled.rs": "ed35fbe5be966d3bdd3d3cf6f7d090dce664e1312a8484b36cdab2dbbbeaaa54",
"crates/nsh-engine/tests/engine/component_bench.rs": "3d9625ac6a683620aff4c277addb251f724ce1fdc20fc1dfd228a7b3fbf17aa4",
- "crates/nsh-engine/tests/engine/components.rs": "8bc03ffd81b3ef86d101cac1d2fbcab88df98aa198bb8d55fe01a1e65653a21e",
+ "crates/nsh-engine/tests/engine/components.rs": "49dbba00e8c97e005d872161e40bcbf20314e97dd215388bc9bbfee87cf11b25",
"crates/nsh-engine/tests/engine/counter.rs": "1b0f9981461d121290e72789c2efe495c46dd19df138a9dc908977fa7364aa05",
"crates/nsh-engine/tests/engine/errors.rs": "a050ab6f85a865b7701563de2230c2c316cf94a40eea15a17702a020d2657c42",
"crates/nsh-engine/tests/engine/external_wake.rs": "4c114be3b7489e607e9826c8264bbf9bdd161479fbda8f598da6595661ab09ff",
"crates/nsh-engine/tests/engine/fetch.rs": "30a4ad7fea1aa1f3e481d9774ed3aefcd92832f36f3347841a8ead672e05368d",
"crates/nsh-engine/tests/engine/fs.rs": "71f693539ff71b4ddccddd925739a52a695e2fd22335cfad396304395147b01f",
"crates/nsh-engine/tests/engine/handle_bench.rs": "fdfb1c00cbae1dfa73b3b5fcd373bd839774d2f137642a5dd1b09bbc8ad12675",
+ "crates/nsh-engine/tests/engine/host.rs": "b9d3364dd822cb84fae74a159fcb1d51ef13279907d1e86ab527d6b26124a498",
"crates/nsh-engine/tests/engine/host_modules.rs": "261558f3b5badd08e89586af65893603f195588508b99e3d6164f572bc045cba",
"crates/nsh-engine/tests/engine/hot.rs": "98e44b2518e746f86a61ef503af030e7d460becfa3b66891d3a98228bcf8be46",
"crates/nsh-engine/tests/engine/hover.rs": "555c2bf8043e52d181dae2e4a35d01182fe44258d5051be1c5412ad1613b7482",
"crates/nsh-engine/tests/engine/images.rs": "775dad08ac5ca247b8cb5388e40c9a70b3583636bcc1bf8bc4d09551aacad372",
"crates/nsh-engine/tests/engine/js_touch.rs": "dee2b2ac3299459a90ef785e24081e0fd280dc9aa91d650de69e8c938ce61888",
"crates/nsh-engine/tests/engine/keymap.rs": "85fe9f59f13bc07c66148a1d2cf8ef62858e914a83d84dd451c4d6af7d8ad1a4",
- "crates/nsh-engine/tests/engine/list.rs": "757a325fb32271922cf977d35307431e6c8fb6e1db19d4991cc9cb783e06e1bd",
- "crates/nsh-engine/tests/engine/list_bench.rs": "df4cb2074bf17123776168d8650ce2575595a3b7162796b0dfe793c880e022c6",
- "crates/nsh-engine/tests/engine/logs.rs": "e40d974c261a3b025b80dbbbb2be83e2d58f8ccd07ef9a77b82f740d5795bd9b",
- "crates/nsh-engine/tests/engine/main.rs": "0db1654e25ae62e4d496446e38e8c0b369fc82d1435ea8b791ed5c0e8ab703f1",
+ "crates/nsh-engine/tests/engine/list.rs": "a2167ff0e4eba7d4066b9687ecfd155609e81fa76967bdfcac661f6b1b61e9b1",
+ "crates/nsh-engine/tests/engine/list_bench.rs": "54cb3ab6e5b415e02cc7f8fb6e79f0a2c3ca2f3fc022fcb5f4a545f25c179080",
+ "crates/nsh-engine/tests/engine/logs.rs": "22201bd9432b8e3801064aba2a5b77d3a64f4a4b148133af5a39e2e26a33c0e2",
+ "crates/nsh-engine/tests/engine/main.rs": "cd4bd38e3eee7064bbad3d25eb8a72f9db14bcb1d0d0d4883c319a343cd09249",
"crates/nsh-engine/tests/engine/native.rs": "91e9f9255e84e0f4f93e40e72fe1953d4bf305818cee95b102be715ba9bf6fc3",
"crates/nsh-engine/tests/engine/net.rs": "d6b6b5cb734f7a0ded0063d193919ea8ca1d783511102d5078bb4e8e6c07ceb7",
"crates/nsh-engine/tests/engine/package.rs": "5dd7e22c6d63c010b1ed3d701810442d112120b143fef6481cd44d33c43f09fa",
@@ -426,7 +428,7 @@
"crates/nsh-engine/tests/engine/storage.rs": "271620e86942712de84bbc3c0ed71763f38d8e9f56bee3717fa38ab6494131f2",
"crates/nsh-engine/tests/engine/system.rs": "fc1b5c7d5417d256cdeb51451110b728f31b85862aab377e3730540df13d4a63",
"crates/nsh-engine/tests/engine/test_ui.rs": "e5e6f8b9acd4124668deec4fc8860ab1849c1f3be82c1244330ace6e4599b5fb",
- "crates/nsh-engine/tests/engine/theme.rs": "fb247e36f8ebf78c568fa6319e4dd4b5532f2f973783737f6c8778246be97c08",
+ "crates/nsh-engine/tests/engine/theme.rs": "32eaa004b7b5ace88ec4b855d7734f6e9dddf5dbc0db170058f00cdd1e34cf55",
"crates/nsh-engine/tests/engine/websocket.rs": "d598ae8ba425749118c9269d6cde057c38fae0f029758b58538d8e79368cf133",
"crates/nsh-engine/unitless.txt": "608551ec4de1da091099bad8e64d961a352681da134c4af9b58b9a29a9e43018",
"crates/nsh-engine/untyped.txt": "0e94c66fb47844aa29a7b5deab131001f3ab89ea46e79c1d75679bf618788c10",
@@ -450,16 +452,16 @@
"crates/nsh-js/src/dom.rs": "b75190400a363ed60f5d6737be4cb4d14f8d69e819201bb090bfc4dedb98d56d",
"crates/nsh-js/src/env.rs": "571522044cb3d43799d185e363edf87622a03580487db85e5827ea59229d0ac5",
"crates/nsh-js/src/error.rs": "01a5f911656faa39aa30dbcd71789ce30c35b3e740c5f3ec5125c87b85282bd5",
- "crates/nsh-js/src/host.rs": "3740d306e43b9eb5c81f30623044f44ee166828c9100425f691bc3d244e94931",
+ "crates/nsh-js/src/host.rs": "b089d561e7872692f75459055b117a732f6ae5f0c4930e2f1e7c485ab0976789",
"crates/nsh-js/src/hot.rs": "6286e13ee994063c1c72292bfdb44c964dad45b127edb24a2d76461bd163d621",
"crates/nsh-js/src/input_action.rs": "eb70b0792eff82ccda139f63700656167ac04753872fca2aa63485673d9ab047",
- "crates/nsh-js/src/keymap.rs": "cf6a81a283c6f3ea77b07dabc1ab8ed3d64b88cdc3d51e8889cff6cc757806a3",
+ "crates/nsh-js/src/keymap.rs": "aece0cd38a1bb2fd432069f4135cb3f2efaa15ebc6fd048d11f498458f1b8d8b",
"crates/nsh-js/src/lib.rs": "396374fe32e378c38d5ad936b6146569fcf183c16f5133e6ba8d46b231ba29d1",
"crates/nsh-js/src/list.rs": "dafec1262d993ed59e80067ed925ba3e1ee4481a68a1a53dd604126e4e81a998",
- "crates/nsh-js/src/modules.rs": "c23d93fbf0bd5d5680fa02e402ed2c95bd574f6c3d6a900e9722bdf55230362e",
+ "crates/nsh-js/src/modules.rs": "28a938a4cb141000513eac5e70e8e4c419d2b709a31f15506252a8f49108cb8c",
"crates/nsh-js/src/navigation.rs": "23d6bce4611eff5da1516561caf7d89082059b2aaae0cec47f72efe955998401",
"crates/nsh-js/src/net.rs": "2c1e993f6e58ecb095a78ceac05357ab747cf8d21769507ebbc3c74c63fe6950",
- "crates/nsh-js/src/platform_modules.rs": "26c25867b012588b1d5fc0783a99b0c650915a9a48d797a57f73ecae34008d09",
+ "crates/nsh-js/src/platform_modules.rs": "971d3d89b57be8abd8f521e92eaca170df7e04485be861360bee18cefd0bc002",
"crates/nsh-js/src/protocol.rs": "058fa87197c16aa0bbc76345244eddef44f0fb7a8c669ff974486889648ac4dd",
"crates/nsh-js/src/reactive.rs": "36773de3b3e8e1368cb9960fe3925d7d15e746dc85df03c659c55527d7683e67",
"crates/nsh-js/src/rejections.rs": "7873bcd5daa9b755951b0ded491e72ba9c9a82f77526dfc41df829a998fda7da",
@@ -568,34 +570,35 @@
"crates/nsh-platform-android/java/com/longbridge/nsh/Json.java": "80d814fa3a0357463394fb0d84d98ec02687abd42b2d3521037db2e1ad20db27",
"crates/nsh-platform-android/java/com/longbridge/nsh/ModalHost.java": "a40b13eadf1061ae52d9ef92ea55352d836af64e0d846a48a3e9856c56a4ef79",
"crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "6ae84283cfb51b9976a3c1c59550e826c13add4c0c7f00ae4ed59b85dea69465",
- "crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "b97b3c79261f9362e07c70cf73c288fe27ea20e7a5f66e9a48a6bf4a8e1017a3",
+ "crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "5949506a2d24366244796464fa41195987236c8c5804d1a9f4c865ff8ab9af37",
"crates/nsh-platform-android/java/com/longbridge/nsh/Net.java": "891adea10cb4f8cf0020ed3854eac241572b00b32506e129987715db2a183149",
"crates/nsh-platform-android/java/com/longbridge/nsh/Node.java": "d16e8e7d2375e3633c3cff5edeba08cf38d08726801f67721b12ba51bcce4e5c",
"crates/nsh-platform-android/java/com/longbridge/nsh/NshComponent.java": "70005073c746cc06ea2421a788443abd20447dacbdbe62a9cd4e8b2cbb4b3011",
"crates/nsh-platform-android/java/com/longbridge/nsh/NshComponentContext.java": "2c1a1fcb84cb5c9742ff2a51021be1c304a8dd7db5e1841a6078bc3a0fab0d97",
"crates/nsh-platform-android/java/com/longbridge/nsh/NshPackage.java": "8f15ba9b542631a20ff0edba6c2a159ff547fe713ea98e1a160e8b52c0c76474",
"crates/nsh-platform-android/java/com/longbridge/nsh/NshTrust.java": "9a9027f5eab16e5f54641c72914b319c53e0de2f2502a1baaf3eec941f2ea527",
- "crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "f9e25b8234d2786c8bb13073f02b958b7739d8a38db4f5c122450e2b4a29ebb6",
+ "crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "5f6259c51630cc857ce95526d433f5d5eda3fc8e54ae3dba772a1a6c1fdb059e",
"crates/nsh-platform-android/java/com/longbridge/nsh/Pressable.java": "5bb17da3a0e1fd7519d34498752183f4e86fdc6e8d17102cd2e5a31fb3bdf98d",
"crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java": "9fa43f2b9a68c711fbafde8e7c7eda2ac44782838288b1e6ca42c05472d6f9d3",
"crates/nsh-platform-android/java/com/longbridge/nsh/SelectionArea.java": "376a3abe901a71c03ca95edd4189a7fce1b3c620b5b4830b269ff68c99de213e",
"crates/nsh-platform-android/java/com/longbridge/nsh/SelectionHandle.java": "5c0ad0957390540d21c4b9e351c343a2c7f0532934f6ed54b8cb621102d169a8",
"crates/nsh-platform-android/java/com/longbridge/nsh/StackBox.java": "f712333f289b2eba41611a7ef36b677d1f12a7850d69a9fa389e77959ec5eb1c",
"crates/nsh-platform-android/java/com/longbridge/nsh/SystemUi.java": "6001517437d9828a2b5cf65f9b4c7bea96380decf5f5f02c81de4991934dc208",
- "crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "aebaf9af4985c6502a043f4dad054b0453b84cc4a2f772a031ae00a653c917cf",
+ "crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "26b3bcdfcd9d872ebe55429f561026d8361fc8a7a9f5ceb4f63c78613d14a795",
"crates/nsh-platform-android/java/com/longbridge/nsh/TextBox.java": "6ad8195352f29338e96c144985a4797a8894e3b0fcbf473bf7a1ea297c3ec5a5",
"crates/nsh-platform-android/java/com/longbridge/nsh/TextLayouts.java": "bc02e66bdb1408a904d3c799f2d228487742cfa7ceb9fe24391c80fb4e368c42",
"crates/nsh-platform-android/java/com/longbridge/nsh/TouchTracker.java": "29d218455c922e29bf69e58d48cc797637e5030c768dfb846e6cb7969cc99621",
"crates/nsh-platform-android/java/com/longbridge/nsh/VideoBox.java": "2e7c5fed70f1227353f2f6363dcd6c4fa4d5f277a2739515fe13bba2f675e3f7",
"crates/nsh-platform-android/java/com/longbridge/nsh/WebBox.java": "5f49a4b3748f8df0180ec45cb3b3c296694ca0ba3fdf2c7d117cb4e021b39965",
"crates/nsh-platform-android/java/com/longbridge/nsh/WidgetBox.java": "51340bdf442b737f2271e091ec2114e0550b005e8a5eba02ad0947c53c574120",
- "crates/nsh-platform-android/src/agent.rs": "7ef85fa373ab3d4516afa3b9b6fc221de133edd12d092366d0ec37a372f3e6b5",
+ "crates/nsh-platform-android/src/agent.rs": "c67465629a6d49e13dad3c12bc44550d1d500bcea1222905634df8b39c68b97b",
"crates/nsh-platform-android/src/animation.rs": "c1fef8de76e942b48c05e108723effc49ed4a57cbe726d9b73037b77e6704a26",
"crates/nsh-platform-android/src/dev.rs": "fc60e6257001b3e5c27802d4320e138bebf21f66a561928297a68bbc5de0d5da",
- "crates/nsh-platform-android/src/encode.rs": "1f7b9bb75a90b6c882577af186dd72c41b342f358262f3453100187a07f4b115",
- "crates/nsh-platform-android/src/host.rs": "15101195191e4d8b60fcb04f0a7ae1b02f488dd368c216908256f89151c60b3f",
+ "crates/nsh-platform-android/src/encode.rs": "053353b68306cc26811df3a0e8d63f607efca8e47e80b943f167619befe37c0c",
+ "crates/nsh-platform-android/src/host.rs": "bc55d6f5bb8cb0c02f620f4ad31e7cc215aaaf7d943a45332a08ff4fe065880d",
"crates/nsh-platform-android/src/java.rs": "342ea89f068da2261731704ba4cc3628835a1c06f5b8db07e071abcac8d4ef3f",
- "crates/nsh-platform-android/src/lib.rs": "f7dd6f6e125ff9a6c488fb9322cea08da0fa84a234e7e03a9e251007b874a352",
+ "crates/nsh-platform-android/src/keymap.rs": "7710df8e27d117d3902ab7788a425406cc6b2b2226f3a42abbc60f16239b4f6d",
+ "crates/nsh-platform-android/src/lib.rs": "10282c14a4d56f60291ae8bb6c07593275592ad7ce034b63ec91c96d5d41660c",
"crates/nsh-platform-android/src/logger.rs": "7fe95c849533e54aa753757090b5747c1131e0523f2f5b24f260fb77bd0aa2f8",
"crates/nsh-platform-android/src/net.rs": "e51b9e715af5652a6d0697beaa707d021935128c06751010e773c0bda9baaa49",
"crates/nsh-platform-android/src/package.rs": "f69038eb79ac0e6885cf831fce67572e4b068fb164c21ff64abd40c82ef56fa0",
@@ -604,8 +607,8 @@
"crates/nsh-platform-android/src/text.rs": "5ca4425a2ab7a7030667c0b012ba3c85b3a126f3f015fafa785c5343cc3b6b8a",
"crates/nsh-platform-ios/Cargo.toml": "0fe3eabb15eceffb19a896318ce623e6f0752da8b8abb174dfd517162c5dd3c6",
"crates/nsh-platform-ios/include/module.modulemap": "687c700460603d8f9ca214ff058f91d0d6edc9e3a7350d981015c73872e5cea1",
- "crates/nsh-platform-ios/include/nsh.h": "b8505a1f2a0cb882be5dc798d475249b7bb2ae2850cf5575137a8c9e1c5d174f",
- "crates/nsh-platform-ios/src/agent.rs": "adb7c1b931ad01ccdb88dd42aa6ba9f27e407d7dbbecb34181ef4f4e5ee1e45a",
+ "crates/nsh-platform-ios/include/nsh.h": "20924d096a81b7a91ee4ccdafad44d0ce76b13eaa3f17c59046c911a0cd60104",
+ "crates/nsh-platform-ios/src/agent.rs": "a94f761b87e51ce68ebd13fa24477a452f04a6ad9b96005da6851aa52c526be3",
"crates/nsh-platform-ios/src/animation.rs": "662ffef6013aa336d05648576e149e7736a35c059ab549cc50f26cbfd75f966f",
"crates/nsh-platform-ios/src/app.rs": "b307e39e915d38961129c41a5a5c3858198a38b2f9e0e28655d2c020658e513d",
"crates/nsh-platform-ios/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
@@ -613,36 +616,37 @@
"crates/nsh-platform-ios/src/component.rs": "b53803ac0876795ea7b574dab2cfa771c18f30360ebcb2ad2989d716d52e9e66",
"crates/nsh-platform-ios/src/decor.rs": "bf54501f9a913cd17d03eea457fa3e20a90a765ff7171208bbee11136e5c7249",
"crates/nsh-platform-ios/src/dev.rs": "6a4d83df5efd1b0690558d817a37d2404459f855c389e7103463e06dc08e46c3",
- "crates/nsh-platform-ios/src/embed.rs": "3e9aa5fbcc4996305604aeee1a90863303e09405864968506205034fadddc64d",
+ "crates/nsh-platform-ios/src/embed.rs": "6b45045a29a9c5cf78b4754943f618df68b3ef957b0b6faa712f36876930214a",
"crates/nsh-platform-ios/src/image.rs": "67dab1f77c0809b63ffb798989f51b595b0663d3ef1bd3f6ffa93cc2dc982b34",
- "crates/nsh-platform-ios/src/input.rs": "5d6f6d43a3d011c8a513f9208590789408f31b0eed2067845d6ccbd7851712b2",
+ "crates/nsh-platform-ios/src/input.rs": "d32418441866e41b8a38df75beba6060acd18acc13ce8da11c3c6a6087a1e38a",
"crates/nsh-platform-ios/src/interaction.rs": "10b85e580a1c507a884d62c222c3f0b115ff82eac0f38e944954e17f9219d0ec",
- "crates/nsh-platform-ios/src/lib.rs": "25c0ad2a5615c46eca35d5420d02c3e24d869b092533023569d5599c5a32f6a9",
- "crates/nsh-platform-ios/src/mount.rs": "8c85c83c95e1e3eb9a29b79ea49820ec37b11955ee4998175d6c293b102eace2",
+ "crates/nsh-platform-ios/src/keymap.rs": "578fb8e85781ffa08f53ff1aaf65baa1ea80a08dc6892f4d6f180d649aaf54d3",
+ "crates/nsh-platform-ios/src/lib.rs": "57025f16e8c3081ad070eaad14458e64bf0059f8c195e4e6653faf0dd8c7f901",
+ "crates/nsh-platform-ios/src/mount.rs": "4eda9f5a722b53e6ac26cfa14b2adee1895081cdf09a6acc26679c0297b5ac19",
"crates/nsh-platform-ios/src/native_view.rs": "e73867cf00a330e184a6be6830d7b25894db62c1149e85fbe97b316f374831e6",
"crates/nsh-platform-ios/src/navigation.rs": "f881a30c8db5b5b01d360b1c497547dd1cbaa228ecfee7c55ccccece87d2a3bb",
"crates/nsh-platform-ios/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
"crates/nsh-platform-ios/src/press.rs": "3a68966f15bb361d8eba789b9a3cce2b4877c7fa9a7e8aca4f9868e2faeb2536",
"crates/nsh-platform-ios/src/selection_area.rs": "d492cfabf02baaeac68898eaf8a231141f635c5875d8336a401414346edbdfc8",
- "crates/nsh-platform-ios/src/surface.rs": "554efab1eafd60337541b6fca2278807c182ab525533169717ab33f2fbeb8efa",
- "crates/nsh-platform-ios/src/synth.rs": "db7aef0e5815132a49d334f2f3bc5324ffe00a3913825672af87936854321c8b",
+ "crates/nsh-platform-ios/src/surface.rs": "6696d2eb70b4202995c5755b41bbca46939f4aaf285a33fd51a9bf58053dece5",
+ "crates/nsh-platform-ios/src/synth.rs": "5ed5e9e78a632820a05ae9114d275283a5b7bccb782d1b1f80031ca013c69a52",
"crates/nsh-platform-ios/src/system.rs": "3c96018a1a03e54ccad60d4b3cdb3848d4a687864e865558c257ff4a4e4abd2f",
- "crates/nsh-platform-ios/src/target.rs": "994f0a282f57b553a1c392b191a2853dbd50b453b7155fa4e13d7c771e810545",
+ "crates/nsh-platform-ios/src/target.rs": "6dae6b295faeb46f663b167c0b45c70dcd418c4e2013a9a3058cc6a602fa8c59",
"crates/nsh-platform-ios/src/text.rs": "142376f7efed62e4d4c2ed8059d7ab0642356ce95d32db4767cb4f756905377c",
"crates/nsh-platform-ios/src/text_selection.rs": "cd68c073fed1d2baed7121fd72d2e703e7c14e5b9963fbc4f75098f38a00b6b2",
"crates/nsh-platform-ios/src/text_view.rs": "b7bd9d38bab972227360ba7bee4050d895283e406de2bc5e153f29dcf2465d1b",
"crates/nsh-platform-ios/src/touch.rs": "cb910d75ca29435063749c0620df765b116f739e19d79889497572a5c9461f42",
"crates/nsh-platform-ios/src/video.rs": "aad3d01262fc4520b9523eabff0a68fa6373c71433f0331bf6200e69b6907bf1",
- "crates/nsh-platform-ios/src/view.rs": "042e66b4200c4713809decb8bc0833d15dfffd6b8a2f0f77581732463b2f071a",
+ "crates/nsh-platform-ios/src/view.rs": "1807d29d3965e290e0c2d94751acc04f2e27e8833ebe9978c2c31de19fc607b5",
"crates/nsh-platform-ios/src/visual.rs": "dc968d6bbfa977b5791ae1f9cf3d859d3aef5567799f02ac3588b3f280744480",
"crates/nsh-platform-ios/src/web.rs": "2bf4ec4a10212c735585347f72175af0e784b7fe50b7e44037093c48e56c2844",
"crates/nsh-platform-ios/src/zoom.rs": "274010fd16832c2c93bde502c2c1d6ad308d59e594cdf859d28d87b5ec6ab865",
- "crates/nsh-platform-ios/swift/NativeShell.swift": "052e146f7bc8c99509f459fd908efa4718ce1e9a0dc5a9c0948786598d162df7",
+ "crates/nsh-platform-ios/swift/NativeShell.swift": "6058b673239941e863b8cc97180d09b4fbd8a4957cd32cedfb1e2d1ae6104d87",
"crates/nsh-platform-linux/Cargo.toml": "2ea41863aacd26d6bd568126f0c6f089f682555b4adc8fac5e2a7bd49dde007c",
"crates/nsh-platform-linux/src/access.rs": "9b9aa4d728b4451dfd80e0c3306deca2d7abf1308bb3acc3c2f0957257ed06b0",
"crates/nsh-platform-linux/src/agent.rs": "433d0a4d9b12c7373a2941d255c5f65103a34639d27b9c60caf9e6799c3c23d8",
"crates/nsh-platform-linux/src/animation.rs": "7fc1b4f7ec5b1f2010216bfbe8f4a773b7ac08549b326d439e266f0188be514b",
- "crates/nsh-platform-linux/src/app.rs": "bdc83b899efa9d5aed4ede07303e7ef82803b0ce7b4648f4fa6e0c5edc2b3e52",
+ "crates/nsh-platform-linux/src/app.rs": "3a1acad9886b53f25f263cd39cf29d9c2ad12c02f254b12aae0088b25fb891a6",
"crates/nsh-platform-linux/src/canvas.rs": "555850bb5a9cb0b9f09438ce360f460fb9f2144c4701001ba0bfd7dd91726dfe",
"crates/nsh-platform-linux/src/chrome.rs": "b0b1ba6b3e5280a5ede9afe7cc629738c1f99b3f57af3a5864c41ca5038e309e",
"crates/nsh-platform-linux/src/decor.rs": "9250081c55b8248278ccdcc8971184d6bfb820f2e6893d407ab76e84c21f37c1",
@@ -658,7 +662,7 @@
"crates/nsh-platform-linux/src/navigation.rs": "77be5eef778948105eeea4d91306fbd70f2afede4d43d5432bff377db6b49d93",
"crates/nsh-platform-linux/src/scroll.rs": "fb7b5cf5843a20d67f1a0550bbb53a1ac807684660ae691367d9bcbef2894310",
"crates/nsh-platform-linux/src/selection_area.rs": "f38e30a766c3d7f52954c6b0c8aca3a52dbdee94c90c4ad6f21b0b6aff66ea4d",
- "crates/nsh-platform-linux/src/surface.rs": "3182b74b110d3a684fad43616c7542bc61e6bd0017a2932fe974f101045833cd",
+ "crates/nsh-platform-linux/src/surface.rs": "f2087a9d1df07a1f5a94a49037fa0acd3409a873c334b66a858cff67161a617c",
"crates/nsh-platform-linux/src/synth.rs": "ca932e93904d5b3b734a874be5337c2f6eeeea72c35e4c5bf57c04d864c30df2",
"crates/nsh-platform-linux/src/system.rs": "fea65629cfbcf37eb050fdf9bb9f736e092af0375bfa8dd4f6ea425390f378be",
"crates/nsh-platform-linux/src/text.rs": "87d1b461b411da98a59e120e13290cc41d665bbd9c7eeb65d62db58c81fcc6ff",
@@ -669,7 +673,7 @@
"crates/nsh-platform-macos/src/access.rs": "5bafcc7cb9aa39c5d931f5c0fa5fef547b3d32ee6bcb1b38212cd7f1915c0b25",
"crates/nsh-platform-macos/src/agent.rs": "db50231031040a91216b29a9b41b6a936016d6ff2d1c8cac2399dc08d8f9b6e7",
"crates/nsh-platform-macos/src/animation.rs": "2458ba49973e39a739ad031482ef4c158a29e80c69413cf06bc615add190ad29",
- "crates/nsh-platform-macos/src/app.rs": "c1562689dc7c467a0e763a39b874babfebd92b0201b6709ce7c3dadc08af60c3",
+ "crates/nsh-platform-macos/src/app.rs": "f4866d3554882d2e91c78725a8da7191d9a1f52e743c975f97f713862bcb6580",
"crates/nsh-platform-macos/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
"crates/nsh-platform-macos/src/canvas.rs": "6134e252a2751484fb7a6db63f9c5ed95364126f38f988fc016a846de3a54963",
"crates/nsh-platform-macos/src/chrome.rs": "83129a8a83998c110487f496c04ad9c5e8363c6d6ea6f64666789ac13d1fbb6c",
@@ -688,7 +692,7 @@
"crates/nsh-platform-macos/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
"crates/nsh-platform-macos/src/scroll.rs": "824c167ffa1e9b6cfee91ab7aa1504b557da5c71003b18f736ab96f0872e1bdc",
"crates/nsh-platform-macos/src/selection_area.rs": "0425f31d882802bdee2f7bc2275dfcffa8274f194c2fe2a5a096efc8b91df4e6",
- "crates/nsh-platform-macos/src/surface.rs": "3068eae4156da32035457523a212c0f03a40096043d6972ca6bcd97fd79fbbca",
+ "crates/nsh-platform-macos/src/surface.rs": "52cb73ba0201bcb9fef631535a07144b9ca32f87bad03265070732404e75d054",
"crates/nsh-platform-macos/src/synth.rs": "c6510dcd66cb600e382e74d2d86352196188303bd86a85f12afb21213cf51776",
"crates/nsh-platform-macos/src/system.rs": "1e7be08090cecf5741c52799b7770a0b0dc82ffe78a752abee8ff98f2321577f",
"crates/nsh-platform-macos/src/text.rs": "ff5de5d8db0099f156a8dd643f7d85a804325f78bb9d3b7ea965e36c97fbaabf",
@@ -727,10 +731,10 @@
"crates/nsh-platform-web/build.rs": "6e60cd4d9737c2766e2631ff775410cc0aca1fc53d8463356ccce49f3a11f803",
"crates/nsh-platform-web/src/animation.rs": "b19491130e0b1009a483abccfd0a335684973072137c8e14d500827f7acf65b0",
"crates/nsh-platform-web/src/canvas.rs": "9fc4c10800eaa2b69ae34903fbc8aa0a3e4a65e53b8151d3390ee883bd8cb4b9",
- "crates/nsh-platform-web/src/encode.rs": "b776129aee4d45596a5d6c1a6c2631a055e6aceb2a8161a96e96dd870a3c8a30",
+ "crates/nsh-platform-web/src/encode.rs": "ee16f1f491eb67549e5f0479fffdf762e1f4e1d16fb556e66237c00a169e4a07",
"crates/nsh-platform-web/src/ffi.rs": "1ef25ae33cc562695438e574d7fead4a8b9d1536ce9ed78e8619d73c4c616d23",
"crates/nsh-platform-web/src/filesystem.rs": "34cb25848b20fe11967437368e5f5feda3f1297982232eb54aa27ab2611cfccb",
- "crates/nsh-platform-web/src/lib.rs": "4470f61a9480768ceea2654edc371997e558cdd6ca2001e1696deca8480dc778",
+ "crates/nsh-platform-web/src/lib.rs": "3479021a47c4c54b29e79bb20cf94062b9d4acf6aac2155895cf62d474c0246f",
"crates/nsh-platform-web/src/local_time.rs": "63f2781b1a5364c3914bbefb1bf0538d77876a1324a2e7a9548231711b8241f4",
"crates/nsh-platform-web/src/net.rs": "6ad23669ffb7f920da1d4c0acd9cdf6bf6a1b8f33317dfd27eb40ce0b133fe9e",
"crates/nsh-platform-web/src/storage.rs": "fdff261938b0d75b6b1ff633b4788cc386de23aede2b563fb226508042a3c27a",
@@ -740,7 +744,8 @@
"crates/nsh-platform-web/web/local-time.test.mjs": "e8b62dde3208d979eeb281bad19fcb7c19566732500e969ab2ae5c4cf373c9b2",
"crates/nsh-platform-web/web/mouse-browser.test.mjs": "8f3e5d180558a4d4553d01be86dd080ae80638734c83095d90b5507e2ee65420",
"crates/nsh-platform-web/web/mouse.test.mjs": "b6db2b6bb88c0e5b13bab082027c0d84162156350e1c86bde91cbf1f98f63644",
- "crates/nsh-platform-web/web/nsh.js": "55bfc70c093b86520a668cb46db9082265fa534b2965363f7c782787f2157cf0",
+ "crates/nsh-platform-web/web/nsh.js": "2ef82397994e5a5ee1fae4e08ed8c8c0e964f014411335a5a324e2608ed97adc",
+ "crates/nsh-platform-web/web/nsh.test.mjs": "f67b256bcb312be5ac46a7e0c11e07ee52fdb4e467f41551db460def709062f0",
"crates/nsh-platform-web/web/scroll-browser.test.mjs": "3f8150ebd2b69d7ab306307868b938b99435308ff37c2c999cf297a0f94fd30d",
"crates/nsh-platform-web/web/scroll.test.mjs": "4ea1e845ddd2e480a284caef06f2fcb4dd26f85d0d0808ac8216977cfaff0b30",
"crates/nsh-platform-windows/Cargo.toml": "ca891951f7e5d38889c956e0f00aacffa11da045ed4bb849065c781950c75d9f",
@@ -748,7 +753,7 @@
"crates/nsh-platform-windows/src/access/uia.rs": "9b37dace78dd3ec658728ff71c73b61d79da7e7852dbaa573a8588d02769ea9c",
"crates/nsh-platform-windows/src/agent.rs": "a4094f8b42c1096461dd13e2b6145fd497f2dfbf7b171ad35cda3d349ce45e99",
"crates/nsh-platform-windows/src/animation.rs": "5e849ee6e81ef77bfcd70e255228431f3ea2b67266530a10d962582541b78c29",
- "crates/nsh-platform-windows/src/app.rs": "5f98ffac0f08e7dfc58d21f90cf6d4f7e5f4a346b4456cfe47b76d99c6899ff6",
+ "crates/nsh-platform-windows/src/app.rs": "29fe32d6642335242fee5afda1859f905947a55c4af8cb979e350df1af877072",
"crates/nsh-platform-windows/src/canvas.rs": "ada51da124bade5308b0dbdc3301b5001c0ae0963ec47164800b316174c21f17",
"crates/nsh-platform-windows/src/chrome.rs": "1d7cddfb2cbcb9e6fd0c02e4ff72ee468caf89871142c3a6a8ffb5207e9717e0",
"crates/nsh-platform-windows/src/compose.rs": "fb996e3c8fa97934361aa72c27f599255f242fc98c79ee1c6226b4ee94c314e9",
@@ -772,7 +777,7 @@
"crates/nsh-platform-windows/src/render.rs": "50cc1fd242bd15fc495a77ca2383251ce5ed712688a552de908e3fc268d3d9f8",
"crates/nsh-platform-windows/src/scroll.rs": "28279bf52c6a8e36f173e9e3501470f1c7a17d5b7899390905a08f66202da461",
"crates/nsh-platform-windows/src/selection_area.rs": "79cc6116c2cfd1d33dd5b5e6bdc15222c3460aeb6eddf288fccc09489417b3fe",
- "crates/nsh-platform-windows/src/surface.rs": "680f0210bcfa893a1f4c182a097b4a96dd18d4095c5ad67f27ba0ff539fdae63",
+ "crates/nsh-platform-windows/src/surface.rs": "2477b05ae6b5711c8614c240251f04b7d5b7799aa012267341026714b7107808",
"crates/nsh-platform-windows/src/synth.rs": "27fc28c8d4d0b54dec35abfaeaa283f39bc3fc33eeb9d5b1cd9c280a6511966a",
"crates/nsh-platform-windows/src/system.rs": "0466f5714b717e08d7fbaec35cc7109be0bac8d30d2a997db13420c7eb23436b",
"crates/nsh-platform-windows/src/text.rs": "da82b3aba6af2b92c2615dbd27bc40ac7cdda0009027a7190faa349a8d2070e7",
@@ -780,24 +785,25 @@
"crates/nsh-platform-windows/src/transition.rs": "dac21868ca439f92c4ecb41197fec60f4b8cf54e0364213dbcf4b8ae9e01cc24",
"crates/nsh-platform-windows/src/view.rs": "064cccd47c5b13724fafe4082dc86954b676b746186c01100e466ad764294ea1",
"crates/nsh-platform-windows/src/window.rs": "84b85a4d05b9194198b647a7244f4dbeec4323df9a85ff6475f73c3ffec46ce3",
- "crates/nsh-testing/Cargo.toml": "a32d7aa17b13e244f7e9c2c6c8a8859cbdec3e878c1ddf1655fdd4b5b7113c0e",
- "crates/nsh-testing/src/agent.rs": "dadd07658ee16f5d6e57f89629ad14233e380ca4b614645d3816df7c6755aee3",
+ "crates/nsh-testing/Cargo.toml": "8cab49bdd6a4fce1584e25482263ccb82327a95fde6a2b83b32d9f9447fd974a",
+ "crates/nsh-testing/src/agent.rs": "be0bc6365aa19cbe444fbd8522675d99d6e907ef12ec71a5de86c8228fe86961",
"crates/nsh-testing/src/agent/forward.rs": "6e62c09570ba63b910caa280eba059fd2ef87f056bca59d2e5413b4c313ad4b2",
"crates/nsh-testing/src/driver.rs": "9a97ff5d9f7796965e7659298c2b885f214e826ff218c946d64f25f3d7712895",
"crates/nsh-testing/src/headless/animation.rs": "a38102dc120bd208331b64d94c28cfb2b1aba52845bf31d7f387fa552a6ad18a",
- "crates/nsh-testing/src/headless/keyboard.rs": "986176b5894e26d549dc9d6a1db119e06a4c7b224ff32e4e13dcb77f73e4c44e",
+ "crates/nsh-testing/src/headless/keyboard.rs": "57f9729e15ab46479abbac9926d81f3c751cacd3be3e2b6136009a11b6dfb34d",
"crates/nsh-testing/src/headless/live.rs": "97e9ebda01076dbbc525618303316f379397af8f07a4e0fcc3372a89096f6597",
"crates/nsh-testing/src/headless/menus.rs": "79276a0066e4505bcf3e5043f2bb800415a84e537415633f79815235b122461f",
- "crates/nsh-testing/src/headless/mod.rs": "fc6ed20e9dd3f5ebb55d03ea8129f43d213934c6ba3adf307308c348fa12bdf8",
+ "crates/nsh-testing/src/headless/mod.rs": "7d91786f0375f981748d58887bd81c1b41ea24f835a7ed16cd6986afdfc17df1",
"crates/nsh-testing/src/headless/net.rs": "05b51b76f89a71f11b085001950812cd581bc9f13f9111a867b6cb3dc608f3c4",
"crates/nsh-testing/src/headless/net_fetch_tests.rs": "798c72ea94333a2ce720d313a95b7e5612cf73c1830c9c8aed4ea5550e52f6e9",
"crates/nsh-testing/src/headless/pointer.rs": "b535b3b01c7d7803b8c92aca81258db65caedeff7d13b59d28f8874def6f6087",
"crates/nsh-testing/src/headless/stub.rs": "4f6a52cea583a94614c5552ec02b188d40878e69e65cd5b04ff8904a9f73ff3b",
- "crates/nsh-testing/src/lib.rs": "ac12cd696637cae87b62c08396b334a01b565ae9d0698383ebb2623e1f1eb160",
+ "crates/nsh-testing/src/lib.rs": "f2ba1344b98370030cd612d5c03c7a8cdfed8c5d346fd1f9005eb091e66cc837",
"crates/nsh-testing/src/menu.rs": "4bc761c4ef832b71e25630b3232d0b0a5367394b6632b2745edabb807248486e",
"crates/nsh-testing/src/module.rs": "209147852295109003570770a3383fd938a10175849fb8e2be80823c6a660394",
- "crates/nsh-testing/src/run.rs": "69b39dc5e84d927b39269e73b921c7c497b88db2bd073f7475c7ad578e5794c5",
- "crates/nsh-testing/src/runner.rs": "9ac5462e7834dacf12916681b65e2edb7b482f350dba658d8ee338594b220447",
+ "crates/nsh-testing/src/page_host.rs": "6c8c8d516a491dc41568cde09fda8bc7f5974bcbce3350b4d907260eaf3eb2e8",
+ "crates/nsh-testing/src/run.rs": "0b1c17a4059417d52ca40aa080311db5df08c95bca4f95cd0fd05abfe77dd730",
+ "crates/nsh-testing/src/runner.rs": "320e1368ddaf6e60da66ff7faef74f753bcdc5953d4cbff0dc07170dbb7a2534",
"crates/nsh-testing/src/screen.rs": "04cd1b008006e1b5aae501c31ac139443bdbff1b901b21038033fa746fb20895",
"crates/nsh-testing/src/selector.rs": "9567a7d5ecba869684cbe4a9b4d7441c074dab2e6d564ca6463302da0dd40599",
"crates/nsh-testing/src/storage.rs": "72eb5c29dec32f9a8d9afaa3b8d4053a8fc8cc76b0f2bf31f1f76cfd5ad4020e",
@@ -809,7 +815,14 @@
"crates/nsh-testing/tests/testing/clock/App.tsx": "88a13af9015a64565d9d5a2b208e29a3f92bf5ad42c26f30fb6b52fa588b7e8d",
"crates/nsh-testing/tests/testing/environment/App.test.tsx": "187831fa2adc650723de6e17ed53e1bd88f80169e91daa946c2b0277c3714077",
"crates/nsh-testing/tests/testing/environment/App.tsx": "84583720c58d63385b056e0f2145d35b22b6facc1d5eeb6a49ddff86d83cbefb",
- "crates/nsh-testing/tests/testing/main.rs": "9be3af39d046d30e1ca4c5ec969292480178bb1f033037bd605ab7e604941c8b",
+ "crates/nsh-testing/tests/testing/host/App.tsx": "4e0cbba5c962cd49fc8d4133296be0dbcc25eead3401fa2ec25b93e0409a0c7f",
+ "crates/nsh-testing/tests/testing/host/Broken.test.tsx": "38fc46c28004adf81e13f73e8e47322ba73c0b8b20fae40b4e221210a3037c7b",
+ "crates/nsh-testing/tests/testing/host/Host.test.tsx": "af3b65cc75680b47ea1c1cb73f53a34842c0bbb4ac693042652a5aa8f5206cb7",
+ "crates/nsh-testing/tests/testing/host/NoHost.test.tsx": "57da9b0c7f7dede954ad2def033eb98936f3133ad436d7ae49b695b841793990",
+ "crates/nsh-testing/tests/testing/host/host.js": "8d127bcc8a8bc959cb23c48a53fedc83f106dbfd0fd575b8812a1095bffeb1b7",
+ "crates/nsh-testing/tests/testing/host/none.js": "56c2d2a72379cc68ea04dbf5acb31d79a2c7673d3c272b2fb6a4ad6965a74ad6",
+ "crates/nsh-testing/tests/testing/host/nsh.json": "76e6426bcd425eb7673cb1e68d59e91acbb2f055e8c13ffaf9b05e21d727589c",
+ "crates/nsh-testing/tests/testing/main.rs": "31dffcf89c9c0efdff611143476e2ea6ff31fdec888e94d90a65f143ab6a9576",
"crates/nsh-testing/tests/testing/platform/App.test.tsx": "0126c606f69d8fd7b880b0a4c046f0da6187db574ab492a21715fedcc65d8277",
"crates/nsh-testing/tests/testing/platform/App.tsx": "d0a26d35006662ae6f6410493a647defeb5a01dbed7f7fdf78ed9b573b72b956",
"crates/nsh-testing/tests/testing/queries/App.test.tsx": "cfc603ff619f179002576cd89994cd09c3f07cec4782ebf33538aef55c687449",
@@ -849,31 +862,31 @@
"docs/dev/compatibility.md": "26336d6349cbedd0a70990dd5a4d2e7e48c604242974ac164e68bbeb4ad1cddd",
"docs/dev/components.md": "9f4a41c405a016275002fc9d850a222dcf941eb47f96201a1a384ba3f8a34914",
"docs/dev/dock.md": "51527992aeec0387fe7ebdb82b7d24ed4ab6f0dc19b3332e23236cb7a3ad1041",
- "docs/dev/elements.md": "94da584a4b2a7dfbd54c83799329ed05220ce8f2c86107df1e556148dd2b790f",
- "docs/dev/embedding.md": "db9490c56c428005722ff46a5c1a2e632b6f6dfd4a1283b99b766629cca58c5f",
+ "docs/dev/elements.md": "7b5f776fa9895a1a6aba0a275a8ac76a43b5660ae0468032b61e833922c9781b",
+ "docs/dev/embedding.md": "d072df7567fb6b178220b5d15926fd94e10a2b421115b4777ec9f61ac31157bd",
"docs/dev/file-drop.md": "ee9e7e263e32789d03893108e68b47d2447a0fa19b29be6b348f269884cb81b0",
"docs/dev/geometry.md": "cc747bc4cea9d983b1ac3d634be58a8eac27254ecb912c2940173f168de8f9a9",
"docs/dev/gpui.md": "15cb649e427c84858f177b0d85ae0540dcd4ae23a3b8b3df414a9f1dab6b0284",
"docs/dev/images.md": "9e797fd623bf317a5e96fdc7d04ac1bf85f2fc82d267a2d220e3e39c9a6f187a",
- "docs/dev/js-modules.md": "991718901c2e0ee19c3776a7f806d30717c0a1842a5f4c5f1bfb18343795ebb8",
+ "docs/dev/js-modules.md": "209d69c37352648f365c24e062af5016248ac499eab4c3d7b673b48f302cb215",
"docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b",
"docs/dev/linux.md": "4441ec64b6edc5c03084bd203aa5114c066f9e2c0541ee320be8f17b6f82c3fe",
- "docs/dev/macos.md": "827c809b5774681c77daf05ac6603342c027dfd459e78f55558044861711dcd3",
+ "docs/dev/macos.md": "c2b332f4a6e72b60520ebf34d8875a6776662ddc9d1ffa62963744a62961c988",
"docs/dev/native-pipeline.md": "07f803365af6ac90d5247c70d2e85ce9e03393f0e9e122c46690f85e381a0c55",
- "docs/dev/performance.md": "4f23f433adab44865db61546c7b6e4515ea0a292ec3f625fe86881edf5e6997b",
- "docs/dev/pitfalls.md": "80fc820be5136534546eee9790dbc3b824cd1053fe45432f0187f4d25641bdb3",
+ "docs/dev/performance.md": "2d37115997936be2c23bd57dcb9fe9411b171ce87ef0d553348c9f87baa9f8e5",
+ "docs/dev/pitfalls.md": "ec02fe363e800a5f338afed88e16a83d21971549fe47c4da553d4e865cb84d21",
"docs/dev/reactive-components.md": "cd447fe8cfbe9026d7cadd5fa42d9c387e4c1b1b7e1c5adddd55c3f1beb94a4d",
"docs/dev/rust-classes.md": "1f70015213dbcf56e6d6e4641019092003c22eb57ca324d9c4a85578b4468139",
"docs/dev/rust-modules.md": "f793bfb12272ded93e80876fd2c72ff8dc0f1bd0337977f39b750af495a83592",
- "docs/dev/sandbox.md": "4dd889e136fc64a6024a60e07372868e9b25753128a0cf3925966f083daf7f27",
- "docs/dev/scrolling-and-lists.md": "158f7fc20773ad96683db9f6c6b2588f97274e7c9ed4e2958dc925b1ecf0e750",
+ "docs/dev/sandbox.md": "86768ef1e0d3aeb6468fa075eb9a3a00adbe4ac4f576586d82fc495ca398fe5b",
+ "docs/dev/scrolling-and-lists.md": "448cdbc996047d274b683315632f90463a1fd973c2c59c64f29e07e0f97cee3f",
"docs/dev/signed-packages.md": "6e762f506428d53d347106e3619921a5595391105128305090da8eb8a20c220e",
"docs/dev/styles.md": "fcb5410f36c3d0ec08c300a7d624b19ad9d62404f665e9f4a2c61dda001ae532",
"docs/dev/text-view.md": "ef89f0a8af990356b33b5bfdd0f79e9d86342024e04ad8e08aba9e9ab13c6693",
"docs/dev/text.md": "7a4f09386899d5e6a5c3dd7c886063db73fcb5b288b47d0ff37cbfc8b4911dfd",
- "docs/dev/tooling.md": "1e605160ca6bb185136e1ef28f1cb703c4db5ac24647570c2c7b5a7c80f65fee",
+ "docs/dev/tooling.md": "0ae4e35b8ebc1e61db51a9fb44b25733eb36e7d11282a0cc26b418b6efa08a0c",
"docs/dev/tui.md": "47b56095f90ba9caa3efc65acc10bb3e255b674d9cf05d71fd5a30f7a43b1770",
- "docs/dev/web.md": "7c6f900d89b7f46f840cbaf271664f5979130ec9175b5695eed8afc30cccbbf4",
+ "docs/dev/web.md": "fd9039c76d0f8259cfdcb894add27d2cbcd04eda67d06c57d614b9f0ce6c1ade",
"docs/dev/windows.md": "099245b28187723d0ce93f9b6f0b4775bb19f25c625e8b3db558fe175dd0bb6e",
"examples/bench/GridTs.tsx": "e1388190424c85d044232f9dfdc897acc69fc4464b23b57db149f9276336e3b4",
"examples/bench/README.md": "75d32dc5f73955e42cd171baaeb7df39b19cbdd25e67e661df7976a887ca3506",
@@ -887,8 +900,8 @@
"examples/counter/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"examples/desktop/App.tsx": "069c5a14642c56403f8786b8b96187d226f08e4078f37a7551f253433c65b49d",
"examples/desktop/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
- "examples/embed-android/README.md": "2694fb610d63a1e2bd816533240fb0bac3c66d203a0be7e076d06098c75f2573",
- "examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "482e4f1f734450f2a4acb9dfacee0995ef69947e70ff25e7fa2ce93c6cb2c782",
+ "examples/embed-android/README.md": "d74d883aa1e65a564c01e897a0781adc8279aa5c6e13f5e247b117bcf2f223ab",
+ "examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "ce94b18d9bb436c129cc38ee579c8823b1bbaa216bc7e11a5ff6d344e732dcbf",
"examples/embed-gpui/.cargo/config.toml": "b6f90679563664c70622f32e0736e8e40b211948180594f372b175d90b9b1a1b",
"examples/embed-gpui/Cargo.lock": "16bedb298e480edba2cf2548b07cd3583c7471c3ef7aac852c86da70da7b2886",
"examples/embed-gpui/Cargo.toml": "a1f4727ec5fbc760f3678bcabfb14e3d34d37d71fb0b243e40920e55b8ed1f21",
@@ -907,9 +920,9 @@
"examples/embed-gpui/src/trading.rs": "d74b621e04b6a04a622033c319574c8eca80051bb7bb2619a872377c42ebdefd",
"examples/embed-gpui/src/watchlist.rs": "7d46e105a6f00e31a3d225e5b1430616f0360cac5b7ac8a63add0ebdb6397ce2",
"examples/embed-gpui/themes/violet.json": "7da650a48cebde43683ef6f50bbb27a5000dd79911763e46982688160a59e2ab",
- "examples/embed-ios/README.md": "a5f2a52371ad660b6c5e24333f7282475e4b544a564770035f10efbcb1a6937d",
- "examples/embed-ios/main.swift": "6c2bb633521fc06e45351115ff8b34d5b182106e7171e5ab4c8d376585962020",
- "examples/embedded/App.tsx": "11c06c478613046c7ebb3b936366d08abd66e4f82e3b45037ea1648d01cacb27",
+ "examples/embed-ios/README.md": "99c5f76f3c3a2acb2008796fb71ca864ac853a293583005acb82ca155d5da527",
+ "examples/embed-ios/main.swift": "fbb648c731f509cdb9ce20a70c91090291863a8f643482ab8727a32324a0e3f7",
+ "examples/embedded/App.tsx": "416fbcb5da5df7d68448da5e3a620efec9afa8f730efc432345e9fe7ebdbf26b",
"examples/embedded/nsh.json": "3ee671a3404ac47b07142fc0803df84417f265279c139a0616c37f772580c8ce",
"examples/embedded/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"examples/gallery-rs/App.test.tsx": "7aaa088af8ed0749b5dda6bb90e7c8746db0735d0706b7475ff0b38e1d8bc27e",
@@ -1055,12 +1068,14 @@
"examples/gallery/tests/error-boundary.test.tsx": "f71d9c316fcbea37f15a7b3be31b7e82aacb09c6b550553f3636dccd9534e94d",
"examples/gallery/tests/fixtures/error-boundary.tsx": "94516c85cc83f4824d71bcfac24ba972d5dc3bf6ea39060752a5d6e097ecca7a",
"examples/gallery/tests/fixtures/list-search.tsx": "ead5f110c6bf3885ea230b10c2a80fb0891d26c69ba46e33a437c6e89c381143",
+ "examples/gallery/tests/fixtures/mention.tsx": "329706d1ac88a20f6f60fd82bc4a8ba97dd0092838110355463588036c7420af",
"examples/gallery/tests/fixtures/press.tsx": "a91869ba8b693abaa3ca130a46bea568a4c251f20e4cfa7db6965a45a456181e",
"examples/gallery/tests/form.test.tsx": "8a82127b536f73bf3fbcf23881cf802b30d0346314d2783bcf9da52d45e3a49b",
"examples/gallery/tests/gestures.test.tsx": "adf7fcf2afe5029f2441eaf048251a1d33530467df91747f170968fbdc6f3a08",
"examples/gallery/tests/hover.test.tsx": "dae609cc2f03243b33ef3d46f58b1dadef73645b1223f1796c3f246606baa5bb",
"examples/gallery/tests/links.test.tsx": "db1b97338bc81b437228d2015926f55a469fb1ffef35cdd57961977952003963",
"examples/gallery/tests/list-search.test.tsx": "80eaf7e15d7f285662f1c29c662ac075722f04a0cb44220f8a1a5c55033f1ac6",
+ "examples/gallery/tests/mention.test.tsx": "ca76505adb487f51a89b7272bcd8afa4a750807df1a9c10d8c006f780446476f",
"examples/gallery/tests/menus.test.tsx": "2db06b0b97d2a3dfcaae959fcec37452980158e93a97a94ceda2e318a2f39313",
"examples/gallery/tests/navigation.test.tsx": "76fcd06532791d18abb88afef2c951ab950adf148f10ecd1e8fe32cc3a718569",
"examples/gallery/tests/network.test.tsx": "aaab1989a62f65e9c92c69ef8fe56fdba65677db382a00796b37b21199bd7d15",
@@ -1090,6 +1105,11 @@
"examples/hopper/theme.ts": "635f518b81e7408e5969ca59dbd25860381857f37e9980d225a1a54ba373a38b",
"examples/hopper/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"examples/hopper/ui.tsx": "d896dfce94d4c96585275f3b47e2e418e2867ef8aeec5c6148adf3401155f8a0",
+ "examples/host-echo/App.test.tsx": "ccd51aa627dc36087d36614b02c97dd1f08513146261b2b282a7997d1594d8f0",
+ "examples/host-echo/App.tsx": "b71e5c39233a5bd6891ced4f713af67e414607daa119bd3e28bff38fc0597a54",
+ "examples/host-echo/host.js": "da4dfc0d7be297fdb12ad686bcd251d9da4036ecd2aa5b2058d685f4b7ebfa9b",
+ "examples/host-echo/nsh.json": "c0059e3ecaeb57be9f29502f727184234d7166e1e0e1f9fb7c22a80f97000430",
+ "examples/host-echo/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"examples/rust-exports/App.test.tsx": "98df2fa881eb3b6d1c383c0b6f77d6753d178e93df556111258dbdfc66db7cec",
"examples/rust-exports/App.tsx": "3c64df906f96cc4495f481c133713d33e9dd95c493f449a0e7aa7342a34b315b",
"examples/rust-exports/README.md": "6e6e2798773876a6c586ce914d31680f2a2890c0d65c3067c794dd946d86c2c8",
@@ -1160,7 +1180,7 @@
"tests/compat/cases/core/core-error-boundary.tsx": "c2de89bed501a5483446830f8ec19e0a1fe7131fe0af8c0fbf8dcb6b9942979a",
"tests/compat/cases/core/core-input-action.tsx": "9e6c67b4c7cad5580219045666bd1f5d91ea377c3a371717adb9d25950194a59",
"tests/compat/cases/core/core-list-binding.tsx": "b9e52b8e5f875188a98b09226d410410318846c3df9f97b1852365e063fe7dbd",
- "tests/compat/cases/core/core-list.tsx": "1851cf6e7c7b0bdc7794eb2fa12b316b8e0d96fea3dd1e99398e54d1c013ec9e",
+ "tests/compat/cases/core/core-list.tsx": "0abc03a9be7e19288cba790203ccd7d0fe8e73969cb208a30124a1f6b92f0bfe",
"tests/compat/cases/core/core-overlay.tsx": "96a006df0796850f1b9dfa68cc4fe5ed6868988635ec7f47713523c47fa303ea",
"tests/compat/cases/core/core-primitives.tsx": "2dc25a3b00f0f6c3450128d85b52c6354e42618f0ce6599cd7b3196bea81df12",
"tests/compat/cases/core/core-reactive.tsx": "bcde98c407709730db5a6d2a620f01f322804d9184f0f727626034660ad2f5d3",
@@ -1168,7 +1188,7 @@
"tests/compat/cases/core/core-resource.tsx": "cc3d33c1321a04615b7fef7ac0d759fa5d76e7a3f5aae671c5ebff050a2769b5",
"tests/compat/cases/core/module-url.tsx": "8bf4a7a606496d4d5f321a8f722892de01e845d56bec5f42d9767ab3c516bb1d",
"tests/compat/cases/core/nsh.json": "c4354909c4780abb723666fe31c2c82d50cbe3839f62c169dc0ec2f145d8a728",
- "tests/compat/cases/core/script.txt": "46009ab58c932240d2aa0997b14d47eac6a9d01c92cbe845b73df3abcd7d0bd2",
+ "tests/compat/cases/core/script.txt": "5820c9c5590a75d1ac7b87de7020f36616dfc2c6836b018285724e23257df97a",
"tests/compat/cases/core/steps.tsx": "bcfbdf96ffeb91d9d4cc5abd0a562cf612fe73c04ddf9d8bfdeb5a7d14aacc37",
"tests/compat/cases/core/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"tests/compat/cases/events/App.tsx": "cec0a2e9ebfbf3c0c0b1d6e05760023d66bad7b30ec1e27b0213ad6752eeee38",
@@ -1202,18 +1222,19 @@
"tests/compat/cases/macos/mod-standard-system.tsx": "17363588e50524931b2a21b06b7319c39864ef77c35d65cd3708524c185337e5",
"tests/compat/cases/macos/nsh.json": "81592a22d477fd7aa971a58119b710cd6b49d58517f6dac65e2f091567c4c020",
"tests/compat/cases/macos/script.txt": "adfe51bb062be578136db927b767640d286ee3b4e8847f3446b0cb47a5847514",
- "tests/compat/cases/modules/App.tsx": "a96f5194e1ccd1fbfe3c40d03ab18d4edc78465ffd15c00e768bf041372d40da",
+ "tests/compat/cases/modules/App.tsx": "48cdf7cfd2b54f36c5b3b920879411583c898e4656c2c96443b962cbc899503a",
"tests/compat/cases/modules/mod-accessibility.tsx": "139148dc45f9402dee9aaaf78587690fa4ede4a209ec39c23c2e3eb379ae1e6b",
"tests/compat/cases/modules/mod-android.tsx": "0bc2aca9677c91fa0d1e3693110581c5f76fe32ea2e0eb3f8fe5d4470395c8f9",
"tests/compat/cases/modules/mod-clipboard.tsx": "0ca63a3ad7e0b77672e688fb3e5ba3d1fe79904fbd5640646074c1ca22d4951c",
"tests/compat/cases/modules/mod-desktop.tsx": "23c2fa95911c0ac2be36fd1fddf78c1c7aacfcf7e79e66f0df89b416e8cc3909",
"tests/compat/cases/modules/mod-device.tsx": "6b384f6830bb0da03344986ec02a1e394d307a5b5ef13dc1fd53ba272d04de34",
+ "tests/compat/cases/modules/mod-host.tsx": "6fb70e966bb0208e1fb9c2dc847146ad720227a3fb421e4b5695a2d6c525037c",
"tests/compat/cases/modules/mod-ios.tsx": "15398c7e334c6067bce708ef6303d534380b1b2d3cf9ca90c7fdd6423fa85b3f",
"tests/compat/cases/modules/mod-keymap.tsx": "d3d40c3ec2a9d72a42a33335255b93703e1b87f6c94a693837ab79b69ff16ae1",
"tests/compat/cases/modules/mod-standard-access.tsx": "c6f8c9d94ac06655950666e356af86d219379f95b4d6978c9b81a07077f5fd5d",
"tests/compat/cases/modules/mod-storage.tsx": "cfa8ffaf50301e957f0da33a075c6f4a8be93e758555d720dab07f7ae473410d",
"tests/compat/cases/modules/nsh.json": "8aded49d92a6f2cb8db2e005d097aa70f60f26e5e6639b98f45cd89154f3feb2",
- "tests/compat/cases/modules/script.txt": "25dcfb68b52245396ca162aa57b002b0ce100c6c10c5fd48b472de9f9b9bb8aa",
+ "tests/compat/cases/modules/script.txt": "328850d9e625f328bd10cf96100058a569bf447be7fbb6de2dffc26e70760c12",
"tests/compat/cases/paint/App.tsx": "c8bcbb4b62d5ecabe0fc98c7e6524e451193952701215ebe614a92168107c316",
"tests/compat/cases/paint/nsh.json": "56f891ce05e7aefa7949e3445a603cb477313be037897c75acb12f136c1a85a8",
"tests/compat/cases/paint/paint-borders.tsx": "388fb9d77dced948c4a014ce42af65f3509153347ad574778983b0263277a428",
@@ -1383,11 +1404,11 @@
"website/demos/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
"website/demos/ui-navigation.tsx": "0ede3abc087bc9a66a69d3efb9f1997dfc0da76008e7c88311b5a13ee4c2e01d",
"website/docs/accessibility.md": "acc7f899e192a16af5d3a07395a26c87e3ada2e05dfbad8bf453779e461fcb51",
- "website/docs/android.md": "6056ebd29e675f8dce7acd16d711b9a7c66c5860bf3665d20b6125e31103817d",
+ "website/docs/android.md": "ded49e2a009b7e012a7d7f0e37f15c00e04c82333a99a7ca2ffbdfd6f87df83e",
"website/docs/animation.md": "74859043103a0e12b3e2cf22f5b29ecf3cca7dd9e4009801b38c9d5d5aa2ceed",
"website/docs/architecture.md": "1a7f3fa3704a5e1c5f4dd6c2df4986c07cdaa247977f7b1e3a13464bfd82a8e5",
"website/docs/canvas.md": "5589e8b7ceae1856e21081b30d3dc424dc977064fcbb711331f25165c962b4e7",
- "website/docs/cli.md": "a62a929b93aba13d6074966228300854273c7cec3e00d9c631f6465cda98017d",
+ "website/docs/cli.md": "5550c883425a234aaff5dcd9f41fbb554b02658e7b21130a26b9b73c8d30f0f5",
"website/docs/coding-guides.md": "38953161931200894317fdb0aaa25e5fd65a52b28ac2ac03e2196f771050dca9",
"website/docs/comparison.md": "80f11e255cf3463f29d5610213b57808e1ffa92c5de021ebff9e9e153455dca5",
"website/docs/compatibility.md": "f2a0af8428dd5232052921e94a6a08914ee4e523c8f3dbac0db51f25c0da6650",
@@ -1401,13 +1422,13 @@
"website/docs/images-and-assets.md": "25b3f1455863969f1ce2dcb8961a30783c425c3d8015fe51a4ef5a134cfc2aa6",
"website/docs/index.md": "31a039507f14aaf881b9fb351b18a837c5a53650ee8449b14afd91ba1fc22281",
"website/docs/installation.md": "a7b1a6804f151719d35b8b62eb5b3861712a24cd90175730266ee5fb0874027d",
- "website/docs/ios.md": "2b6907f65fbe688850c11bda89d54caf4cd43f0e350d34160544fb3b8e9f5811",
+ "website/docs/ios.md": "a7a5d71586c90f020360c0287071a404b2905acc89353e857ca124d568ea6c81",
"website/docs/jsx-and-components.md": "75c24c3cb56503befc7c06d9eaf67c844318890d9fc989427231ecc9e6e9e627",
"website/docs/layerfs.md": "827ff94d53cb27ed7a686776b2532de70d0cfaebf0cfc7ba39b433f42ec5342b",
"website/docs/layout.md": "80c8f0995e88d01959766d6d95e86ead1034599b848e6e572ddc0c554fe5bf89",
- "website/docs/linux.md": "6df424ab04997a1c29fa85ec34ec883c150aaab1f1165d631445f0699a217616",
+ "website/docs/linux.md": "c5432744bf1ebf18d8afa74075ccb0581e98f5e98255a07cb9c34ab00153b917",
"website/docs/lists.md": "b59033e583a9c147c0a7cf7309bf22b3744946b0a6a6bac3ee1a3f079aa51d54",
- "website/docs/macos.md": "83aeb6c4d8214a401cb6413c32cb74b0d62243d59bbbececa32a6dd4ddd18420",
+ "website/docs/macos.md": "4b36f58b9662268707a5868a0443eaa55b44ad59778a25b71e421c970143412f",
"website/docs/native-components.md": "cd8272bfb271dece1f395009077a762c972c282bbd9246265ee9f6a4ced0e656",
"website/docs/navigation.md": "8a4a9ac5d9a44ce8e9a30680b4ab9c0a4d6d4bfd556b8c7347074eefb3ba6357",
"website/docs/networking.md": "7447d4ffeefffb33318ada3594160ce30a47adabb4c59bb66bd360a465c5a2b4",
@@ -1415,21 +1436,21 @@
"website/docs/performance.md": "f6c4652eef83d81abeee6f2d2dfb370b14a7bb78b2dbb25313afe3a25a20dece",
"website/docs/platform-specific.md": "acca9b1bce6444f64370e60eb2911b6e8467a21dbf2078823236ab22c95ad872",
"website/docs/platforms.md": "78f8662ab5b59f11c4a4f0c1f8f520800a46831b5a311cb5e7d6ea825531d09f",
- "website/docs/project-structure.md": "8465844846b69eddb6a2bf532b2cafa8a63e583c5b1ce246952c73d972588230",
+ "website/docs/project-structure.md": "5f65867b10167d7efe437147d20e794264deff24a647f9924d956e5d8a0f88a0",
"website/docs/reactivity.md": "8c6731f9556ec0c3a4f401c7861c7198cca53a4bd5e95b57f2f773e7acdbf7a8",
"website/docs/rust.md": "b3d18c6b202c136d7efbba0f7f87e9d0ae26d2469551f1bb09f47e955333ed34",
- "website/docs/sandbox-and-capabilities.md": "4938ef6da08859de906c236ead2a8e70a478c599580dbbfb3ed2201de4bb0dc1",
+ "website/docs/sandbox-and-capabilities.md": "1860458dbe33fad7df623efa372bb9568b6e839aad269429d7595b5733cfb0d9",
"website/docs/scrolling.md": "54356ccb5d65b3f37263339c91e1fb0ee2bc5db32729a9e9b235f2cce51e6850",
"website/docs/signed-packages.md": "203da373bbcf21d84240b2626d91860e4f3d745733cbeebc22a42a042d0185ee",
"website/docs/styling.md": "30ecef6fd8f7e881867574898cd05dfb7bb943a996a8da48db2dbfa8af1c4bd3",
- "website/docs/tailwind.md": "0bfcc244e1cd81cdb3fdbed95c36af126a1284a2f559712007005927c4bea4af",
- "website/docs/testing-and-trace.md": "a0713c55a7c8c1091c85209fd43fd74911a1bba4bf78f871de0676a894b9112a",
+ "website/docs/tailwind.md": "e3c48864cf4018b4908bed97b92f69ac66395c4cf74600d84b3d1650de843292",
+ "website/docs/testing-and-trace.md": "b4afd8ac17ad3fe7a41a4979495a30575fe19392f5e7b159626479d9737cc5d4",
"website/docs/text.md": "afecd14b1f27f340c52dd22666b26f014edd698f1d4ce96dd61188d5203e74c1",
"website/docs/touch-and-gestures.md": "4733ec6aad04835324c174900e099737e39bb6cffa84227959193d95c5d2c39c",
"website/docs/tui.md": "28f225fcce009393ab1a3a1ac672b3cf3a599e3712fbd344516ac0d3b8a2f7a4",
"website/docs/web-view-and-video.md": "249dc38d55b3549052396f782f8087d3abae3e81c57fc64b24589f352acadd5e",
- "website/docs/web.md": "1aa3728395fd9a551a0f8fa34d02d384191d8fa82ab52ed85e852078951ba817",
- "website/docs/windows.md": "e715393ff39d08901c3a21add178277da9fdf6c38cf1b32d7a2921aab3896ea2",
+ "website/docs/web.md": "73e74007aae291bab262fb1372f41c8f61eb723005780b911555e2a45be0723b",
+ "website/docs/windows.md": "5c52b9a5e1c03a11a8d13a1686b27a082c21c9663f88050375b44f8f4b55ac03",
"website/elements/alert-dialog.md": "ee0f2bcd0d4a622a5e1521dbd9c854cf3e315b5848104ef3f07ac8f6033d9e5c",
"website/elements/button.md": "0ee605f47eb3588b4efeb8771bf36b63962101cacc1ed1883d8d22a7a3568da9",
"website/elements/canvas.md": "c55d6fc031c3b04f91962ea80714c0593db133fe60b228412097b4c97ca20490",
@@ -1463,7 +1484,7 @@
"website/elements/view.md": "a532351633e057acff510fa3dc45c6380724fb166a5784ed15b928b77a13dc37",
"website/elements/web-view.md": "7a9ad687ee2afb124f317f4e103572eb07fa8a71d7202d09c8f971130feea591",
"website/modules/globals.md": "2c4c6b555c0cd2d0aea5dbebd478d1eb0597a419c379fda4853d55d8f79b8215",
- "website/modules/index.md": "ecd4c0b82d978205fa9e75673e854f04b9602550f0056cd30f2fd864a65f6fa8",
+ "website/modules/index.md": "38620a429fd176cb882a3f7e958a92e6ffeb57b576540707182cfe4b6d92dd7a",
"website/modules/nsh-accessibility.md": "c446bab2d7fe6becb274bf60546d49e13cd4bab1fe17a80ab494c2f7b3ee775e",
"website/modules/nsh-android.md": "3bc295d3913732b6f1b75d78a8e879e32fd00f8e65424c7b8468ecb80185a8bb",
"website/modules/nsh-animated.md": "f50131304ae97c0537af4927bf4ab62524c91068806ce81f3b9daeae6f09662e",
@@ -1473,9 +1494,10 @@
"website/modules/nsh-desktop.md": "74d5b00ac1d1e27da1e439aed901745108146f9495bed44ef114696e1abb5c85",
"website/modules/nsh-device.md": "5a49c9c522b883559d6f56ec648dd8502833c69223d52737eebf74bc8f12460e",
"website/modules/nsh-gesture.md": "242251b48db3e0c70247cfdb243468a0e2641608e70b66914b696450cfca8bfa",
+ "website/modules/nsh-host.md": "5056a6c3c92b50b37db6a20aeded716aa2fcd23b93992eee5a1615179e1dcc1d",
"website/modules/nsh-image.md": "9291d338ebcc168cedbfc597188691994ac25d7ac800da3659bcc83b356a197e",
"website/modules/nsh-ios.md": "2c6ea1a8c6c25bb06064e6cd6924cd3ab8a890921b151107d206880ab870eea6",
- "website/modules/nsh-keymap.md": "b45421fbeb6179a5910a4bf2c2e001ddffbd22037a4e87d7e623a63d32d7c206",
+ "website/modules/nsh-keymap.md": "145bec74a49a6f057f8f92a00478f15d87c54037065c7ac1e599409411fbb9ae",
"website/modules/nsh-list.md": "91d2b6895835c0327bda430508c483f08d1403d5218e99988555eb2928b38b0d",
"website/modules/nsh-native.md": "6bcbcbebb59cafdf01ab56b9c71c6731b941bd8f8431e353cb87bbafd18b18c5",
"website/modules/nsh-storage.md": "4d2918fcd5d8a19ff6a2716fcd5717191350f66b39af22c4ca37985387a4c1e2",
@@ -1570,11 +1592,11 @@
"website/tests/storage.test.ts": "4711c006141dfea8792fbb1fe2c219746155f57a32650e0fc80aa56b404d1804",
"website/tsconfig.json": "7b292fd267352f901284513544cd818de4a8a8fe15880f662b3a0900b2a93c99",
"website/zh-CN/docs/accessibility.md": "63977505bc163dfe40b4d0e40168944c28153dde29a73be1db9931d90bb29bde",
- "website/zh-CN/docs/android.md": "b64f4fdecf8de8eff2feec7eb185de3982f2d98e78e30cc5aa5eab7399d95aed",
+ "website/zh-CN/docs/android.md": "eba50396501d0dde571dedf1d10ce224d93edc781652025d83e366e5a421c4c5",
"website/zh-CN/docs/animation.md": "a6d266e73b250cb3274784b48dfcc99ad791897ffdc5d6788e80a11db4fdebd2",
"website/zh-CN/docs/architecture.md": "6759f09942a51486476a68a37e12c833be9430012e8b2da137d9123632c00294",
"website/zh-CN/docs/canvas.md": "8a237d25ef877ce00715d88a03e50493667a9961ccfe045372485b0ff48c7b77",
- "website/zh-CN/docs/cli.md": "d6971d9ce2f9875fcd1073a15c2171ad71d767a4365669ad172844c6d6ef3967",
+ "website/zh-CN/docs/cli.md": "0bd6cfcd9dd4bec72d407438787d0b2c990b05032d5b4ce84e7f36005ab61c11",
"website/zh-CN/docs/coding-guides.md": "34af3e5c4f13861a7336aca26e636aad3a4a2a71ebbc5d806d25135c7882a716",
"website/zh-CN/docs/comparison.md": "05e6ab8a44d3db224895d7029e029dbb48a9e74092e5135099ab0179a6ee974c",
"website/zh-CN/docs/compatibility.md": "1990079dc6a9ed2c4c72a0ccd6a10aae154e5ca722f2c6d899d5874345f62d76",
@@ -1588,13 +1610,13 @@
"website/zh-CN/docs/images-and-assets.md": "de6564e02b94897bfa6865353acfa8d007b331f0dde1abe572db12281c654d63",
"website/zh-CN/docs/index.md": "bdde9f1324a8a2511bc1b740f3a1e42be071ab1390363a7152a4bde1705c2deb",
"website/zh-CN/docs/installation.md": "149ece60315c3d00485a5307421a5b2d4898d6259ff59f1c9861295a56a079d9",
- "website/zh-CN/docs/ios.md": "615b26e421eb981ece4f360544732b81e9ca4ace2440b6edf05880f142091953",
+ "website/zh-CN/docs/ios.md": "09170b3c662d4c158705a0c95bc3bafbb74432ad58e4a556c576b96dcfe7e70c",
"website/zh-CN/docs/jsx-and-components.md": "7c80e941fdd795698c237edcfd5f600d748a870f4671b05973d1bc0c2279bf97",
"website/zh-CN/docs/layerfs.md": "817b28c2b67d90fa5bc754f7b7dcebf07edcb8328d814e56f394ee91989c8217",
"website/zh-CN/docs/layout.md": "34c9288812aa015497363034a38d338a3138808ea936c21948b1db1fca72733b",
- "website/zh-CN/docs/linux.md": "b92ff6039e94e50758c13feb3c3fbe38dea346f6e644d35b5863a2bfef8c1298",
+ "website/zh-CN/docs/linux.md": "04a113952bfbc8666cfaab4a12ce971d09d5699118b169115b0bb78896e4ffd5",
"website/zh-CN/docs/lists.md": "7f70739117e058a511ac1a5b824cd4d109ce407f17b8aacc0f1f3203112ee907",
- "website/zh-CN/docs/macos.md": "fb469d4765f3fc5e448f28655841f670cc292878766958b604e913caaf508def",
+ "website/zh-CN/docs/macos.md": "7defc3eca134507e1c50b4593f14a121924913df0f22fa2ad645fa6926e28c1b",
"website/zh-CN/docs/native-components.md": "53687a8c5bde004f6f8be3f15ab2a82b5c063dc4acadc7a69713c9c150512831",
"website/zh-CN/docs/navigation.md": "cf0b0ed2c31d94591a764e54789a8016b248a71d6bbc2e83961c6febd28b7305",
"website/zh-CN/docs/networking.md": "3f0d3eaf3d29565c18c92b227cececba879063672f66aeede14fdb05da1c61bf",
@@ -1602,21 +1624,21 @@
"website/zh-CN/docs/performance.md": "51e47481cf34e1d5b80cb63214a1f13ad89ad2ad902d0ba476f5b52f93a6a7af",
"website/zh-CN/docs/platform-specific.md": "418a89f4880460fd661451290ce6c5d583c6fbee7423978b1d5ac90d098e6412",
"website/zh-CN/docs/platforms.md": "dcfde4bb011232303c45849ed0c1b84f30a0a7ef57121855e5ad32a9cd3cba0e",
- "website/zh-CN/docs/project-structure.md": "1006d1463cf46514347f888024a9a3a492486952fe6d2fe162c0f16c43d74f9a",
+ "website/zh-CN/docs/project-structure.md": "a22b70ff32192cd92edc0bced0a192bad4275ea9f686bbe77a11c0634597fe59",
"website/zh-CN/docs/reactivity.md": "2f5ab6ca8af99fe3b6c5aa885e3ea5ee878e612bba4b15cd75de37b2c7f4dbb3",
"website/zh-CN/docs/rust.md": "4d8eebbf32c628977ad3d87b1c227a056a6d7be8cd140ababd3ea6294d70d473",
- "website/zh-CN/docs/sandbox-and-capabilities.md": "9c021c767938186c7f75cfcb08bfd5200829aa755f013491933e17701dbfb741",
+ "website/zh-CN/docs/sandbox-and-capabilities.md": "878c315de9764988f997e6c7c8929e2b4b73eac8303362c737065b31cc96fdb3",
"website/zh-CN/docs/scrolling.md": "8e6237c42f3b0d82d87c3c3163661382b00517a6866af57bdd807ddcfe71bf7f",
"website/zh-CN/docs/signed-packages.md": "2e48499114cf8cfee2cfbfb866b46afa8b5dc9c615f2640462295e0fbaf0b47d",
"website/zh-CN/docs/styling.md": "9bea733d0c0389b8d095b41f57ddaa10eb18a702a964708699c63833c530a798",
- "website/zh-CN/docs/tailwind.md": "36506cf5250db89d07f86389cf2bcdf86e15a0107a4a6424e8f0585fd0b59d8b",
- "website/zh-CN/docs/testing-and-trace.md": "1f570db23c1e31467dd358d62d7e692f1b279b0205b839828c9fa789c8aa1dc1",
+ "website/zh-CN/docs/tailwind.md": "ddb45309d81505a86bea22c582c5ae6ba17561d871c9f3a546d30286ed0fd00c",
+ "website/zh-CN/docs/testing-and-trace.md": "e32c9cb30062fc83bf33d8a680d9137709169fbd689cae429a93b5ee76dac05d",
"website/zh-CN/docs/text.md": "3e0c854979fe43694024b1c80a9a6dc8d2632009fb2ca1468d978b7084e2ff33",
"website/zh-CN/docs/touch-and-gestures.md": "6c52f75dee8de1b1a4db4359e2c155a11131f35bac36f3dfc8b90e758fed2cd1",
"website/zh-CN/docs/tui.md": "759679285a6efb60adc31bddc0b29a712a8375b947f1405cd1f923345f4cf59f",
"website/zh-CN/docs/web-view-and-video.md": "45ab7af5e283a3021de62b39b7ffe9f64414fd41fe5337d9e51c30656f3e1fb1",
- "website/zh-CN/docs/web.md": "f0f47e4b4a0cf26d44807d9e782176f391a7134f1dd5ab192132b0215a9c5cf9",
- "website/zh-CN/docs/windows.md": "6694ce31c742015a5f2d26d746039f5d73081ddcdd556b8c8cbd8ef988531747",
+ "website/zh-CN/docs/web.md": "d22060420052d788555373fecb5297f242542e62a388bc696da88cfb9abf1331",
+ "website/zh-CN/docs/windows.md": "2bc90fb626da5f2c2d65fc39714b21e9c38fefd32154cd16f81693f804ff34a5",
"website/zh-CN/elements/alert-dialog.md": "ddee942832b7ddb4afbfcaec5af36245af4189b995a65d55d762634176d4213e",
"website/zh-CN/elements/button.md": "322208c0b3b5664f6d5ed83828f77b22c7f7f7a532e84664da7ead9179d144b6",
"website/zh-CN/elements/canvas.md": "69200ec9827d47f515d4f2fc2559cff30371d040712ce11341c1de6ab26b11c7",
@@ -1650,7 +1672,7 @@
"website/zh-CN/elements/view.md": "07ceb854787d0ccda316a08c2f0ad34523f28f85a0b74c4adef29be612d898a2",
"website/zh-CN/elements/web-view.md": "16fc5b2d4e1a2158f7838d96fa8361c8401f14c5ce5f36a474daef0e246d2fd9",
"website/zh-CN/modules/globals.md": "8fe3c160dfea2a833e3af37266399a79db319bd3a8279f1bbd0f7173cc33aeea",
- "website/zh-CN/modules/index.md": "04185943e6282fdc4cf7073f88cc080399934cc0e1636877b8c3422d0b9ff62d",
+ "website/zh-CN/modules/index.md": "7648b4fe4cc43384c7080513b144f5dbf9b23d1ffd9f6096f8fdd63eebb6a35e",
"website/zh-CN/modules/nsh-accessibility.md": "53b1949ebe056f6e8d2cde899f6160ce6ac5f78b8e9b404b86c257513391f360",
"website/zh-CN/modules/nsh-android.md": "5732783701f5b8eba5134b9d7f4a87be6930e3cd9a567a5d21063c479f8c9e31",
"website/zh-CN/modules/nsh-animated.md": "0fc4cd367f3b4d87bf228017fbf8f1d941cd439f9583a8d9641738d5d222b2b8",
@@ -1660,9 +1682,10 @@
"website/zh-CN/modules/nsh-desktop.md": "d69cebeb28d6d77643af44d53c01f2d5f135ea096b8381b0b631691fb74e05a9",
"website/zh-CN/modules/nsh-device.md": "2aef14241d7a65e204194fe1663499820a8857b98ad0c8450bac2887beea0d25",
"website/zh-CN/modules/nsh-gesture.md": "016833be47b4af7906a3b30834f18d2a0daa031f344ef7f655197870be3b0934",
+ "website/zh-CN/modules/nsh-host.md": "c550fb10e43d254b20b78590bbac0a27a828deaee6259ce72ecc616df0985f08",
"website/zh-CN/modules/nsh-image.md": "ba5af499d466a18bdba503b45d7957c2bcfa64a9454538ab92e271c31cf795be",
"website/zh-CN/modules/nsh-ios.md": "e1220439df4359e9ba96b0457169683d68f92559fc77544fc741768c45a34ed1",
- "website/zh-CN/modules/nsh-keymap.md": "ce0661d9b5d0c1c359d984d03c721cd1d23f9b695c5e4ca01f581914993a94df",
+ "website/zh-CN/modules/nsh-keymap.md": "97a0870b80c96e3d99e199056b5c1d6b21d6554484a06e545e308f8dea3fa771",
"website/zh-CN/modules/nsh-list.md": "bca6d00c4c39da107b74accbd135653d0ef18845fa5aa4248262e53bffa7cdb1",
"website/zh-CN/modules/nsh-native.md": "f4938e9e7310c840b6bb5517570d7b23d19cf97d7fdd5dd5827cfd148ddb5c0b",
"website/zh-CN/modules/nsh-storage.md": "3a4b00515785ced4dab774b340a333487f9b7c4ec5bcf64a7ff00b1eab5b7041",
@@ -1674,11 +1697,15 @@
"website/zh-CN/modules/tailwind-reference.md": "9f366de10fe05a3fcf34f7e2ad7a6d7f8dc82379084f4958bef2c5761aff968f"
},
"upstream_repository": "https://github.com/longbridge/nsh",
- "upstream_revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc",
+ "upstream_revision": "7ed244990097dbbec6b177e26dbc357cb0dba654",
"file_sources": {
"AGENTS.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "111b3a933319fadb0f1da9a59909ca526f256386"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "Cargo.lock": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"Cargo.toml": {
"repository": "https://github.com/longbridge/nsh",
@@ -1688,13 +1715,69 @@
"repository": "https://github.com/longbridge/nsh",
"revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
},
+ "crates/nsh-base/src/lib.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-base/src/theme.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-bundler/src/bundle.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/dev.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/main.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/package.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/test.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/test_web.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/web.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/web/dev.html": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-cli/src/web/index.html": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/access.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/core/components.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
"crates/nsh-core/src/core/layout_tree.rs": {
"repository": "https://github.com/longbridge/nsh",
"revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
},
+ "crates/nsh-core/src/core/list.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
"crates/nsh-core/src/core/mod.rs": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"crates/nsh-core/src/core/overlay.rs": {
"repository": "https://github.com/longbridge/nsh",
@@ -1706,19 +1789,395 @@
},
"crates/nsh-core/src/core/tests.rs": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/js/host.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/js/mod.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/keymap.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/ops.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/platform.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/runtime/host.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/runtime/mod.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/ui/ambient.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/ui/env.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/ui/services.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-core/src/ui/task.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/src/lib.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/components.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/host.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/list.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/list_bench.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/logs.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/main.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-engine/tests/engine/theme.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-js/src/host.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-js/src/keymap.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-js/src/modules.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-js/src/platform_modules.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/src/agent.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/src/encode.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/src/host.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/src/keymap.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-android/src/lib.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/include/nsh.h": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/agent.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/embed.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/input.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/keymap.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/lib.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/mount.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/surface.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/synth.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/target.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/src/view.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-ios/swift/NativeShell.swift": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-linux/src/app.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-linux/src/surface.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-macos/src/app.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-macos/src/surface.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-web/src/encode.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-web/src/lib.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-web/web/nsh.js": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-web/web/nsh.test.mjs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-windows/src/app.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-platform-windows/src/surface.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/Cargo.toml": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/agent.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/headless/keyboard.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/headless/mod.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/lib.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/page_host.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/run.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/src/runner.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/App.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/Broken.test.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/Host.test.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/NoHost.test.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/host.js": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/none.js": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/host/nsh.json": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "crates/nsh-testing/tests/testing/main.rs": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"docs/dev/compatibility.md": {
"repository": "https://github.com/longbridge/nsh",
"revision": "111b3a933319fadb0f1da9a59909ca526f256386"
},
+ "docs/dev/elements.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/embedding.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/js-modules.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/macos.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
"docs/dev/performance.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"docs/dev/pitfalls.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/sandbox.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/scrolling-and-lists.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/tooling.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "docs/dev/web.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/embed-android/README.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/embed-ios/README.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/embed-ios/main.swift": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/embedded/App.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/gallery/tests/fixtures/mention.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/gallery/tests/mention.test.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/host-echo/App.test.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/host-echo/App.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/host-echo/host.js": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/host-echo/nsh.json": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "examples/host-echo/tsconfig.json": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "tests/compat/cases/core/core-list.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "tests/compat/cases/core/script.txt": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "tests/compat/cases/modules/App.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "tests/compat/cases/modules/mod-host.tsx": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "tests/compat/cases/modules/script.txt": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"website/DESIGN.md": {
"repository": "https://github.com/longbridge/nsh",
@@ -1726,7 +2185,11 @@
},
"website/docs/android.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/cli.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"website/docs/comparison.md": {
"repository": "https://github.com/longbridge/nsh",
@@ -1738,12 +2201,56 @@
},
"website/docs/ios.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/linux.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/macos.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"website/docs/platforms.md": {
"repository": "https://github.com/longbridge/nsh",
"revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
},
+ "website/docs/project-structure.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/sandbox-and-capabilities.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/tailwind.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/testing-and-trace.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/web.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/docs/windows.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/modules/index.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/modules/nsh-host.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/modules/nsh-keymap.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
"website/src/components/Home.astro": {
"repository": "https://github.com/longbridge/nsh",
"revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
@@ -1778,7 +2285,11 @@
},
"website/zh-CN/docs/android.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/zh-CN/docs/cli.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
},
"website/zh-CN/docs/comparison.md": {
"repository": "https://github.com/longbridge/nsh",
@@ -1790,11 +2301,55 @@
},
"website/zh-CN/docs/ios.md": {
"repository": "https://github.com/longbridge/nsh",
- "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/zh-CN/docs/linux.md": {
+ "repository": "https://github.com/longbridge/nsh",
+ "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+ },
+ "website/zh-CN/docs/macos.md": {
... 1045 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org