diff --git a/.cursor/rules/imports.mdc b/.cursor/rules/imports.mdc
new file mode 100644
index 000000000..9ad83322f
--- /dev/null
+++ b/.cursor/rules/imports.mdc
@@ -0,0 +1,38 @@
+---
+description: Rust module and import practices
+globs: **/*.rs
+alwaysApply: false
+---
+
+# Rust Modules And Imports
+
+For Rust files:
+
+- `crates/sage-apps/src/lib.rs` is exempt from this rule.
+- Group imports as `std`, external crates, then `crate`, with blank lines between groups.
+- Put `mod` groups first, then `pub use`, then less-visible `use` groups.
+- Within each `mod`/`use` group, don't add blank lines; only separate groups.
+- After `mod`, group statements of the same kind by visibility, most visible first (`pub`, `pub(crate)`, private).
+- Prefer `use` imports over repeated long paths in signatures/bodies.
+- Import crate-local items directly from `crate` (`use crate::{A, b};`), not `crate::submodule`; use at most one crate brace group per scope/module.
+- Keep conventional qualifiers when clearer, e.g. `use std::fmt; impl fmt::Display ...`.
+- Put visibility on the item definition, not the re-export, where possible.
+- Re-export child modules with `pub use child::*;` where possible; only use more restrictive re-export visibility if linting shows none of the child items are public.
+- Re-export intended child items from parent modules instead of forcing callers through long internal paths.
+- Avoid unrelated import/re-export churn.
+
+```rust
+mod registry;
+
+pub use registry::*;
+
+use std::fmt;
+
+use crate::SageApp;
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ // ...
+ }
+}
+```
diff --git a/.gitignore b/.gitignore
index 13ee7f29c..345d1e575 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,4 @@ target
.env
/test.sqlite*
+/builtin-apps/build/
diff --git a/Cargo.lock b/Cargo.lock
index badb16b27..8a69c3fad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -239,27 +239,6 @@ dependencies = [
"stable_deref_trait",
]
-[[package]]
-name = "ashpd"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
-dependencies = [
- "enumflags2",
- "futures-channel",
- "futures-util",
- "rand 0.9.2",
- "raw-window-handle",
- "serde",
- "serde_repr",
- "tokio",
- "url",
- "wayland-backend",
- "wayland-client",
- "wayland-protocols",
- "zbus",
-]
-
[[package]]
name = "asn1-rs"
version = "0.6.2"
@@ -914,6 +893,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bzip2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
+dependencies = [
+ "libbz2-rs-sys",
+]
+
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -1743,6 +1731,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -2045,6 +2039,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
+[[package]]
+name = "deflate64"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
+
[[package]]
name = "der"
version = "0.7.10"
@@ -2226,15 +2226,6 @@ version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8975ffdaa0ef3661bfe02dbdcc06c9f829dfafe6a3c474de366a8d5e44276921"
-[[package]]
-name = "dlib"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
-dependencies = [
- "libloading 0.8.9",
-]
-
[[package]]
name = "dlopen2"
version = "0.8.1"
@@ -2622,6 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
dependencies = [
"crc32fast",
+ "libz-rs-sys",
"libz-sys",
"miniz_oxide",
]
@@ -2717,11 +2709,26 @@ dependencies = [
"new_debug_unreachable",
]
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
[[package]]
name = "futures-channel"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
@@ -2729,15 +2736,15 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
@@ -2757,9 +2764,9 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
@@ -2776,9 +2783,9 @@ dependencies = [
[[package]]
name = "futures-macro"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
@@ -2787,15 +2794,15 @@ dependencies = [
[[package]]
name = "futures-sink"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
@@ -2805,10 +2812,11 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
+ "futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -2816,7 +2824,6 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
- "pin-utils",
"slab",
]
@@ -2982,11 +2989,26 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+ "wasm-bindgen",
+]
+
[[package]]
name = "ghash"
version = "0.5.1"
@@ -3888,6 +3910,12 @@ dependencies = [
"spin",
]
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "lebe"
version = "0.5.3"
@@ -3918,6 +3946,12 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "libbz2-rs-sys"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
+
[[package]]
name = "libc"
version = "0.2.178"
@@ -3992,6 +4026,15 @@ dependencies = [
"glob",
]
+[[package]]
+name = "libz-rs-sys"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
+dependencies = [
+ "zlib-rs",
+]
+
[[package]]
name = "libz-sys"
version = "1.1.23"
@@ -4059,6 +4102,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+[[package]]
+name = "lzma-rust2"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
+dependencies = [
+ "sha2",
+]
+
[[package]]
name = "mac"
version = "0.1.1"
@@ -4162,6 +4214,16 @@ dependencies = [
"url",
]
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -4361,9 +4423,9 @@ dependencies = [
[[package]]
name = "num-conv"
-version = "0.1.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-derive"
@@ -4919,6 +4981,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
[[package]]
name = "pem"
version = "3.0.6"
@@ -5219,6 +5291,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "ppmd-rust"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -5475,6 +5553,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "radium"
version = "0.7.0"
@@ -5855,11 +5939,10 @@ dependencies = [
[[package]]
name = "rfd"
-version = "0.15.4"
+version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
- "ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
@@ -5875,7 +5958,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -6261,10 +6344,43 @@ dependencies = [
"syn 2.0.111",
]
+[[package]]
+name = "sage-apps"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "erased-serde",
+ "futures",
+ "hex",
+ "mime_guess",
+ "parking_lot",
+ "reqwest 0.12.24",
+ "sage",
+ "sage-api",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2",
+ "specta",
+ "specta-typescript",
+ "sqlx",
+ "tauri",
+ "tauri-plugin-dialog",
+ "tauri-specta",
+ "tempfile",
+ "tokio",
+ "tracing",
+ "url",
+ "uuid",
+ "zip",
+]
+
[[package]]
name = "sage-assets"
version = "0.12.10"
dependencies = [
+ "anyhow",
"base64 0.22.1",
"chia-wallet-sdk",
"futures-lite",
@@ -6388,19 +6504,25 @@ name = "sage-tauri"
version = "0.12.10"
dependencies = [
"anyhow",
+ "async-trait",
"aws-lc-rs",
"chia-wallet-sdk",
+ "futures",
"glob",
+ "hex",
+ "mime_guess",
"reqwest 0.12.24",
"rustls",
"sage",
"sage-api",
"sage-api-macro",
+ "sage-apps",
"sage-config",
"sage-rpc",
"sage-wallet",
"serde",
"serde_json",
+ "sha2",
"specta",
"specta-typescript",
"tauri",
@@ -6417,8 +6539,12 @@ dependencies = [
"tauri-plugin-sharesheet",
"tauri-plugin-window-state",
"tauri-specta",
+ "tempfile",
"tokio",
"tracing",
+ "url",
+ "uuid",
+ "zip",
]
[[package]]
@@ -6508,12 +6634,6 @@ dependencies = [
"syn 2.0.111",
]
-[[package]]
-name = "scoped-tls"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
-
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -6670,9 +6790,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
-version = "1.0.3"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -6929,6 +7049,7 @@ dependencies = [
"paste",
"specta-macros",
"thiserror 1.0.69",
+ "url",
]
[[package]]
@@ -7553,9 +7674,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
-version = "2.4.2"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19"
+checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
@@ -7571,13 +7692,15 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
-version = "2.4.4"
+version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9"
+checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
+ "log",
+ "objc2-foundation 0.3.2",
"percent-encoding",
"schemars 0.8.22",
"serde",
@@ -7587,7 +7710,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
- "toml 0.9.8",
+ "toml 1.1.0+spec-1.1.0",
"url",
]
@@ -7819,7 +7942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.61.2",
@@ -7949,30 +8072,31 @@ dependencies = [
[[package]]
name = "time"
-version = "0.3.44"
+version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
+ "js-sys",
"num-conv",
"powerfmt",
- "serde",
+ "serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
-version = "0.1.6"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
-version = "0.2.24"
+version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -8017,7 +8141,6 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
- "tracing",
"windows-sys 0.61.2",
]
@@ -8103,13 +8226,28 @@ checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
dependencies = [
"indexmap 2.12.1",
"serde_core",
- "serde_spanned 1.0.3",
+ "serde_spanned 1.1.1",
"toml_datetime 0.7.3",
"toml_parser",
"toml_writer",
"winnow 0.7.14",
]
+[[package]]
+name = "toml"
+version = "1.1.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
+dependencies = [
+ "indexmap 2.12.1",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.2",
+]
+
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -8128,6 +8266,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "toml_edit"
version = "0.19.15"
@@ -8178,11 +8325,11 @@ dependencies = [
[[package]]
name = "toml_parser"
-version = "1.0.4"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow 0.7.14",
+ "winnow 1.0.2",
]
[[package]]
@@ -8193,9 +8340,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
-version = "1.0.4"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@@ -8376,6 +8523,12 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -8440,6 +8593,12 @@ dependencies = [
"unic-common",
]
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -8688,7 +8847,16 @@ version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.46.0",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
]
[[package]]
@@ -8758,6 +8926,28 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.12.1",
+ "wasm-encoder",
+ "wasmparser",
+]
+
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -8771,6 +8961,18 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.10.0",
+ "hashbrown 0.15.5",
+ "indexmap 2.12.1",
+ "semver",
+]
+
[[package]]
name = "wayland-backend"
version = "0.3.11"
@@ -8780,7 +8982,6 @@ dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.3",
- "scoped-tls",
"smallvec",
"wayland-sys",
]
@@ -8839,8 +9040,6 @@ version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
- "dlib",
- "log",
"pkg-config",
]
@@ -9506,6 +9705,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winnow"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
+
[[package]]
name = "winreg"
version = "0.55.0"
@@ -9522,6 +9727,94 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "indexmap 2.12.1",
+ "prettyplease",
+ "syn 2.0.111",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.111",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.10.0",
+ "indexmap 2.12.1",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.12.1",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
[[package]]
name = "wl-clipboard-rs"
version = "0.9.2"
@@ -9730,7 +10023,6 @@ dependencies = [
"ordered-stream",
"serde",
"serde_repr",
- "tokio",
"tracing",
"uds_windows",
"uuid",
@@ -9862,6 +10154,79 @@ dependencies = [
"syn 2.0.111",
]
+[[package]]
+name = "zip"
+version = "8.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59"
+dependencies = [
+ "aes",
+ "bzip2",
+ "constant_time_eq",
+ "crc32fast",
+ "deflate64",
+ "flate2",
+ "getrandom 0.4.2",
+ "hmac",
+ "indexmap 2.12.1",
+ "lzma-rust2",
+ "memchr",
+ "pbkdf2",
+ "ppmd-rust",
+ "sha1",
+ "time",
+ "typed-path",
+ "zeroize",
+ "zopfli",
+ "zstd",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "zune-core"
version = "0.4.12"
@@ -9910,7 +10275,6 @@ dependencies = [
"endi",
"enumflags2",
"serde",
- "url",
"winnow 0.7.14",
"zvariant_derive",
"zvariant_utils",
diff --git a/Cargo.toml b/Cargo.toml
index 8479a1a63..2c8cebd44 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -55,6 +55,7 @@ sage-database = { path = "./crates/sage-database" }
sage-keychain = { path = "./crates/sage-keychain" }
sage-wallet = { path = "./crates/sage-wallet" }
sage-assets = { path = "./crates/sage-assets" }
+sage-apps = { path = "./crates/sage-apps" }
sage-rpc = { path = "./crates/sage-rpc" }
# Serialization
@@ -67,7 +68,7 @@ hex = "0.4.3"
base64 = "0.22.1"
# Tauri
-tauri = "2.10.2"
+tauri = { version = "2.10.2", features = ["unstable"] } # Unstable reason: Multiwebview support
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-opener = "2.5.3"
tauri-plugin-os = "2.3.2"
diff --git a/README.md b/README.md
index d82cb8b55..bc0b85a1a 100644
--- a/README.md
+++ b/README.md
@@ -53,16 +53,16 @@ You can run the app with:
```bash
# For development purposes:
-pnpm tauri dev
+pnpm tauri:dev
# If you need optimizations:
-pnpm tauri dev --release
+pnpm tauri:dev --release
```
And build the application with:
```bash
-pnpm tauri build
+pnpm tauri:build
```
You can also run the app in the iOS or Android simulator, though it may take some prior setup:
diff --git a/builtin-apps/src/runtime/origin-cleanup/app.js b/builtin-apps/src/runtime/origin-cleanup/app.js
new file mode 100644
index 000000000..f5f51cc30
--- /dev/null
+++ b/builtin-apps/src/runtime/origin-cleanup/app.js
@@ -0,0 +1,123 @@
+import './bridge.js';
+import { getSageClient } from './sdk.js';
+
+const log = (...args) => window.__SAGE_TEST__?.log?.(...args);
+
+async function deleteIndexedDb(name) {
+ return await new Promise((resolve) => {
+ const req = indexedDB.deleteDatabase(name);
+
+ req.onsuccess = () => resolve(null);
+ req.onerror = () => resolve(`indexedDB ${name}: ${req.error}`);
+ req.onblocked = () => resolve(`indexedDB ${name}: blocked`);
+ });
+}
+
+async function clearOriginData() {
+ const errors = [];
+
+ try {
+ localStorage.clear();
+ } catch (err) {
+ errors.push(`localStorage: ${String(err)}`);
+ }
+
+ try {
+ sessionStorage.clear();
+ } catch (err) {
+ errors.push(`sessionStorage: ${String(err)}`);
+ }
+
+ try {
+ if ('caches' in window) {
+ const keys = await caches.keys();
+ await Promise.all(keys.map((key) => caches.delete(key)));
+ }
+ } catch (err) {
+ errors.push(`caches: ${String(err)}`);
+ }
+
+ try {
+ if (indexedDB.databases) {
+ const dbs = await indexedDB.databases();
+
+ for (const db of dbs) {
+ if (!db.name) continue;
+
+ const error = await deleteIndexedDb(db.name);
+ if (error) errors.push(error);
+ }
+ } else {
+ errors.push('indexedDB.databases unavailable');
+ }
+ } catch (err) {
+ errors.push(`indexedDB: ${String(err)}`);
+ }
+
+ try {
+ for (const cookie of document.cookie.split(';')) {
+ const name = cookie.split('=')[0]?.trim();
+ if (!name) continue;
+
+ document.cookie = `${name}=; Max-Age=0; path=/`;
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
+ }
+ } catch (err) {
+ errors.push(`cookies: ${String(err)}`);
+ }
+
+ return errors;
+}
+
+async function report(sage, cleanupId, ok, errors) {
+ const payload = {
+ kind: 'originCleanup.completed',
+ cleanupId,
+ ok,
+ errors,
+ };
+
+ log('bridgeSend originCleanup.completed start', payload);
+
+ const result = await sage.app.bridgeSend(payload);
+
+ log('bridgeSend originCleanup.completed ok', result);
+}
+
+(async () => {
+ log('start', window.location.href);
+
+ const sage = await getSageClient();
+ log('getSageClient ok');
+
+ const ping = await sage.app.bridgePing();
+ log('bridgePing ok', ping);
+
+ const params = new URLSearchParams(window.location.search);
+ const cleanupId = params.get('cleanupId');
+
+ if (!cleanupId) {
+ throw new Error('missing cleanupId');
+ }
+
+ const errors = await clearOriginData();
+
+ await report(sage, cleanupId, errors.length === 0, errors);
+})().catch(async (err) => {
+ log('fatal', err instanceof Error ? err.message : String(err));
+
+ try {
+ const sage = await getSageClient();
+ const params = new URLSearchParams(window.location.search);
+ const cleanupId = params.get('cleanupId') ?? '';
+
+ await report(sage, cleanupId, false, [
+ err instanceof Error ? err.message : String(err),
+ ]);
+ } catch (fallbackErr) {
+ log(
+ 'fallback failed',
+ fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
+ );
+ }
+});
diff --git a/builtin-apps/src/runtime/origin-cleanup/index.html b/builtin-apps/src/runtime/origin-cleanup/index.html
new file mode 100644
index 000000000..20a67c6eb
--- /dev/null
+++ b/builtin-apps/src/runtime/origin-cleanup/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Origin Cleanup
+
+
+
+
+
+
\ No newline at end of file
diff --git a/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json b/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json
new file mode 100644
index 000000000..e5699dcb6
--- /dev/null
+++ b/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json
@@ -0,0 +1,23 @@
+{
+ "manifestVersion": 0,
+ "name": "Sage Origin Cleanup",
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "version": "1.0.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": ["bridge.send"],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html"
+}
+
diff --git a/builtin-apps/src/sandbox-test/_shared/debug-tools.js b/builtin-apps/src/sandbox-test/_shared/debug-tools.js
new file mode 100644
index 000000000..ffdd2ad55
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/_shared/debug-tools.js
@@ -0,0 +1,152 @@
+(function () {
+ function ensureRoot() {
+ let root = document.getElementById('__sage_test_debug_root');
+ if (root) return root;
+
+ root = document.createElement('div');
+ root.id = '__sage_test_debug_root';
+ Object.assign(root.style, {
+ position: 'fixed',
+ top: '6px',
+ left: '6px',
+ right: '6px',
+ bottom: '6px',
+ zIndex: '2147483647',
+ pointerEvents: 'none',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '6px',
+ fontFamily: 'monospace',
+ minHeight: '0',
+ minWidth: '0',
+ boxSizing: 'border-box',
+ });
+
+ document.body.appendChild(root);
+ return root;
+ }
+
+ function mountDebugLabel() {
+ const params = new URLSearchParams(window.location.search);
+ const appId = window.location.host || 'unknown-app';
+ const phase = params.get('phase');
+ const runId = params.get('runId');
+
+ const parts = [appId];
+ if (phase) parts.push(phase);
+ if (runId) parts.push(runId);
+
+ const el = document.createElement('div');
+ el.textContent = parts.join(' • ');
+
+ Object.assign(el.style, {
+ alignSelf: 'stretch',
+ flex: '0 0 auto',
+ pointerEvents: 'none',
+ padding: '3px 6px',
+ borderRadius: '4px',
+ background: 'rgba(0,0,0,0.72)',
+ color: '#00ff88',
+ fontSize: '10px',
+ lineHeight: '1.2',
+ whiteSpace: 'normal',
+ overflowWrap: 'anywhere',
+ wordBreak: 'break-word',
+ maxWidth: '100%',
+ boxSizing: 'border-box',
+ });
+
+ ensureRoot().appendChild(el);
+ return el;
+ }
+
+ function ensureLogPane() {
+ let pane = document.getElementById('__sage_test_log_pane');
+ if (pane) return pane;
+
+ pane = document.createElement('div');
+ pane.id = '__sage_test_log_pane';
+
+ Object.assign(pane.style, {
+ flex: '1 1 auto',
+ minHeight: '0',
+ minWidth: '0',
+ pointerEvents: 'auto',
+ overflowY: 'auto',
+ overflowX: 'hidden',
+ padding: '6px',
+ borderRadius: '4px',
+ background: 'rgba(0,0,0,0.72)',
+ color: '#e5e7eb',
+ fontSize: '10px',
+ lineHeight: '1.35',
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'anywhere',
+ wordBreak: 'break-word',
+ boxSizing: 'border-box',
+ overscrollBehavior: 'contain',
+ });
+
+ ensureRoot().appendChild(pane);
+ return pane;
+ }
+
+ function stringify(value) {
+ if (typeof value === 'string') return value;
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+ }
+
+ function log(...args) {
+ const pane = ensureLogPane();
+ const line = document.createElement('div');
+ line.textContent = args.map(stringify).join(' ');
+ pane.appendChild(line);
+ pane.scrollTop = pane.scrollHeight;
+ }
+
+ function patchConsole() {
+ const orig = {
+ log: console.log.bind(console),
+ warn: console.warn.bind(console),
+ error: console.error.bind(console),
+ };
+
+ console.log = (...args) => {
+ log('[log]', ...args);
+ orig.log(...args);
+ };
+
+ console.warn = (...args) => {
+ log('[warn]', ...args);
+ orig.warn(...args);
+ };
+
+ console.error = (...args) => {
+ log('[error]', ...args);
+ orig.error(...args);
+ };
+
+ window.addEventListener('error', (event) => {
+ log('[window.error]', event.message);
+ });
+
+ window.addEventListener('unhandledrejection', (event) => {
+ log('[unhandledrejection]', event.reason);
+ });
+ }
+
+ window.__SAGE_TEST__ = {
+ ...(window.__SAGE_TEST__ || {}),
+ mountDebugLabel,
+ ensureLogPane,
+ log,
+ };
+
+ mountDebugLabel();
+ ensureLogPane();
+ patchConsole();
+})();
diff --git a/builtin-apps/src/sandbox-test/_shared/icon.svg b/builtin-apps/src/sandbox-test/_shared/icon.svg
new file mode 100644
index 000000000..1f0dd22f3
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/_shared/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/builtin-apps/src/sandbox-test/_shared/index.html b/builtin-apps/src/sandbox-test/_shared/index.html
new file mode 100644
index 000000000..b73c6619b
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/_shared/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Sage Builtin Test App
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/builtin-apps/src/sandbox-test/network-allow-a/app.js b/builtin-apps/src/sandbox-test/network-allow-a/app.js
new file mode 100644
index 000000000..0f28146e8
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/network-allow-a/app.js
@@ -0,0 +1,115 @@
+import './bridge.js';
+import { getSageClient } from './sdk.js';
+
+const log = (...args) => window.__SAGE_TEST__?.log?.(...args);
+
+(async () => {
+ log('start', window.location.href);
+
+ const sage = await getSageClient();
+ log('getSageClient ok');
+
+ const ping = await sage.app.bridgePing();
+ log('bridgePing ok', ping);
+
+ const params = new URLSearchParams(window.location.search);
+ const runId = params.get('runId');
+
+ if (!runId) {
+ throw new Error('missing runId');
+ }
+
+ const allowedUrl = 'https://example.com/';
+ const blockedUrl = 'https://example.org/';
+
+ async function tryFetch(url) {
+ const controller = new AbortController();
+ const timeout = window.setTimeout(() => controller.abort(), 5000);
+
+ try {
+ await fetch(url, {
+ method: 'GET',
+ mode: 'no-cors',
+ cache: 'no-store',
+ signal: controller.signal,
+ });
+
+ window.clearTimeout(timeout);
+ return true;
+ } catch {
+ window.clearTimeout(timeout);
+ return false;
+ }
+ }
+
+ let allowedOk = false;
+ let blockedOk = false;
+ let error = null;
+
+ try {
+ allowedOk = await tryFetch(allowedUrl);
+ log('allowedOk', allowedUrl, allowedOk);
+
+ blockedOk = await tryFetch(blockedUrl);
+ log('blockedOk', blockedUrl, blockedOk);
+ } catch (err) {
+ error = err instanceof Error ? err.message : String(err);
+ log('network probe error', error);
+ }
+
+ const payload = {
+ runId,
+ mode: 'allow-a',
+ allowedUrl,
+ blockedUrl,
+ allowedOk,
+ blockedOk,
+ error,
+ };
+
+ log('bridgeSend network start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'network',
+ data: payload,
+ },
+ });
+
+ log('bridgeSend network ok', result);
+})().catch(async (err) => {
+ log('fatal', err instanceof Error ? err.message : String(err));
+
+ try {
+ const sage = await getSageClient();
+ const params = new URLSearchParams(window.location.search);
+
+ const payload = {
+ runId: params.get('runId'),
+ mode: 'allow-a',
+ allowedUrl: 'https://example.com/',
+ blockedUrl: 'https://example.org/',
+ allowedOk: false,
+ blockedOk: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+
+ log('fallback bridgeSend network start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'network',
+ data: payload,
+ },
+ });
+
+ log('fallback bridgeSend network ok', result);
+ } catch (fallbackErr) {
+ log(
+ 'fallback failed',
+ fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
+ );
+ }
+});
diff --git a/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json b/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json
new file mode 100644
index 000000000..ea554a0ed
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json
@@ -0,0 +1,24 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0",
+ "testedMax": "99.0.0"
+ },
+ "name": "Network Allow A",
+ "version": "1.0.0",
+ "icon": "icon.svg",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [
+ "https://example.com"
+ ]
+ }
+ },
+ "capabilities": {
+ "required": ["bridge.send"],
+ "optional": []
+ }
+ },
+ "files": []
+}
diff --git a/builtin-apps/src/sandbox-test/network-allow-b/app.js b/builtin-apps/src/sandbox-test/network-allow-b/app.js
new file mode 100644
index 000000000..744ac72ba
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/network-allow-b/app.js
@@ -0,0 +1,115 @@
+import './bridge.js';
+import { getSageClient } from './sdk.js';
+
+const log = (...args) => window.__SAGE_TEST__?.log?.(...args);
+
+(async () => {
+ log('start', window.location.href);
+
+ const sage = await getSageClient();
+ log('getSageClient ok');
+
+ const ping = await sage.app.bridgePing();
+ log('bridgePing ok', ping);
+
+ const params = new URLSearchParams(window.location.search);
+ const runId = params.get('runId');
+
+ if (!runId) {
+ throw new Error('missing runId');
+ }
+
+ const allowedUrl = 'https://example.org/';
+ const blockedUrl = 'https://example.com/';
+
+ async function tryFetch(url) {
+ const controller = new AbortController();
+ const timeout = window.setTimeout(() => controller.abort(), 5000);
+
+ try {
+ await fetch(url, {
+ method: 'GET',
+ mode: 'no-cors',
+ cache: 'no-store',
+ signal: controller.signal,
+ });
+
+ window.clearTimeout(timeout);
+ return true;
+ } catch {
+ window.clearTimeout(timeout);
+ return false;
+ }
+ }
+
+ let allowedOk = false;
+ let blockedOk = false;
+ let error = null;
+
+ try {
+ allowedOk = await tryFetch(allowedUrl);
+ log('allowedOk', allowedUrl, allowedOk);
+
+ blockedOk = await tryFetch(blockedUrl);
+ log('blockedOk', blockedUrl, blockedOk);
+ } catch (err) {
+ error = err instanceof Error ? err.message : String(err);
+ log('network probe error', error);
+ }
+
+ const payload = {
+ runId,
+ mode: 'allow-b',
+ allowedUrl,
+ blockedUrl,
+ allowedOk,
+ blockedOk,
+ error,
+ };
+
+ log('bridgeSend network start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'network',
+ data: payload,
+ },
+ });
+
+ log('bridgeSend network ok', result);
+})().catch(async (err) => {
+ log('fatal', err instanceof Error ? err.message : String(err));
+
+ try {
+ const sage = await getSageClient();
+ const params = new URLSearchParams(window.location.search);
+
+ const payload = {
+ runId: params.get('runId'),
+ mode: 'allow-b',
+ allowedUrl: 'https://example.org/',
+ blockedUrl: 'https://example.com/',
+ allowedOk: false,
+ blockedOk: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+
+ log('fallback bridgeSend network start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'network',
+ data: payload,
+ },
+ });
+
+ log('fallback bridgeSend network ok', result);
+ } catch (fallbackErr) {
+ log(
+ 'fallback failed',
+ fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
+ );
+ }
+});
diff --git a/builtin-apps/src/sandbox-test/network-allow-b/sage-manifest.json b/builtin-apps/src/sandbox-test/network-allow-b/sage-manifest.json
new file mode 100644
index 000000000..a556b760a
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/network-allow-b/sage-manifest.json
@@ -0,0 +1,22 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0",
+ "testedMax": "99.0.0"
+ },
+ "name": "Network Allow B",
+ "version": "1.0.0",
+ "icon": "icon.svg",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": ["https://example.org"]
+ }
+ },
+ "capabilities": {
+ "required": ["bridge.send"],
+ "optional": []
+ }
+ },
+ "files": []
+}
diff --git a/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js b/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js
new file mode 100644
index 000000000..795eb29fe
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js
@@ -0,0 +1,146 @@
+import './bridge.js';
+import { getSageClient } from './sdk.js';
+
+const log = (...args) => window.__SAGE_TEST__?.log?.(...args);
+
+(async () => {
+ log('start', window.location.href);
+
+ const sage = await getSageClient();
+ log('getSageClient ok');
+
+ const ping = await sage.app.bridgePing();
+ log('bridgePing ok', ping);
+
+ const params = new URLSearchParams(window.location.search);
+ const runId = params.get('runId');
+
+ if (!runId) {
+ throw new Error('missing runId');
+ }
+
+ const LOCAL_STORAGE_KEY = 'sage_probe_local_storage';
+ const DB_NAME = 'sage_probe_db';
+ const STORE_NAME = 'probe_store';
+ const DB_KEY = 'sage_probe_key';
+
+ async function readIndexedDbProbe() {
+ try {
+ return await new Promise((resolve) => {
+ const open = indexedDB.open(DB_NAME);
+
+ open.onerror = () => resolve(false);
+
+ open.onupgradeneeded = () => {
+ try {
+ const db = open.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME);
+ }
+ } catch {}
+ };
+
+ open.onsuccess = () => {
+ try {
+ const db = open.result;
+
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.close();
+ resolve(false);
+ return;
+ }
+
+ const tx = db.transaction(STORE_NAME, 'readonly');
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.get(DB_KEY);
+
+ req.onerror = () => {
+ db.close();
+ resolve(false);
+ };
+
+ req.onsuccess = () => {
+ db.close();
+ resolve(typeof req.result === 'string' && req.result.length > 0);
+ };
+ } catch {
+ resolve(false);
+ }
+ };
+ });
+ } catch {
+ return false;
+ }
+ }
+
+ async function report(data) {
+ log('bridgeSend isolation start', data);
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'isolation',
+ data,
+ },
+ });
+ log('bridgeSend isolation ok', result);
+ }
+
+ let localStorageVisible = false;
+ let indexedDbVisible = false;
+ let error = null;
+
+ try {
+ try {
+ const value = localStorage.getItem(LOCAL_STORAGE_KEY);
+ localStorageVisible = typeof value === 'string' && value.length > 0;
+ log('localStorageVisible', localStorageVisible);
+ } catch {
+ localStorageVisible = false;
+ log('localStorage read failed');
+ }
+
+ indexedDbVisible = await readIndexedDbProbe();
+ log('indexedDbVisible', indexedDbVisible);
+ } catch (err) {
+ error = err instanceof Error ? err.message : String(err);
+ log('probe error', error);
+ }
+
+ await report({
+ runId,
+ localStorageVisible,
+ indexedDbVisible,
+ error,
+ });
+})().catch(async (err) => {
+ log('fatal', err instanceof Error ? err.message : String(err));
+
+ try {
+ const sage = await getSageClient();
+ const params = new URLSearchParams(window.location.search);
+
+ const payload = {
+ runId: params.get('runId'),
+ localStorageVisible: false,
+ indexedDbVisible: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+
+ log('fallback bridgeSend isolation start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'isolation',
+ data: payload,
+ },
+ });
+
+ log('fallback bridgeSend isolation ok', result);
+ } catch (fallbackErr) {
+ log(
+ 'fallback failed',
+ fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
+ );
+ }
+});
diff --git a/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json
new file mode 100644
index 000000000..c50a494cc
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json
@@ -0,0 +1,18 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0",
+ "testedMax": "99.0.0"
+ },
+ "name": "Sage Storage Isolation",
+ "version": "1.0.0",
+ "icon": "icon.svg",
+ "permissions": {
+ "capabilities": {
+ "required": ["bridge.send"],
+ "optional": []
+ }
+ },
+ "files": []
+}
+
diff --git a/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json
new file mode 100644
index 000000000..a4cfdb8fb
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json
@@ -0,0 +1,18 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0",
+ "testedMax": "99.0.0"
+ },
+ "name": "Sage Storage Isolation",
+ "version": "1.0.0",
+ "icon": "icon.svg",
+ "permissions": {
+ "capabilities": {
+ "required": ["bridge.send", "storage.persistent_webview"],
+ "optional": []
+ }
+ },
+ "files": []
+}
+
diff --git a/builtin-apps/src/sandbox-test/storage-persistence/app.js b/builtin-apps/src/sandbox-test/storage-persistence/app.js
new file mode 100644
index 000000000..4c9ad77b9
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/storage-persistence/app.js
@@ -0,0 +1,268 @@
+import './bridge.js';
+import { getSageClient } from './sdk.js';
+
+const log = (...args) => window.__SAGE_TEST__?.log?.(...args);
+
+(async () => {
+ log('start', window.location.href);
+
+ const sage = await getSageClient();
+ log('getSageClient ok');
+
+ const ping = await sage.app.bridgePing();
+ log('bridgePing ok', ping);
+
+ const params = new URLSearchParams(window.location.search);
+ const runId = params.get('runId');
+ const phase = params.get('phase');
+
+ if (!runId) {
+ throw new Error('missing runId');
+ }
+
+ if (phase !== 'write' && phase !== 'read') {
+ throw new Error('missing or invalid phase');
+ }
+
+ const LOCAL_STORAGE_KEY = `sandbox_persistence_local_storage:${runId}:incognito`;
+ const DB_NAME = `sandbox_persistence_db_${runId}_incognito`;
+ const STORE_NAME = 'probe_store';
+ const DB_KEY = 'probe_key';
+
+ async function writeIndexedDbValue() {
+ try {
+ return await new Promise((resolve) => {
+ const open = indexedDB.open(DB_NAME);
+
+ open.onerror = () => resolve(false);
+
+ open.onupgradeneeded = () => {
+ try {
+ const db = open.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME);
+ }
+ } catch {}
+ };
+
+ open.onsuccess = () => {
+ try {
+ const db = open.result;
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.put('present', DB_KEY);
+
+ req.onerror = () => {
+ db.close();
+ resolve(false);
+ };
+
+ req.onsuccess = () => {
+ tx.oncomplete = () => {
+ db.close();
+ resolve(true);
+ };
+
+ tx.onerror = () => {
+ db.close();
+ resolve(false);
+ };
+ };
+ } catch {
+ resolve(false);
+ }
+ };
+ });
+ } catch {
+ return false;
+ }
+ }
+
+ async function readIndexedDbValue() {
+ try {
+ return await new Promise((resolve) => {
+ const open = indexedDB.open(DB_NAME);
+
+ open.onerror = () => resolve(false);
+
+ open.onupgradeneeded = () => {
+ try {
+ const db = open.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME);
+ }
+ } catch {}
+ };
+
+ open.onsuccess = () => {
+ try {
+ const db = open.result;
+
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.close();
+ resolve(false);
+ return;
+ }
+
+ const tx = db.transaction(STORE_NAME, 'readonly');
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.get(DB_KEY);
+
+ req.onerror = () => {
+ db.close();
+ resolve(false);
+ };
+
+ req.onsuccess = () => {
+ db.close();
+ resolve(req.result === 'present');
+ };
+ } catch {
+ resolve(false);
+ }
+ };
+ });
+ } catch {
+ return false;
+ }
+ }
+
+ async function reportWrite(data) {
+ log('bridgeSend persistence_write start', data);
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'persistence_write',
+ data,
+ },
+ });
+ log('bridgeSend persistence_write ok', result);
+ }
+
+ async function reportRead(data) {
+ log('bridgeSend persistence_read start', data);
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'persistence_read',
+ data,
+ },
+ });
+ log('bridgeSend persistence_read ok', result);
+ }
+
+ if (phase === 'write') {
+ let localStorageWrote = false;
+ let indexedDbWrote = false;
+ let error = null;
+
+ try {
+ try {
+ localStorage.setItem(LOCAL_STORAGE_KEY, 'present');
+ localStorageWrote =
+ localStorage.getItem(LOCAL_STORAGE_KEY) === 'present';
+ log('localStorageWrote', localStorageWrote);
+ } catch {
+ localStorageWrote = false;
+ log('localStorage write failed');
+ }
+
+ indexedDbWrote = await writeIndexedDbValue();
+ log('indexedDbWrote', indexedDbWrote);
+ } catch (err) {
+ error = err instanceof Error ? err.message : String(err);
+ log('write phase error', error);
+ }
+
+ await reportWrite({
+ runId,
+ localStorageWrote,
+ indexedDbWrote,
+ error,
+ });
+
+ return;
+ }
+
+ let localStoragePresent = false;
+ let indexedDbPresent = false;
+ let error = null;
+
+ try {
+ try {
+ localStoragePresent =
+ localStorage.getItem(LOCAL_STORAGE_KEY) === 'present';
+ log('localStoragePresent', localStoragePresent);
+ } catch {
+ localStoragePresent = false;
+ log('localStorage read failed');
+ }
+
+ indexedDbPresent = await readIndexedDbValue();
+ log('indexedDbPresent', indexedDbPresent);
+ } catch (err) {
+ error = err instanceof Error ? err.message : String(err);
+ log('read phase error', error);
+ }
+
+ await reportRead({
+ runId,
+ localStoragePresent,
+ indexedDbPresent,
+ error,
+ });
+})().catch(async (err) => {
+ log('fatal', err instanceof Error ? err.message : String(err));
+
+ try {
+ const sage = await getSageClient();
+ const params = new URLSearchParams(window.location.search);
+ const phase = params.get('phase');
+
+ if (phase === 'write') {
+ const payload = {
+ runId: params.get('runId'),
+ localStorageWrote: false,
+ indexedDbWrote: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+
+ log('fallback bridgeSend persistence_write start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'persistence_write',
+ data: payload,
+ },
+ });
+
+ log('fallback bridgeSend persistence_write ok', result);
+ return;
+ }
+
+ const payload = {
+ runId: params.get('runId'),
+ localStoragePresent: false,
+ indexedDbPresent: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+
+ log('fallback bridgeSend persistence_read start', payload);
+
+ const result = await sage.app.bridgeSend({
+ kind: 'sandbox_report',
+ report: {
+ type: 'persistence_read',
+ data: payload,
+ },
+ });
+
+ log('fallback bridgeSend persistence_read ok', result);
+ } catch (fallbackErr) {
+ log(
+ 'fallback failed',
+ fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
+ );
+ }
+});
diff --git a/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.incognito.json b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.incognito.json
new file mode 100644
index 000000000..6a8ac2077
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.incognito.json
@@ -0,0 +1,17 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0",
+ "testedMax": "99.0.0"
+ },
+ "name": "Storage Persistence",
+ "version": "1.0.0",
+ "icon": "icon.svg",
+ "permissions": {
+ "capabilities": {
+ "required": ["bridge.send"],
+ "optional": []
+ }
+ },
+ "files": []
+}
diff --git a/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.persistent.json b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.persistent.json
new file mode 100644
index 000000000..202e08fdb
--- /dev/null
+++ b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.persistent.json
@@ -0,0 +1,17 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0",
+ "testedMax": "99.0.0"
+ },
+ "name": "Storage Persistence",
+ "version": "1.0.0",
+ "icon": "icon.svg",
+ "permissions": {
+ "capabilities": {
+ "required": ["bridge.send", "storage.persistent_webview"],
+ "optional": []
+ }
+ },
+ "files": []
+}
diff --git a/builtin-apps/src/system/apps/app-install/index.html b/builtin-apps/src/system/apps/app-install/index.html
new file mode 100644
index 000000000..ccd273c36
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sage Update Review
+
+
+
+
+
+
diff --git a/builtin-apps/src/system/apps/app-install/package.json b/builtin-apps/src/system/apps/app-install/package.json
new file mode 100644
index 000000000..7a973cd0f
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "app-install-system-app",
+ "private": true,
+ "type": "module"
+}
diff --git a/builtin-apps/src/system/apps/app-install/postcss.config.js b/builtin-apps/src/system/apps/app-install/postcss.config.js
new file mode 100644
index 000000000..c97c6b959
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/postcss.config.js
@@ -0,0 +1,13 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ plugins: {
+ tailwindcss: {
+ config: join(dir, 'tailwind.config.js'),
+ },
+ autoprefixer: {},
+ },
+};
diff --git a/builtin-apps/src/system/apps/app-install/public/icon.svg b/builtin-apps/src/system/apps/app-install/public/icon.svg
new file mode 100644
index 000000000..af1297f54
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/public/icon.svg
@@ -0,0 +1,10 @@
+
diff --git a/builtin-apps/src/system/apps/app-install/sage-manifest.json b/builtin-apps/src/system/apps/app-install/sage-manifest.json
new file mode 100644
index 000000000..00493b54b
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/sage-manifest.json
@@ -0,0 +1,27 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "name": "Install App",
+ "version": "0.1.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": [
+ "environment.theme.get_current",
+ "environment.theme.listen_changed",
+ "environment.theme.css_vars"
+ ],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html",
+ "icon": "icon.svg"
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/App.tsx b/builtin-apps/src/system/apps/app-install/src/App.tsx
new file mode 100644
index 000000000..d7f707087
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/App.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useState } from 'react';
+import { formatSageError, getSageSystemClient } from '@sage-system-app/sdk';
+import { previewUrl } from './api';
+import { ErrorState } from './components/ErrorState';
+import { LoadingState } from './components/LoadingState';
+import { ReviewInstallView } from './components/ReviewInstallView';
+import { SelectSourceView } from './components/SelectSourceView';
+import type { LoadState } from './types';
+
+export function App() {
+ const [state, setState] = useState({ kind: 'loading' });
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ try {
+ const client = await getSageSystemClient();
+
+ const definitions = await client.capabilities.listUserDefinitions();
+
+ const params = new URLSearchParams(window.location.search);
+ const mode = params.get('mode') ?? 'select-source';
+ const appUrl = params.get('appUrl');
+
+ if (mode === 'url' && appUrl) {
+ const source = await previewUrl(appUrl);
+
+ if (!cancelled) {
+ setState({
+ kind: 'review',
+ definitions,
+ source,
+ });
+ }
+
+ return;
+ }
+
+ if (!cancelled) {
+ setState({ kind: 'selecting', definitions });
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setState({ kind: 'error', error: formatSageError(err) });
+ }
+ }
+ }
+
+ void load();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (state.kind === 'loading') return ;
+ if (state.kind === 'error') return ;
+
+ if (state.kind === 'selecting') {
+ return (
+
+ setState({
+ kind: 'review',
+ definitions: state.definitions,
+ source,
+ })
+ }
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/api.ts b/builtin-apps/src/system/apps/app-install/src/api.ts
new file mode 100644
index 000000000..4d6bc9e5d
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/api.ts
@@ -0,0 +1,73 @@
+import {
+ getSageSystemClient,
+ type SageGrantedPermissionsInput,
+ type SageAppWalletScope,
+ type WalletListWalletsResult,
+} from '@sage-system-app/sdk';
+import type { InstallSource } from './types';
+
+export async function closeSelf() {
+ const client = await getSageSystemClient();
+ void client.runtimeManager.closeSelf();
+}
+
+export async function listWallets(): Promise {
+ const client = await getSageSystemClient();
+ return await client.wallet.listWallets();
+}
+
+export async function previewUrl(appUrl: string): Promise {
+ const client = await getSageSystemClient();
+ const preview = await client.appInstall.previewUrl({ appUrl });
+
+ return {
+ kind: 'url',
+ appUrl: preview.appUrl,
+ preview,
+ };
+}
+
+export async function selectAndPreviewZip(): Promise {
+ const client = await getSageSystemClient();
+
+ const selected = await client.fileSystem.selectFile({
+ title: 'Select Sage app package',
+ filters: [{ name: 'Zip Archive', extensions: ['zip'] }],
+ });
+
+ if (!selected.path) {
+ return null;
+ }
+
+ const manifest = await client.appInstall.previewZip({
+ zipPath: selected.path,
+ });
+
+ return {
+ kind: 'zip',
+ zipPath: selected.path,
+ manifest,
+ };
+}
+
+export async function installSource(
+ source: InstallSource,
+ grantedPermissions: SageGrantedPermissionsInput,
+ walletScope: SageAppWalletScope,
+) {
+ const client = await getSageSystemClient();
+
+ if (source.kind === 'zip') {
+ await client.appInstall.installZip({
+ zipPath: source.zipPath,
+ grantedPermissions,
+ walletScope,
+ });
+ } else {
+ await client.appInstall.installUrl({
+ appUrl: source.appUrl,
+ grantedPermissions,
+ walletScope,
+ });
+ }
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx b/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx
new file mode 100644
index 000000000..e78078aff
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx
@@ -0,0 +1,14 @@
+import { AppModalShell } from '@sage-app/ui';
+
+export function ErrorState({ error }: { error: string }) {
+ return (
+
+
+ {error}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx b/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx
new file mode 100644
index 000000000..c743fe244
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx
@@ -0,0 +1,12 @@
+import { AppModalShell } from '@sage-app/ui';
+
+export function LoadingState() {
+ return (
+
+ Loading installer…
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/components/ReviewInstallView.tsx b/builtin-apps/src/system/apps/app-install/src/components/ReviewInstallView.tsx
new file mode 100644
index 000000000..7b1fd8a1d
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/components/ReviewInstallView.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useMemo, useState } from 'react';
+import {
+ AppModalShell,
+ AppPermissionEditor,
+ WalletScopeEditor,
+} from '@sage-app/ui';
+import {
+ formatSageError,
+ type SageAppCapabilityDefinitionView,
+ type SageAppWalletScope,
+ type SageGrantedPermissionsInput,
+ type SystemWalletView,
+} from '@sage-system-app/sdk';
+import { closeSelf, installSource, listWallets } from '../api';
+import type { InstallSource } from '../types';
+import { resolveInstallIcon } from '../utils/icons';
+import {
+ buildPreviewApp,
+ emptyGrantedPermissions,
+ initialGrantedPermissions,
+ installManifest,
+} from '../utils/permissions';
+import { UnsupportedManifestView } from './UnsupportedManifestView';
+
+type Step = 'permissions' | 'wallets';
+
+export function ReviewInstallView({
+ source,
+ definitions,
+}: {
+ source: InstallSource;
+ definitions: SageAppCapabilityDefinitionView[];
+}) {
+ const manifest = installManifest(source);
+
+ const [step, setStep] = useState('permissions');
+ const [installing, setInstalling] = useState(false);
+ const [error, setError] = useState(null);
+ const [wallets, setWallets] = useState([]);
+ const [walletsLoading, setWalletsLoading] = useState(true);
+ const [permissionsViewed, setPermissionsViewed] = useState(false);
+
+ const [walletScope, setWalletScope] = useState({
+ kind: 'allWallets',
+ });
+
+ const [grantedPermissions, setGrantedPermissions] =
+ useState(() =>
+ manifest
+ ? initialGrantedPermissions(manifest, definitions)
+ : emptyGrantedPermissions(),
+ );
+
+ useEffect(() => {
+ let disposed = false;
+
+ async function loadWallets() {
+ try {
+ setWalletsLoading(true);
+ const result = await listWallets();
+
+ if (!disposed) {
+ setWallets(result.wallets);
+ }
+ } catch (err) {
+ if (!disposed) {
+ setError(formatSageError(err));
+ }
+ } finally {
+ if (!disposed) {
+ setWalletsLoading(false);
+ }
+ }
+ }
+
+ void loadWallets();
+
+ return () => {
+ disposed = true;
+ };
+ }, []);
+
+ const previewApp = useMemo(() => {
+ if (!manifest) return null;
+ return buildPreviewApp(manifest, grantedPermissions);
+ }, [manifest, grantedPermissions]);
+
+ const canInstall =
+ !installing &&
+ !walletsLoading &&
+ (walletScope.kind === 'allWallets' || walletScope.fingerprints.length > 0);
+
+ async function install() {
+ if (!manifest || !canInstall) return;
+
+ setInstalling(true);
+ setError(null);
+
+ try {
+ await installSource(source, grantedPermissions, walletScope);
+ await closeSelf();
+ } catch (err) {
+ setError(formatSageError(err));
+ } finally {
+ setInstalling(false);
+ }
+ }
+
+ if (!manifest || !previewApp) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ {step === 'wallets' ? (
+
+ ) : null}
+
+ {step === 'permissions' ? (
+
+ ) : (
+
+ )}
+
+
+ }
+ >
+
+ {step === 'permissions' ? (
+
+ ) : walletsLoading ? (
+
+ Loading wallets…
+
+ ) : (
+ <>
+
+ >
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/components/SelectSourceView.tsx b/builtin-apps/src/system/apps/app-install/src/components/SelectSourceView.tsx
new file mode 100644
index 000000000..c307845eb
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/components/SelectSourceView.tsx
@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { SystemModalShell } from '@sage-app/ui';
+import { formatSageError } from '@sage-system-app/sdk';
+import { closeSelf, previewUrl, selectAndPreviewZip } from '../api';
+import type { InstallSource } from '../types';
+
+export function SelectSourceView({
+ onReview,
+}: {
+ onReview: (source: InstallSource) => void;
+}) {
+ const [urlInput, setUrlInput] = useState('');
+ const [working, setWorking] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handlePreviewUrl() {
+ setWorking(true);
+ setError(null);
+
+ try {
+ onReview(await previewUrl(urlInput.trim()));
+ } catch (err) {
+ setError(formatSageError(err));
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ async function handleSelectZip() {
+ setWorking(true);
+ setError(null);
+
+ try {
+ const source = await selectAndPreviewZip();
+ if (source) onReview(source);
+ } catch (err) {
+ setError(formatSageError(err));
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ return (
+
+
+
+ {/* Install from URL */}
+
+
Install from URL
+
+ Best for published apps and updates.
+
+
+
+ setUrlInput(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && urlInput.trim()) {
+ event.preventDefault();
+ void handlePreviewUrl();
+ }
+ }}
+ />
+
+
+
+
+
+ {/* Install from ZIP */}
+
+
Install from ZIP
+
+ Useful for local builds, testing, or manual package installs.
+
+
+
+
+
+ {/* Error */}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {/* Footer */}
+
+
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/components/UnsupportedManifestView.tsx b/builtin-apps/src/system/apps/app-install/src/components/UnsupportedManifestView.tsx
new file mode 100644
index 000000000..4240f8f0f
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/components/UnsupportedManifestView.tsx
@@ -0,0 +1,60 @@
+import { AppModalShell } from '@sage-app/ui';
+import { closeSelf } from '../api';
+import type { InstallSource } from '../types';
+
+export function UnsupportedManifestView({
+ source,
+ error,
+}: {
+ source: InstallSource;
+ error: string | null;
+}) {
+ const partial =
+ source.kind === 'url' && source.preview.manifest.kind === 'partial'
+ ? source.preview.manifest
+ : null;
+
+ return (
+
+
+
+ }
+ >
+
+ {partial ? (
+ <>
+
+ Requires Sage {partial.manifest_header.sageVersion.min}
+ {partial.manifest_header.sageVersion.testedMax
+ ? ` · tested up to ${partial.manifest_header.sageVersion.testedMax}`
+ : null}
+
+
+
+ {partial.parse_error}
+
+ >
+ ) : (
+
+ This app manifest cannot be installed by this Sage version.
+
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/main.tsx b/builtin-apps/src/system/apps/app-install/src/main.tsx
new file mode 100644
index 000000000..ed0dc6330
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/builtin-apps/src/system/apps/app-install/src/styles.css b/builtin-apps/src/system/apps/app-install/src/styles.css
new file mode 100644
index 000000000..ad480ae12
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/styles.css
@@ -0,0 +1,19 @@
+@import "@sage-app/sdk/theme.css";
+@config "../tailwind.config.js";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background: transparent;
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/types.ts b/builtin-apps/src/system/apps/app-install/src/types.ts
new file mode 100644
index 000000000..8dd55ef4c
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/types.ts
@@ -0,0 +1,20 @@
+import type {
+ SageAppCapabilityDefinitionView,
+ SageAppPackageManifest,
+ SageAppUrlPreview,
+} from '@sage-system-app/sdk';
+
+export type InstallSource =
+ | { kind: 'zip'; zipPath: string; manifest: SageAppPackageManifest }
+ | { kind: 'url'; appUrl: string; preview: SageAppUrlPreview };
+
+export type LoadState =
+ | { kind: 'loading' }
+ | { kind: 'selecting'; definitions: SageAppCapabilityDefinitionView[] }
+ | {
+ kind: 'review';
+ definitions: SageAppCapabilityDefinitionView[];
+ source: InstallSource;
+ }
+ | { kind: 'error'; error: string };
+
diff --git a/builtin-apps/src/system/apps/app-install/src/utils/format.ts b/builtin-apps/src/system/apps/app-install/src/utils/format.ts
new file mode 100644
index 000000000..338fac151
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/utils/format.ts
@@ -0,0 +1,22 @@
+import type { SageAppPackageManifest } from '@sage-system-app/sdk';
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown';
+ if (bytes < 1024) return `${bytes} B`;
+
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let value = bytes / 1024;
+ let unitIndex = 0;
+
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024;
+ unitIndex += 1;
+ }
+
+ const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
+ return `${value.toFixed(digits)} ${units[unitIndex]}`;
+}
+
+export function manifestSize(manifest: SageAppPackageManifest): number {
+ return manifest.files.reduce((sum, file) => sum + (file.size ?? 0), 0);
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/utils/icons.ts b/builtin-apps/src/system/apps/app-install/src/utils/icons.ts
new file mode 100644
index 000000000..fc57a4c19
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/utils/icons.ts
@@ -0,0 +1,17 @@
+import type { AppIcon } from '@sage-app/ui';
+import type { InstallSource } from '../types';
+
+export function resolveInstallIcon(source: InstallSource): AppIcon | null {
+ if (source.kind === 'url') {
+ if (source.preview.icon === null) {
+ return null;
+ }
+
+ return {
+ kind: 'bytes',
+ icon: source.preview.icon,
+ };
+ }
+
+ return null;
+}
diff --git a/builtin-apps/src/system/apps/app-install/src/utils/permissions.ts b/builtin-apps/src/system/apps/app-install/src/utils/permissions.ts
new file mode 100644
index 000000000..c38545fc0
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/src/utils/permissions.ts
@@ -0,0 +1,43 @@
+import {
+ emptyGrantedPermissionsInput,
+ initialGrantedPermissionsInput,
+ inputToGrantedPermissionsView,
+} from '@sage-app/ui';
+import type {
+ SageAppCapabilityDefinitionView,
+ SageAppPackageManifest,
+ SageGrantedPermissionsInput,
+ UserSageAppView,
+} from '@sage-system-app/sdk';
+import type { InstallSource } from '../types';
+
+export const emptyGrantedPermissions = emptyGrantedPermissionsInput;
+export const initialGrantedPermissions = initialGrantedPermissionsInput;
+
+export function installManifest(
+ source: InstallSource,
+): SageAppPackageManifest | null {
+ if (source.kind === 'zip') return source.manifest;
+ if (source.preview.manifest.kind !== 'full') return null;
+ return source.preview.manifest.manifest;
+}
+
+export function buildPreviewApp(
+ manifest: SageAppPackageManifest,
+ grantedPermissions: SageGrantedPermissionsInput,
+): UserSageAppView {
+ return {
+ common: {
+ identity: {
+ id: '__install_preview__',
+ originId: '__install_preview__',
+ },
+ grantedPermissions: inputToGrantedPermissionsView(grantedPermissions),
+ walletScope: { kind: 'selectedWallets', fingerprints: [] },
+ activeSnapshot: { manifest },
+ icon: null,
+ },
+ source: { kind: 'zip' },
+ pendingUpdate: null,
+ };
+}
diff --git a/builtin-apps/src/system/apps/app-install/tailwind.config.js b/builtin-apps/src/system/apps/app-install/tailwind.config.js
new file mode 100644
index 000000000..13edcb743
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/tailwind.config.js
@@ -0,0 +1,14 @@
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createSageTailwindConfig } from '../../tailwind.shared.js';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(dir, '../../../../..');
+
+export default createSageTailwindConfig({
+ content: [
+ join(dir, 'index.html'),
+ join(dir, 'src/**/*.{ts,tsx}'),
+ join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'),
+ ],
+});
diff --git a/builtin-apps/src/system/apps/app-install/tsconfig.json b/builtin-apps/src/system/apps/app-install/tsconfig.json
new file mode 100644
index 000000000..3d61827d7
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/builtin-apps/src/system/apps/app-install/vite.config.ts b/builtin-apps/src/system/apps/app-install/vite.config.ts
new file mode 100644
index 000000000..c2498e6d6
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-install/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineSageSystemAppConfig } from '../../vite.system-app.config';
+
+export default defineSageSystemAppConfig(import.meta.url);
diff --git a/builtin-apps/src/system/apps/app-update/index.html b/builtin-apps/src/system/apps/app-update/index.html
new file mode 100644
index 000000000..ccd273c36
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sage Update Review
+
+
+
+
+
+
diff --git a/builtin-apps/src/system/apps/app-update/package.json b/builtin-apps/src/system/apps/app-update/package.json
new file mode 100644
index 000000000..0be8e9458
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "app-update-system-app",
+ "private": true,
+ "type": "module"
+}
diff --git a/builtin-apps/src/system/apps/app-update/postcss.config.js b/builtin-apps/src/system/apps/app-update/postcss.config.js
new file mode 100644
index 000000000..c97c6b959
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/postcss.config.js
@@ -0,0 +1,13 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ plugins: {
+ tailwindcss: {
+ config: join(dir, 'tailwind.config.js'),
+ },
+ autoprefixer: {},
+ },
+};
diff --git a/builtin-apps/src/system/apps/app-update/sage-manifest.json b/builtin-apps/src/system/apps/app-update/sage-manifest.json
new file mode 100644
index 000000000..992f99964
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/sage-manifest.json
@@ -0,0 +1,26 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "name": "Update App",
+ "version": "0.1.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": [
+ "environment.theme.get_current",
+ "environment.theme.listen_changed",
+ "environment.theme.css_vars"
+ ],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html"
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/App.tsx b/builtin-apps/src/system/apps/app-update/src/App.tsx
new file mode 100644
index 000000000..47d2bccbc
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/App.tsx
@@ -0,0 +1,32 @@
+import { SystemModalShell } from '@sage-app/ui';
+import { useLoadState } from './hooks/useLoadState';
+import { UpdateReviewBody } from './components/UpdateReviewBody';
+import { PermissionsReviewBody } from './components/PermissionsReviewBody';
+import { useSageSystemClient } from '@sage-system-app/sdk';
+
+export function App() {
+ const sage = useSageSystemClient();
+ const { state, reload } = useLoadState();
+
+ if (state.kind === 'loading') {
+ return (
+
+ Loading review…
+
+ );
+ }
+
+ if (state.kind === 'error') {
+ return (
+
+ {state.error}
+
+ );
+ }
+
+ if (state.mode === 'review-permissions') {
+ return ;
+ }
+
+ return ;
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/components/NoUpdateBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/NoUpdateBody.tsx
new file mode 100644
index 000000000..b16978556
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/components/NoUpdateBody.tsx
@@ -0,0 +1,18 @@
+export function NoUpdateBody({ name, onClose }: any) {
+ return (
+
+
App is up to date
+
+ No installable update is available for {name}.
+
+
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/components/PartialUpdateBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/PartialUpdateBody.tsx
new file mode 100644
index 000000000..5d5197ddf
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/components/PartialUpdateBody.tsx
@@ -0,0 +1,15 @@
+export function PartialUpdateBody({ header, error }: any) {
+ return (
+
+
Update cannot be installed
+
+
+ {header.name} requires unsupported manifest features.
+
+
+
+ {error}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/components/PermissionsReviewBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/PermissionsReviewBody.tsx
new file mode 100644
index 000000000..ebe265299
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/components/PermissionsReviewBody.tsx
@@ -0,0 +1,145 @@
+import { useState } from 'react';
+import {
+ appIconFromCommonView,
+ AppModalShell,
+ AppPermissionEditor,
+ inputToGrantedPermissionsView,
+ WalletScopeEditor,
+} from '@sage-app/ui';
+import {
+ formatSageError,
+ getSageSystemClient,
+ type SageAppWalletScope,
+ type SageGrantedPermissionsInput,
+} from '@sage-system-app/sdk';
+import type { LoadState } from '../types';
+
+type PermissionsReadyState = Extract;
+type ReviewTab = 'permissions' | 'wallets';
+
+export function PermissionsReviewBody({
+ state,
+}: {
+ state: PermissionsReadyState;
+}) {
+ const [tab, setTab] = useState('permissions');
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [grantedPermissions, setGrantedPermissions] =
+ useState(state.app.common.grantedPermissions);
+
+ const [walletScope, setWalletScope] = useState(
+ state.app.common.walletScope,
+ );
+
+ async function close() {
+ const client = await getSageSystemClient();
+ await client.runtimeManager.closeSelf();
+ }
+
+ async function submit() {
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ const client = await getSageSystemClient();
+
+ await client.appPermissions.applyPermissions({
+ appId: state.app.common.identity.id,
+ grantedPermissions,
+ walletScope,
+ });
+
+ await close();
+ } catch (err) {
+ setError(formatSageError(err));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+ {tab === 'permissions' ? (
+
+ ) : (
+
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx
new file mode 100644
index 000000000..668fb76cf
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx
@@ -0,0 +1,170 @@
+import { useMemo, useState } from 'react';
+import {
+ appIconFromCommonView,
+ AppModalShell,
+ UpdateDecisionPermissionEditor,
+} from '@sage-app/ui';
+import {
+ formatSageError,
+ getSageSystemClient,
+ type SageGrantedPermissionsInput,
+} from '@sage-system-app/sdk';
+import { NoUpdateBody } from './NoUpdateBody';
+import { PartialUpdateBody } from './PartialUpdateBody';
+
+export function UpdateReviewBody({ state, onReload }: any) {
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [permissionsViewed, setPermissionsViewed] = useState(false);
+
+ const preview = state.updateContext?.preview ?? null;
+
+ const additionalGrantedPermissions =
+ useMemo(() => {
+ const decision = state.app.pendingUpdate?.decision;
+
+ if (decision?.kind !== 'review') {
+ return {
+ capabilities: [],
+ network: {
+ whitelist: [],
+ whitelistByNetwork: {},
+ },
+ };
+ }
+
+ return {
+ capabilities: decision.requiredUserGrantableCapabilities ?? [],
+ network: {
+ whitelist: decision.requiredNetworkWhitelist ?? [],
+ whitelistByNetwork: decision.requiredNetworkWhitelistByNetwork ?? {},
+ },
+ };
+ }, [state.app.pendingUpdate?.decision]);
+
+ async function close() {
+ const client = await getSageSystemClient();
+ await client.runtimeManager.closeSelf();
+ }
+
+ async function submit() {
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ const client = await getSageSystemClient();
+
+ await client.appUpdate.applyUpdate({
+ appId: state.app.common.identity.id,
+ additionalGrantedPermissions,
+ });
+
+ await close();
+ } catch (err) {
+ setError(formatSageError(err));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ if (!preview) {
+ return (
+ }
+ >
+
+
+ );
+ }
+
+ if (preview.manifest.kind === 'partial') {
+ return (
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ }
+ >
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
+
+function UpdateIssueFooter({
+ onReload,
+ onClose,
+}: {
+ onReload: () => void;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts b/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts
new file mode 100644
index 000000000..bbad62159
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts
@@ -0,0 +1,82 @@
+import { useCallback, useEffect, useState } from 'react';
+import { formatSageError, useSageSystemClient } from '@sage-system-app/sdk';
+import type { LoadState, Mode } from '../types';
+
+export function useLoadState() {
+ const [state, setState] = useState({ kind: 'loading' });
+ const sage = useSageSystemClient();
+
+ const reload = useCallback(() => {
+ let cancelled = false;
+
+ async function load() {
+ setState({ kind: 'loading' });
+
+ try {
+ const params = new URLSearchParams(window.location.search);
+ const appId = params.get('appId');
+ const mode = (params.get('mode') ?? 'review-update') as Mode;
+
+ if (!appId) {
+ setState({ kind: 'error', error: 'Missing appId' });
+ return;
+ }
+
+ const [definitions, walletsResult] = await Promise.all([
+ sage.capabilities.listUserDefinitions(),
+ sage.wallet.listWallets(),
+ ]);
+
+ if (mode === 'review-permissions') {
+ const permissionsContext = await sage.appPermissions.getReviewContext(
+ {
+ appId,
+ },
+ );
+
+ if (!cancelled) {
+ setState({
+ kind: 'ready',
+ mode,
+ app: permissionsContext.app,
+ permissionsContext,
+ updateContext: null,
+ definitions,
+ wallets: walletsResult.wallets,
+ });
+ }
+
+ return;
+ }
+
+ const updateContext = await sage.appUpdate.getReviewContext({ appId });
+
+ if (!cancelled) {
+ setState({
+ kind: 'ready',
+ mode: 'review-update',
+ app: updateContext.app,
+ updateContext,
+ permissionsContext: null,
+ definitions,
+ wallets: walletsResult.wallets,
+ });
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setState({ kind: 'error', error: formatSageError(err) });
+ }
+ }
+ }
+
+ void load();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [sage]);
+
+ useEffect(() => reload(), [reload]);
+
+ return { state, reload };
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/hooks/useUpdatePermissions.ts b/builtin-apps/src/system/apps/app-update/src/hooks/useUpdatePermissions.ts
new file mode 100644
index 000000000..4881e8a97
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/hooks/useUpdatePermissions.ts
@@ -0,0 +1,47 @@
+import { useEffect, useMemo, useState } from 'react';
+import type {
+ AppUpdateReviewContext,
+ SageAppCapabilityDefinitionView,
+ SageGrantedPermissionsInput,
+ UserSageAppView,
+} from '@sage-system-app/sdk';
+import { definitionMap } from '../utils/definitions';
+import { nextPermissionsForUpdate } from '../utils/permissions';
+
+export function useUpdatePermissions(args: {
+ app: UserSageAppView;
+ context: AppUpdateReviewContext | null;
+ definitions: SageAppCapabilityDefinitionView[];
+}) {
+ const { app, context, definitions } = args;
+
+ const [grantedPermissions, setGrantedPermissions] =
+ useState(app.common.grantedPermissions);
+
+ const definitionsByKey = useMemo(
+ () => definitionMap(definitions),
+ [definitions],
+ );
+
+ useEffect(() => {
+ if (!context) {
+ setGrantedPermissions(app.common.grantedPermissions);
+ return;
+ }
+
+ const next = nextPermissionsForUpdate({
+ app,
+ context,
+ definitionsByKey,
+ });
+
+ if (next) {
+ setGrantedPermissions(next);
+ }
+ }, [app, context, definitionsByKey]);
+
+ return {
+ grantedPermissions,
+ setGrantedPermissions,
+ };
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/main.tsx b/builtin-apps/src/system/apps/app-update/src/main.tsx
new file mode 100644
index 000000000..ed0dc6330
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/builtin-apps/src/system/apps/app-update/src/styles.css b/builtin-apps/src/system/apps/app-update/src/styles.css
new file mode 100644
index 000000000..ad480ae12
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/styles.css
@@ -0,0 +1,19 @@
+@import "@sage-app/sdk/theme.css";
+@config "../tailwind.config.js";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background: transparent;
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/types.ts b/builtin-apps/src/system/apps/app-update/src/types.ts
new file mode 100644
index 000000000..98d0caeed
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/types.ts
@@ -0,0 +1,22 @@
+import type {
+ AppPermissionsReviewContext,
+ AppUpdateReviewContext,
+ SageAppCapabilityDefinitionView,
+ SystemWalletView,
+ UserSageAppView,
+} from '@sage-system-app/sdk';
+
+export type Mode = 'review-update' | 'review-permissions';
+
+export type LoadState =
+ | { kind: 'loading' }
+ | { kind: 'error'; error: string }
+ | {
+ kind: 'ready';
+ mode: Mode;
+ app: UserSageAppView;
+ updateContext: AppUpdateReviewContext | null;
+ permissionsContext: AppPermissionsReviewContext | null;
+ definitions: SageAppCapabilityDefinitionView[];
+ wallets: SystemWalletView[];
+ };
diff --git a/builtin-apps/src/system/apps/app-update/src/utils/definitions.ts b/builtin-apps/src/system/apps/app-update/src/utils/definitions.ts
new file mode 100644
index 000000000..ae91ffb63
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/utils/definitions.ts
@@ -0,0 +1,15 @@
+import type {
+ SageAppCapabilityDefinitionView,
+ UserBridgeCapability,
+} from '@sage-system-app/sdk';
+
+export function definitionMap(definitions: SageAppCapabilityDefinitionView[]) {
+ return new Map(definitions.map((d) => [d.key as UserBridgeCapability, d]));
+}
+
+export function isUserGrantable(
+ definitions: Map,
+ capability: UserBridgeCapability,
+) {
+ return definitions.get(capability)?.flags.userGrantable === true;
+}
diff --git a/builtin-apps/src/system/apps/app-update/src/utils/permissions.ts b/builtin-apps/src/system/apps/app-update/src/utils/permissions.ts
new file mode 100644
index 000000000..56060af56
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/src/utils/permissions.ts
@@ -0,0 +1,120 @@
+import type {
+ SageGrantedPermissionsView,
+ SageNetworkWhitelistEntry,
+ UserBridgeCapability,
+} from '@sage-system-app/sdk';
+import { isUserGrantable } from './definitions';
+
+type RequestedWhitelistByNetwork = Record<
+ string,
+ {
+ required?: SageNetworkWhitelistEntry[];
+ optional?: SageNetworkWhitelistEntry[];
+ }
+>;
+
+function networkKey(entry: SageNetworkWhitelistEntry) {
+ return `${entry.scheme}://${entry.host}`;
+}
+
+function sortCapabilities(values: Iterable) {
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
+}
+
+function sortNetwork(values: Iterable) {
+ return [...values].sort((a, b) => networkKey(a).localeCompare(networkKey(b)));
+}
+
+export function nextPermissionsForUpdate(args: {
+ app: any;
+ context: any;
+ definitionsByKey: Map;
+}): SageGrantedPermissionsView | null {
+ if (!args.context.preview || args.context.preview.manifest.kind !== 'full') {
+ return null;
+ }
+
+ const nextRequested = args.context.preview.manifest.manifest.permissions;
+
+ const nextCaps = {
+ required: nextRequested.capabilities.required ?? [],
+ optional: nextRequested.capabilities.optional ?? [],
+ };
+
+ const nextNetwork = {
+ required: nextRequested.network.whitelist.required ?? [],
+ optional: nextRequested.network.whitelist.optional ?? [],
+ byNetwork: (nextRequested.network.whitelistByNetwork ??
+ {}) as RequestedWhitelistByNetwork,
+ };
+
+ const nextAllowedCaps = new Set([...nextCaps.required, ...nextCaps.optional]);
+ const nextAllowedNetwork = new Set([
+ ...nextNetwork.required.map(networkKey),
+ ...nextNetwork.optional.map(networkKey),
+ ]);
+
+ const retainedCapabilities = (
+ args.app.common.grantedPermissions.capabilities ?? []
+ )
+ .filter((c: UserBridgeCapability) => nextAllowedCaps.has(c))
+ .filter((c: UserBridgeCapability) =>
+ isUserGrantable(args.definitionsByKey, c),
+ );
+
+ const requiredGrantable = nextCaps.required.filter(
+ (c: UserBridgeCapability) => isUserGrantable(args.definitionsByKey, c),
+ );
+
+ const retainedNetwork = (
+ args.app.common.grantedPermissions.network.whitelist ?? []
+ ).filter((e: SageNetworkWhitelistEntry) =>
+ nextAllowedNetwork.has(networkKey(e)),
+ );
+
+ const networkMap = new Map();
+
+ for (const e of retainedNetwork) networkMap.set(networkKey(e), e);
+ for (const e of nextNetwork.required) networkMap.set(networkKey(e), e);
+
+ const whitelistByNetwork: Record = {};
+
+ for (const [networkId, requestedWhitelist] of Object.entries(
+ nextNetwork.byNetwork,
+ )) {
+ const allowedNetworkKeys = new Set([
+ ...(requestedWhitelist.required ?? []).map(networkKey),
+ ...(requestedWhitelist.optional ?? []).map(networkKey),
+ ]);
+
+ const retained = (
+ args.app.common.grantedPermissions.network.whitelistByNetwork?.[
+ networkId
+ ] ?? []
+ ).filter((entry: SageNetworkWhitelistEntry) =>
+ allowedNetworkKeys.has(networkKey(entry)),
+ );
+
+ const entries = new Map();
+
+ for (const entry of retained) entries.set(networkKey(entry), entry);
+ for (const entry of requestedWhitelist.required ?? []) {
+ entries.set(networkKey(entry), entry);
+ }
+
+ if (entries.size > 0) {
+ whitelistByNetwork[networkId] = sortNetwork(entries.values());
+ }
+ }
+
+ return {
+ capabilities: sortCapabilities([
+ ...retainedCapabilities,
+ ...requiredGrantable,
+ ]),
+ network: {
+ whitelist: sortNetwork(networkMap.values()),
+ whitelistByNetwork,
+ },
+ };
+}
diff --git a/builtin-apps/src/system/apps/app-update/tailwind.config.js b/builtin-apps/src/system/apps/app-update/tailwind.config.js
new file mode 100644
index 000000000..13edcb743
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/tailwind.config.js
@@ -0,0 +1,14 @@
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createSageTailwindConfig } from '../../tailwind.shared.js';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(dir, '../../../../..');
+
+export default createSageTailwindConfig({
+ content: [
+ join(dir, 'index.html'),
+ join(dir, 'src/**/*.{ts,tsx}'),
+ join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'),
+ ],
+});
diff --git a/builtin-apps/src/system/apps/app-update/tsconfig.json b/builtin-apps/src/system/apps/app-update/tsconfig.json
new file mode 100644
index 000000000..3d61827d7
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/builtin-apps/src/system/apps/app-update/vite.config.ts b/builtin-apps/src/system/apps/app-update/vite.config.ts
new file mode 100644
index 000000000..c2498e6d6
--- /dev/null
+++ b/builtin-apps/src/system/apps/app-update/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineSageSystemAppConfig } from '../../vite.system-app.config';
+
+export default defineSageSystemAppConfig(import.meta.url);
diff --git a/builtin-apps/src/system/apps/bridge-approval/index.html b/builtin-apps/src/system/apps/bridge-approval/index.html
new file mode 100644
index 000000000..02397c5e0
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Bridge Approval
+
+
+
+
+
+
diff --git a/builtin-apps/src/system/apps/bridge-approval/package.json b/builtin-apps/src/system/apps/bridge-approval/package.json
new file mode 100644
index 000000000..43ed1fc5b
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "bridge-approval-system-app",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@sage-system-app/sdk": "workspace:*"
+ }
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/postcss.config.js b/builtin-apps/src/system/apps/bridge-approval/postcss.config.js
new file mode 100644
index 000000000..c97c6b959
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/postcss.config.js
@@ -0,0 +1,13 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ plugins: {
+ tailwindcss: {
+ config: join(dir, 'tailwind.config.js'),
+ },
+ autoprefixer: {},
+ },
+};
diff --git a/builtin-apps/src/system/apps/bridge-approval/sage-manifest.json b/builtin-apps/src/system/apps/bridge-approval/sage-manifest.json
new file mode 100644
index 000000000..0506163a8
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/sage-manifest.json
@@ -0,0 +1,26 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "name": "Bridge Approval",
+ "version": "0.1.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": [
+ "environment.theme.get_current",
+ "environment.theme.listen_changed",
+ "environment.theme.css_vars"
+ ],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html"
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/App.tsx b/builtin-apps/src/system/apps/bridge-approval/src/App.tsx
new file mode 100644
index 000000000..8f9c88223
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/App.tsx
@@ -0,0 +1,277 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { AppIcon, appIconFromCommonView, AppModalShell } from '@sage-app/ui';
+import {
+ useSageSystemClient,
+ type PendingBridgeApprovalView,
+ type SageAppRuntimeRecordView,
+} from '@sage-system-app/sdk';
+import { Clock } from 'lucide-react';
+import { AppApprovalBody } from './approval/AppApprovalBody';
+
+function appNameFromRuntime(
+ runtime: SageAppRuntimeRecordView | null,
+): string | null {
+ return runtime?.app.common.activeSnapshot.manifest.name ?? null;
+}
+
+function appIconFromRuntime(
+ runtime: SageAppRuntimeRecordView | null,
+): AppIcon | null {
+ if (!runtime) return null;
+ return appIconFromCommonView(runtime.app.common);
+}
+
+function appIdFromRuntime(
+ runtime: SageAppRuntimeRecordView | null,
+): string | null {
+ return runtime?.app.common.identity.id ?? null;
+}
+
+function titleForApproval(approval: PendingBridgeApprovalView) {
+ switch (approval.approval.kind) {
+ case 'sendXch':
+ return 'Approve XCH transaction';
+ case 'getSecretKey':
+ return 'Approve secret key access';
+ case 'capabilityGrant':
+ return 'Approve permission grant';
+ case 'networkWhitelistGrant':
+ return 'Approve network access';
+ }
+}
+
+function formatCountdown(expiresAt: number, now: number) {
+ const seconds = Math.max(0, Math.ceil((expiresAt - now) / 1000));
+ return seconds <= 0 ? 'Expires now' : `Expires in ${seconds}s`;
+}
+
+function queueText(count: number) {
+ if (count <= 0) return null;
+ return `+${count} pending`;
+}
+
+function MetaPill({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function App() {
+ const sage = useSageSystemClient();
+
+ const [approvals, setApprovals] = useState([]);
+ const [activeAppId, setActiveAppId] = useState(null);
+ const [activeAppName, setActiveAppName] = useState(null);
+ const [activeAppIcon, setActiveAppIcon] = useState(null);
+ const [expanded, setExpanded] = useState(false);
+ const [working, setWorking] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
+ const [error, setError] = useState(null);
+
+ async function refreshActiveRuntime() {
+ const active = await sage.runtimeManager.getActiveTaskbarRuntime();
+
+ setActiveAppId(appIdFromRuntime(active));
+ setActiveAppName(appNameFromRuntime(active));
+ setActiveAppIcon(appIconFromRuntime(active));
+
+ return active;
+ }
+
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 250);
+ return () => window.clearInterval(id);
+ }, []);
+
+ useEffect(() => {
+ async function refreshInitialState() {
+ try {
+ const [pending, active] = await Promise.all([
+ sage.bridgeApprovals.listPending(),
+ sage.runtimeManager.getActiveTaskbarRuntime(),
+ ]);
+
+ setApprovals(pending);
+ setActiveAppId(appIdFromRuntime(active));
+ setActiveAppName(appNameFromRuntime(active));
+ setActiveAppIcon(appIconFromRuntime(active));
+ } catch (err) {
+ console.error('[approval] refreshInitialState failed', err);
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoaded(true);
+ }
+ }
+
+ void refreshInitialState();
+ }, [sage]);
+
+ useEffect(() => {
+ const offApprovals = sage.bridgeApprovals.onChanged((event) => {
+ setApprovals(event.approvals);
+ });
+
+ const offActiveRuntime = sage.runtimeManager.onActiveTaskbarRuntimeChanged(
+ () => {
+ void refreshActiveRuntime().catch((err) => {
+ console.error('[approval] failed to refresh active runtime', err);
+ });
+ },
+ );
+
+ return () => {
+ offApprovals();
+ offActiveRuntime();
+ };
+ }, [sage]);
+
+ const activeApprovals = useMemo(() => {
+ if (!activeAppId) return [];
+
+ return approvals
+ .filter((item) => item.appId === activeAppId)
+ .sort((a, b) => a.expiresAtMs - b.expiresAtMs);
+ }, [approvals, activeAppId]);
+
+ const activeApproval = activeApprovals[0] ?? null;
+ const pendingForActiveAppCount = activeApprovals.length;
+ const queuedApprovalCount = Math.max(0, pendingForActiveAppCount - 1);
+
+ const countdownText = activeApproval
+ ? formatCountdown(activeApproval.expiresAtMs, now)
+ : null;
+
+ useEffect(() => {
+ setExpanded(false);
+ setError(null);
+ }, [activeApproval?.approvalId]);
+
+ async function resolve(approved: boolean) {
+ if (!activeApproval || working) return;
+
+ setWorking(true);
+ setError(null);
+
+ try {
+ await sage.bridgeApprovals.resolve({
+ approvalId: activeApproval.approvalId,
+ approved,
+ reason: approved ? null : 'User denied the request',
+ });
+
+ setApprovals(await sage.bridgeApprovals.listPending());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ if (!loaded) {
+ return null;
+ }
+
+ if (!activeApproval) {
+ return (
+
+
+
+ }
+ >
+
+ There are no approval requests to review.
+
+
+ );
+ }
+
+ if (!activeAppId || !activeAppName) {
+ return (
+
+
+ Approval exists, but no active taskbar app was resolved.
+
+
+ );
+ }
+
+ const moreText = queueText(queuedApprovalCount);
+
+ return (
+
+
+
+
+
+
+
+ }
+ >
+
+
+
Approval required
+
+ {countdownText ? (
+
+
+ {countdownText}
+
+ ) : null}
+
+ {moreText ?
{moreText} : null}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/AppApprovalBody.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/AppApprovalBody.tsx
new file mode 100644
index 000000000..42b69e321
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/AppApprovalBody.tsx
@@ -0,0 +1,51 @@
+import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk';
+import { CapabilityGrantApprovalCard } from './CapabilityGrantApprovalCard';
+import { GetSecretKeyApprovalCard } from './GetSecretKeyApprovalCard';
+import { NetworkWhitelistGrantApprovalCard } from './NetworkWhitelistGrantApprovalCard';
+import { SendXchApprovalCard } from './SendXchApprovalCard';
+
+interface Props {
+ approval: RustBridgeApprovalRequest;
+ appName: string;
+ expanded: boolean;
+}
+
+export function AppApprovalBody({ approval, appName, expanded }: Props) {
+ switch (approval.kind) {
+ case 'getSecretKey':
+ return (
+
+ );
+
+ case 'sendXch':
+ return (
+
+ );
+
+ case 'capabilityGrant':
+ return (
+
+ );
+
+ case 'networkWhitelistGrant':
+ return (
+
+ );
+ }
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/CapabilityGrantApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/CapabilityGrantApprovalCard.tsx
new file mode 100644
index 000000000..7a9026a29
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/CapabilityGrantApprovalCard.tsx
@@ -0,0 +1,38 @@
+import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk';
+import { ApprovalDetailRow } from './shared';
+
+interface Props {
+ approval: Extract;
+ appName: string;
+ expanded: boolean;
+}
+
+export function CapabilityGrantApprovalCard({
+ approval,
+ appName,
+ expanded,
+}: Props) {
+ const label = approval.definition.label;
+ const description = approval.definition.description;
+
+ return (
+
+
{label}
+
+
+ {description ?? `${appName} wants access to this permission.`}
+
+
+ {expanded ? (
+
+ ) : null}
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/GetSecretKeyApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/GetSecretKeyApprovalCard.tsx
new file mode 100644
index 000000000..1b0598e80
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/GetSecretKeyApprovalCard.tsx
@@ -0,0 +1,38 @@
+import { Wallet } from 'lucide-react';
+import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk';
+import { ApprovalDetailRow, ApprovalMetaPill } from './shared';
+
+interface Props {
+ approval: Extract;
+ appName: string;
+ expanded: boolean;
+}
+
+export function GetSecretKeyApprovalCard({ approval, appName }: Props) {
+ const fingerprint = approval.fingerprint;
+
+ return (
+
+
+
+
+
+
+
+
+
Get Secret Key
+
Wallet
+
+
+
+ {appName} wants to get your secret key.
+
+
+
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/NetworkWhitelistGrantApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/NetworkWhitelistGrantApprovalCard.tsx
new file mode 100644
index 000000000..2ab0bfb7a
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/NetworkWhitelistGrantApprovalCard.tsx
@@ -0,0 +1,24 @@
+import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk';
+
+interface Props {
+ approval: Extract<
+ RustBridgeApprovalRequest,
+ { kind: 'networkWhitelistGrant' }
+ >;
+ appName: string;
+ expanded: boolean;
+}
+
+export function NetworkWhitelistGrantApprovalCard({ approval }: Props) {
+ const target = `${approval.entry.scheme}://${approval.entry.host}`;
+
+ return (
+
+
Network access
+
+
+ {target}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx
new file mode 100644
index 000000000..2153bbd23
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx
@@ -0,0 +1,101 @@
+import { Wallet } from 'lucide-react';
+import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk';
+import { ApprovalDetailRow, ApprovalMetaPill } from './shared';
+
+interface Props {
+ approval: Extract;
+ appName: string;
+ expanded: boolean;
+}
+
+function truncateMiddle(value: string, maxLength = 120) {
+ if (value.length <= maxLength) {
+ return value;
+ }
+
+ const head = Math.ceil((maxLength - 1) / 2);
+ const tail = Math.floor((maxLength - 1) / 2);
+ return `${value.slice(0, head)}…${value.slice(value.length - tail)}`;
+}
+
+function memoKey(memo: string, indexWithinSameValue: number) {
+ return `${memo}::${indexWithinSameValue}`;
+}
+
+export function SendXchApprovalCard({ approval, appName, expanded }: Props) {
+ const summary = approval.summary;
+
+ const hasFee = summary.fee !== '0';
+ const memos = summary.memos ?? [];
+ const hasMemos = memos.length > 0;
+
+ const memoEntries = memos.map(
+ (memo: string, index: number, all: string[]) => {
+ const duplicateIndex = all
+ .slice(0, index)
+ .filter((previous: string) => previous === memo).length;
+
+ return {
+ key: memoKey(memo, duplicateIndex),
+ value: memo,
+ };
+ },
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {appName} wants to send funds from your wallet.
+
+
+
+
+
+
+
+ {hasFee ?
: null}
+ {hasMemos ? (
+
+ ) : null}
+
+
+ {expanded && hasMemos ? (
+
+
+ Memo previews
+
+
+
+ {memoEntries.map((memo, index) => (
+
+
+ Memo {index + 1}
+
+
+ {truncateMiddle(memo.value, 160)}
+
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/shared.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/shared.tsx
new file mode 100644
index 000000000..952d95730
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/shared.tsx
@@ -0,0 +1,36 @@
+export function ApprovalMetaPill({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ApprovalDetailRow({
+ label,
+ value,
+ mono,
+ breakAll,
+}: {
+ label: string;
+ value: React.ReactNode;
+ mono?: boolean;
+ breakAll?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/main.tsx b/builtin-apps/src/system/apps/bridge-approval/src/main.tsx
new file mode 100644
index 000000000..cb0593d81
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/main.tsx
@@ -0,0 +1,12 @@
+import React, { Suspense } from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+ Loading approval...}>
+
+
+ ,
+);
diff --git a/builtin-apps/src/system/apps/bridge-approval/src/styles.css b/builtin-apps/src/system/apps/bridge-approval/src/styles.css
new file mode 100644
index 000000000..ad480ae12
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/src/styles.css
@@ -0,0 +1,19 @@
+@import "@sage-app/sdk/theme.css";
+@config "../tailwind.config.js";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background: transparent;
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/tailwind.config.js b/builtin-apps/src/system/apps/bridge-approval/tailwind.config.js
new file mode 100644
index 000000000..13edcb743
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/tailwind.config.js
@@ -0,0 +1,14 @@
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createSageTailwindConfig } from '../../tailwind.shared.js';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(dir, '../../../../..');
+
+export default createSageTailwindConfig({
+ content: [
+ join(dir, 'index.html'),
+ join(dir, 'src/**/*.{ts,tsx}'),
+ join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'),
+ ],
+});
diff --git a/builtin-apps/src/system/apps/bridge-approval/tsconfig.json b/builtin-apps/src/system/apps/bridge-approval/tsconfig.json
new file mode 100644
index 000000000..3d61827d7
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/builtin-apps/src/system/apps/bridge-approval/vite.config.ts b/builtin-apps/src/system/apps/bridge-approval/vite.config.ts
new file mode 100644
index 000000000..c2498e6d6
--- /dev/null
+++ b/builtin-apps/src/system/apps/bridge-approval/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineSageSystemAppConfig } from '../../vite.system-app.config';
+
+export default defineSageSystemAppConfig(import.meta.url);
diff --git a/builtin-apps/src/system/apps/donation/index.html b/builtin-apps/src/system/apps/donation/index.html
new file mode 100644
index 000000000..1ad69eba6
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Donation
+
+
+
+
+
+
diff --git a/builtin-apps/src/system/apps/donation/package.json b/builtin-apps/src/system/apps/donation/package.json
new file mode 100644
index 000000000..43ed1fc5b
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "bridge-approval-system-app",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@sage-system-app/sdk": "workspace:*"
+ }
+}
diff --git a/builtin-apps/src/system/apps/donation/postcss.config.js b/builtin-apps/src/system/apps/donation/postcss.config.js
new file mode 100644
index 000000000..c97c6b959
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/postcss.config.js
@@ -0,0 +1,13 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ plugins: {
+ tailwindcss: {
+ config: join(dir, 'tailwind.config.js'),
+ },
+ autoprefixer: {},
+ },
+};
diff --git a/builtin-apps/src/system/apps/donation/sage-manifest.json b/builtin-apps/src/system/apps/donation/sage-manifest.json
new file mode 100644
index 000000000..602f49082
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/sage-manifest.json
@@ -0,0 +1,28 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "name": "Donation",
+ "version": "0.1.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": [
+ "environment.theme.get_current",
+ "environment.theme.listen_changed",
+ "environment.theme.css_vars",
+ "wallet.get_xch_usd_price",
+ "wallet.send_xch"
+ ],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html"
+}
diff --git a/builtin-apps/src/system/apps/donation/src/App.tsx b/builtin-apps/src/system/apps/donation/src/App.tsx
new file mode 100644
index 000000000..fbc667fec
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/App.tsx
@@ -0,0 +1,289 @@
+import { AppModalShell } from '@sage-app/ui';
+import {
+ formatSageError,
+ useSageSystemClient,
+ type DonationDetails,
+} from '@sage-system-app/sdk';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { AmountPicker } from './components/AmountPicker';
+import { DeveloperCard } from './components/DeveloperCard';
+import { FeeInput } from './components/FeeInput';
+import {
+ appIconFromInline,
+ DEFAULT_FEE_XCH,
+ DEFAULT_USD,
+ DEFAULT_XCH,
+ getTargetAppId,
+ parsePositiveNumber,
+ type DonationMode,
+ xchToMojos,
+} from './utils';
+
+export function App() {
+ const sage = useSageSystemClient();
+
+ const [details, setDetails] = useState(null);
+ const [mode, setMode] = useState('usd');
+ const [usdInput, setUsdInput] = useState(DEFAULT_USD);
+ const [xchInput, setXchInput] = useState(DEFAULT_XCH);
+ const [feeInput, setFeeInput] = useState(DEFAULT_FEE_XCH);
+ const [priceUsd, setPriceUsd] = useState(null);
+ const [priceLoading, setPriceLoading] = useState(true);
+ const [sending, setSending] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [error, setError] = useState(null);
+
+ const targetAppId = getTargetAppId();
+
+ const loadPrice = useCallback(async () => {
+ try {
+ setPriceLoading(true);
+ const price = await sage.wallet.getXchUsdPrice();
+
+ setPriceUsd(price.usd);
+ } catch {
+ setPriceUsd(null);
+ setMode('xch');
+ } finally {
+ setPriceLoading(false);
+ }
+ }, [sage]);
+
+ useEffect(() => {
+ let disposed = false;
+
+ async function load() {
+ try {
+ if (!targetAppId) {
+ throw new Error('Missing donation target app id.');
+ }
+
+ const next = await sage.donations.getDetails({ appId: targetAppId });
+
+ if (!disposed) {
+ setDetails(next);
+ }
+ } catch (err) {
+ if (!disposed) {
+ setError(formatSageError(err));
+ }
+ } finally {
+ if (!disposed) {
+ setLoaded(true);
+ }
+ }
+ }
+
+ void load();
+
+ return () => {
+ disposed = true;
+ };
+ }, [sage, targetAppId]);
+
+ useEffect(() => {
+ let disposed = false;
+
+ async function load() {
+ try {
+ setPriceLoading(true);
+ const price = await sage.wallet.getXchUsdPrice();
+
+ if (!disposed) {
+ setPriceUsd(price.usd);
+ }
+ } catch {
+ if (!disposed) {
+ setPriceUsd(null);
+ setMode('xch');
+ }
+ } finally {
+ if (!disposed) {
+ setPriceLoading(false);
+ }
+ }
+ }
+
+ void load();
+
+ return () => {
+ disposed = true;
+ };
+ }, [sage]);
+
+ const derived = useMemo(() => {
+ if (mode === 'usd') {
+ const usd = parsePositiveNumber(usdInput);
+
+ if (!usd || !priceUsd) {
+ return {
+ usd,
+ xch: null as number | null,
+ mojos: null as string | null,
+ };
+ }
+
+ const xch = usd / priceUsd;
+
+ return {
+ usd,
+ xch,
+ mojos: xchToMojos(xch),
+ };
+ }
+
+ const xch = parsePositiveNumber(xchInput);
+
+ if (!xch) {
+ return {
+ usd: null as number | null,
+ xch,
+ mojos: null as string | null,
+ };
+ }
+
+ return {
+ usd: priceUsd ? xch * priceUsd : null,
+ xch,
+ mojos: xchToMojos(xch),
+ };
+ }, [mode, usdInput, xchInput, priceUsd]);
+
+ const feeMojos = useMemo(() => {
+ const trimmed = feeInput.trim();
+
+ if (trimmed === '' || trimmed === '0') {
+ return '0';
+ }
+
+ const feeXch = parsePositiveNumber(trimmed);
+ return feeXch ? xchToMojos(feeXch) : null;
+ }, [feeInput]);
+
+ const canSend =
+ !!details?.donationAddress &&
+ !!derived.mojos &&
+ feeMojos !== null &&
+ !sending;
+
+ async function sendDonation() {
+ if (
+ !canSend ||
+ !details?.donationAddress ||
+ !derived.mojos ||
+ feeMojos === null
+ ) {
+ return;
+ }
+
+ setSending(true);
+ setError(null);
+
+ try {
+ await sage.wallet.sendXch({
+ address: details.donationAddress,
+ amount: derived.mojos,
+ fee: feeMojos,
+ memos: [],
+ });
+
+ await sage.runtimeManager.closeSelf();
+ } catch (err) {
+ setError(formatSageError(err));
+ } finally {
+ setSending(false);
+ }
+ }
+
+ if (!loaded) {
+ return (
+
+ Loading donation…
+
+ );
+ }
+
+ if (!details) {
+ return (
+
+
+
+ }
+ >
+
+ {error ?? 'This app does not have a donation address.'}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ }
+ >
+
+
+
+
void loadPrice()}
+ derivedXch={derived.xch}
+ derivedUsd={derived.usd}
+ />
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/donation/src/components/AmountPicker.tsx b/builtin-apps/src/system/apps/donation/src/components/AmountPicker.tsx
new file mode 100644
index 000000000..29243f817
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/components/AmountPicker.tsx
@@ -0,0 +1,192 @@
+import { RefreshCw } from 'lucide-react';
+import { useMemo } from 'react';
+import type { DonationMode } from '../utils';
+import { formatXchAmount, USD_PRESETS } from '../utils';
+
+interface Props {
+ mode: DonationMode;
+ setMode: (mode: DonationMode) => void;
+ usdInput: string;
+ setUsdInput: (value: string) => void;
+ xchInput: string;
+ setXchInput: (value: string) => void;
+ priceUsd: number | null;
+ priceLoading: boolean;
+ onRefreshPrice: () => void;
+ derivedXch: number | null;
+ derivedUsd: number | null;
+}
+
+function numericInputEquals(a: string, b: string): boolean {
+ const aNumber = Number(a.trim());
+ const bNumber = Number(b.trim());
+
+ return (
+ a.trim() !== '' &&
+ b.trim() !== '' &&
+ Number.isFinite(aNumber) &&
+ Number.isFinite(bNumber) &&
+ aNumber === bNumber
+ );
+}
+
+export function AmountPicker({
+ mode,
+ setMode,
+ usdInput,
+ setUsdInput,
+ xchInput,
+ setXchInput,
+ priceUsd,
+ priceLoading,
+ onRefreshPrice,
+ derivedXch,
+ derivedUsd,
+}: Props) {
+ const presets = useMemo(() => {
+ if (mode === 'usd') {
+ return USD_PRESETS.map((usd) => ({
+ key: `usd-${usd}`,
+ label: `$${usd}`,
+ value: String(usd),
+ disabled: false,
+ }));
+ }
+
+ return USD_PRESETS.map((usd) => {
+ const xch = priceUsd ? usd / priceUsd : null;
+ const formatted = xch ? formatXchAmount(xch) : null;
+
+ return {
+ key: `xch-${usd}`,
+ label: formatted ? `${formatted} XCH` : `$${usd}`,
+ value: formatted ?? '',
+ disabled: !formatted,
+ };
+ });
+ }, [mode, priceUsd]);
+
+ const showPresets = mode === 'usd' || priceUsd !== null;
+ const activeInput = mode === 'usd' ? usdInput : xchInput;
+
+ return (
+
+
+
Amount
+
+
+ {priceLoading ? (
+ 'Loading XCH price…'
+ ) : priceUsd !== null ? (
+ `1 XCH ≈ $${priceUsd.toFixed(2)}`
+ ) : (
+ <>
+ XCH price unavailable
+
+ >
+ )}
+
+
+
+ {showPresets ? (
+
+ {presets.map((preset) => {
+ const selected =
+ !preset.disabled && numericInputEquals(activeInput, preset.value);
+
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {
+ if (mode === 'usd') {
+ setUsdInput(event.target.value);
+ } else {
+ setXchInput(event.target.value);
+ }
+ }}
+ placeholder={mode === 'usd' ? '10.00' : '0.025'}
+ inputMode='decimal'
+ className='min-w-0 flex-1 bg-transparent text-right text-sm outline-none'
+ />
+
+ {mode === 'usd' ? 'USD' : 'XCH'}
+
+
+
+
+
+ {mode === 'usd'
+ ? derivedXch !== null
+ ? `≈ ${formatXchAmount(derivedXch)} XCH`
+ : '—'
+ : derivedUsd !== null
+ ? `≈ $${derivedUsd.toFixed(2)}`
+ : '—'}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/donation/src/components/DeveloperCard.tsx b/builtin-apps/src/system/apps/donation/src/components/DeveloperCard.tsx
new file mode 100644
index 000000000..dfa7b0177
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/components/DeveloperCard.tsx
@@ -0,0 +1,31 @@
+import type { DonationDetails } from '@sage-system-app/sdk';
+import { inlineImageSrc } from '../utils';
+
+export function DeveloperCard({ details }: { details: DonationDetails }) {
+ const authorAvatarSrc = inlineImageSrc(details.authorAvatar);
+
+ return (
+
+ {authorAvatarSrc ? (
+

+ ) : (
+
+ {(details.authorName ?? details.appName).slice(0, 1).toUpperCase()}
+
+ )}
+
+
+
+ Support {details.authorName ?? details.appName}
+
+
+ Developer of {details.appName}
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/donation/src/components/FeeInput.tsx b/builtin-apps/src/system/apps/donation/src/components/FeeInput.tsx
new file mode 100644
index 000000000..c42dcb57b
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/components/FeeInput.tsx
@@ -0,0 +1,54 @@
+import { ChevronDown } from 'lucide-react';
+import { useState } from 'react';
+
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function FeeInput({ value, onChange }: Props) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+
+
+ {expanded ? (
+
+ ) : null}
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/donation/src/main.tsx b/builtin-apps/src/system/apps/donation/src/main.tsx
new file mode 100644
index 000000000..cb0593d81
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/main.tsx
@@ -0,0 +1,12 @@
+import React, { Suspense } from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+ Loading approval...}>
+
+
+ ,
+);
diff --git a/builtin-apps/src/system/apps/donation/src/styles.css b/builtin-apps/src/system/apps/donation/src/styles.css
new file mode 100644
index 000000000..ad480ae12
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/styles.css
@@ -0,0 +1,19 @@
+@import "@sage-app/sdk/theme.css";
+@config "../tailwind.config.js";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background: transparent;
+}
diff --git a/builtin-apps/src/system/apps/donation/src/utils.ts b/builtin-apps/src/system/apps/donation/src/utils.ts
new file mode 100644
index 000000000..e777d5b31
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/src/utils.ts
@@ -0,0 +1,73 @@
+import type { AppIcon } from '@sage-app/ui';
+import type { SageAppIconView } from '@sage-system-app/sdk';
+
+export type DonationMode = 'usd' | 'xch';
+
+export const DEFAULT_USD = '10';
+export const DEFAULT_XCH = '0.05';
+export const DEFAULT_FEE_XCH = '0.0001';
+export const USD_PRESETS = [2, 5, 10, 20];
+
+export function getTargetAppId() {
+ return new URL(window.location.href).searchParams.get('appId');
+}
+
+export function xchToMojos(xch: number): string {
+ return String(Math.floor(xch * 1_000_000_000_000));
+}
+
+export function parsePositiveNumber(value: string): number | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+export function formatXchAmount(value: number): string {
+ if (!Number.isFinite(value) || value <= 0) return '0';
+
+ for (let decimals = 0; decimals <= 12; decimals++) {
+ const rounded = value.toFixed(decimals);
+ const roundedNumber = Number(rounded);
+
+ if (roundedNumber <= 0) continue;
+
+ const relativeError = Math.abs(roundedNumber - value) / value;
+
+ if (relativeError < 0.0005) {
+ return rounded.replace(/\.?0+$/, '');
+ }
+ }
+
+ return value.toFixed(12).replace(/\.?0+$/, '');
+}
+
+export function appIconFromInline(
+ icon: SageAppIconView | null | undefined,
+): AppIcon | null {
+ if (!icon) return null;
+
+ return {
+ kind: 'bytes',
+ icon: {
+ bytes: icon.bytes,
+ mime: icon.mime,
+ },
+ };
+}
+
+export function inlineImageSrc(
+ icon: SageAppIconView | null | undefined,
+): string | null {
+ if (!icon) return null;
+
+ const bytes = new Uint8Array(icon.bytes);
+ let binary = '';
+
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+
+ return `data:${icon.mime};base64,${btoa(binary)}`;
+}
diff --git a/builtin-apps/src/system/apps/donation/tailwind.config.js b/builtin-apps/src/system/apps/donation/tailwind.config.js
new file mode 100644
index 000000000..13edcb743
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/tailwind.config.js
@@ -0,0 +1,14 @@
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createSageTailwindConfig } from '../../tailwind.shared.js';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(dir, '../../../../..');
+
+export default createSageTailwindConfig({
+ content: [
+ join(dir, 'index.html'),
+ join(dir, 'src/**/*.{ts,tsx}'),
+ join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'),
+ ],
+});
diff --git a/builtin-apps/src/system/apps/donation/tsconfig.json b/builtin-apps/src/system/apps/donation/tsconfig.json
new file mode 100644
index 000000000..3d61827d7
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/builtin-apps/src/system/apps/donation/vite.config.ts b/builtin-apps/src/system/apps/donation/vite.config.ts
new file mode 100644
index 000000000..c2498e6d6
--- /dev/null
+++ b/builtin-apps/src/system/apps/donation/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineSageSystemAppConfig } from '../../vite.system-app.config';
+
+export default defineSageSystemAppConfig(import.meta.url);
diff --git a/builtin-apps/src/system/apps/sandbox-tests/index.html b/builtin-apps/src/system/apps/sandbox-tests/index.html
new file mode 100644
index 000000000..2d6a74f26
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Sandbox Tests
+
+
+
+
+
+
diff --git a/builtin-apps/src/system/apps/sandbox-tests/package.json b/builtin-apps/src/system/apps/sandbox-tests/package.json
new file mode 100644
index 000000000..43ed1fc5b
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "bridge-approval-system-app",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@sage-system-app/sdk": "workspace:*"
+ }
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/postcss.config.js b/builtin-apps/src/system/apps/sandbox-tests/postcss.config.js
new file mode 100644
index 000000000..c97c6b959
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/postcss.config.js
@@ -0,0 +1,13 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ plugins: {
+ tailwindcss: {
+ config: join(dir, 'tailwind.config.js'),
+ },
+ autoprefixer: {},
+ },
+};
diff --git a/builtin-apps/src/system/apps/sandbox-tests/public/icon.svg b/builtin-apps/src/system/apps/sandbox-tests/public/icon.svg
new file mode 100644
index 000000000..d61a27e08
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/public/icon.svg
@@ -0,0 +1,27 @@
+
diff --git a/builtin-apps/src/system/apps/sandbox-tests/sage-manifest.json b/builtin-apps/src/system/apps/sandbox-tests/sage-manifest.json
new file mode 100644
index 000000000..7a927399a
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/sage-manifest.json
@@ -0,0 +1,27 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "name": "Sandbox Tests",
+ "icon": "icon.svg",
+ "version": "0.1.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": [
+ "environment.theme.get_current",
+ "environment.theme.listen_changed",
+ "environment.theme.css_vars"
+ ],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html"
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/App.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/App.tsx
new file mode 100644
index 000000000..a9cfe0b2d
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/App.tsx
@@ -0,0 +1,170 @@
+import { AppIcon, AppModalShell } from '@sage-app/ui';
+import { formatSageError } from '@sage-system-app/sdk';
+import { useEffect, useMemo, useState } from 'react';
+import {
+ closeSelf,
+ getSandboxState,
+ onSandboxStateChanged,
+ rerunSandboxTests,
+ type SandboxStateView,
+} from './sandboxApi';
+import { SandboxResultList } from './components/SandboxResultList';
+import { SandboxTabs } from './components/SandboxTabs';
+import {
+ isCurrentSandboxRunActive,
+ selectedSandboxState,
+ type SandboxTab,
+} from './sandboxState';
+
+const appIcon: AppIcon = {
+ kind: 'url',
+ iconUrl: '/icon.svg',
+};
+
+function emptyTextForTab(tab: SandboxTab) {
+ switch (tab) {
+ case 'effective':
+ return 'No effective sandbox gate state is available yet.';
+ case 'previous':
+ return 'No completed sandbox test run is available yet.';
+ case 'current':
+ return 'No sandbox test run is currently active.';
+ }
+}
+
+export function App() {
+ const [state, setState] = useState(null);
+ const [activeTab, setActiveTab] = useState('effective');
+ const [loaded, setLoaded] = useState(false);
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState(null);
+
+ const currentRunning = isCurrentSandboxRunActive(state);
+
+ const selectedState = useMemo(
+ () => selectedSandboxState(state, activeTab),
+ [state, activeTab],
+ );
+
+ useEffect(() => {
+ let disposed = false;
+
+ async function load() {
+ try {
+ const next = await getSandboxState();
+
+ if (!disposed) {
+ setState(next);
+ }
+ } catch (err) {
+ if (!disposed) {
+ setError(formatSageError(err));
+ }
+ } finally {
+ if (!disposed) {
+ setLoaded(true);
+ }
+ }
+ }
+
+ void load();
+
+ const off = onSandboxStateChanged((next) => {
+ setState(next);
+
+ const stillRunning = isCurrentSandboxRunActive(next);
+
+ if (!stillRunning) {
+ setRunning(false);
+
+ setActiveTab((prev) => (prev === 'current' ? 'previous' : prev));
+ }
+ });
+
+ return () => {
+ disposed = true;
+ off();
+ };
+ }, []);
+
+ async function rerun() {
+ setRunning(true);
+ setError(null);
+ setActiveTab('current');
+
+ try {
+ const next = await rerunSandboxTests();
+ setState(next);
+
+ if (!isCurrentSandboxRunActive(next)) {
+ setRunning(false);
+ setActiveTab('previous');
+ }
+ } catch (err) {
+ setRunning(false);
+ setError(formatSageError(err));
+ }
+ }
+
+ if (!loaded) {
+ return (
+
+
+ Loading sandbox tests…
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ }
+ >
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxResultList.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxResultList.tsx
new file mode 100644
index 000000000..15bdfff5c
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxResultList.tsx
@@ -0,0 +1,153 @@
+import {
+ formatCapabilityLabel,
+ listSandboxCapabilities,
+ type SandboxGateState,
+} from '../sandboxState';
+import { resolveBackgroundTintWithAlpha } from '@sage-app/ui';
+
+function statusClass(status: string) {
+ switch (status) {
+ case 'passed':
+ return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700';
+ case 'failed':
+ return 'border-destructive/40 bg-destructive/10 text-destructive';
+ case 'running':
+ return 'border-yellow-500/30 bg-yellow-500/10 text-yellow-700';
+ case 'pending':
+ return 'border-border bg-muted text-muted-foreground';
+ default:
+ return 'border-border bg-muted text-muted-foreground';
+ }
+}
+
+function statusLabel(status: string) {
+ return status.slice(0, 1).toUpperCase() + status.slice(1);
+}
+
+function isObject(value: unknown): value is Record {
+ return !!value && typeof value === 'object';
+}
+
+function normalizeCapabilityResult(value: unknown): {
+ status: string;
+ details: string | null;
+} {
+ if (!isObject(value)) {
+ return {
+ status: 'unknown',
+ details: null,
+ };
+ }
+
+ const status = typeof value.status === 'string' ? value.status : 'unknown';
+
+ const details =
+ typeof value.details === 'string' && value.details.length > 0
+ ? value.details
+ : null;
+
+ return { status, details };
+}
+
+export function SandboxResultList({
+ state,
+ emptyText,
+}: {
+ state: SandboxGateState | null;
+ emptyText: string;
+}) {
+ const entries = listSandboxCapabilities(state);
+
+ if (!state || entries.length === 0) {
+ return (
+
+ {emptyText}
+
+ );
+ }
+
+ return (
+
+
+
+
+ Overall status
+
+
+ {statusLabel(state.overallCriticalStatus)}
+
+
+
+
+ {state.overallCriticalStatus}
+
+
+
+
+ {entries.map(([capability, rawResult], index) => {
+ const result = normalizeCapabilityResult(rawResult);
+
+ return (
+
0 ? 'border-t border-border' : '',
+ ].join(' ')}
+ >
+
+
+
+
+ {formatCapabilityLabel(capability)}
+
+
+ {result.details ? (
+
+
+
+
+ {result.details}
+
+
+ ) : null}
+
+
+
+
+ {result.status}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxTabs.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxTabs.tsx
new file mode 100644
index 000000000..092f0bdef
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxTabs.tsx
@@ -0,0 +1,46 @@
+import type { SandboxTab } from '../sandboxState';
+
+interface Props {
+ activeTab: SandboxTab;
+ currentEnabled: boolean;
+ onChange: (tab: SandboxTab) => void;
+}
+
+const tabs: Array<{
+ id: SandboxTab;
+ label: string;
+}> = [
+ { id: 'effective', label: 'Effective gate' },
+ { id: 'previous', label: 'Previous completed' },
+ { id: 'current', label: 'Current running' },
+];
+
+export function SandboxTabs({ activeTab, currentEnabled, onChange }: Props) {
+ return (
+
+ {tabs.map((tab) => {
+ const disabled = tab.id === 'current' && !currentEnabled;
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/main.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/main.tsx
new file mode 100644
index 000000000..cb0593d81
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/main.tsx
@@ -0,0 +1,12 @@
+import React, { Suspense } from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+ Loading approval...}>
+
+
+ ,
+);
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/sandboxApi.ts b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxApi.ts
new file mode 100644
index 000000000..107b1b3b0
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxApi.ts
@@ -0,0 +1,26 @@
+import {
+ getSageSystemClient,
+ type SandboxStateView,
+} from '@sage-system-app/sdk';
+
+const client = await getSageSystemClient();
+
+export type { SandboxStateView };
+
+export async function getSandboxState(): Promise {
+ return await client.sandbox.getState();
+}
+
+export async function rerunSandboxTests(): Promise {
+ return await client.sandbox.rerunTests();
+}
+
+export function onSandboxStateChanged(
+ handler: (event: SandboxStateView) => void,
+): () => void {
+ return client.sandbox.onStateChanged(handler);
+}
+
+export async function closeSelf(): Promise {
+ return await client.runtimeManager.closeSelf();
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/sandboxState.ts b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxState.ts
new file mode 100644
index 000000000..d055a54ee
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxState.ts
@@ -0,0 +1,58 @@
+import type { SandboxStateView } from './sandboxApi';
+
+export type SandboxTab = 'effective' | 'previous' | 'current';
+
+export type SandboxGateState = NonNullable;
+
+export function getLiveSandboxState(state: SandboxStateView | null) {
+ return state?.currentRun?.state ?? null;
+}
+
+export function getBaselineSandboxState(state: SandboxStateView | null) {
+ return state?.baseline ?? null;
+}
+
+export function getEffectiveSandboxState(state: SandboxStateView | null) {
+ return state?.effective ?? null;
+}
+
+export function isCurrentSandboxRunActive(state: SandboxStateView | null) {
+ return state?.currentRun?.state?.overallCriticalStatus === 'running';
+}
+
+export function selectedSandboxState(
+ state: SandboxStateView | null,
+ tab: SandboxTab,
+): SandboxGateState | null {
+ switch (tab) {
+ case 'current':
+ return getLiveSandboxState(state);
+ case 'previous':
+ return getBaselineSandboxState(state);
+ case 'effective':
+ return getEffectiveSandboxState(state);
+ }
+}
+
+export function formatCapabilityLabel(value: string): string {
+ return value
+ .replace(/^sandbox\./, '')
+ .replace(/_/g, ' ')
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+export function listSandboxCapabilities(state: SandboxGateState | null) {
+ if (!state) return [];
+
+ return Object.entries(state)
+ .filter(
+ ([key]) =>
+ key !== 'overallCriticalStatus' &&
+ key !== 'startedAt' &&
+ key !== 'finishedAt',
+ )
+ .sort(([a], [b]) =>
+ formatCapabilityLabel(a).localeCompare(formatCapabilityLabel(b)),
+ );
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/styles.css b/builtin-apps/src/system/apps/sandbox-tests/src/styles.css
new file mode 100644
index 000000000..ad480ae12
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/src/styles.css
@@ -0,0 +1,19 @@
+@import "@sage-app/sdk/theme.css";
+@config "../tailwind.config.js";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background: transparent;
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/tailwind.config.js b/builtin-apps/src/system/apps/sandbox-tests/tailwind.config.js
new file mode 100644
index 000000000..13edcb743
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/tailwind.config.js
@@ -0,0 +1,14 @@
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createSageTailwindConfig } from '../../tailwind.shared.js';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(dir, '../../../../..');
+
+export default createSageTailwindConfig({
+ content: [
+ join(dir, 'index.html'),
+ join(dir, 'src/**/*.{ts,tsx}'),
+ join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'),
+ ],
+});
diff --git a/builtin-apps/src/system/apps/sandbox-tests/tsconfig.json b/builtin-apps/src/system/apps/sandbox-tests/tsconfig.json
new file mode 100644
index 000000000..3d61827d7
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/builtin-apps/src/system/apps/sandbox-tests/vite.config.ts b/builtin-apps/src/system/apps/sandbox-tests/vite.config.ts
new file mode 100644
index 000000000..c2498e6d6
--- /dev/null
+++ b/builtin-apps/src/system/apps/sandbox-tests/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineSageSystemAppConfig } from '../../vite.system-app.config';
+
+export default defineSageSystemAppConfig(import.meta.url);
diff --git a/builtin-apps/src/system/apps/task-manager/index.html b/builtin-apps/src/system/apps/task-manager/index.html
new file mode 100644
index 000000000..fe053d619
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Task Manager
+
+
+
+
+
+
diff --git a/builtin-apps/src/system/apps/task-manager/package.json b/builtin-apps/src/system/apps/task-manager/package.json
new file mode 100644
index 000000000..3945cfdf6
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "task-manager-system-app",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@sage-system-app/sdk": "workspace:*"
+ }
+}
diff --git a/builtin-apps/src/system/apps/task-manager/postcss.config.js b/builtin-apps/src/system/apps/task-manager/postcss.config.js
new file mode 100644
index 000000000..c97c6b959
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/postcss.config.js
@@ -0,0 +1,13 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ plugins: {
+ tailwindcss: {
+ config: join(dir, 'tailwind.config.js'),
+ },
+ autoprefixer: {},
+ },
+};
diff --git a/builtin-apps/src/system/apps/task-manager/public/icon.svg b/builtin-apps/src/system/apps/task-manager/public/icon.svg
new file mode 100644
index 000000000..7d7cbd44a
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/public/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/builtin-apps/src/system/apps/task-manager/sage-manifest.json b/builtin-apps/src/system/apps/task-manager/sage-manifest.json
new file mode 100644
index 000000000..895e7d8c1
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/sage-manifest.json
@@ -0,0 +1,27 @@
+{
+ "manifestVersion": 0,
+ "sageVersion": {
+ "min": "0.0.0"
+ },
+ "name": "Task Manager",
+ "version": "0.1.0",
+ "permissions": {
+ "network": {
+ "whitelist": {
+ "required": [],
+ "optional": []
+ }
+ },
+ "capabilities": {
+ "required": [
+ "environment.theme.get_current",
+ "environment.theme.listen_changed",
+ "environment.theme.css_vars"
+ ],
+ "optional": []
+ }
+ },
+ "files": [],
+ "entry": "index.html",
+ "icon": "icon.svg"
+}
diff --git a/builtin-apps/src/system/apps/task-manager/src/App.tsx b/builtin-apps/src/system/apps/task-manager/src/App.tsx
new file mode 100644
index 000000000..96304d658
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/src/App.tsx
@@ -0,0 +1,392 @@
+import { useEffect, useMemo, useState } from 'react';
+import {
+ focusRuntime,
+ hideRuntime,
+ killRuntime,
+ listRuntimes,
+ onRuntimesChanged,
+ type RuntimeRecord,
+} from './taskManagerApi';
+import type { SageAppRuntimeRecordView } from '@sage-system-app/sdk';
+
+function formatDuration(ms: number) {
+ const s = Math.floor(Math.max(0, ms) / 1000);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+
+ if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`;
+ if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`;
+ return `${sec}s`;
+}
+
+function formatTime(value: number) {
+ return new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }).format(new Date(value));
+}
+
+function runtimeAppId(runtime: SageAppRuntimeRecordView) {
+ return runtime.app.common.identity.id;
+}
+
+function runtimeAppName(runtime: SageAppRuntimeRecordView) {
+ return runtime.app.common.activeSnapshot.manifest.name;
+}
+
+function runtimeKind(runtime: SageAppRuntimeRecordView) {
+ return runtime.app.kind === 'user' ? 'User' : 'System';
+}
+
+function runtimeVisible(runtime: SageAppRuntimeRecordView) {
+ return runtime.visibility === 'Visible';
+}
+
+function ActionButton({
+ children,
+ danger,
+ disabled,
+ onClick,
+}: {
+ children: string;
+ danger?: boolean;
+ disabled?: boolean;
+ onClick: () => void | Promise;
+}) {
+ return (
+
+ );
+}
+
+function Metric({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
+
+export function App() {
+ const [runtimes, setRuntimes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [busyAppId, setBusyAppId] = useState(null);
+ const [now, setNow] = useState(() => Date.now());
+
+ async function refresh() {
+ setLoading(true);
+ try {
+ setRuntimes(await listRuntimes());
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void (async () => {
+ const tauri = (window as any).__TAURI__;
+
+ console.log('[probe] __TAURI__', tauri);
+ console.log('[probe] modules', Object.keys(tauri ?? {}));
+
+ const webviewApi = tauri?.webview;
+ const windowApi = tauri?.window;
+ const eventApi = tauri?.event;
+
+ console.log('[probe] webview api keys', Object.keys(webviewApi ?? {}));
+ console.log('[probe] window api keys', Object.keys(windowApi ?? {}));
+ console.log('[probe] event api keys', Object.keys(eventApi ?? {}));
+
+ try {
+ const currentWebview = webviewApi?.getCurrentWebview?.();
+ console.log('[probe] current webview', currentWebview);
+ console.log(
+ '[probe] current webview keys',
+ Object.keys(currentWebview ?? {}),
+ );
+
+ await currentWebview?.listen?.(
+ 'probe-current-webview-listen',
+ (event: unknown) => {
+ console.log('[probe] current webview received event', event);
+ },
+ );
+
+ console.log('[probe] current webview listen OK');
+ } catch (err) {
+ console.log('[probe] current webview listen DENIED/FAILED', err);
+ }
+
+ try {
+ const allWebviews = await webviewApi?.getAllWebviews?.();
+ console.log('[probe] getAllWebviews OK', allWebviews);
+
+ for (const webview of allWebviews ?? []) {
+ console.log(
+ '[probe] webview item',
+ webview,
+ Object.keys(webview ?? {}),
+ );
+
+ try {
+ await webview.listen?.(
+ 'probe-other-webview-listen',
+ (event: unknown) => {
+ console.log(
+ '[probe] received on listed webview',
+ webview.label,
+ event,
+ );
+ },
+ );
+
+ console.log('[probe] listen on listed webview OK', webview.label);
+ } catch (err) {
+ console.log(
+ '[probe] listen on listed webview DENIED/FAILED',
+ webview.label,
+ err,
+ );
+ }
+ }
+ } catch (err) {
+ console.log('[probe] getAllWebviews DENIED/FAILED', err);
+ }
+
+ try {
+ const allWindows = await windowApi?.getAllWindows?.();
+ console.log('[probe] getAllWindows OK', allWindows);
+
+ for (const win of allWindows ?? []) {
+ console.log('[probe] window item', win, Object.keys(win ?? {}));
+
+ try {
+ await win.listen?.('probe-window-listen', (event: unknown) => {
+ console.log(
+ '[probe] received on listed window',
+ win.label,
+ event,
+ );
+ });
+
+ console.log('[probe] listen on listed window OK', win.label);
+ } catch (err) {
+ console.log(
+ '[probe] listen on listed window DENIED/FAILED',
+ win.label,
+ err,
+ );
+ }
+ }
+ } catch (err) {
+ console.log('[probe] getAllWindows DENIED/FAILED', err);
+ }
+
+ try {
+ await eventApi?.listen?.('probe-global-listen', (event: unknown) => {
+ console.log('[probe] received global event', event);
+ });
+
+ console.log('[probe] global event listen OK');
+ } catch (err) {
+ console.log('[probe] global event listen DENIED/FAILED', err);
+ }
+
+ try {
+ await eventApi?.emit?.('probe-global-emit', { hello: 'world' });
+ console.log('[probe] global emit OK');
+ } catch (err) {
+ console.log('[probe] global emit DENIED/FAILED', err);
+ }
+ })();
+ }, []);
+
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(id);
+ }, []);
+
+ useEffect(() => {
+ let disposed = false;
+
+ void refresh();
+
+ const unsubscribe = onRuntimesChanged((event) => {
+ if (disposed) return;
+ setRuntimes(event.runtimes);
+ setLoading(false);
+ });
+
+ return () => {
+ disposed = true;
+ unsubscribe();
+ };
+ }, []);
+
+ const sorted = useMemo(
+ () =>
+ [...runtimes].sort((a, b) =>
+ runtimeAppName(a).localeCompare(runtimeAppName(b)),
+ ),
+ [runtimes],
+ );
+
+ async function runAction(appId: string, action: () => Promise) {
+ setBusyAppId(appId);
+ try {
+ await action();
+ } finally {
+ setBusyAppId(null);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ !runtimeVisible(runtime)).length
+ }
+ />
+
+
+
+ {loading && sorted.length === 0 ? (
+ Loading runtimes…
+ ) : sorted.length === 0 ? (
+ No running apps.
+ ) : (
+ sorted.map((runtime, index) => {
+ const appId = runtimeAppId(runtime);
+ const visible = runtimeVisible(runtime);
+ const busy = busyAppId === appId;
+
+ return (
+
+
+
+
+
+
+ {runtimeAppName(runtime)}
+
+
+
+ {visible ? 'Visible' : 'Hidden'}
+
+
+
+
+ {appId}
+
+
+
+
+
+
+
+
+
+
+
+
+ runAction(appId, () => focusRuntime(appId))
+ }
+ >
+ Focus
+
+
+
runAction(appId, () => hideRuntime(appId))}
+ >
+ Hide
+
+
+
+ runAction(appId, async () => {
+ await killRuntime(appId);
+ await refresh();
+ })
+ }
+ >
+ Kill
+
+
+
+ );
+ })
+ )}
+
+
+
+ );
+}
diff --git a/builtin-apps/src/system/apps/task-manager/src/main.tsx b/builtin-apps/src/system/apps/task-manager/src/main.tsx
new file mode 100644
index 000000000..93e192f5f
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/src/main.tsx
@@ -0,0 +1,10 @@
+import './styles.css';
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/builtin-apps/src/system/apps/task-manager/src/styles.css b/builtin-apps/src/system/apps/task-manager/src/styles.css
new file mode 100644
index 000000000..614527a13
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/src/styles.css
@@ -0,0 +1,16 @@
+@import "@sage-app/sdk/theme.css";
+@config "../tailwind.config.js";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ min-height: 100%;
+}
+
+body {
+ margin: 0;
+}
diff --git a/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts b/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts
new file mode 100644
index 000000000..615b92e6b
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts
@@ -0,0 +1,41 @@
+import {
+ getSageSystemClient,
+ type RuntimeManagerRuntimesChangedEvent,
+ type RuntimeTargetParams,
+ type SageAppRuntimeRecordView,
+ type SystemKillRuntimeResult,
+} from '@sage-system-app/sdk';
+
+const client = await getSageSystemClient();
+
+export type { SageAppRuntimeRecordView as RuntimeRecord };
+
+export function onRuntimesChanged(
+ handler: (event: RuntimeManagerRuntimesChangedEvent) => void,
+): () => void {
+ return client.runtimeManager.onRuntimesChanged(handler);
+}
+
+export async function listRuntimes(): Promise {
+ return await client.runtimeManager.listRuntimes();
+}
+
+export async function focusRuntime(appId: string): Promise {
+ return await client.runtimeManager.focusRuntime({
+ appId,
+ } satisfies RuntimeTargetParams);
+}
+
+export async function hideRuntime(appId: string): Promise {
+ return await client.runtimeManager.hideRuntime({
+ appId,
+ } satisfies RuntimeTargetParams);
+}
+
+export async function killRuntime(
+ appId: string,
+): Promise {
+ return await client.runtimeManager.killRuntime({
+ appId,
+ } satisfies RuntimeTargetParams);
+}
diff --git a/builtin-apps/src/system/apps/task-manager/tailwind.config.js b/builtin-apps/src/system/apps/task-manager/tailwind.config.js
new file mode 100644
index 000000000..13edcb743
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/tailwind.config.js
@@ -0,0 +1,14 @@
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createSageTailwindConfig } from '../../tailwind.shared.js';
+
+const dir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(dir, '../../../../..');
+
+export default createSageTailwindConfig({
+ content: [
+ join(dir, 'index.html'),
+ join(dir, 'src/**/*.{ts,tsx}'),
+ join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'),
+ ],
+});
diff --git a/builtin-apps/src/system/apps/task-manager/tsconfig.json b/builtin-apps/src/system/apps/task-manager/tsconfig.json
new file mode 100644
index 000000000..3d61827d7
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/builtin-apps/src/system/apps/task-manager/vite.config.ts b/builtin-apps/src/system/apps/task-manager/vite.config.ts
new file mode 100644
index 000000000..c2498e6d6
--- /dev/null
+++ b/builtin-apps/src/system/apps/task-manager/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineSageSystemAppConfig } from '../../vite.system-app.config';
+
+export default defineSageSystemAppConfig(import.meta.url);
diff --git a/builtin-apps/src/system/build.mjs b/builtin-apps/src/system/build.mjs
new file mode 100644
index 000000000..55ac4a825
--- /dev/null
+++ b/builtin-apps/src/system/build.mjs
@@ -0,0 +1,113 @@
+import { spawn } from 'node:child_process';
+import { readdirSync, statSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+const packageRoot = resolve(import.meta.dirname);
+const appsRoot = join(packageRoot, 'apps');
+const outRoot = resolve(packageRoot, '../../build/dist/system');
+
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
+
+const onlyApps = process.argv.slice(2);
+const concurrency = Number(process.env.SYSTEM_APPS_CONCURRENCY ?? 6);
+
+function listAllApps() {
+ return readdirSync(appsRoot)
+ .map((name) => ({ name, dir: join(appsRoot, name) }))
+ .filter((entry) => statSync(entry.dir).isDirectory());
+}
+
+const apps =
+ onlyApps.length > 0
+ ? listAllApps().filter((app) => onlyApps.includes(app.name))
+ : listAllApps();
+
+if (apps.length === 0) {
+ console.log('[system-apps] no apps to build');
+ process.exit(0);
+}
+
+function run(command, args) {
+ return new Promise((resolvePromise, rejectPromise) => {
+ const child = spawn(command, args, {
+ cwd: packageRoot,
+ stdio: 'inherit',
+ shell: false,
+ });
+
+ child.on('error', rejectPromise);
+
+ child.on('exit', (code, signal) => {
+ if (code === 0) {
+ resolvePromise();
+ return;
+ }
+
+ rejectPromise(
+ new Error(
+ `${command} ${args.join(' ')} failed with ${
+ signal ? `signal ${signal}` : `exit code ${code}`
+ }`,
+ ),
+ );
+ });
+ });
+}
+
+async function buildApp(app) {
+ console.log(`\n==> Building system app: ${app.name}`);
+
+ await run(pnpm, [
+ 'exec',
+ 'tsc',
+ '--noEmit',
+ '--project',
+ join(app.dir, 'tsconfig.json'),
+ ]);
+
+ await run(pnpm, [
+ 'exec',
+ 'vite',
+ 'build',
+ '--config',
+ join(app.dir, 'vite.config.ts'),
+ ]);
+
+ await run(pnpm, [
+ 'exec',
+ 'sage-app',
+ 'finalize-manifest',
+ '--source',
+ join(app.dir, 'sage-manifest.json'),
+ '--dist',
+ join(outRoot, app.name),
+ ]);
+
+ console.log(`\n✓ Finished system app: ${app.name}`);
+}
+
+async function runWithConcurrency(items, limit, worker) {
+ let index = 0;
+
+ const workers = Array.from(
+ { length: Math.min(limit, items.length) },
+ async () => {
+ while (index < items.length) {
+ const item = items[index];
+ index += 1;
+ await worker(item);
+ }
+ },
+ );
+
+ await Promise.all(workers);
+}
+
+try {
+ await runWithConcurrency(apps, concurrency, buildApp);
+ console.log('\n[system-apps] all builds finished');
+} catch (error) {
+ console.error('\n[system-apps] build failed');
+ console.error(error);
+ process.exit(1);
+}
diff --git a/builtin-apps/src/system/package.json b/builtin-apps/src/system/package.json
new file mode 100644
index 000000000..e10abb264
--- /dev/null
+++ b/builtin-apps/src/system/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "sage-system-apps",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "node ./build.mjs"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "^2.10.1",
+ "@sage-system-app/sdk": "workspace:*",
+ "@sage-app/ui": "workspace:*",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.28",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react": "^5.0.4",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1",
+ "tailwindcss": "^3.4.17",
+ "postcss": "^8.4.49",
+ "autoprefixer": "^10.4.20"
+ }
+}
diff --git a/builtin-apps/src/system/tailwind.shared.js b/builtin-apps/src/system/tailwind.shared.js
new file mode 100644
index 000000000..c4e80bb0d
--- /dev/null
+++ b/builtin-apps/src/system/tailwind.shared.js
@@ -0,0 +1,28 @@
+export function createSageTailwindConfig({ content }) {
+ return {
+ content,
+ theme: {
+ extend: {
+ colors: {
+ background: 'var(--background)',
+ foreground: 'var(--foreground)',
+ card: 'var(--card)',
+ 'card-foreground': 'var(--card-foreground)',
+ primary: 'var(--primary)',
+ 'primary-foreground': 'var(--primary-foreground)',
+ secondary: 'var(--secondary)',
+ 'secondary-foreground': 'var(--secondary-foreground)',
+ muted: 'var(--muted)',
+ 'muted-foreground': 'var(--muted-foreground)',
+ destructive: 'var(--destructive)',
+ 'destructive-foreground': 'var(--destructive-foreground)',
+ border: 'var(--border)',
+ input: 'var(--input)',
+ ring: 'var(--ring)',
+ popover: 'var(--popover)',
+ 'popover-foreground': 'var(--popover-foreground)',
+ },
+ },
+ },
+ };
+}
diff --git a/builtin-apps/src/system/tsconfig.base.json b/builtin-apps/src/system/tsconfig.base.json
new file mode 100644
index 000000000..95f6b32ae
--- /dev/null
+++ b/builtin-apps/src/system/tsconfig.base.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "baseUrl": "../../..",
+ "paths": {
+ "@sage-app/ui": ["packages/sage-app-ui/src/index.ts"]
+ },
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": false,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true
+ }
+}
diff --git a/builtin-apps/src/system/vite.system-app.config.ts b/builtin-apps/src/system/vite.system-app.config.ts
new file mode 100644
index 000000000..7dbfe1a75
--- /dev/null
+++ b/builtin-apps/src/system/vite.system-app.config.ts
@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { basename, dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export function defineSageSystemAppConfig(importMetaUrl: string) {
+ const dir = dirname(fileURLToPath(importMetaUrl));
+ const appName = basename(dir);
+ const workspaceRoot = resolve(dir, '../../../../..');
+
+ return defineConfig({
+ root: dir,
+ plugins: [react()],
+ publicDir: resolve(dir, 'public'),
+ build: {
+ outDir: resolve(
+ workspaceRoot,
+ 'builtin-apps',
+ 'build',
+ 'dist',
+ 'system',
+ appName,
+ ),
+ emptyOutDir: true,
+ rollupOptions: {
+ input: resolve(dir, 'index.html'),
+ },
+ },
+ });
+}
diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json
index 16263fdd9..0f498f29f 100644
--- a/crates/sage-api/endpoints.json
+++ b/crates/sage-api/endpoints.json
@@ -98,5 +98,6 @@
"update_nft_collection": true,
"redownload_nft": true,
"increase_derivation_index": true,
- "is_asset_owned": true
+ "is_asset_owned": true,
+ "get_xch_usd_price": true
}
diff --git a/crates/sage-api/src/requests/actions.rs b/crates/sage-api/src/requests/actions.rs
index 06e29c222..7e05a0388 100644
--- a/crates/sage-api/src/requests/actions.rs
+++ b/crates/sage-api/src/requests/actions.rs
@@ -29,6 +29,23 @@ pub struct ResyncCat {
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ResyncCatResponse {}
+#[cfg_attr(
+ feature = "openapi",
+ crate::openapi_attr(tag = "Price", description = "Get XCH-USD price")
+)]
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[cfg_attr(feature = "tauri", derive(specta::Type))]
+#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
+pub struct GetXchUsdPrice {}
+
+#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Price"))]
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[cfg_attr(feature = "tauri", derive(specta::Type))]
+#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
+pub struct GetXchUsdPriceResponse {
+ pub usd: f64,
+}
+
/// Update a `CAT` token's metadata and visibility
#[cfg_attr(
feature = "openapi",
diff --git a/crates/sage-apps/Cargo.toml b/crates/sage-apps/Cargo.toml
new file mode 100644
index 000000000..f896ccfbf
--- /dev/null
+++ b/crates/sage-apps/Cargo.toml
@@ -0,0 +1,38 @@
+[package]
+name = "sage-apps"
+version = "0.1.0"
+edition = "2024"
+
+[lints]
+workspace = true
+
+[dependencies]
+sage = { workspace = true }
+sage-api = { workspace = true, features = ["tauri"] }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+specta = { workspace = true, features = ["derive", "function", "url"] }
+tauri-specta = { workspace = true, features = ["derive"] }
+specta-typescript = { workspace = true }
+tauri = { workspace = true, features = ["macos-private-api"] }
+tauri-plugin-dialog = "2.7.1"
+tokio = { workspace = true }
+tracing = { workspace = true }
+reqwest = { workspace = true, features = ["json", "rustls-tls"] }
+anyhow = { workspace = true }
+hex = { workspace = true }
+sqlx = { workspace = true, features = ["sqlite", "runtime-tokio"] }
+sha2 = "0.10.9"
+uuid = { version = "1.19.0", features = ["v4", "serde"] }
+tempfile = "3"
+zip = "8.5.1"
+mime_guess = "2"
+url = "2"
+async-trait = "0.1.89"
+erased-serde = "0.4.9"
+serde_path_to_error = "0.1.20"
+parking_lot = "0.12.5"
+futures = "0.3.32"
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/sage-apps/src/bin/export_bridge_types.rs b/crates/sage-apps/src/bin/export_bridge_types.rs
new file mode 100644
index 000000000..6f6dab3ae
--- /dev/null
+++ b/crates/sage-apps/src/bin/export_bridge_types.rs
@@ -0,0 +1,26 @@
+use sage_apps::{export_system_bridge_typescript, export_user_bridge_typescript};
+
+fn main() {
+ let bridge = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "system".to_string());
+
+ let output = match bridge.as_str() {
+ "system" => export_system_bridge_typescript(),
+ "user" => export_user_bridge_typescript(),
+ other => {
+ eprintln!("unknown bridge kind: {other}");
+ std::process::exit(1);
+ }
+ };
+
+ match output {
+ Ok(ts) => {
+ println!("{ts}");
+ }
+ Err(err) => {
+ eprintln!("{err}");
+ std::process::exit(1);
+ }
+ }
+}
diff --git a/crates/sage-apps/src/bridge.rs b/crates/sage-apps/src/bridge.rs
new file mode 100644
index 000000000..2023d0aaa
--- /dev/null
+++ b/crates/sage-apps/src/bridge.rs
@@ -0,0 +1,20 @@
+mod bridge_request;
+mod commands;
+mod debug;
+mod event_emit;
+mod methods;
+mod registry;
+mod state;
+mod ts_exports;
+mod types;
+
+pub use commands::*;
+pub use ts_exports::*;
+pub use types::*;
+
+pub(crate) use bridge_request::*;
+pub(crate) use debug::*;
+pub(crate) use event_emit::*;
+pub(crate) use methods::*;
+pub(crate) use registry::*;
+pub(crate) use state::*;
diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs
new file mode 100644
index 000000000..b3c42f4ca
--- /dev/null
+++ b/crates/sage-apps/src/bridge/bridge_request.rs
@@ -0,0 +1,385 @@
+use tauri::{AppHandle, Manager, State, Webview};
+
+use crate::{
+ AppState, AppsHostState, BridgeApprovalsChangedEvent, BridgeCapability, BridgeContext,
+ BridgeMethod, BridgeMethodCapability, BridgeOrigin, BridgeRegistry, BridgeRegistryKind,
+ BridgeTools, ResolveBridgeApprovalArgs, RustBridgeApprovalRequest, RustBridgeInvokeResult,
+ RustBridgeRequest, RustBridgeResponse, SharedSageApp, SystemBridgeCapability,
+ UserBridgeCapability, assert_bridge_origin, emit_bridge_response_to_app,
+ emit_system_runtime_event_to_listeners, ensure_app_is_enabled_for_scope,
+ ensure_approval_expiry_loop, get_pending_approval, get_system_capability_definition,
+ get_user_capability_definition, list_pending_approvals, remove_pending_approval, resolve_app,
+ start_bridge_approval_runtime, sync_bridge_approval_runtime, write_pending_approval,
+};
+
+pub(crate) async fn process(
+ app_handle: AppHandle,
+ webview: Webview,
+ app_state: State<'_, AppState>,
+ request: RustBridgeRequest,
+) -> Result {
+ if let Err(result) = assert_bridge_version(&request) {
+ return Ok(result);
+ }
+
+ let webview_label = webview.label().to_string();
+
+ let origin = match assert_bridge_origin(&app_handle, &webview_label).await {
+ Ok(origin) => origin,
+ Err(err) => {
+ return Ok(RustBridgeInvokeResult::error(
+ &request.id,
+ "permission_denied",
+ format!("Bridge origin denied: {err}"),
+ ));
+ }
+ };
+
+ process_shared(
+ &app_handle,
+ &app_state,
+ &origin,
+ BridgeRegistryKind::User,
+ &request,
+ false,
+ )
+ .await
+}
+
+pub(crate) async fn process_system(
+ app_handle: AppHandle,
+ webview: Webview,
+ app_state: State<'_, AppState>,
+ request: RustBridgeRequest,
+) -> Result {
+ if let Err(result) = assert_bridge_version(&request) {
+ return Ok(result);
+ }
+ let webview_label = webview.label().to_string();
+
+ let origin = match assert_system_bridge_origin(&app_handle, &webview_label).await {
+ Ok(origin) => origin,
+ Err(err) => {
+ return Ok(RustBridgeInvokeResult::error(
+ &request.id,
+ "permission_denied",
+ format!("Bridge origin denied: {err}"),
+ ));
+ }
+ };
+
+ process_shared(
+ &app_handle,
+ &app_state,
+ &origin,
+ BridgeRegistryKind::System,
+ &request,
+ false,
+ )
+ .await
+}
+
+pub(crate) async fn process_after_approval(
+ app_handle: &AppHandle,
+ app_state: &State<'_, AppState>,
+ apps_state: &State<'_, AppsHostState>,
+ args: ResolveBridgeApprovalArgs,
+) -> Result<(), String> {
+ let pending = get_pending_approval(apps_state, &args.approval_id).await?;
+ remove_pending_approval(apps_state, &args.approval_id).await;
+
+ sync_bridge_approval_runtime(app_handle, apps_state).await?;
+
+ let approvals_changed_event =
+ BridgeApprovalsChangedEvent::new_from_list(list_pending_approvals(apps_state).await);
+
+ emit_system_runtime_event_to_listeners(app_handle, apps_state, approvals_changed_event).await;
+
+ let app = resolve_app(app_handle, &pending.app_id)
+ .await
+ .map_err(|err| format!("Failed to resolve app: {err}"))?;
+
+ let origin =
+ assert_bridge_origin(app_handle, &app.with_app(SharedSageApp::webview_label)).await?;
+
+ let invoke_result = if args.approved {
+ process_shared(
+ app_handle,
+ app_state,
+ &origin,
+ pending.registry_kind,
+ &pending.request,
+ true,
+ )
+ .await?
+ } else {
+ RustBridgeInvokeResult::error(
+ &pending.request.id,
+ "user_denied",
+ args.reason
+ .unwrap_or_else(|| "User denied the request".to_string()),
+ )
+ };
+
+ emit_bridge_response_to_app(app_handle, &origin.app, &invoke_result.try_into()?).await?;
+ Ok(())
+}
+
+async fn process_shared(
+ app_handle: &AppHandle,
+ app_state: &State<'_, AppState>,
+ origin: &BridgeOrigin,
+ registry_kind: BridgeRegistryKind,
+ request: &RustBridgeRequest,
+ approved: bool,
+) -> Result {
+ let registry = BridgeRegistry::new(registry_kind);
+
+ let app = &origin.app;
+ if let Err(err) = ensure_app_is_enabled_for_scope(app_state, app).await {
+ return Ok(RustBridgeInvokeResult::error(
+ &request.id,
+ "app_not_enabled_for_scope",
+ err,
+ ));
+ }
+
+ let method = match assert_method(®istry, request) {
+ Ok(method) => method,
+ Err(response) => return Ok(response.into()),
+ };
+
+ match method.capability() {
+ BridgeMethodCapability::Ungated => {}
+
+ BridgeMethodCapability::Required(capability) => {
+ if let Err(response) = verify_capability(&origin.app, request, capability) {
+ return Ok(response.into());
+ }
+ }
+ }
+
+ if approved {
+ let response =
+ execute_bridge_request(app_handle, app_state, origin, registry, request).await;
+
+ return Ok(response.into());
+ }
+
+ match method.approval_request(BridgeContext { app }, request) {
+ Ok(Some(approval)) => {
+ request_approval(app_handle, app.id(), registry_kind, approval, request).await?;
+ Ok(RustBridgeInvokeResult::Pending {})
+ }
+ Ok(None) => {
+ let response =
+ execute_bridge_request(app_handle, app_state, origin, registry, request).await;
+
+ Ok(response.into())
+ }
+ Err(err) => Ok(RustBridgeInvokeResult::error(
+ &request.id,
+ err.code,
+ err.message,
+ )),
+ }
+}
+
+async fn execute_bridge_request(
+ app_handle: &AppHandle,
+ app_state: &State<'_, AppState>,
+ origin: &BridgeOrigin,
+ registry: BridgeRegistry,
+ request: &RustBridgeRequest,
+) -> RustBridgeResponse {
+ let method = match assert_method(®istry, request) {
+ Ok(method) => method,
+ Err(response) => return response,
+ };
+
+ let result = method
+ .handle(
+ BridgeContext { app: &origin.app },
+ BridgeTools {
+ app_handle,
+ app_state,
+ host_state: &app_handle.state::(),
+ },
+ request,
+ )
+ .await;
+
+ match result {
+ Ok(value) => match erased_serde::serialize(&*value, serde_json::value::Serializer) {
+ Ok(value) => RustBridgeResponse::success(&request.id, &value),
+ Err(err) => RustBridgeResponse::error(
+ &request.id,
+ "internal_error",
+ format!("failed to encode {} result: {err}", method.name()),
+ ),
+ },
+ Err(err) => RustBridgeResponse::error(&request.id, err.code, err.message),
+ }
+}
+
+async fn request_approval(
+ app_handle: &AppHandle,
+ app_id: String,
+ registry_kind: BridgeRegistryKind,
+ approval: RustBridgeApprovalRequest,
+ request: &RustBridgeRequest,
+) -> Result<(), String> {
+ let apps_state = app_handle.state::();
+ write_pending_approval(
+ &apps_state,
+ app_id.clone(),
+ registry_kind,
+ &approval,
+ request,
+ )
+ .await;
+
+ ensure_approval_expiry_loop(app_handle, &apps_state).await;
+
+ let approvals_changed_event =
+ BridgeApprovalsChangedEvent::new_from_list(list_pending_approvals(&apps_state).await);
+ emit_system_runtime_event_to_listeners(app_handle, &apps_state, approvals_changed_event).await;
+
+ start_bridge_approval_runtime(app_handle, &apps_state, Vec::from([app_id])).await?;
+
+ Ok(())
+}
+
+fn verify_capability(
+ app: &SharedSageApp,
+ request: &RustBridgeRequest,
+ capability: BridgeCapability,
+) -> Result<(), RustBridgeResponse> {
+ match capability {
+ BridgeCapability::User(capability) => {
+ let definition = get_user_capability_definition(capability);
+
+ verify_user_capability(
+ app,
+ request,
+ capability,
+ definition.flags().shared_with_app(),
+ )
+ }
+
+ BridgeCapability::System(capability) => {
+ let definition = get_system_capability_definition(capability);
+
+ verify_system_capability(
+ app,
+ request,
+ capability,
+ definition.flags().shared_with_app(),
+ )
+ }
+ }
+}
+
+fn verify_user_capability(
+ app: &SharedSageApp,
+ request: &RustBridgeRequest,
+ capability: UserBridgeCapability,
+ shared_with_app: bool,
+) -> Result<(), RustBridgeResponse> {
+ if !shared_with_app {
+ return Err(RustBridgeResponse::error(
+ &request.id,
+ "permission_denied",
+ format!("Capability {} is not shared with apps", capability.key()),
+ ));
+ }
+
+ let effective_capabilities = app.with(|app| {
+ app.common()
+ .requested_permissions()
+ .capabilities()
+ .resolve_effective_grants(app.common().granted_permissions().capabilities().copied())
+ });
+
+ if !effective_capabilities.contains(&capability) {
+ return Err(RustBridgeResponse::error(
+ &request.id,
+ "permission_denied",
+ format!("Permission denied for {}", capability.key()),
+ ));
+ }
+
+ Ok(())
+}
+
+fn verify_system_capability(
+ app: &SharedSageApp,
+ request: &RustBridgeRequest,
+ capability: SystemBridgeCapability,
+ shared_with_app: bool,
+) -> Result<(), RustBridgeResponse> {
+ if !shared_with_app {
+ return Err(RustBridgeResponse::error(
+ &request.id,
+ "permission_denied",
+ format!("Capability {} is not shared with apps", capability.key()),
+ ));
+ }
+
+ let granted = app.with(|app| {
+ app.system_granted_permissions()
+ .is_some_and(|permissions| permissions.capabilities().contains(&capability))
+ });
+
+ if !granted {
+ return Err(RustBridgeResponse::error(
+ &request.id,
+ "permission_denied",
+ format!("Permission denied for {}", capability.key()),
+ ));
+ }
+
+ Ok(())
+}
+
+fn assert_method<'a>(
+ registry: &'a BridgeRegistry,
+ request: &RustBridgeRequest,
+) -> Result<&'a dyn BridgeMethod, RustBridgeResponse> {
+ let Some(method) = registry.get(&request.method) else {
+ return Err(RustBridgeResponse::error(
+ &request.id,
+ "method_not_found",
+ format!("Unknown bridge method: {}", request.method),
+ ));
+ };
+
+ Ok(method)
+}
+
+async fn assert_system_bridge_origin(
+ app_handle: &AppHandle,
+ webview_label: &String,
+) -> Result {
+ let origin = assert_bridge_origin(app_handle, webview_label).await?;
+
+ if !origin.app.is_system_app() {
+ return Err("origin app is not a system app".to_string());
+ }
+
+ Ok(origin)
+}
+
+fn assert_bridge_version(request: &RustBridgeRequest) -> Result<(), RustBridgeInvokeResult> {
+ if let Some(version) = &request.bridge_version
+ && version != "v1"
+ {
+ return Err(RustBridgeInvokeResult::error(
+ &request.id,
+ "unsupported_bridge_version",
+ format!("Unsupported Sage bridge version: {version}"),
+ ));
+ }
+
+ Ok(())
+}
diff --git a/crates/sage-apps/src/bridge/commands.rs b/crates/sage-apps/src/bridge/commands.rs
new file mode 100644
index 000000000..d183db535
--- /dev/null
+++ b/crates/sage-apps/src/bridge/commands.rs
@@ -0,0 +1,25 @@
+use tauri::{AppHandle, State, Webview};
+
+use crate::{AppState, RustBridgeInvokeResult, RustBridgeRequest, process, process_system};
+
+#[tauri::command]
+#[specta::specta]
+pub async fn apps_invoke_bridge(
+ app: AppHandle,
+ webview: Webview,
+ app_state: State<'_, AppState>,
+ request: RustBridgeRequest,
+) -> Result {
+ process(app, webview, app_state, request).await
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn apps_invoke_system_bridge(
+ app: AppHandle,
+ webview: Webview,
+ app_state: State<'_, AppState>,
+ request: RustBridgeRequest,
+) -> Result {
+ process_system(app, webview, app_state, request).await
+}
diff --git a/crates/sage-apps/src/bridge/debug.rs b/crates/sage-apps/src/bridge/debug.rs
new file mode 100644
index 000000000..f003d4afe
--- /dev/null
+++ b/crates/sage-apps/src/bridge/debug.rs
@@ -0,0 +1,15 @@
+macro_rules! comms_debug {
+ ($($arg:tt)*) => {
+ if $crate::sage_apps_comms_debug_enabled() {
+ tracing::info!(target: "sage_apps_comms", $($arg)*);
+ }
+ };
+}
+
+pub(crate) use comms_debug;
+
+pub(crate) fn sage_apps_comms_debug_enabled() -> bool {
+ cfg!(debug_assertions)
+ && std::env::var("SAGE_APPS_COMMS_DEBUG")
+ .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
+}
diff --git a/crates/sage-apps/src/bridge/event_emit.rs b/crates/sage-apps/src/bridge/event_emit.rs
new file mode 100644
index 000000000..bf4d76e65
--- /dev/null
+++ b/crates/sage-apps/src/bridge/event_emit.rs
@@ -0,0 +1,266 @@
+use serde::Serialize;
+use specta::Type;
+use tauri::{AppHandle, Emitter, Manager, State};
+
+use crate::{
+ AppsHostState, RustBridgeResponse, SharedSageApp, SystemBridgeCapability, UserBridgeCapability,
+ comms_debug, ensure_app_is_enabled_for_scope, get_sage_webview, get_webview_in_sage_window,
+ list_runtimes, resolve_running_app,
+};
+
+const SAGE_RUNTIME_EVENT_NAME: &str = "apps:runtime-event";
+
+#[derive(Debug, Clone, Serialize, Type)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct RuntimeEvent {
+ #[serde(rename = "type")]
+ pub event_type: &'static str,
+
+ pub payload: T,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum AppRuntimeEventRail {
+ User,
+ System,
+}
+
+impl AppRuntimeEventRail {
+ pub(crate) fn event_name(self) -> &'static str {
+ match self {
+ Self::User => "sage-bridge:event",
+ Self::System => "sage-system-bridge:event",
+ }
+ }
+}
+
+pub(crate) trait UserRuntimeEvent: Serialize + Type + Clone {
+ const TYPE: &'static str;
+ const REQUIRED_CAPABILITY: UserBridgeCapability;
+}
+
+pub(crate) trait SystemRuntimeEvent: Serialize + Type + Clone {
+ const TYPE: &'static str;
+ const REQUIRED_CAPABILITY: SystemBridgeCapability;
+}
+
+fn runtime_event(event_type: &'static str, payload: T) -> RuntimeEvent {
+ RuntimeEvent {
+ event_type,
+ payload,
+ }
+}
+
+pub(crate) async fn emit_user_runtime_event_to_listeners(
+ app_handle: &AppHandle,
+ apps_state: &State<'_, AppsHostState>,
+ event: T,
+) where
+ T: UserRuntimeEvent,
+{
+ let Ok(runtimes) = list_runtimes(apps_state).await else {
+ return;
+ };
+
+ for runtime in runtimes {
+ let Some((app_id, can_receive)) = runtime.with_runtime(|record| {
+ if record.internal() {
+ return None;
+ }
+
+ let app = record.app();
+
+ if !app.is_user_app() {
+ return None;
+ }
+
+ let can_receive = app.is_capability_granted(T::REQUIRED_CAPABILITY.into());
+
+ Some((app.id(), can_receive))
+ }) else {
+ continue;
+ };
+
+ if can_receive {
+ let _ = emit_user_runtime_event_to_app_id(app_handle, &app_id, event.clone()).await;
+ }
+ }
+
+ let _ = emit_user_runtime_event_to_sage_webview(app_handle, event);
+}
+
+pub(crate) async fn emit_system_runtime_event_to_listeners(
+ app_handle: &AppHandle,
+ apps_state: &State<'_, AppsHostState>,
+ event: T,
+) where
+ T: SystemRuntimeEvent,
+{
+ let Ok(runtimes) = list_runtimes(apps_state).await else {
+ return;
+ };
+ comms_debug!(
+ "system:event:listeners type={} required_capability={}",
+ T::TYPE,
+ T::REQUIRED_CAPABILITY.key(),
+ );
+
+ for runtime in runtimes {
+ let Some((app_id, can_receive)) = runtime.with_runtime(|record| {
+ if record.internal() {
+ comms_debug!(
+ "system:event:skip type={} runtime={} reason=internal",
+ T::TYPE,
+ record.app_id(),
+ );
+ return None;
+ }
+
+ let app = record.app();
+
+ if !app.is_system_app() {
+ comms_debug!(
+ "system:event:skip type={} app={} reason=not_system",
+ T::TYPE,
+ app.id(),
+ );
+ return None;
+ }
+
+ let can_receive = app.is_capability_granted(T::REQUIRED_CAPABILITY.into());
+
+ comms_debug!(
+ "system:event:candidate type={} app={} required_capability={} can_receive={}",
+ T::TYPE,
+ app.id(),
+ T::REQUIRED_CAPABILITY.key(),
+ can_receive,
+ );
+
+ Some((app.id(), can_receive))
+ }) else {
+ continue;
+ };
+
+ if can_receive {
+ comms_debug!("system:event:emit type={} app={}", T::TYPE, app_id,);
+
+ let result =
+ emit_system_runtime_event_to_app_id(app_handle, &app_id, event.clone()).await;
+
+ if let Err(err) = result {
+ comms_debug!(
+ "system:event:emit_failed type={} app={} error={}",
+ T::TYPE,
+ app_id,
+ err,
+ );
+ }
+ }
+ }
+
+ let _ = emit_system_runtime_event_to_sage_webview(app_handle, event);
+}
+
+pub(crate) async fn emit_user_runtime_event_to_app_id(
+ app_handle: &AppHandle,
+ app_id: &str,
+ event: T,
+) -> Result<(), String>
+where
+ T: UserRuntimeEvent,
+{
+ emit_runtime_event_to_app_id(
+ app_handle,
+ app_id,
+ AppRuntimeEventRail::User,
+ T::TYPE,
+ event,
+ )
+ .await
+}
+
+pub(crate) async fn emit_system_runtime_event_to_app_id(
+ app_handle: &AppHandle,
+ app_id: &str,
+ event: T,
+) -> Result<(), String>
+where
+ T: SystemRuntimeEvent,
+{
+ emit_runtime_event_to_app_id(
+ app_handle,
+ app_id,
+ AppRuntimeEventRail::System,
+ T::TYPE,
+ event,
+ )
+ .await
+}
+
+pub(crate) fn emit_user_runtime_event_to_sage_webview(
+ app_handle: &AppHandle,
+ event: T,
+) -> Result<(), String>
+where
+ T: UserRuntimeEvent,
+{
+ get_sage_webview(app_handle)?
+ .emit(SAGE_RUNTIME_EVENT_NAME, runtime_event(T::TYPE, event))
+ .map_err(|err| format!("failed to emit runtime event to Sage webview: {err}"))
+}
+
+pub(crate) fn emit_system_runtime_event_to_sage_webview(
+ app_handle: &AppHandle,
+ event: T,
+) -> Result<(), String>
+where
+ T: SystemRuntimeEvent,
+{
+ get_sage_webview(app_handle)?
+ .emit(SAGE_RUNTIME_EVENT_NAME, runtime_event(T::TYPE, event))
+ .map_err(|err| format!("failed to emit runtime event to Sage webview: {err}"))
+}
+
+pub(crate) async fn emit_bridge_response_to_app(
+ app_handle: &AppHandle,
+ app: &SharedSageApp,
+ response: &RustBridgeResponse,
+) -> Result<(), String> {
+ get_webview_in_sage_window(app_handle, &app.webview_label())?
+ .emit("sage-bridge:response", response)
+ .map_err(|err| format!("failed to emit bridge response: {err}"))
+}
+
+async fn emit_runtime_event_to_app_id(
+ app_handle: &AppHandle,
+ app_id: &str,
+ rail: AppRuntimeEventRail,
+ event_type: &'static str,
+ event: T,
+) -> Result<(), String>
+where
+ T: Serialize + Type + Clone,
+{
+ let apps_state = app_handle.state::();
+
+ let runtime = resolve_running_app(&apps_state, app_id)
+ .await
+ .map_err(|err| format!("failed to resolve runtime for app {app_id}: {err}"))?;
+
+ let app = runtime.into_app();
+ ensure_app_is_enabled_for_scope(&app_handle.state(), &app).await?;
+
+ let webview_label = app.webview_label();
+ comms_debug!(
+ "runtime:event:emit_to_app app_id={} webview_label={} rail={:?} type={}",
+ app_id,
+ webview_label,
+ rail,
+ event_type,
+ );
+
+ get_webview_in_sage_window(app_handle, &webview_label)?
+ .emit(rail.event_name(), runtime_event(event_type, event))
+ .map_err(|err| format!("failed to emit runtime event: {err}"))
+}
diff --git a/crates/sage-apps/src/bridge/methods.rs b/crates/sage-apps/src/bridge/methods.rs
new file mode 100644
index 000000000..50a73af45
--- /dev/null
+++ b/crates/sage-apps/src/bridge/methods.rs
@@ -0,0 +1,7 @@
+mod shared;
+mod system;
+mod user;
+
+pub(crate) use shared::*;
+pub(crate) use system::*;
+pub(crate) use user::*;
diff --git a/crates/sage-apps/src/bridge/methods/shared.rs b/crates/sage-apps/src/bridge/methods/shared.rs
new file mode 100644
index 000000000..8851d1c6d
--- /dev/null
+++ b/crates/sage-apps/src/bridge/methods/shared.rs
@@ -0,0 +1,109 @@
+use async_trait::async_trait;
+use serde::de::DeserializeOwned;
+
+use crate::{
+ AppState, AppsHostState, BridgeCapability, RustBridgeApprovalRequest, RustBridgeRequest,
+ SharedSageApp, SystemBridgeCapability, UserBridgeCapability,
+};
+
+#[async_trait]
+pub(crate) trait BridgeMethod: Send + Sync {
+ fn name(&self) -> &'static str;
+ fn capability(&self) -> BridgeMethodCapability;
+
+ fn approval_request(
+ &self,
+ ctx: BridgeContext<'_>,
+ request: &RustBridgeRequest,
+ ) -> BridgeApprovalRequestResult;
+
+ async fn handle(
+ &self,
+ ctx: BridgeContext<'_>,
+ tools: BridgeTools<'_>,
+ request: &RustBridgeRequest,
+ ) -> BridgeHandleResult;
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum BridgeMethodCapability {
+ Ungated,
+ Required(BridgeCapability),
+}
+
+#[derive(Debug)]
+pub(crate) struct BridgeContext<'a> {
+ pub app: &'a SharedSageApp,
+}
+
+#[derive(Debug)]
+pub(crate) struct BridgeTools<'a> {
+ pub app_handle: &'a tauri::AppHandle,
+ pub app_state: &'a tauri::State<'a, AppState>,
+ pub host_state: &'a tauri::State<'a, AppsHostState>,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct BridgeMethodHandleError {
+ pub code: &'static str,
+ pub message: String,
+}
+
+pub(crate) type BridgeApprovalRequestResult =
+ Result